inital commit

This commit is contained in:
root
2025-09-12 10:00:32 +05:00
parent f6bce5d120
commit b4455b7024
32 changed files with 1603 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
.env
target/
.idea/
.DS_Store
+104
View File
@@ -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": "описание ошибки" }
```
+43
View File
@@ -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
+248
View File
@@ -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: 515 минут
- refreshToken: 730 дней
## 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
- Сброс пароля через почту
+19
View File
@@ -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 — нет или просрочен токен
+72 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> 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> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
@@ -35,6 +36,76 @@
<artifactId>spring-boot-starter</artifactId> <artifactId>spring-boot-starter</artifactId>
</dependency> </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> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <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
+18
View File
@@ -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$$;