From c3cfcecc0b7374bea55400e552ca3e59f38c2084 Mon Sep 17 00:00:00 2001 From: AnnZhimol <ankavanka731@gmail.com> Date: Sat, 13 May 2023 14:12:19 +0400 Subject: [PATCH] lab 6 complete --- build.gradle | 6 ++ front/package-lock.json | 56 +++++++++-- front/package.json | 1 + front/src/App.js | 20 +++- front/src/components/Catalog.jsx | 2 +- front/src/components/Categories.jsx | 10 +- front/src/components/DrivingSchools.jsx | 7 +- front/src/components/Login.css | 21 ++++ front/src/components/Login.jsx | 70 ++++++++++++++ front/src/components/Logout.jsx | 20 ++++ front/src/components/OneDrivingSchool.jsx | 12 ++- .../src/components/ReportStudentCategory.jsx | 7 +- front/src/components/Students.jsx | 6 +- front/src/components/commons/Header.jsx | 11 +++ front/src/components/commons/ItemTable.jsx | 9 +- front/src/components/commons/ItemTableSC.jsx | 7 +- front/src/components/commons/Table.jsx | 2 +- front/src/components/commons/TableSC.jsx | 2 +- front/src/components/withAuth.js | 24 +++++ front/src/services/DataService.js | 25 +++-- .../ru/ulstu/is/cbapp/WebConfiguration.java | 6 ++ .../PasswordEncoderConfiguration.java | 14 +++ .../configuration/SecurityConfiguration.java | 87 +++++++++++++++++ .../cbapp/configuration/jwt/JwtException.java | 11 +++ .../is/cbapp/configuration/jwt/JwtFilter.java | 93 ++++++++++++++++++ .../configuration/jwt/JwtProperties.java | 27 ++++++ .../cbapp/configuration/jwt/JwtsProvider.java | 51 ++++++++++ .../cbapp/user/controller/UserController.java | 26 +++++ .../user/controller/UserMvcController.java | 44 +++++++++ .../controller/UserSignupMvcController.java | 50 ++++++++++ .../ru/ulstu/is/cbapp/user/model/User.java | 75 +++++++++++++++ .../ru/ulstu/is/cbapp/user/model/UserDto.java | 46 +++++++++ .../ulstu/is/cbapp/user/model/UserRole.java | 20 ++++ .../is/cbapp/user/model/UserSignupDto.java | 41 ++++++++ .../cbapp/user/repository/UserRepository.java | 8 ++ .../user/service/UserNotFoundException.java | 7 ++ .../is/cbapp/user/service/UserService.java | 96 +++++++++++++++++++ .../is/cbapp/util/error/AdviceController.java | 45 +++++++++ .../util/validation/ValidationException.java | 13 +++ .../cbapp/util/validation/ValidatorUtil.java | 27 ++++++ src/main/resources/application.properties | 3 + src/main/resources/static/styles/login.css | 21 ++++ src/main/resources/templates/category.html | 6 +- src/main/resources/templates/default.html | 2 + .../templates/drivingSchool-one.html | 6 +- .../resources/templates/drivingSchool.html | 9 +- src/main/resources/templates/index.html | 3 +- src/main/resources/templates/login.html | 43 +++++++++ src/main/resources/templates/signup.html | 28 ++++++ src/main/resources/templates/student.html | 6 +- src/main/resources/templates/users.html | 37 +++++++ 51 files changed, 1211 insertions(+), 58 deletions(-) create mode 100644 front/src/components/Login.css create mode 100644 front/src/components/Login.jsx create mode 100644 front/src/components/Logout.jsx create mode 100644 front/src/components/withAuth.js create mode 100644 src/main/java/ru/ulstu/is/cbapp/configuration/PasswordEncoderConfiguration.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/configuration/SecurityConfiguration.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtException.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtFilter.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtProperties.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtsProvider.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/controller/UserController.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/controller/UserMvcController.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/controller/UserSignupMvcController.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/model/User.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/model/UserDto.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/model/UserRole.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/model/UserSignupDto.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/repository/UserRepository.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/service/UserNotFoundException.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/user/service/UserService.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/util/error/AdviceController.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/util/validation/ValidationException.java create mode 100644 src/main/java/ru/ulstu/is/cbapp/util/validation/ValidatorUtil.java create mode 100644 src/main/resources/static/styles/login.css create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/signup.html create mode 100644 src/main/resources/templates/users.html diff --git a/build.gradle b/build.gradle index 92f3afb..32a9205 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.h2database:h2:2.1.210' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + implementation 'com.auth0:java-jwt:4.4.0' + implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + implementation 'org.hibernate.validator:hibernate-validator' implementation 'org.springdoc:springdoc-openapi-ui:1.6.5' diff --git a/front/package-lock.json b/front/package-lock.json index fa02810..5bdfd7c 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -14,6 +14,7 @@ "react": "^18.2.0", "react-bootstrap": "^2.7.2", "react-dom": "^18.2.0", + "react-router": "^6.10.0", "react-router-dom": "^6.6.1", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" @@ -15157,11 +15158,11 @@ } }, "node_modules/react-router": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", - "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.1.tgz", + "integrity": "sha512-OZINSdjJ2WgvAi7hgNLazrEV8SGn6xrKA+MkJe9wVDMZ3zQ6fdJocUjpCUCI0cNrelWjcvon0S/QK/j0NzL3KA==", "dependencies": { - "@remix-run/router": "1.3.2" + "@remix-run/router": "1.6.1" }, "engines": { "node": ">=14" @@ -15186,6 +15187,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-router-dom/node_modules/react-router": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", + "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", + "dependencies": { + "@remix-run/router": "1.3.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router/node_modules/@remix-run/router": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.1.tgz", + "integrity": "sha512-YUkWj+xs0oOzBe74OgErsuR3wVn+efrFhXBWrit50kOiED+pvQe2r6MWY0iJMQU/mSVKxvNzL4ZaYvjdX+G7ZA==", + "engines": { + "node": ">=14" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -28839,11 +28862,18 @@ "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==" }, "react-router": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", - "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.1.tgz", + "integrity": "sha512-OZINSdjJ2WgvAi7hgNLazrEV8SGn6xrKA+MkJe9wVDMZ3zQ6fdJocUjpCUCI0cNrelWjcvon0S/QK/j0NzL3KA==", "requires": { - "@remix-run/router": "1.3.2" + "@remix-run/router": "1.6.1" + }, + "dependencies": { + "@remix-run/router": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.1.tgz", + "integrity": "sha512-YUkWj+xs0oOzBe74OgErsuR3wVn+efrFhXBWrit50kOiED+pvQe2r6MWY0iJMQU/mSVKxvNzL4ZaYvjdX+G7ZA==" + } } }, "react-router-dom": { @@ -28853,6 +28883,16 @@ "requires": { "@remix-run/router": "1.3.2", "react-router": "6.8.1" + }, + "dependencies": { + "react-router": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", + "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", + "requires": { + "@remix-run/router": "1.3.2" + } + } } }, "react-scripts": { diff --git a/front/package.json b/front/package.json index e662b43..03344ec 100644 --- a/front/package.json +++ b/front/package.json @@ -9,6 +9,7 @@ "react": "^18.2.0", "react-bootstrap": "^2.7.2", "react-dom": "^18.2.0", + "react-router": "^6.10.0", "react-router-dom": "^6.6.1", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" diff --git a/front/src/App.js b/front/src/App.js index 28a7eab..9a54f90 100644 --- a/front/src/App.js +++ b/front/src/App.js @@ -7,6 +7,9 @@ import DrivingSchools from './components/DrivingSchools.jsx'; import Categories from './components/Categories.jsx'; import OneDrivingSchool from './components/OneDrivingSchool.jsx'; import CountStudInCategory from './components/CountStudInCategory.jsx'; +import Login from './components/Login.jsx'; +import Logout from './components/Logout.jsx'; +import { useState, useEffect } from 'react'; function Router(props) { return useRoutes(props.rootRoute); @@ -21,15 +24,30 @@ function Router(props) { { path: '/categories', element: <Categories />, label: 'Категории' }, { path: '/studcategory', element: <CountStudInCategory />, label: 'Количество студентов в категории' }, { path: '/drivingSchool/:id', element: <OneDrivingSchool />}, + { path: '/login', element: <Login />}, + { path: '/logout', element: <Logout />}, ]; const links = routes.filter(route => route.hasOwnProperty('label')); + const [token, setToken] = useState(localStorage.getItem('token')); + useEffect(() => { + + function handleStorageChange() { + setToken(localStorage.getItem('token')); + } + + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); const rootRoute = [ { path: '/', element: render(links), children: routes } ]; function render(links) { return ( <div className="App"> - <Header links={links} /> + <Header token={token} links={links} /> <div className="w-100"> <Outlet /> </div> diff --git a/front/src/components/Catalog.jsx b/front/src/components/Catalog.jsx index 6c1aec3..52e973e 100644 --- a/front/src/components/Catalog.jsx +++ b/front/src/components/Catalog.jsx @@ -36,7 +36,7 @@ export default function Catalog(props) { return <> <div>{props.name}</div> - <Button variant="success" onClick={handleAdd}>Добавить</Button> + {localStorage.getItem("role") === "ADMIN" && <Button variant="success" onClick={handleAdd}>Добавить</Button>} <Table headers={props.headers} items={props.items} diff --git a/front/src/components/Categories.jsx b/front/src/components/Categories.jsx index 57e701e..fca1ba1 100644 --- a/front/src/components/Categories.jsx +++ b/front/src/components/Categories.jsx @@ -4,9 +4,9 @@ import Button from 'react-bootstrap/Button'; import { useState, useEffect } from 'react'; import DataService from '../services/DataService'; import CatalogSC from "./CatalogSC.jsx"; +import withAuth from './withAuth'; - -export default function Categories(props) { +function Categories(props) { const headers = [ {name: 'name', label: "Название"}, ]; @@ -102,7 +102,9 @@ export default function Categories(props) { onDelete={handleDelete} onClose={reset} onBtnAdd={reset} - form={form}> + form={form} + role={props.role}> </CatalogSC> </div> -} \ No newline at end of file +} +export default withAuth(Categories); \ No newline at end of file diff --git a/front/src/components/DrivingSchools.jsx b/front/src/components/DrivingSchools.jsx index 7f662fc..0ff9d54 100644 --- a/front/src/components/DrivingSchools.jsx +++ b/front/src/components/DrivingSchools.jsx @@ -6,9 +6,9 @@ import Catalog from "./Catalog.jsx"; import DrivingSchool from "../models/DrivingSchool"; import ModalForm from './commons/ModalForm'; import { useNavigate } from "react-router-dom"; +import withAuth from './withAuth'; - -export default function DrivingSchools(props) { +function DrivingSchools(props) { const headers = [ {name: 'name', label: "Название"}, {name: 'countStudents', label: "Студенты"}, @@ -151,4 +151,5 @@ export default function DrivingSchools(props) { </div> -} \ No newline at end of file +} +export default withAuth(DrivingSchools); \ No newline at end of file diff --git a/front/src/components/Login.css b/front/src/components/Login.css new file mode 100644 index 0000000..7b886ac --- /dev/null +++ b/front/src/components/Login.css @@ -0,0 +1,21 @@ +body { + background-color: #f8f9fa; +} + +.card { + margin-bottom: 20px; +} + +.card-title { + margin-bottom: 10px; +} + +.btn-primary { + background-color: #007bff; + border-color: #007bff; +} + +.btn-primary:hover { + background-color: #0069d9; + border-color: #0062cc; +} \ No newline at end of file diff --git a/front/src/components/Login.jsx b/front/src/components/Login.jsx new file mode 100644 index 0000000..38d55cf --- /dev/null +++ b/front/src/components/Login.jsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import './Login.css'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import { useNavigate} from "react-router-dom"; + +export default function Login(props) { + const navigate = useNavigate(); + const [loginData, setLoginData] = useState({ login: '', password: '' }); + const hostURL = 'http://localhost:8080'; + const handleLoginSubmit = (e) => { + e.preventDefault(); + + login(loginData.login, loginData.password); + }; + + const login = async function (login, password) { + const requestParams = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({login: login, password: password}), + }; + const response = await fetch(hostURL + "/jwt/login", requestParams); + const result = await response.text(); + if (response.status === 200) { + localStorage.setItem("token", result); + localStorage.setItem("user", login); + let jwtData = result.split('.')[1] + let decodedJwtJsonData = window.atob(jwtData); + let decodedJwtData = JSON.parse(decodedJwtJsonData); + + let role = decodedJwtData.role; + localStorage.setItem("role", role.toUpperCase()); + window.dispatchEvent(new Event("storage")); + navigate("/"); + } else { + localStorage.removeItem("token"); + localStorage.removeItem("user"); + localStorage.removeItem("role"); + alert(result); + } + } + + + return ( + <div className="container-fluid"> + <div className="row justify-content-center align-items-center vh-100"> + <div className="col-sm-6 col-md-4"> + <div className="card"> + <div className="card-body"> + <h5 className="card-title">Авторизация</h5> + <form onSubmit={handleLoginSubmit}> + <div className="form-group mb-3"> + <label htmlFor="login">Логин</label> + <input type="text" className="form-control" id="login" value={loginData.login} onChange={(e) => setLoginData({ ...loginData, login: e.target.value })} /> + </div> + <div className="form-group mb-3"> + <label htmlFor="loginPassword">Пароль</label> + <input type="password" className="form-control" id="loginPassword" value={loginData.password} onChange={(e) => setLoginData({ ...loginData, password: e.target.value })} /> + </div> + <button type="submit" className="btn btn-primary">Вход</button> + </form> + </div> + </div> + </div> + </div> + </div> + ); +} \ No newline at end of file diff --git a/front/src/components/Logout.jsx b/front/src/components/Logout.jsx new file mode 100644 index 0000000..2c8c451 --- /dev/null +++ b/front/src/components/Logout.jsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +function Logout() { + const navigate = useNavigate(); + + useEffect(() => { + // Удаление токена из localStorage или другого места + localStorage.removeItem('token'); + localStorage.removeItem("user"); + localStorage.removeItem("role"); + window.dispatchEvent(new Event("storage")); + // Перенаправление пользователя на страницу входа или другую страницу + navigate('/login'); + }, [navigate]); + + return null; +} + +export default Logout; \ No newline at end of file diff --git a/front/src/components/OneDrivingSchool.jsx b/front/src/components/OneDrivingSchool.jsx index 72f2826..3a2f44f 100644 --- a/front/src/components/OneDrivingSchool.jsx +++ b/front/src/components/OneDrivingSchool.jsx @@ -8,8 +8,9 @@ import Form from 'react-bootstrap/Form'; import Button from 'react-bootstrap/Button'; import ModalForm from './commons/ModalForm'; import styles from "./OneDrivingSchool.module.css"; +import withAuth from './withAuth'; -export default function OneDrivingSchool(props) { +function OneDrivingSchool(props) { const { id } = useParams(); const url = '/drivingSchool/id'; @@ -225,9 +226,9 @@ export default function OneDrivingSchool(props) { </Link> <h1>Название: {drivingSchool.name}</h1> <h2>Количество студентов: {drivingSchool.countStudents}</h2> - <Button name="Зачисление" onClick={showModalFormHire} variant="btn btn-outline-success">Зачислить студента</Button> - <Button name='Отчисление' onClick={showModalFormDismiss} variant="btn btn-outline-danger">Отчислить студента</Button> - <Button name='Выбор категории' onClick={showModalFormChooseCategory} variant="btn btn-outline-primary">Выбор категории</Button> + {localStorage.getItem("role") === "ADMIN" && <Button name="Зачисление" onClick={showModalFormHire} variant="btn btn-outline-success">Зачислить студента</Button>} + {localStorage.getItem("role") === "ADMIN" && <Button name='Отчисление' onClick={showModalFormDismiss} variant="btn btn-outline-danger">Отчислить студента</Button>} + {localStorage.getItem("role") === "ADMIN" && <Button name='Выбор категории' onClick={showModalFormChooseCategory} variant="btn btn-outline-primary">Выбор категории</Button>} <div > <table className={`table table-hover`}> <thead> @@ -253,4 +254,5 @@ export default function OneDrivingSchool(props) { <ModalForm show={isShowChooseCategory} onClose={unshowModalFormChooseCategory} modalTitle={"Управление категориями"} form={formCheckBoxesCategory}></ModalForm> </div> -} \ No newline at end of file +} +export default withAuth(OneDrivingSchool); \ No newline at end of file diff --git a/front/src/components/ReportStudentCategory.jsx b/front/src/components/ReportStudentCategory.jsx index d496dc6..c46ab31 100644 --- a/front/src/components/ReportStudentCategory.jsx +++ b/front/src/components/ReportStudentCategory.jsx @@ -6,7 +6,8 @@ import Form from 'react-bootstrap/Form'; import Button from 'react-bootstrap/Button'; import DrivingSchool from '../models/DrivingSchool'; import { Link} from "react-router-dom"; -export default function ReportStudentCategory(props) { +import withAuth from './withAuth'; +function ReportStudentCategory(props) { const headersEmp = [ {name: 'surname', label: "Фамилия"}, {name: 'name', label: "Имя"}, @@ -69,4 +70,6 @@ export default function ReportStudentCategory(props) { </table> </div> </div> -} \ No newline at end of file +} + +export default withAuth(ReportStudentCategory); \ No newline at end of file diff --git a/front/src/components/Students.jsx b/front/src/components/Students.jsx index b2c36f3..d9c96ed 100644 --- a/front/src/components/Students.jsx +++ b/front/src/components/Students.jsx @@ -4,7 +4,8 @@ import Button from 'react-bootstrap/Button'; import { useState, useEffect } from 'react'; import DataService from '../services/DataService'; import CatalogSC from "./CatalogSC.jsx"; -export default function Students(props) { +import withAuth from './withAuth'; +function Students(props) { const headers = [ {name: 'surname', label: "Фамилия"}, {name: 'name', label: "Имя"}, @@ -112,4 +113,5 @@ export default function Students(props) { form={form}> </CatalogSC> </div> -} \ No newline at end of file +} +export default withAuth(Students); \ No newline at end of file diff --git a/front/src/components/commons/Header.jsx b/front/src/components/commons/Header.jsx index 479c283..c92055d 100644 --- a/front/src/components/commons/Header.jsx +++ b/front/src/components/commons/Header.jsx @@ -2,6 +2,8 @@ import { NavLink } from 'react-router-dom'; import Container from 'react-bootstrap/Container'; import Nav from 'react-bootstrap/Nav'; import Navbar from 'react-bootstrap/Navbar'; +import styles from "./Header.module.css"; +import { useState, useEffect } from 'react'; export default function Header(props) { return ( @@ -17,6 +19,15 @@ export default function Header(props) { </NavLink> ) } + {props.token && props.token !== 'undefined' ? + <NavLink className="nav-link" to="/logout"> + <div>Выйти</div> + </NavLink> + : + <NavLink className="nav-link" to="/login"> + <div>Войти</div> + </NavLink> + } </Nav> </Navbar.Collapse> </Container> diff --git a/front/src/components/commons/ItemTable.jsx b/front/src/components/commons/ItemTable.jsx index 307c6f4..6b30a63 100644 --- a/front/src/components/commons/ItemTable.jsx +++ b/front/src/components/commons/ItemTable.jsx @@ -14,9 +14,10 @@ export default function ItemTable(props) { { props.headers.map((header) => <td key={`${header.name}_${props.item.id}`}>{props.item[header.name]}</td>) } - {props.isOnlyView || <td key={`controls_${props.item.id}`}> - <Button variant="btn btn-outline-warning" onClick={chooseDrivingSchool}>Выбрать</Button> - <Button variant="btn btn-outline-primary" onClick={edit}>Редактировать</Button> - <Button variant="btn btn-outline-danger" onClick={remove}>Удалить</Button></td>} + {localStorage.getItem("role") !== "ADMIN" ||props.isOnlyView || <td key={`controls_${props.item.id}`}> + {localStorage.getItem("role") === "ADMIN" && <Button variant="btn btn-outline-warning" onClick={chooseDrivingSchool}>Выбрать</Button>} + {localStorage.getItem("role") === "ADMIN" && <Button variant="btn btn-outline-primary" onClick={edit}>Редактировать</Button>} + {localStorage.getItem("role") === "ADMIN" && <Button variant="btn btn-outline-danger" onClick={remove}>Удалить</Button>} + </td>} </tr> } \ No newline at end of file diff --git a/front/src/components/commons/ItemTableSC.jsx b/front/src/components/commons/ItemTableSC.jsx index 4fec8bb..ae9627b 100644 --- a/front/src/components/commons/ItemTableSC.jsx +++ b/front/src/components/commons/ItemTableSC.jsx @@ -11,8 +11,9 @@ export default function ItemTableSC(props) { { props.headers.map((header) => <td key={`${header.name}_${props.item.id}`}>{props.item[header.name]}</td>) } - {props.isOnlyView || <td key={`controls_${props.item.id}`}> - <Button variant="btn btn-outline-primary" onClick={edit}>Редактировать</Button> - <Button variant="btn btn-outline-danger" onClick={remove}>Удалить</Button></td>} + {localStorage.getItem("role") !== "ADMIN" ||props.isOnlyView || <td key={`controls_${props.item.id}`}> + {localStorage.getItem("role") === "ADMIN" && <Button variant="btn btn-outline-primary" onClick={edit}>Редактировать</Button>} + {localStorage.getItem("role") === "ADMIN" && <Button variant="btn btn-outline-danger" onClick={remove}>Удалить</Button>} + </td>} </tr> } \ No newline at end of file diff --git a/front/src/components/commons/Table.jsx b/front/src/components/commons/Table.jsx index 0b990d7..15fec78 100644 --- a/front/src/components/commons/Table.jsx +++ b/front/src/components/commons/Table.jsx @@ -17,7 +17,7 @@ export default function Table(props) { { props.headers.map((header) => <th key={header.name}>{header.label}</th>) } - {props.isOnlyView || <th key='controls'>Элементы управления</th>} + {localStorage.getItem("role") !== "ADMIN" || props.isOnlyView || <th key='controls'>Элементы управления</th>} </tr> </thead> diff --git a/front/src/components/commons/TableSC.jsx b/front/src/components/commons/TableSC.jsx index 20c8127..50f9618 100644 --- a/front/src/components/commons/TableSC.jsx +++ b/front/src/components/commons/TableSC.jsx @@ -14,7 +14,7 @@ export default function TableSC(props) { { props.headers.map((header) => <th key={header.name}>{header.label}</th>) } - {props.isOnlyView || <th key='controls'>Элементы управления</th>} + {localStorage.getItem("role") !== "ADMIN" || props.isOnlyView || <th key='controls'>Элементы управления</th>} </tr> </thead> diff --git a/front/src/components/withAuth.js b/front/src/components/withAuth.js new file mode 100644 index 0000000..adf1f7b --- /dev/null +++ b/front/src/components/withAuth.js @@ -0,0 +1,24 @@ +import React from 'react' +import { useNavigate} from "react-router-dom"; +import { useEffect } from 'react'; + +const withAuth = (Component) => { + const AuthenticatedComponent = (props) => { + const navigate = useNavigate(); + const token = localStorage.getItem('token'); + + useEffect(() => { + if (!token || token === 'undefined') { + navigate('/login'); + } + }, [navigate, token]); + + if (token && token !== 'undefined') { + return <Component {...props} /> + } + return null; + } + return AuthenticatedComponent; +} + +export default withAuth; \ No newline at end of file diff --git a/front/src/services/DataService.js b/front/src/services/DataService.js index 84c3858..3918153 100644 --- a/front/src/services/DataService.js +++ b/front/src/services/DataService.js @@ -11,32 +11,45 @@ function toJSON(data) { } return jsonObj; } +const getTokenForHeader = function () { + return "Bearer " + localStorage.getItem("token"); +} export default class DataService { static dataUrlPrefix = 'http://localhost:8080/api'; static async readAll(url, transformer) { - const response = await axios.get(this.dataUrlPrefix + url); + const response = await axios.get(this.dataUrlPrefix + url, {headers: { + "Authorization": getTokenForHeader(), + }}); return response.data.map(item => transformer(item)); } static async read(url, transformer) { - const response = await axios.get(this.dataUrlPrefix + url); + const response = await axios.get(this.dataUrlPrefix + url, {headers: { + "Authorization": getTokenForHeader(), + }}); return transformer(response.data); } static async create(url, data) { - const response = await axios.post(this.dataUrlPrefix + url); + const response = await axios.post(this.dataUrlPrefix + url, {headers: { + "Authorization": getTokenForHeader(), + }}); return true; } - static async update(url, data) { - const response = await axios.put(this.dataUrlPrefix + url); + static async update(url, data) { + const response = await axios.put(this.dataUrlPrefix + url, {headers: { + "Authorization": getTokenForHeader(), + }}); return true; } static async delete(url) { - const response = await axios.delete(this.dataUrlPrefix + url); + const response = await axios.delete(this.dataUrlPrefix + url, {headers: { + "Authorization": getTokenForHeader(), + }}); return response.data.id; } } \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/WebConfiguration.java b/src/main/java/ru/ulstu/is/cbapp/WebConfiguration.java index f5229a0..09b8171 100644 --- a/src/main/java/ru/ulstu/is/cbapp/WebConfiguration.java +++ b/src/main/java/ru/ulstu/is/cbapp/WebConfiguration.java @@ -8,6 +8,12 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; public class WebConfiguration implements WebMvcConfigurer { public static final String REST_API = "/api"; + @Override + public void addViewControllers(ViewControllerRegistry registry) { + WebMvcConfigurer.super.addViewControllers(registry); + registry.addViewController("login"); + } + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**").allowedMethods("*"); diff --git a/src/main/java/ru/ulstu/is/cbapp/configuration/PasswordEncoderConfiguration.java b/src/main/java/ru/ulstu/is/cbapp/configuration/PasswordEncoderConfiguration.java new file mode 100644 index 0000000..d19434b --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/configuration/PasswordEncoderConfiguration.java @@ -0,0 +1,14 @@ +package ru.ulstu.is.cbapp.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfiguration { + @Bean + public PasswordEncoder createPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/configuration/SecurityConfiguration.java b/src/main/java/ru/ulstu/is/cbapp/configuration/SecurityConfiguration.java new file mode 100644 index 0000000..724f2de --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/configuration/SecurityConfiguration.java @@ -0,0 +1,87 @@ +package ru.ulstu.is.cbapp.configuration; + +import ru.ulstu.is.cbapp.configuration.jwt.JwtFilter; +import ru.ulstu.is.cbapp.user.controller.UserController; +import ru.ulstu.is.cbapp.user.controller.UserSignupMvcController; +import ru.ulstu.is.cbapp.user.model.UserRole; +import ru.ulstu.is.cbapp.user.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true) +public class SecurityConfiguration { + private final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class); + private static final String LOGIN_URL = "/login"; + public static final String SPA_URL_MASK = "/{path:[^\\.]*}"; + private UserService userService; + private JwtFilter jwtFilter; + + public SecurityConfiguration(UserService userService) { + this.userService = userService; + this.jwtFilter = new JwtFilter(userService); + createAdminOnStartup(); + createTestUsersOnStartup(); + } + + private void createAdminOnStartup() { + final String admin = "admin"; + if (userService.findByLogin(admin) == null) { + log.info("Admin user successfully created"); + userService.createUser(admin, admin, admin, UserRole.ADMIN); + } + } + + private void createTestUsersOnStartup() { + final String[] userNames = {"user1", "user2", "user3"}; + for (String user : userNames) { + if (userService.findByLogin(user) == null) { + log.info("User %s successfully created".formatted(user)); + userService.createUser(user, user, user, UserRole.USER); + } + } + } + + @Bean + public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { + http.cors() + .and() + .csrf().disable() + .authorizeHttpRequests((a) -> a + .requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.PUT, "/api/**").hasRole("ADMIN") + .requestMatchers(HttpMethod.POST, "/api/**").hasRole("ADMIN") + .requestMatchers("/api/**").authenticated() + .requestMatchers(HttpMethod.POST, UserController.URL_LOGIN).permitAll()) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .anonymous().and().authorizeHttpRequests((a) -> + a.requestMatchers(LOGIN_URL, UserSignupMvcController.SIGNUP_URL, "/h2-console/**") + .permitAll().requestMatchers("/users").hasRole("ADMIN").anyRequest().authenticated()) + .formLogin() + .loginPage(LOGIN_URL).permitAll() + .and() + .logout().permitAll() + .logoutSuccessUrl("/login") + .and() + .userDetailsService(userService); + + return http.build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers("/css/**", "/js/**", "/templates/**", "/webjars/**", "/styles/**"); } +} diff --git a/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtException.java b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtException.java new file mode 100644 index 0000000..10e4434 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtException.java @@ -0,0 +1,11 @@ +package ru.ulstu.is.cbapp.configuration.jwt; + +public class JwtException extends RuntimeException { + public JwtException(Throwable throwable) { + super(throwable); + } + + public JwtException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtFilter.java b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtFilter.java new file mode 100644 index 0000000..8c598af --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtFilter.java @@ -0,0 +1,93 @@ +package ru.ulstu.is.cbapp.configuration.jwt; + +import ru.ulstu.is.cbapp.user.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +public class JwtFilter extends AbstractPreAuthenticatedProcessingFilter { + private static final String AUTHORIZATION = "Authorization"; + public static final String TOKEN_BEGIN_STR = "Bearer "; + + private final UserService userService; + + public JwtFilter(UserService userService) { + this.userService = userService; + } + + private String getTokenFromRequest(HttpServletRequest request) { + String bearer = request.getHeader(AUTHORIZATION); + if (StringUtils.hasText(bearer) && bearer.startsWith(TOKEN_BEGIN_STR)) { + return bearer.substring(TOKEN_BEGIN_STR.length()); + } + return null; + } + + private void raiseException(ServletResponse response, int status, String message) throws IOException { + if (response instanceof final HttpServletResponse httpResponse) { + httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + httpResponse.setStatus(status); + final byte[] body = new ObjectMapper().writeValueAsBytes(message); + response.getOutputStream().write(body); + } + } + + @Override + public void doFilter(ServletRequest request, + ServletResponse response, + FilterChain chain) throws IOException, ServletException { + if (request instanceof final HttpServletRequest httpRequest) { + final String token = getTokenFromRequest(httpRequest); + if (StringUtils.hasText(token)) { + try { + final UserDetails user = userService.loadUserByToken(token); + final UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (JwtException e) { + raiseException(response, HttpServletResponse.SC_UNAUTHORIZED, e.getMessage()); + return; + } catch (Exception e) { + e.printStackTrace(); + raiseException(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + String.format("Internal error: %s", e.getMessage())); + return; + } + } + } + HttpServletRequest httpRequest = (HttpServletRequest) request; + if (httpRequest.getRequestURI().startsWith("/api/")) { + // Для URL, начинающихся с /api/, выполняем проверку наличия токена + super.doFilter(request, response, chain); + } else { + // Для остальных URL выполняем авторизацию + chain.doFilter(request, response); + } + } + + @Override + protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) { + String token = getTokenFromRequest(request); + // Возвращаем токен как принципала + return token; + } + + @Override + protected Object getPreAuthenticatedCredentials(HttpServletRequest request) { + return new WebAuthenticationDetailsSource().buildDetails(request); + } +} diff --git a/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtProperties.java b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtProperties.java new file mode 100644 index 0000000..901fb6d --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtProperties.java @@ -0,0 +1,27 @@ +package ru.ulstu.is.cbapp.configuration.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "jwt", ignoreInvalidFields = true) +public class JwtProperties { + private String devToken = ""; + private Boolean isDev = true; + + public String getDevToken() { + return devToken; + } + + public void setDevToken(String devToken) { + this.devToken = devToken; + } + + public Boolean isDev() { + return isDev; + } + + public void setDev(Boolean dev) { + isDev = dev; + } +} diff --git a/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtsProvider.java b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtsProvider.java new file mode 100644 index 0000000..56037cf --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/configuration/jwt/JwtsProvider.java @@ -0,0 +1,51 @@ +package ru.ulstu.is.cbapp.configuration.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; + +@Component +public class JwtsProvider { + + @Value("jwt.secret") + private String secret; + + public String generateToken(String login, String role) { + Date date = Date.from(LocalDate.now().plusDays(15).atStartOfDay(ZoneId.systemDefault()).toInstant()); + + JwtBuilder builder = Jwts.builder() + .setSubject(login) + .setExpiration(date) + .signWith(SignatureAlgorithm.HS512, secret); + Claims claims = Jwts.claims(); + claims.put("role", role); + builder.addClaims(claims); + return builder.compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser().setSigningKey(secret).parseClaimsJws(token); + return true; + } catch (Exception e) { + return false; + } + } + + public String getLogin(String token) { + return Jwts.parser() + .setSigningKey(secret) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } +} diff --git a/src/main/java/ru/ulstu/is/cbapp/user/controller/UserController.java b/src/main/java/ru/ulstu/is/cbapp/user/controller/UserController.java new file mode 100644 index 0000000..8f7fac7 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/controller/UserController.java @@ -0,0 +1,26 @@ +package ru.ulstu.is.cbapp.user.controller; + +import ru.ulstu.is.cbapp.WebConfiguration; +import ru.ulstu.is.cbapp.user.model.UserDto; +import ru.ulstu.is.cbapp.user.service.UserService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + + +@RestController() +public class UserController { + public static final String URL_LOGIN = "/jwt/login"; + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @PostMapping(URL_LOGIN) + public String login(@RequestBody @Valid UserDto userDto) { + return userService.loginAndGetToken(userDto); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/controller/UserMvcController.java b/src/main/java/ru/ulstu/is/cbapp/user/controller/UserMvcController.java new file mode 100644 index 0000000..fcca42e --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/controller/UserMvcController.java @@ -0,0 +1,44 @@ +package ru.ulstu.is.cbapp.user.controller; + +import ru.ulstu.is.cbapp.user.model.User; +import ru.ulstu.is.cbapp.user.model.UserDto; +import ru.ulstu.is.cbapp.user.model.UserRole; +import ru.ulstu.is.cbapp.user.service.UserService; +import org.springframework.data.domain.Page; +import org.springframework.security.access.annotation.Secured; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + + +import java.util.List; +import java.util.stream.IntStream; + +@Controller +@RequestMapping("/users") +public class UserMvcController { + private final UserService userService; + + public UserMvcController(UserService userService) { + this.userService = userService; + } + + @GetMapping + @Secured({UserRole.AsString.ADMIN}) + public String getUsers(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "5") int size, + Model model) { + final Page<UserDto> users = userService.findAllPages(page, size) + .map(UserDto::new); + model.addAttribute("users", users); + final int totalPages = users.getTotalPages(); + final List<Integer> pageNumbers = IntStream.rangeClosed(1, totalPages) + .boxed() + .toList(); + model.addAttribute("pages", pageNumbers); + model.addAttribute("totalPages", totalPages); + return "users"; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/controller/UserSignupMvcController.java b/src/main/java/ru/ulstu/is/cbapp/user/controller/UserSignupMvcController.java new file mode 100644 index 0000000..ad8db68 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/controller/UserSignupMvcController.java @@ -0,0 +1,50 @@ +package ru.ulstu.is.cbapp.user.controller; + +import ru.ulstu.is.cbapp.user.model.User; +import ru.ulstu.is.cbapp.user.model.UserSignupDto; +import ru.ulstu.is.cbapp.user.service.UserService; +import ru.ulstu.is.cbapp.util.validation.ValidationException; +import jakarta.validation.Valid; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping(UserSignupMvcController.SIGNUP_URL) +public class UserSignupMvcController { + public static final String SIGNUP_URL = "/signup"; + + private final UserService userService; + + public UserSignupMvcController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public String showSignupForm(Model model) { + model.addAttribute("userDto", new UserSignupDto()); + return "signup"; + } + + @PostMapping + public String signup(@ModelAttribute("userDto") @Valid UserSignupDto userSignupDto, + BindingResult bindingResult, + Model model) { + if (bindingResult.hasErrors()) { + model.addAttribute("errors", bindingResult.getAllErrors()); + return "signup"; + } + try { + final User user = userService.createUser( + userSignupDto.getLogin(), userSignupDto.getPassword(), userSignupDto.getPasswordConfirm()); + return "redirect:/login?created=" + user.getLogin(); + } catch (ValidationException e) { + model.addAttribute("errors", e.getMessage()); + return "signup"; + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/model/User.java b/src/main/java/ru/ulstu/is/cbapp/user/model/User.java new file mode 100644 index 0000000..9ca440c --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/model/User.java @@ -0,0 +1,75 @@ +package ru.ulstu.is.cbapp.user.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + + +import java.util.Objects; + +@Entity +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @Column(nullable = false, unique = true, length = 64) + @NotBlank + @Size(min = 3, max = 64) + private String login; + @Column(nullable = false, length = 64) + @NotBlank + @Size(min = 6, max = 64) + private String password; + private UserRole role; + + public User() { + } + + public User(String login, String password) { + this(login, password, UserRole.USER); + } + + public User(String login, String password, UserRole role) { + this.login = login; + this.password = password; + this.role = role; + } + + public Long getId() { + return id; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public UserRole getRole() { + return role; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(id, user.id) && Objects.equals(login, user.login); + } + + @Override + public int hashCode() { + return Objects.hash(id, login); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/model/UserDto.java b/src/main/java/ru/ulstu/is/cbapp/user/model/UserDto.java new file mode 100644 index 0000000..fbfb259 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/model/UserDto.java @@ -0,0 +1,46 @@ +package ru.ulstu.is.cbapp.user.model; + +public class UserDto { + private long id; + private String login; + private UserRole role; + private String password; + + public UserDto(User user) { + this.id = user.getId(); + this.login = user.getLogin(); + this.role = user.getRole(); + this.password = user.getPassword(); + } + + public long getId() { + return id; + } + + public String getLogin() { + return login; + } + + public UserRole getRole() { + return role; + } + + public String getPassword() { + return password; + } + + public UserDto() { + } + + public void setLogin(String login) { + this.login = login; + } + + public void setRole(UserRole role) { + this.role = role; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/model/UserRole.java b/src/main/java/ru/ulstu/is/cbapp/user/model/UserRole.java new file mode 100644 index 0000000..bc20dc7 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/model/UserRole.java @@ -0,0 +1,20 @@ +package ru.ulstu.is.cbapp.user.model; + +import org.springframework.security.core.GrantedAuthority; + +public enum UserRole implements GrantedAuthority { + ADMIN, + USER; + + private static final String PREFIX = "ROLE_"; + + @Override + public String getAuthority() { + return PREFIX + this.name(); + } + + public static final class AsString { + public static final String ADMIN = PREFIX + "ADMIN"; + public static final String USER = PREFIX + "USER"; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/model/UserSignupDto.java b/src/main/java/ru/ulstu/is/cbapp/user/model/UserSignupDto.java new file mode 100644 index 0000000..14080f9 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/model/UserSignupDto.java @@ -0,0 +1,41 @@ +package ru.ulstu.is.cbapp.user.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + + +public class UserSignupDto { + @NotBlank + @Size(min = 3, max = 64) + private String login; + @NotBlank + @Size(min = 6, max = 64) + private String password; + @NotBlank + @Size(min = 6, max = 64) + private String passwordConfirm; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPasswordConfirm() { + return passwordConfirm; + } + + public void setPasswordConfirm(String passwordConfirm) { + this.passwordConfirm = passwordConfirm; + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/repository/UserRepository.java b/src/main/java/ru/ulstu/is/cbapp/user/repository/UserRepository.java new file mode 100644 index 0000000..cfe26fc --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/repository/UserRepository.java @@ -0,0 +1,8 @@ +package ru.ulstu.is.cbapp.user.repository; + +import ru.ulstu.is.cbapp.user.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository<User, Long> { + User findOneByLoginIgnoreCase(String login); +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/service/UserNotFoundException.java b/src/main/java/ru/ulstu/is/cbapp/user/service/UserNotFoundException.java new file mode 100644 index 0000000..7ceb619 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/service/UserNotFoundException.java @@ -0,0 +1,7 @@ +package ru.ulstu.is.cbapp.user.service; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String login) { + super(String.format("User not found '%s'", login)); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/user/service/UserService.java b/src/main/java/ru/ulstu/is/cbapp/user/service/UserService.java new file mode 100644 index 0000000..88efa9f --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/user/service/UserService.java @@ -0,0 +1,96 @@ +package ru.ulstu.is.cbapp.user.service; + +import ru.ulstu.is.cbapp.configuration.jwt.JwtException; +import ru.ulstu.is.cbapp.configuration.jwt.JwtsProvider; +import ru.ulstu.is.cbapp.user.model.User; +import ru.ulstu.is.cbapp.user.model.UserDto; +import ru.ulstu.is.cbapp.user.model.UserRole; +import ru.ulstu.is.cbapp.user.repository.UserRepository; +import ru.ulstu.is.cbapp.util.validation.ValidationException; +import ru.ulstu.is.cbapp.util.validation.ValidatorUtil; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + + +import java.util.Collections; +import java.util.Objects; + +@Service +public class UserService implements UserDetailsService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final ValidatorUtil validatorUtil; + private final JwtsProvider jwtProvider; + + public UserService(UserRepository userRepository, + PasswordEncoder passwordEncoder, + ValidatorUtil validatorUtil, + JwtsProvider jwtProvider) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.validatorUtil = validatorUtil; + this.jwtProvider = jwtProvider; + } + + public Page<User> findAllPages(int page, int size) { + return userRepository.findAll(PageRequest.of(page - 1, size, Sort.by("id").ascending())); + } + + public User findByLogin(String login) { + return userRepository.findOneByLoginIgnoreCase(login); + } + + public User createUser(String login, String password, String passwordConfirm) { + return createUser(login, password, passwordConfirm, UserRole.USER); + } + + public User createUser(String login, String password, String passwordConfirm, UserRole role) { + if (findByLogin(login) != null) { + throw new ValidationException(String.format("User '%s' already exists", login)); + } + final User user = new User(login, passwordEncoder.encode(password), role); + validatorUtil.validate(user); + if (!Objects.equals(password, passwordConfirm)) { + throw new ValidationException("Passwords not equals"); + } + return userRepository.save(user); + } + + public String loginAndGetToken(UserDto userDto) { + final User user = findByLogin(userDto.getLogin()); + if (user == null) { + throw new UserNotFoundException(userDto.getLogin()); + } + if (!passwordEncoder.matches(userDto.getPassword(), user.getPassword())) { + throw new UserNotFoundException(user.getLogin()); + } + return jwtProvider.generateToken(user.getLogin(), user.getRole().name()); + } + + public UserDetails loadUserByToken(String token) throws UsernameNotFoundException { + if (!jwtProvider.validateToken(token)) { + throw new JwtException("Bad token"); + } + final String userLogin = jwtProvider.getLogin(token); + if (userLogin.isEmpty()) { + throw new JwtException("Token is not contain Login"); + } + return loadUserByUsername(userLogin); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + final User userEntity = findByLogin(username); + if (userEntity == null) { + throw new UsernameNotFoundException(username); + } + return new org.springframework.security.core.userdetails.User( + userEntity.getLogin(), userEntity.getPassword(), Collections.singleton(userEntity.getRole())); + } +} diff --git a/src/main/java/ru/ulstu/is/cbapp/util/error/AdviceController.java b/src/main/java/ru/ulstu/is/cbapp/util/error/AdviceController.java new file mode 100644 index 0000000..edf5618 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/util/error/AdviceController.java @@ -0,0 +1,45 @@ +package ru.ulstu.is.cbapp.util.error; + +import ru.ulstu.is.cbapp.util.validation.ValidationException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import java.nio.file.AccessDeniedException; + +import java.util.stream.Collectors; + +@ControllerAdvice(annotations = RestController.class) +public class AdviceController { + @ExceptionHandler({ + ValidationException.class + }) + public ResponseEntity<Object> handleException(Throwable e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({ + AccessDeniedException.class + }) + public ResponseEntity<Object> handleAccessDeniedException(Throwable e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.FORBIDDEN); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity<Object> handleBindException(MethodArgumentNotValidException e) { + final ValidationException validationException = new ValidationException( + e.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.toSet())); + return handleException(validationException); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity<Object> handleUnknownException(Throwable e) { + e.printStackTrace(); + return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/ru/ulstu/is/cbapp/util/validation/ValidationException.java b/src/main/java/ru/ulstu/is/cbapp/util/validation/ValidationException.java new file mode 100644 index 0000000..a953b6e --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/util/validation/ValidationException.java @@ -0,0 +1,13 @@ +package ru.ulstu.is.cbapp.util.validation; + +import java.util.Set; + +public class ValidationException extends RuntimeException { + public <T> ValidationException(Set<String> errors) { + super(String.join("\n", errors)); + } + + public <T> ValidationException(String error) { + super(error); + } +} \ No newline at end of file diff --git a/src/main/java/ru/ulstu/is/cbapp/util/validation/ValidatorUtil.java b/src/main/java/ru/ulstu/is/cbapp/util/validation/ValidatorUtil.java new file mode 100644 index 0000000..9c3f627 --- /dev/null +++ b/src/main/java/ru/ulstu/is/cbapp/util/validation/ValidatorUtil.java @@ -0,0 +1,27 @@ +package ru.ulstu.is.cbapp.util.validation; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class ValidatorUtil { + private final Validator validator; + + public ValidatorUtil() { + this.validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + public <T> void validate(T object) { + final Set<ConstraintViolation<T>> errors = validator.validate(object); + if (!errors.isEmpty()) { + throw new ValidationException(errors.stream() + .map(ConstraintViolation::getMessage) + .collect(Collectors.toSet())); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index da7b0b1..d4ce457 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,3 +9,6 @@ spring.jpa.hibernate.ddl-auto=update spring.h2.console.enabled=true spring.h2.console.settings.trace=false spring.h2.console.settings.web-allow-others=false +jwt.dev-token=my-secret-jwt +jwt.dev=true +jwt.secret = my-secret-jwt diff --git a/src/main/resources/static/styles/login.css b/src/main/resources/static/styles/login.css new file mode 100644 index 0000000..7b886ac --- /dev/null +++ b/src/main/resources/static/styles/login.css @@ -0,0 +1,21 @@ +body { + background-color: #f8f9fa; +} + +.card { + margin-bottom: 20px; +} + +.card-title { + margin-bottom: 10px; +} + +.btn-primary { + background-color: #007bff; + border-color: #007bff; +} + +.btn-primary:hover { + background-color: #0069d9; + border-color: #0062cc; +} \ No newline at end of file diff --git a/src/main/resources/templates/category.html b/src/main/resources/templates/category.html index a7efd99..faee842 100644 --- a/src/main/resources/templates/category.html +++ b/src/main/resources/templates/category.html @@ -7,7 +7,7 @@ <body> <div layout:fragment="content"> <div>Категории</div> - <div> + <div sec:authorize="hasRole('ROLE_ADMIN')"> <a class="btn btn-success button-fixed" onsubmit="openModalEdit()" data-bs-target="#categoryEditModal" data-bs-toggle="modal"> <i class="fa-solid fa-plus"></i> Добавить @@ -20,7 +20,7 @@ <th scope="col">#</th> <th scope="col">ID</th> <th scope="col">Название</th> - <th scope="col">Элементы управления</th> + <th sec:authorize="hasRole('ROLE_ADMIN')" scope="col">Элементы управления</th> </tr> </thead> <tbody> @@ -28,7 +28,7 @@ <th scope="row" th:text="${iterator.index} + 1"/> <td th:text="${category.id}"/> <td th:text="${category.name}" style="width: 60%"/> - <td style="width: 10%"> + <td sec:authorize="hasRole('ROLE_ADMIN')" style="width: 10%"> <div class="btn-group" role="group" aria-label="Basic example"> <a class="btn btn-info button-fixed button-sm" th:data-category="${category.id}" data-bs-target="#categoryEditModal" data-bs-toggle="modal" diff --git a/src/main/resources/templates/default.html b/src/main/resources/templates/default.html index 7fa40a1..54c8a22 100644 --- a/src/main/resources/templates/default.html +++ b/src/main/resources/templates/default.html @@ -32,6 +32,8 @@ th:classappend="${#strings.equals(activeLink, '/category')} ? 'active' : ''">Категории</a> <a class="nav-link" href="/categoryStudent/groupbycategory" th:classappend="${#strings.equals(activeLink, '/categoryStudent/groupbycategory')} ? 'active' : ''">Количество студентов в категории</a> + <a sec:authorize="!isAuthenticated()" class="nav-link" href="/login">Войти</a> + <a sec:authorize="isAuthenticated()" class="nav-link" href="/logout">Выйти</a> </ul> </div> </div> diff --git a/src/main/resources/templates/drivingSchool-one.html b/src/main/resources/templates/drivingSchool-one.html index 43c81ea..d82c770 100644 --- a/src/main/resources/templates/drivingSchool-one.html +++ b/src/main/resources/templates/drivingSchool-one.html @@ -9,7 +9,7 @@ <div layout:fragment="content"> <h1>Название: <span th:text="${drivingSchool.name}"></span></h1> <h2>Количество студентов: <span th:text="${#arrays.length(drivingSchool.students.toArray())}"></span></h2> - <div> + <div sec:authorize="hasRole('ROLE_ADMIN')"> <a class="btn btn-success button-fixed" data-bs-target="#hireModal" data-bs-toggle="modal"> Зачислить студента @@ -24,7 +24,7 @@ <th scope="col">Имя</th> <th scope="col">Номер телефона</th> <th scope="col">Категории</th> - <th scope="col"></th> + <th sec:authorize="hasRole('ROLE_ADMIN')" scope="col"></th> </tr> </thead> <tbody> @@ -35,7 +35,7 @@ <td th:text="${student.name}" /> <td th:text="${student.phoneNumber}" /> <td th:text="${#strings.listJoin(student.categories.![name],',')}"></td> - <th style="width: 20%"> + <th sec:authorize="hasRole('ROLE_ADMIN')" style="width: 20%"> <a class="btn btn-danger button-fixed button-sm" th:data-student="${student.id}" data-bs-target="#dismissModal" data-bs-toggle="modal" th:onclick="openModalDismiss(this.getAttribute('data-student'))">Отчислить</a> diff --git a/src/main/resources/templates/drivingSchool.html b/src/main/resources/templates/drivingSchool.html index 2cfe943..11cf6e0 100644 --- a/src/main/resources/templates/drivingSchool.html +++ b/src/main/resources/templates/drivingSchool.html @@ -7,14 +7,11 @@ <body> <div layout:fragment="content"> <div>Автошколы</div> - <div> + <div sec:authorize="hasRole('ROLE_ADMIN')"> <a class="btn btn-success button-fixed" onsubmit="openModalEdit()" data-bs-target="#drivingSchoolEditModal" data-bs-toggle="modal"> <i class="fa-solid fa-plus"></i> Добавить </a> - <!-- <a class="btn btn-info button-fixed" data-bs-toggle="modal" data-bs-target="#drivingSchoolModal">--> - <!-- Перейти к компании--> - <!-- </a>--> </div> <div class="table-responsive"> <table class="table"> @@ -24,7 +21,7 @@ <th scope="col">ID</th> <th scope="col">Название</th> <th scope="col">Студенты</th> - <th scope="col">Элементы управления</th> + <th sec:authorize="hasRole('ROLE_ADMIN')" scope="col">Элементы управления</th> </tr> </thead> <tbody> @@ -33,7 +30,7 @@ <td th:text="${drivingSchool.id}"/> <td style="width: 60%"><a th:href="@{/drivingSchool/one/{id}(id=${drivingSchool.id})}" th:text="${drivingSchool.name}"></a></td> <td th:text="${#arrays.length(drivingSchool.students.toArray())}" style="width: 60%"/> - <td style="width: 10%"> + <td sec:authorize="hasRole('ROLE_ADMIN')" style="width: 10%"> <div class="btn-group" role="group" aria-label="Basic example"> <a class="btn btn-info button-fixed button-sm" th:data-drivingSchool="${drivingSchool.id}" data-bs-target="#drivingSchoolEditModal" data-bs-toggle="modal" diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index a6eac40..1b01785 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -6,8 +6,7 @@ </head> <body> <div layout:fragment="content"> - <div>Вроде работает</div> - <a href="123">Ошибка :(</a> + <div>Добро пожаловать! Выберите страницу.</div> </div> </body> </html> \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..6675e44 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html lang="en" + xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" + layout:decorate="~{default}"> +<head> + <link th:href="@{/styles/login.css}" rel="stylesheet" /> +</head> +<body> +<div class="container-fluid" layout:fragment="content"> + <div th:if="${param.error}" class="alert alert-danger margin-bottom"> + Пользователь не найден или пароль указан не верно + </div> + <div th:if="${param.logout}" class="alert alert-success margin-bottom"> + Выход успешно произведен + </div> + <div th:if="${param.created}" class="alert alert-success margin-bottom"> + Пользователь '<span th:text="${param.created}"></span>' успешно создан + </div> + <div class="row justify-content-center align-items-center vh-100"> + <div class="col-sm-6 col-md-4"> + <div class="card"> + <div class="card-body"> + <h5 class="card-title">Авторизация</h5> + <form th:action="@{/login}" method="post"> + <div class="mb-3"> + <label htmlFor="login">Логин</label> + <input type="text" name="username" id="username" class="form-control" + placeholder="Логин" required="true" autofocus="true"/> + </div> + <div class="mb-3"> + <label htmlFor="login">Пароль</label> + <input type="password" name="password" id="password" class="form-control" + placeholder="Пароль" required="true"/> + </div> + <button type="submit" class="btn btn-primary button-fixed">Войти</button> + </form> + </div> + </div> + </div> + </div> +</div> +</body> +</html> \ No newline at end of file diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html new file mode 100644 index 0000000..287c11f --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en" + xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" + layout:decorate="~{default}"> +<body> +<div class="container container-padding" layout:fragment="content"> + <div th:if="${errors}" th:text="${errors}" class="margin-bottom alert alert-danger"></div> + <form action="#" th:action="@{/signup}" th:object="${userDto}" method="post"> + <div class="mb-3"> + <input type="text" class="form-control" th:field="${userDto.login}" + placeholder="Логин" required="true" autofocus="true" maxlength="64"/> + </div> + <div class="mb-3"> + <input type="password" class="form-control" th:field="${userDto.password}" + placeholder="Пароль" required="true" minlength="6" maxlength="64"/> + </div> + <div class="mb-3"> + <input type="password" class="form-control" th:field="${userDto.passwordConfirm}" + placeholder="Пароль (подтверждение)" required="true" minlength="6" maxlength="64"/> + </div> + <div class="mb-3"> + <button type="submit" class="btn btn-success button-fixed">Создать</button> + <a class="btn btn-primary button-fixed" href="/login">Назад</a> + </div> + </form> +</div> +</body> +</html> \ No newline at end of file diff --git a/src/main/resources/templates/student.html b/src/main/resources/templates/student.html index 8aa1d77..f1a0ef2 100644 --- a/src/main/resources/templates/student.html +++ b/src/main/resources/templates/student.html @@ -7,7 +7,7 @@ <body> <div layout:fragment="content"> <div>Студенты</div> - <div> + <div sec:authorize="hasRole('ROLE_ADMIN')"> <a class="btn btn-success button-fixed" onsubmit="openModalEdit()" data-bs-target="#studentEditModal" data-bs-toggle="modal"> <i class="fa-solid fa-plus"></i> Добавить @@ -23,7 +23,7 @@ <th scope="col">Имя</th> <th scope="col">Номер телефона</th> <th scope="col">Категории</th> - <th scope="col">Элементы управления</th> + <th sec:authorize="hasRole('ROLE_ADMIN')" scope="col">Элементы управления</th> </tr> </thead> <tbody> @@ -34,7 +34,7 @@ <td th:text="${student.name}" /> <td th:text="${student.phoneNumber}" /> <td th:text="${#strings.listJoin(student.categories.![name],',')}"></td> - <td style="width: 10%"> + <td sec:authorize="hasRole('ROLE_ADMIN')" style="width: 10%"> <div class="btn-group" role="group" aria-label="Basic example"> <a class="btn btn-info button-fixed button-sm" th:data-student="${student.id}" data-bs-target="#studentEditModal" data-bs-toggle="modal" diff --git a/src/main/resources/templates/users.html b/src/main/resources/templates/users.html new file mode 100644 index 0000000..5e2c3d7 --- /dev/null +++ b/src/main/resources/templates/users.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html lang="en" + xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" + layout:decorate="~{default}"> +<body> +<div class="container" layout:fragment="content"> + <div class="table-responsive"> + <table class="table"> + <thead> + <tr> + <th scope="col">#</th> + <th scope="col">ID</th> + <th scope="col">Логин</th> + <th scope="col">Роль</th> + </tr> + </thead> + <tbody> + <tr th:each="user, iterator: ${users}"> + <th scope="row" th:text="${iterator.index} + 1"></th> + <td th:text="${user.id}"></td> + <td th:text="${user.login}" style="width: 60%"></td> + <td th:text="${user.role}" style="width: 20%"></td> + </tr> + </tbody> + </table> + </div> + <div th:if="${totalPages > 0}" class="pagination"> + <span style="float: left; padding: 5px 5px;">Страницы:</span> + <a th:each="page : ${pages}" + th:href="@{/users(page=${page}, size=${users.size})}" + th:text="${page}" + th:class="${page == users.number + 1} ? active"> + </a> + </div> +</div> +</body> +</html> \ No newline at end of file