From b4455b7024e4ebf21d96fc89ed169da3e4e45762 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 12 Sep 2025 10:00:32 +0500 Subject: [PATCH] inital commit --- .gitignore | 5 + docs/admin-users-api.md | 104 ++++++++ docs/admin-users.md | 43 +++ docs/auth-api.md | 248 ++++++++++++++++++ docs/identity.md | 19 ++ pom.xml | 91 ++++++- .../configuration/GlobalExceptionHandler.java | 62 +++++ .../konturai/configuration/OpenApiConfig.java | 40 +++ .../security/JwtAuthenticationFilter.java | 59 +++++ .../configuration/security/JwtService.java | 47 ++++ .../security/SecurityConfig.java | 84 ++++++ .../konturai/controller/AdminController.java | 38 +++ .../konturai/controller/AuthController.java | 62 +++++ .../controller/IdentityController.java | 69 +++++ .../controller/UserAdminController.java | 136 ++++++++++ .../java/kz/konturai/domain/RefreshToken.java | 62 +++++ src/main/java/kz/konturai/domain/Role.java | 14 + src/main/java/kz/konturai/domain/User.java | 77 ++++++ src/main/java/kz/konturai/dto/AuthDtos.java | 42 +++ .../java/kz/konturai/dto/PageResponse.java | 39 +++ .../java/kz/konturai/dto/SetRolesRequest.java | 10 + .../kz/konturai/dto/UpdateUserRequest.java | 12 + .../java/kz/konturai/dto/UserSummary.java | 10 + .../repository/RefreshTokenRepository.java | 11 + .../konturai/repository/UserRepository.java | 16 ++ .../konturai/service/impl/IAuthService.java | 5 + .../kz/konturai/service/spec/AuthService.java | 134 ++++++++++ src/main/resources/application.properties | 1 - src/main/resources/application.yaml | 18 ++ .../db/migration/V1__create_users.sql | 25 ++ .../migration/V2__create_refresh_tokens.sql | 12 + .../db/migration/V3__seed_root_admin.sql | 19 ++ 32 files changed, 1603 insertions(+), 11 deletions(-) create mode 100644 .gitignore create mode 100644 docs/admin-users-api.md create mode 100644 docs/admin-users.md create mode 100644 docs/auth-api.md create mode 100644 docs/identity.md create mode 100644 src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java create mode 100644 src/main/java/kz/konturai/configuration/OpenApiConfig.java create mode 100644 src/main/java/kz/konturai/configuration/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/kz/konturai/configuration/security/JwtService.java create mode 100644 src/main/java/kz/konturai/configuration/security/SecurityConfig.java create mode 100644 src/main/java/kz/konturai/controller/AdminController.java create mode 100644 src/main/java/kz/konturai/controller/AuthController.java create mode 100644 src/main/java/kz/konturai/controller/IdentityController.java create mode 100644 src/main/java/kz/konturai/controller/UserAdminController.java create mode 100644 src/main/java/kz/konturai/domain/RefreshToken.java create mode 100644 src/main/java/kz/konturai/domain/Role.java create mode 100644 src/main/java/kz/konturai/domain/User.java create mode 100644 src/main/java/kz/konturai/dto/AuthDtos.java create mode 100644 src/main/java/kz/konturai/dto/PageResponse.java create mode 100644 src/main/java/kz/konturai/dto/SetRolesRequest.java create mode 100644 src/main/java/kz/konturai/dto/UpdateUserRequest.java create mode 100644 src/main/java/kz/konturai/dto/UserSummary.java create mode 100644 src/main/java/kz/konturai/repository/RefreshTokenRepository.java create mode 100644 src/main/java/kz/konturai/repository/UserRepository.java create mode 100644 src/main/java/kz/konturai/service/impl/IAuthService.java create mode 100644 src/main/java/kz/konturai/service/spec/AuthService.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yaml create mode 100644 src/main/resources/db/migration/V1__create_users.sql create mode 100644 src/main/resources/db/migration/V2__create_refresh_tokens.sql create mode 100644 src/main/resources/db/migration/V3__seed_root_admin.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c045b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +target/ +.idea/ +.DS_Store + diff --git a/docs/admin-users-api.md b/docs/admin-users-api.md new file mode 100644 index 0000000..ef66401 --- /dev/null +++ b/docs/admin-users-api.md @@ -0,0 +1,104 @@ +## Admin Users API + +Требуется `Authorization: Bearer ` и роль `ROLE_ADMIN`. + +### Создать пользователя + +POST `/api/admin/users` + +Body: + +```json +{ "email": "user@example.com", "password": "StrongPass123!" } +``` + +Ответ: `200 OK` (пусто) + +Ошибки: 400 (валидация), 401/403 (нет прав), 409 (email занят) + +### Назначить роли пользователю + +POST `/api/admin/users/roles` + +Body: + +```json +{ "email": "user@example.com", "roles": ["ROLE_USER", "ROLE_ADMIN"] } +``` + +Ответ: `200 OK` (пусто) + +Ошибки: 400 (невалидная роль/пользователь не найден), 401/403 + +### Список доступных ролей + +GET `/api/admin/roles` + +Ответ: + +```json +["ROLE_ADMIN", "ROLE_USER"] +``` + +Ошибки: 401/403 + +### Список пользователей (пагинация) + +GET `/api/admin/users?page=0&size=20` + +Ответ (`Page`): + +```json +{ + "content": [ + { "id": 1, "email": "root@konturai.local", "roles": "ROLE_ADMIN,ROLE_USER" } + ], + "pageable": { "pageNumber": 0, "pageSize": 20, ... }, + "totalElements": 1, + "totalPages": 1, + "last": true, + "size": 20, + "number": 0, + "sort": { ... }, + "first": true, + "numberOfElements": 1, + "empty": false +} +``` + +Ошибки: 401/403 + +### Обновить пользователя + +PUT `/api/admin/users/update` + +Body (любые поля опциональны кроме `id`): + +```json +{ + "id": 1, + "email": "new@mail.com", + "password": "NewPass123!", + "roles": ["ROLE_USER"] +} +``` + +Ответ: `200 OK` (пусто) + +Ошибки: 400 (невалидные данные/роль), 401/403, 404 (пользователь не найден) + +### Удалить пользователя + +DELETE `/api/admin/users?id=1` + +Ответ: `200 OK` (пусто) + +Ошибки: 401/403 + +### Формат ошибок + +Все ошибки возвращают JSON: + +```json +{ "message": "описание ошибки" } +``` diff --git a/docs/admin-users.md b/docs/admin-users.md new file mode 100644 index 0000000..bf9d020 --- /dev/null +++ b/docs/admin-users.md @@ -0,0 +1,43 @@ +## Admin: Пользователи и роли + +Требуется роль `ROLE_ADMIN` и заголовок `Authorization: Bearer `. + +### Создать пользователя + +POST `/api/admin/users` + +Body: + +```json +{ "email": "user@example.com", "password": "StrongPass123!" } +``` + +Ответ: `200 OK` (пусто) + +Ошибки: 400, 401/403, 409 + +### Назначить роли пользователю + +POST `/api/admin/users/roles` + +Body: + +```json +{ "email": "user@example.com", "roles": ["ROLE_USER", "ROLE_ADMIN"] } +``` + +Ответ: `200 OK` (пусто) + +Ошибки: 400 (invalid role / user not found), 401/403 + +### Список доступных ролей + +GET `/api/admin/roles` + +Ответ: + +```json +["ROLE_ADMIN", "ROLE_USER"] +``` + +Ошибки: 401/403 diff --git a/docs/auth-api.md b/docs/auth-api.md new file mode 100644 index 0000000..4c1b97e --- /dev/null +++ b/docs/auth-api.md @@ -0,0 +1,248 @@ +## Документация по аутентификации (Frontend) + +### Обзор + +JWT-аутентификация. Публичные и админские эндпоинты: + +- POST `/api/auth/signin` — вход и выдача JWT + refreshToken +- POST `/api/auth/refresh` — обновление пары токенов (ротация) +- POST `/api/auth/logout` — выход (инвалидация refreshToken) +- POST `/api/admin/users` — создать пользователя (только `ROLE_ADMIN`) + +Все запросы и ответы — JSON (`Content-Type: application/json`). + +### Базовый URL + +- Прод: укажите боевой домен/порт +- Dev локально: `http://localhost:8080` + +### Переменные окружения (frontend) + +- REACT_APP_API_URL или NEXT_PUBLIC_API_URL: базовый URL API +- Храните токен безопасно (HttpOnly cookie предпочтительно; если localStorage — учитывайте XSS риски) + +## Создание пользователя (Admin) + +### POST /api/admin/users + +Требует: `Authorization: Bearer ` и роль `ROLE_ADMIN`. + +Запрос: + +```json +{ + "email": "user@example.com", + "password": "StrongPass123!" +} +``` + +Успех: + +- 200 OK, пустой ответ + +Ошибки: + +- 400 Bad Request — невалидный email/пароль +- 401/403 — нет прав администратора +- 409 Conflict — email уже зарегистрирован + +Пример (fetch): + +```js +await fetch(`${API_URL}/api/admin/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminAccessToken}`, + }, + body: JSON.stringify({ email, password }), +}); +``` + +## Вход + +### POST /api/auth/signin + +Запрос: + +```json +{ + "email": "user@example.com", + "password": "StrongPass123!" +} +``` + +Успех: + +- 200 OK + +```json +{ + "accessToken": "", + "tokenType": "Bearer", + "refreshToken": "" +} +``` + +Ошибки: + +- 400/401 — неверные учетные данные + +Пример (fetch): + +```js +const res = await fetch(`${API_URL}/api/auth/signin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), +}); +const { accessToken, refreshToken } = await res.json(); +// Сохраните токен и используйте в Authorization заголовке +``` + +## Обновление токена (Refresh Token) + +### Общая схема + +- При успешном входе фронт получает пару токенов: `accessToken` (короткоживущий) и `refreshToken` (длинноживущий). +- `accessToken` используется в `Authorization: Bearer `. +- Когда `accessToken` истекает (HTTP 401), фронт вызывает `/api/auth/refresh` с `refreshToken`, получает новую пару токенов (ротация) и повторяет запрос. + +### Рекомендации по хранению + +- `refreshToken` предпочтительно хранить в HttpOnly Secure SameSite cookie (сервер ставит Set-Cookie). +- Альтернатива (менее безопасная): хранить в памяти/secure storage и передавать в теле запроса. + +### POST /api/auth/refresh + +Запрос (вариант с телом): + +```json +{ + "refreshToken": "" +} +``` + +Успех: + +- 200 OK + +```json +{ + "accessToken": "", + "tokenType": "Bearer", + "refreshToken": "" +} +``` + +Ошибки: + +- 400 — отсутствует/некорректный refreshToken +- 401 — просрочен/отозван/невалиден + +Пример (fetch, с телом): + +```js +const res = await fetch(`${API_URL}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), +}); +if (!res.ok) throw new Error('Refresh failed'); +const { accessToken: newAccess, refreshToken: newRefresh } = await res.json(); +``` + +Пример (cookie-стратегия): + +```js +const res = await fetch(`${API_URL}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', +}); +const { accessToken } = await res.json(); +``` + +### Ротация refreshToken + +- При каждом refresh возвращайте новый `refreshToken` и инвалидируйте старый. +- На фронте заменяйте сохранённый refreshToken на новый. + +### TTL (рекомендации) + +- accessToken: 5–15 минут +- refreshToken: 7–30 дней + +## Logout + +- Если refreshToken хранится в cookie: `POST /api/auth/logout` — сервер чистит cookie и отмечает refreshToken как отозванный. +- Если в хранилище фронта — удалите локальные токены и по возможности вызовите `logout` для аннулирования на бэке. + +## Авторизация последующих запросов + +Передавайте JWT в заголовке: + +``` +Authorization: Bearer +``` + +Пример защищенного вызова: + +```js +await fetch(`${API_URL}/api/private/profile`, { + headers: { Authorization: `Bearer ${token}` }, +}); +``` + +## Формат ошибок (пример) + +```json +{ + "timestamp": "2025-09-11T12:34:56Z", + "status": 409, + "error": "Conflict", + "message": "Email already registered", + "path": "/api/auth/signup" +} +``` + +## Валидация на фронте + +- Email: RFC-проверка и нормализация в lowercase +- Пароль: минимум 8 символов, цифра, буква, спецсимвол +- Обработайте статусы 400/401/409 и показывайте человекочитаемые сообщения + +## Хранение токена + +- Предпочтительно: HttpOnly Secure cookie, получаемое от бэкенда +- Альтернатива: `localStorage`/`sessionStorage` (учтите XSS; не вставляйте токен в DOM) + +## Примеры cURL + +Создание пользователя (admin): + +```bash +curl -X POST "$API_URL/api/admin/users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"StrongPass123!"}' +``` + +Вход: + +```bash +curl -X POST "$API_URL/api/auth/signin" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"StrongPass123!"}' +``` + +## Заметки по безопасности + +- Не логируйте пароли и JWT +- Реализуйте logout (инвалидация на клиенте или список отозванных токенов на бэке, если нужно) +- Рекомендуется троттлинг/капча для /signin и создания пользователя + +## Изменения в будущем + +- Ротация refresh-токенов реализована; можно добавить список отозванных токенов +- Подтверждение email +- Сброс пароля через почту diff --git a/docs/identity.md b/docs/identity.md new file mode 100644 index 0000000..0e6e6fb --- /dev/null +++ b/docs/identity.md @@ -0,0 +1,19 @@ +## Identity + +Требуется авторизация `Authorization: Bearer `. + +### Текущий пользователь + +GET `/api/identity/me` + +Ответ: + +```json +{ + "id": 1, + "email": "root@konturai.local", + "roles": ["ROLE_ADMIN", "ROLE_USER"] +} +``` + +Ошибки: 401 — нет или просрочен токен diff --git a/pom.xml b/pom.xml index e97a58a..c283f52 100644 --- a/pom.xml +++ b/pom.xml @@ -1,30 +1,31 @@ - 4.0.0 org.springframework.boot spring-boot-starter-parent 3.5.5 - + kz konturai 0.0.1-SNAPSHOT konturai KonturAI - marketing and targeting AI - + - + - + - - - - + + + + 21 @@ -35,6 +36,76 @@ spring-boot-starter + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + + + + + org.flywaydb + flyway-database-postgresql + + + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + + + me.paulschwarz + spring-dotenv + 4.0.0 + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.2.0 + + org.springframework.boot spring-boot-starter-test @@ -51,4 +122,4 @@ - + \ No newline at end of file diff --git a/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java b/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java new file mode 100644 index 0000000..7310702 --- /dev/null +++ b/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java @@ -0,0 +1,62 @@ +package kz.konturai.configuration; + +import java.util.NoSuchElementException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + public static class ErrorResponse { + public String message; + + public ErrorResponse(String message) { + this.message = message; + } + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse(ex.getMessage())); + } + + @ExceptionHandler({ MethodArgumentNotValidException.class, BindException.class, + HttpMessageNotReadableException.class }) + public ResponseEntity handleValidation(Exception ex) { + String msg = ex.getMessage(); + if (ex instanceof MethodArgumentNotValidException manv && manv.getBindingResult() != null) { + msg = manv.getBindingResult().getAllErrors().stream() + .findFirst() + .map(e -> e.getDefaultMessage() != null ? e.getDefaultMessage() : e.toString()) + .orElse("Validation error"); + } + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse(msg)); + } + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNotFound(NoSuchElementException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(ex.getMessage())); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuth(AuthenticationException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Unauthorized")); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse("Forbidden")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Internal server error")); + } +} diff --git a/src/main/java/kz/konturai/configuration/OpenApiConfig.java b/src/main/java/kz/konturai/configuration/OpenApiConfig.java new file mode 100644 index 0000000..7112a3c --- /dev/null +++ b/src/main/java/kz/konturai/configuration/OpenApiConfig.java @@ -0,0 +1,40 @@ +package kz.konturai.configuration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("KonturAI API") + .description("API для системы маркетинга и таргетинга KonturAI") + .version("1.0.0") + .contact(new Contact() + .name("KonturAI Team") + .email("support@konturai.kz")) + .license(new License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT"))) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme())); + } + + private SecurityScheme createAPIKeyScheme() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .bearerFormat("JWT") + .scheme("bearer"); + } +} diff --git a/src/main/java/kz/konturai/configuration/security/JwtAuthenticationFilter.java b/src/main/java/kz/konturai/configuration/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..de3f218 --- /dev/null +++ b/src/main/java/kz/konturai/configuration/security/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +package kz.konturai.configuration.security; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + Claims claims = jwtService.parseAndValidate(token); + String subject = claims.getSubject(); + Object rolesClaim = claims.get("roles"); + Collection authorities = List.of(); + if (rolesClaim instanceof String s && !s.isEmpty()) { + authorities = java.util.Arrays.stream(s.split(",")) + .map(String::trim) + .filter(r -> !r.isEmpty()) + .map(SimpleGrantedAuthority::new) + .toList(); + } + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + subject, + null, + authorities); + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception ignored) { + SecurityContextHolder.clearContext(); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/kz/konturai/configuration/security/JwtService.java b/src/main/java/kz/konturai/configuration/security/JwtService.java new file mode 100644 index 0000000..e044510 --- /dev/null +++ b/src/main/java/kz/konturai/configuration/security/JwtService.java @@ -0,0 +1,47 @@ +package kz.konturai.configuration.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + private final Key signingKey; + private final long accessTokenTtlSeconds; + + public JwtService( + @Value("${security.jwt.secret-base64:ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl}") String base64Secret, + @Value("${security.jwt.access-ttl-seconds:3600}") long accessTokenTtlSeconds) { + this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)); + this.accessTokenTtlSeconds = accessTokenTtlSeconds; + } + + public String generateToken(String subject, Map claims) { + Instant now = Instant.now(); + Instant expiry = now.plusSeconds(accessTokenTtlSeconds); + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiry)) + .signWith(signingKey, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parseAndValidate(String token) { + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/kz/konturai/configuration/security/SecurityConfig.java b/src/main/java/kz/konturai/configuration/security/SecurityConfig.java new file mode 100644 index 0000000..597fdaa --- /dev/null +++ b/src/main/java/kz/konturai/configuration/security/SecurityConfig.java @@ -0,0 +1,84 @@ +package kz.konturai.configuration.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Value("${app.cors.allowed-origin:http://localhost:5173}") + private String allowedOrigin; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + AuthenticationEntryPoint entryPoint = (request, response, ex) -> { + response.setStatus(401); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"Unauthorized\"}"); + }; + AccessDeniedHandler accessDeniedHandler = (request, response, ex) -> { + response.setStatus(403); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"Forbidden\"}"); + }; + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated()) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(entryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of(allowedOrigin)); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept", "X-Requested-With")); + config.setExposedHeaders(List.of("Authorization")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } +} diff --git a/src/main/java/kz/konturai/controller/AdminController.java b/src/main/java/kz/konturai/controller/AdminController.java new file mode 100644 index 0000000..6b2d419 --- /dev/null +++ b/src/main/java/kz/konturai/controller/AdminController.java @@ -0,0 +1,38 @@ +package kz.konturai.controller; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import kz.konturai.domain.Role; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/admin") +@Tag(name = "Admin", description = "API для административных функций") +@SecurityRequirement(name = "Bearer Authentication") +public class AdminController { + + @Operation(summary = "Получить список ролей", description = "Возвращает список всех доступных ролей в системе") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Список ролей успешно получен"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа") + }) + @GetMapping("/roles") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity> listRoles() { + List roles = Arrays.stream(Role.values()) + .map(Enum::name) + .collect(Collectors.toList()); + return ResponseEntity.ok(roles); + } +} diff --git a/src/main/java/kz/konturai/controller/AuthController.java b/src/main/java/kz/konturai/controller/AuthController.java new file mode 100644 index 0000000..20bd6e3 --- /dev/null +++ b/src/main/java/kz/konturai/controller/AuthController.java @@ -0,0 +1,62 @@ +package kz.konturai.controller; + +import kz.konturai.dto.AuthDtos; +import kz.konturai.service.spec.AuthService; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/auth") +@Tag(name = "Authentication", description = "API для аутентификации пользователей") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + // signup удалён — создание пользователя доступно только админам + + @Operation(summary = "Вход в систему", description = "Аутентификация пользователя по email и паролю") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешная аутентификация"), + @ApiResponse(responseCode = "401", description = "Неверные учетные данные"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @PostMapping("/signin") + public ResponseEntity signIn(@RequestBody AuthDtos.SignInRequest request) { + return ResponseEntity.ok(authService.signIn(request)); + } + + @Operation(summary = "Обновление токена", description = "Обновление access токена с помощью refresh токена") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Токен успешно обновлен"), + @ApiResponse(responseCode = "401", description = "Недействительный refresh токен"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestBody AuthDtos.RefreshRequest request) { + return ResponseEntity.ok(authService.refresh(request)); + } + + @Operation(summary = "Выход из системы", description = "Выход пользователя из системы и инвалидация refresh токена") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешный выход из системы"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @PostMapping("/logout") + public ResponseEntity logout(@RequestBody AuthDtos.RefreshRequest request) { + authService.logout(request.refreshToken); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/kz/konturai/controller/IdentityController.java b/src/main/java/kz/konturai/controller/IdentityController.java new file mode 100644 index 0000000..aeb1919 --- /dev/null +++ b/src/main/java/kz/konturai/controller/IdentityController.java @@ -0,0 +1,69 @@ +package kz.konturai.controller; + +import java.security.Principal; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import kz.konturai.domain.User; +import kz.konturai.repository.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/identity") +@Tag(name = "Identity", description = "API для получения информации о текущем пользователе") +@SecurityRequirement(name = "Bearer Authentication") +public class IdentityController { + + private final UserRepository userRepository; + + public IdentityController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Operation(summary = "Получить информацию о текущем пользователе", description = "Возвращает информацию о текущем аутентифицированном пользователе") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Информация о пользователе успешно получена"), + @ApiResponse(responseCode = "401", description = "Пользователь не аутентифицирован") + }) + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public ResponseEntity me(Principal principal) { + String email = principal.getName(); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + List roles = user.getRoles() == null || user.getRoles().isBlank() + ? List.of() + : Arrays.stream(user.getRoles().split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + return ResponseEntity.ok(new MeResponse(user.getId(), user.getEmail(), roles)); + } + + @Schema(description = "Информация о текущем пользователе") + public static class MeResponse { + @Schema(description = "ID пользователя", example = "1") + public Long id; + @Schema(description = "Email пользователя", example = "user@example.com") + public String email; + @Schema(description = "Список ролей пользователя", example = "[\"ROLE_USER\", \"ROLE_ADMIN\"]") + public List roles; + + public MeResponse(Long id, String email, List roles) { + this.id = id; + this.email = email; + this.roles = roles; + } + } +} diff --git a/src/main/java/kz/konturai/controller/UserAdminController.java b/src/main/java/kz/konturai/controller/UserAdminController.java new file mode 100644 index 0000000..bb3bc94 --- /dev/null +++ b/src/main/java/kz/konturai/controller/UserAdminController.java @@ -0,0 +1,136 @@ +package kz.konturai.controller; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import kz.konturai.dto.AuthDtos; +import kz.konturai.dto.SetRolesRequest; +import kz.konturai.dto.UpdateUserRequest; +import kz.konturai.dto.UserSummary; +import kz.konturai.domain.Role; +import kz.konturai.service.spec.AuthService; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/admin/users") +@Tag(name = "User Management", description = "API для управления пользователями") +@SecurityRequirement(name = "Bearer Authentication") +public class UserAdminController { + + private final AuthService authService; + + public UserAdminController(AuthService authService) { + this.authService = authService; + } + + @Operation(summary = "Создать пользователя", description = "Создание нового пользователя в системе") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Пользователь успешно создан"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @PostMapping + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity createUser(@RequestBody AuthDtos.SignUpRequest request) { + authService.signUp(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Установить роли пользователя", description = "Установка ролей для существующего пользователя") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Роли пользователя успешно обновлены"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @PostMapping("/roles") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity setUserRoles(@RequestBody SetRolesRequest request) { + authService.setUserRoles(request.email(), request.roles()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Получить список ролей", description = "Возвращает список всех доступных ролей в системе") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Список ролей успешно получен"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа") + }) + @GetMapping("/roles") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity> listRoles() { + List roles = Arrays.stream(Role.values()).map(Enum::name).collect(Collectors.toList()); + return ResponseEntity.ok(roles); + } + + @Operation(summary = "Получить список пользователей", description = "Возвращает пагинированный список всех пользователей в системе") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Список пользователей успешно получен"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа") + }) + @GetMapping + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity> listUsers( + @Parameter(description = "Номер страницы (начиная с 0)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Размер страницы", example = "20") @RequestParam(defaultValue = "20") int size) { + Pageable pageable = PageRequest.of(page, size); + org.springframework.data.domain.Page p = authService.listUsers(pageable); + java.util.List list = p.map(u -> new UserSummary(u.getId(), u.getEmail(), u.getRoles())) + .getContent(); + kz.konturai.dto.PageResponse resp = new kz.konturai.dto.PageResponse<>( + list, + p.getNumber(), + p.getSize(), + p.getTotalElements(), + p.getTotalPages(), + p.isFirst(), + p.isLast(), + p.getNumberOfElements()); + return ResponseEntity.ok(resp); + } + + @Operation(summary = "Обновить пользователя", description = "Обновление данных существующего пользователя") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Пользователь успешно обновлен"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @PutMapping("/update") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity updateUser(@RequestBody UpdateUserRequest request) { + authService.updateUser(request.id(), request.email(), request.password(), request.roles()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Удалить пользователя", description = "Удаление пользователя из системы по ID") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Пользователь успешно удален"), + @ApiResponse(responseCode = "403", description = "Недостаточно прав доступа"), + @ApiResponse(responseCode = "400", description = "Некорректные данные запроса") + }) + @DeleteMapping + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public ResponseEntity deleteUser( + @Parameter(description = "ID пользователя для удаления", example = "1") @RequestParam Long id) { + authService.deleteUser(id); + return ResponseEntity.ok().build(); + } + + // DTOs moved to package dto as records +} diff --git a/src/main/java/kz/konturai/domain/RefreshToken.java b/src/main/java/kz/konturai/domain/RefreshToken.java new file mode 100644 index 0000000..b00cb0a --- /dev/null +++ b/src/main/java/kz/konturai/domain/RefreshToken.java @@ -0,0 +1,62 @@ +package kz.konturai.domain; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "refresh_tokens") +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 512) + private String token; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false) + private Instant expiresAt; + + @Column(nullable = false) + private boolean revoked = false; + + public Long getId() { + return id; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public boolean isRevoked() { + return revoked; + } + + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } +} diff --git a/src/main/java/kz/konturai/domain/Role.java b/src/main/java/kz/konturai/domain/Role.java new file mode 100644 index 0000000..9510f9a --- /dev/null +++ b/src/main/java/kz/konturai/domain/Role.java @@ -0,0 +1,14 @@ +package kz.konturai.domain; + +public enum Role { + ROLE_ADMIN, + ROLE_USER; + + public static boolean isValid(String value) { + for (Role r : values()) { + if (r.name().equals(value)) + return true; + } + return false; + } +} diff --git a/src/main/java/kz/konturai/domain/User.java b/src/main/java/kz/konturai/domain/User.java new file mode 100644 index 0000000..ef9d19f --- /dev/null +++ b/src/main/java/kz/konturai/domain/User.java @@ -0,0 +1,77 @@ +package kz.konturai.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String passwordHash; + + @Column(nullable = false) + private String roles; // comma separated, e.g. "ROLE_USER" + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column(nullable = false) + private Instant updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public String getRoles() { + return roles; + } + + public void setRoles(String roles) { + this.roles = roles; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/src/main/java/kz/konturai/dto/AuthDtos.java b/src/main/java/kz/konturai/dto/AuthDtos.java new file mode 100644 index 0000000..dcc5b85 --- /dev/null +++ b/src/main/java/kz/konturai/dto/AuthDtos.java @@ -0,0 +1,42 @@ +package kz.konturai.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public class AuthDtos { + @Schema(description = "Запрос на регистрацию пользователя") + public static class SignUpRequest { + @Schema(description = "Email пользователя", example = "user@example.com") + public String email; + @Schema(description = "Пароль пользователя", example = "password123") + public String password; + } + + @Schema(description = "Запрос на вход в систему") + public static class SignInRequest { + @Schema(description = "Email пользователя", example = "user@example.com") + public String email; + @Schema(description = "Пароль пользователя", example = "password123") + public String password; + } + + @Schema(description = "Ответ с токенами аутентификации") + public static class AuthResponse { + @Schema(description = "Access токен для авторизации", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + public String accessToken; + @Schema(description = "Тип токена", example = "Bearer") + public String tokenType = "Bearer"; + @Schema(description = "Refresh токен для обновления access токена", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + public String refreshToken; + + public AuthResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + } + + @Schema(description = "Запрос на обновление токена") + public static class RefreshRequest { + @Schema(description = "Refresh токен для обновления access токена", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + public String refreshToken; + } +} diff --git a/src/main/java/kz/konturai/dto/PageResponse.java b/src/main/java/kz/konturai/dto/PageResponse.java new file mode 100644 index 0000000..0120a58 --- /dev/null +++ b/src/main/java/kz/konturai/dto/PageResponse.java @@ -0,0 +1,39 @@ +package kz.konturai.dto; + +import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Пагинированный ответ") +public class PageResponse { + @Schema(description = "Содержимое страницы") + public List content; + @Schema(description = "Номер текущей страницы", example = "0") + public int pageNumber; + @Schema(description = "Размер страницы", example = "20") + public int pageSize; + @Schema(description = "Общее количество элементов", example = "100") + public long totalElements; + @Schema(description = "Общее количество страниц", example = "5") + public int totalPages; + @Schema(description = "Является ли это первой страницей", example = "true") + public boolean first; + @Schema(description = "Является ли это последней страницей", example = "false") + public boolean last; + @Schema(description = "Количество элементов на текущей странице", example = "20") + public int numberOfElements; + + public PageResponse() { + } + + public PageResponse(List content, int pageNumber, int pageSize, long totalElements, int totalPages, + boolean first, boolean last, int numberOfElements) { + this.content = content; + this.pageNumber = pageNumber; + this.pageSize = pageSize; + this.totalElements = totalElements; + this.totalPages = totalPages; + this.first = first; + this.last = last; + this.numberOfElements = numberOfElements; + } +} diff --git a/src/main/java/kz/konturai/dto/SetRolesRequest.java b/src/main/java/kz/konturai/dto/SetRolesRequest.java new file mode 100644 index 0000000..c110c5b --- /dev/null +++ b/src/main/java/kz/konturai/dto/SetRolesRequest.java @@ -0,0 +1,10 @@ +package kz.konturai.dto; + +import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Запрос на установку ролей пользователя") +public record SetRolesRequest( + @Schema(description = "Email пользователя", example = "user@example.com") String email, + @Schema(description = "Список ролей для установки", example = "[\"ROLE_USER\", \"ROLE_ADMIN\"]") List roles) { +} diff --git a/src/main/java/kz/konturai/dto/UpdateUserRequest.java b/src/main/java/kz/konturai/dto/UpdateUserRequest.java new file mode 100644 index 0000000..0778092 --- /dev/null +++ b/src/main/java/kz/konturai/dto/UpdateUserRequest.java @@ -0,0 +1,12 @@ +package kz.konturai.dto; + +import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Запрос на обновление данных пользователя") +public record UpdateUserRequest( + @Schema(description = "ID пользователя", example = "1") Long id, + @Schema(description = "Email пользователя", example = "user@example.com") String email, + @Schema(description = "Новый пароль пользователя", example = "newpassword123") String password, + @Schema(description = "Список ролей пользователя", example = "[\"ROLE_USER\", \"ROLE_ADMIN\"]") List roles) { +} diff --git a/src/main/java/kz/konturai/dto/UserSummary.java b/src/main/java/kz/konturai/dto/UserSummary.java new file mode 100644 index 0000000..47d9862 --- /dev/null +++ b/src/main/java/kz/konturai/dto/UserSummary.java @@ -0,0 +1,10 @@ +package kz.konturai.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Краткая информация о пользователе") +public record UserSummary( + @Schema(description = "ID пользователя", example = "1") Long id, + @Schema(description = "Email пользователя", example = "user@example.com") String email, + @Schema(description = "Роли пользователя", example = "ROLE_USER,ROLE_ADMIN") String roles) { +} diff --git a/src/main/java/kz/konturai/repository/RefreshTokenRepository.java b/src/main/java/kz/konturai/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..4aacead --- /dev/null +++ b/src/main/java/kz/konturai/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package kz.konturai.repository; + +import java.util.Optional; +import kz.konturai.domain.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + + long deleteByToken(String token); +} diff --git a/src/main/java/kz/konturai/repository/UserRepository.java b/src/main/java/kz/konturai/repository/UserRepository.java new file mode 100644 index 0000000..4377695 --- /dev/null +++ b/src/main/java/kz/konturai/repository/UserRepository.java @@ -0,0 +1,16 @@ +package kz.konturai.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import kz.konturai.domain.User; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + + Optional findByEmail(String email); + + Page findAll(Pageable pageable); +} diff --git a/src/main/java/kz/konturai/service/impl/IAuthService.java b/src/main/java/kz/konturai/service/impl/IAuthService.java new file mode 100644 index 0000000..fd922a4 --- /dev/null +++ b/src/main/java/kz/konturai/service/impl/IAuthService.java @@ -0,0 +1,5 @@ +package kz.konturai.service.impl; + +public interface IAuthService { + +} diff --git a/src/main/java/kz/konturai/service/spec/AuthService.java b/src/main/java/kz/konturai/service/spec/AuthService.java new file mode 100644 index 0000000..9951a6a --- /dev/null +++ b/src/main/java/kz/konturai/service/spec/AuthService.java @@ -0,0 +1,134 @@ +package kz.konturai.service.spec; + +import kz.konturai.configuration.security.JwtService; +import kz.konturai.domain.RefreshToken; +import kz.konturai.domain.User; +import kz.konturai.dto.AuthDtos; +import kz.konturai.repository.RefreshTokenRepository; +import kz.konturai.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final RefreshTokenRepository refreshTokenRepository; + + public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService, + RefreshTokenRepository refreshTokenRepository) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.refreshTokenRepository = refreshTokenRepository; + } + + @Transactional + public void signUp(AuthDtos.SignUpRequest request) { + if (userRepository.existsByEmail(request.email)) { + throw new IllegalArgumentException("Email already registered"); + } + User user = new User(); + user.setEmail(request.email.trim().toLowerCase()); + user.setPasswordHash(passwordEncoder.encode(request.password)); + user.setRoles("ROLE_USER"); + userRepository.save(user); + } + + public AuthDtos.AuthResponse signIn(AuthDtos.SignInRequest request) { + User user = userRepository.findByEmail(request.email.trim().toLowerCase()) + .orElseThrow(() -> new IllegalArgumentException("Invalid credentials")); + if (!passwordEncoder.matches(request.password, user.getPasswordHash())) { + throw new IllegalArgumentException("Invalid credentials"); + } + String access = jwtService.generateToken(user.getEmail(), + java.util.Map.of("uid", user.getId(), "roles", user.getRoles())); + String refresh = issueRefreshToken(user); + return new AuthDtos.AuthResponse(access, refresh); + } + + @Transactional + public AuthDtos.AuthResponse refresh(AuthDtos.RefreshRequest request) { + RefreshToken rt = refreshTokenRepository.findByToken(request.refreshToken) + .orElseThrow(() -> new IllegalArgumentException("Invalid refresh token")); + if (rt.isRevoked() || rt.getExpiresAt().isBefore(java.time.Instant.now())) { + throw new IllegalArgumentException("Invalid refresh token"); + } + User user = rt.getUser(); + // rotate + rt.setRevoked(true); + refreshTokenRepository.save(rt); + String newRefresh = issueRefreshToken(user); + String newAccess = jwtService.generateToken(user.getEmail(), + java.util.Map.of("uid", user.getId(), "roles", user.getRoles())); + return new AuthDtos.AuthResponse(newAccess, newRefresh); + } + + @Transactional + public void logout(String refreshToken) { + refreshTokenRepository.findByToken(refreshToken) + .ifPresent(rt -> { + rt.setRevoked(true); + refreshTokenRepository.save(rt); + }); + } + + private String issueRefreshToken(User user) { + String token = java.util.UUID.randomUUID().toString(); + RefreshToken rt = new RefreshToken(); + rt.setUser(user); + rt.setToken(token); + long ttlDays = Long.parseLong(System.getProperty("security.refresh.ttl-days", "14")); + rt.setExpiresAt(java.time.Instant.now().plus(java.time.Duration.ofDays(ttlDays))); + refreshTokenRepository.save(rt); + return token; + } + + @Transactional + public void setUserRoles(String email, java.util.List roles) { + for (String r : roles) { + if (!kz.konturai.domain.Role.isValid(r)) { + throw new IllegalArgumentException("Invalid role: " + r); + } + } + User user = userRepository.findByEmail(email.trim().toLowerCase()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + user.setRoles(String.join(",", roles)); + userRepository.save(user); + } + + public Page listUsers(Pageable pageable) { + return userRepository.findAll(pageable); + } + + @Transactional + public void updateUser(Long id, String email, String password, java.util.List roles) { + User user = userRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("User not found")); + if (email != null && !email.isBlank()) { + user.setEmail(email.trim().toLowerCase()); + } + if (password != null && !password.isBlank()) { + user.setPasswordHash(passwordEncoder.encode(password)); + } + if (roles != null && !roles.isEmpty()) { + for (String r : roles) { + if (!kz.konturai.domain.Role.isValid(r)) { + throw new IllegalArgumentException("Invalid role: " + r); + } + } + user.setRoles(String.join(",", roles)); + } + userRepository.save(user); + } + + @Transactional + public void deleteUser(Long id) { + userRepository.deleteById(id); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8e94549..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=konturai diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..17a072d --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,18 @@ +spring: + application: + name: konturai + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://92.38.48.166:5433/konturai} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:password} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:none} + show-sql: true + properties: + hibernate: + format_sql: true + sql: + init: + mode: never diff --git a/src/main/resources/db/migration/V1__create_users.sql b/src/main/resources/db/migration/V1__create_users.sql new file mode 100644 index 0000000..b59ce2f --- /dev/null +++ b/src/main/resources/db/migration/V1__create_users.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + roles VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_users_updated_at ON users; +CREATE TRIGGER trg_users_updated_at +BEFORE UPDATE ON users +FOR EACH ROW EXECUTE PROCEDURE set_updated_at(); + + diff --git a/src/main/resources/db/migration/V2__create_refresh_tokens.sql b/src/main/resources/db/migration/V2__create_refresh_tokens.sql new file mode 100644 index 0000000..b9e6879 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_refresh_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + token VARCHAR(512) NOT NULL UNIQUE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); + + diff --git a/src/main/resources/db/migration/V3__seed_root_admin.sql b/src/main/resources/db/migration/V3__seed_root_admin.sql new file mode 100644 index 0000000..91345b6 --- /dev/null +++ b/src/main/resources/db/migration/V3__seed_root_admin.sql @@ -0,0 +1,19 @@ +-- Seed a root admin user if not exists +-- Uses pgcrypto to generate a bcrypt hash for the initial password + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM users WHERE email = 'root@konturai.local') THEN + INSERT INTO users (email, password_hash, roles, created_at, updated_at) + VALUES ( + 'root@konturai.local', + crypt('ChangeMe123!', gen_salt('bf', 10)), + 'ROLE_ADMIN,ROLE_USER', + NOW(), NOW() + ); + END IF; +END$$; + +