inital commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
target/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
## Admin Users API
|
||||
|
||||
Требуется `Authorization: Bearer <admin_access_token>` и роль `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<UserSummary>`):
|
||||
|
||||
```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": "описание ошибки" }
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
## Admin: Пользователи и роли
|
||||
|
||||
Требуется роль `ROLE_ADMIN` и заголовок `Authorization: Bearer <admin_access_token>`.
|
||||
|
||||
### Создать пользователя
|
||||
|
||||
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
|
||||
@@ -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 <admin_access_token>` и роль `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": "<JWT>",
|
||||
"tokenType": "Bearer",
|
||||
"refreshToken": "<refresh-token>"
|
||||
}
|
||||
```
|
||||
|
||||
Ошибки:
|
||||
|
||||
- 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 <JWT>`.
|
||||
- Когда `accessToken` истекает (HTTP 401), фронт вызывает `/api/auth/refresh` с `refreshToken`, получает новую пару токенов (ротация) и повторяет запрос.
|
||||
|
||||
### Рекомендации по хранению
|
||||
|
||||
- `refreshToken` предпочтительно хранить в HttpOnly Secure SameSite cookie (сервер ставит Set-Cookie).
|
||||
- Альтернатива (менее безопасная): хранить в памяти/secure storage и передавать в теле запроса.
|
||||
|
||||
### POST /api/auth/refresh
|
||||
|
||||
Запрос (вариант с телом):
|
||||
|
||||
```json
|
||||
{
|
||||
"refreshToken": "<refresh-token>"
|
||||
}
|
||||
```
|
||||
|
||||
Успех:
|
||||
|
||||
- 200 OK
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "<new-jwt>",
|
||||
"tokenType": "Bearer",
|
||||
"refreshToken": "<new-refresh-token>"
|
||||
}
|
||||
```
|
||||
|
||||
Ошибки:
|
||||
|
||||
- 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 <JWT>
|
||||
```
|
||||
|
||||
Пример защищенного вызова:
|
||||
|
||||
```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
|
||||
- Сброс пароля через почту
|
||||
@@ -0,0 +1,19 @@
|
||||
## Identity
|
||||
|
||||
Требуется авторизация `Authorization: Bearer <access_token>`.
|
||||
|
||||
### Текущий пользователь
|
||||
|
||||
GET `/api/identity/me`
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"email": "root@konturai.local",
|
||||
"roles": ["ROLE_ADMIN", "ROLE_USER"]
|
||||
}
|
||||
```
|
||||
|
||||
Ошибки: 401 — нет или просрочен токен
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
@@ -35,6 +36,76 @@
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Data JPA for ORM -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- PostgreSQL JDBC Driver -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway database migrations -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Flyway support for PostgreSQL -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Security -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT (JJWT) -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Load variables from .env into Spring Environment -->
|
||||
<dependency>
|
||||
<groupId>me.paulschwarz</groupId>
|
||||
<artifactId>spring-dotenv</artifactId>
|
||||
<version>4.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- SpringDoc OpenAPI for Swagger UI -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -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<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler({ MethodArgumentNotValidException.class, BindException.class,
|
||||
HttpMessageNotReadableException.class })
|
||||
public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleNotFound(NoSuchElementException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(ex.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAuth(AuthenticationException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Unauthorized"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse("Forbidden"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Internal server error"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<? extends GrantedAuthority> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<List<String>> listRoles() {
|
||||
List<String> roles = Arrays.stream(Role.values())
|
||||
.map(Enum::name)
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(roles);
|
||||
}
|
||||
}
|
||||
@@ -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<AuthDtos.AuthResponse> 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<AuthDtos.AuthResponse> 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<Void> logout(@RequestBody AuthDtos.RefreshRequest request) {
|
||||
authService.logout(request.refreshToken);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -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<MeResponse> me(Principal principal) {
|
||||
String email = principal.getName();
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
List<String> 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<String> roles;
|
||||
|
||||
public MeResponse(Long id, String email, List<String> roles) {
|
||||
this.id = id;
|
||||
this.email = email;
|
||||
this.roles = roles;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Void> 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<Void> 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<List<String>> listRoles() {
|
||||
List<String> 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<kz.konturai.dto.PageResponse<UserSummary>> 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<kz.konturai.domain.User> p = authService.listUsers(pageable);
|
||||
java.util.List<UserSummary> list = p.map(u -> new UserSummary(u.getId(), u.getEmail(), u.getRoles()))
|
||||
.getContent();
|
||||
kz.konturai.dto.PageResponse<UserSummary> 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<Void> 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<Void> deleteUser(
|
||||
@Parameter(description = "ID пользователя для удаления", example = "1") @RequestParam Long id) {
|
||||
authService.deleteUser(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
// DTOs moved to package dto as records
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
@Schema(description = "Содержимое страницы")
|
||||
public List<T> 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<T> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> roles) {
|
||||
}
|
||||
@@ -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<String> roles) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<RefreshToken, Long> {
|
||||
Optional<RefreshToken> findByToken(String token);
|
||||
|
||||
long deleteByToken(String token);
|
||||
}
|
||||
@@ -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<User, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Page<User> findAll(Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package kz.konturai.service.impl;
|
||||
|
||||
public interface IAuthService {
|
||||
|
||||
}
|
||||
@@ -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<String> 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<User> listUsers(Pageable pageable) {
|
||||
return userRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateUser(Long id, String email, String password, java.util.List<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
spring.application.name=konturai
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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$$;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user