diff --git a/.codex/config.toml b/.codex/config.toml
index 2ef8f23..86665bd 100644
--- a/.codex/config.toml
+++ b/.codex/config.toml
@@ -3,7 +3,7 @@
web_search = "live"
developer_instructions = """
-Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент.
+Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
Используй проектные скиллы из .agents/skills, когда задача соответствует их description.
diff --git a/AGENTS.md b/AGENTS.md
index 4686c47..4b00695 100755
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,7 +62,7 @@ docker compose logs -f backend
## Критические правила для агентов
### Flyway миграции
-- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway.
+- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)
diff --git a/backend/pom.xml b/backend/pom.xml
index 54b385f..6759af9 100755
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -50,6 +50,12 @@
spring-security-crypto
+
+
+ org.springframework.security
+ spring-security-oauth2-jose
+
+
com.h2database
@@ -63,6 +69,13 @@
opentelemetry-api
1.49.0
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java b/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java
deleted file mode 100644
index 3ccf675..0000000
--- a/backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.magistr.app.config.auth;
-
-import com.magistr.app.model.User;
-import org.springframework.stereotype.Service;
-
-import java.util.Map;
-import java.util.Optional;
-import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
-
-@Service
-public class AuthSessionService {
-
- private final Map sessions = new ConcurrentHashMap<>();
-
- public String createSession(User user) {
- String token = UUID.randomUUID().toString();
- sessions.put(token, new AuthenticatedUser(
- user.getId(),
- user.getUsername(),
- user.getRole(),
- user.getDepartmentId()
- ));
- return token;
- }
-
- public Optional findByToken(String token) {
- if (token == null || token.isBlank()) {
- return Optional.empty();
- }
- return Optional.ofNullable(sessions.get(token));
- }
-}
diff --git a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java
index d07ae1d..8dcd92f 100644
--- a/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java
+++ b/backend/src/main/java/com/magistr/app/config/auth/AuthorizationInterceptor.java
@@ -15,11 +15,11 @@ import java.util.Map;
@Component
public class AuthorizationInterceptor implements HandlerInterceptor {
- private final AuthSessionService sessionService;
+ private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper;
- public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) {
- this.sessionService = sessionService;
+ public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
+ this.jwtTokenService = jwtTokenService;
this.objectMapper = objectMapper;
}
@@ -28,12 +28,15 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
- if (request.getRequestURI().equals("/api/auth/login")) {
+ if (isPublicAuthEndpoint(request)) {
return true;
}
String token = bearerToken(request.getHeader("Authorization"));
- AuthenticatedUser user = sessionService.findByToken(token).orElse(null);
+ AuthenticatedUser user = jwtTokenService.authenticate(
+ token,
+ com.magistr.app.config.tenant.TenantContext.getCurrentTenant()
+ ).orElse(null);
if (user == null) {
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
return false;
@@ -49,6 +52,16 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
return true;
}
+ private boolean isPublicAuthEndpoint(HttpServletRequest request) {
+ if (!request.getRequestURI().startsWith("/api/auth/")) {
+ return false;
+ }
+ return "POST".equalsIgnoreCase(request.getMethod())
+ && (request.getRequestURI().equals("/api/auth/login")
+ || request.getRequestURI().equals("/api/auth/refresh")
+ || request.getRequestURI().equals("/api/auth/logout"));
+ }
+
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear();
diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java b/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java
new file mode 100644
index 0000000..aab5abd
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java
@@ -0,0 +1,66 @@
+package com.magistr.app.config.auth;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+@Component
+@ConfigurationProperties(prefix = "app.jwt")
+public class JwtProperties {
+
+ private String secret = "dev-only-change-this-jwt-secret-32-bytes-minimum";
+ private Duration accessTtl = Duration.ofMinutes(15);
+ private Duration refreshTtl = Duration.ofDays(7);
+ private String refreshCookieName = "magistr_refresh";
+ private boolean refreshCookieSecure = false;
+
+ public String getSecret() {
+ return secret;
+ }
+
+ public void setSecret(String secret) {
+ this.secret = secret;
+ }
+
+ public Duration getAccessTtl() {
+ return accessTtl;
+ }
+
+ public void setAccessTtl(Duration accessTtl) {
+ this.accessTtl = accessTtl;
+ }
+
+ public Duration getRefreshTtl() {
+ return refreshTtl;
+ }
+
+ public void setRefreshTtl(Duration refreshTtl) {
+ this.refreshTtl = refreshTtl;
+ }
+
+ public String getRefreshCookieName() {
+ return refreshCookieName;
+ }
+
+ public void setRefreshCookieName(String refreshCookieName) {
+ this.refreshCookieName = refreshCookieName;
+ }
+
+ public boolean isRefreshCookieSecure() {
+ return refreshCookieSecure;
+ }
+
+ public void setRefreshCookieSecure(boolean refreshCookieSecure) {
+ this.refreshCookieSecure = refreshCookieSecure;
+ }
+
+ public byte[] secretBytes() {
+ byte[] bytes = secret == null ? new byte[0] : secret.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length < 32) {
+ throw new IllegalStateException("JWT_SECRET должен быть не короче 32 байт");
+ }
+ return bytes;
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java b/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java
new file mode 100644
index 0000000..49326af
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java
@@ -0,0 +1,110 @@
+package com.magistr.app.config.auth;
+
+import com.magistr.app.model.Role;
+import com.magistr.app.model.User;
+import com.nimbusds.jose.jwk.source.ImmutableSecret;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.JwtException;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+
+@Service
+public class JwtTokenService {
+
+ private static final String ISSUER = "magistr";
+
+ private final JwtProperties properties;
+ private final JwtEncoder encoder;
+ private final JwtDecoder decoder;
+
+ public JwtTokenService(JwtProperties properties) {
+ this.properties = properties;
+ SecretKey secretKey = new SecretKeySpec(properties.secretBytes(), "HmacSHA256");
+ this.encoder = new NimbusJwtEncoder(new ImmutableSecret(secretKey));
+ this.decoder = NimbusJwtDecoder
+ .withSecretKey(secretKey)
+ .macAlgorithm(MacAlgorithm.HS256)
+ .build();
+ }
+
+ public String createAccessToken(User user, String tenant) {
+ return createAccessToken(new AuthenticatedUser(
+ user.getId(),
+ user.getUsername(),
+ user.getRole(),
+ user.getDepartmentId()
+ ), tenant);
+ }
+
+ public String createAccessToken(AuthenticatedUser user, String tenant) {
+ Instant now = Instant.now();
+ JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
+ .issuer(ISSUER)
+ .issuedAt(now)
+ .expiresAt(now.plus(properties.getAccessTtl()))
+ .subject(String.valueOf(user.id()))
+ .id(UUID.randomUUID().toString())
+ .claim("tenant", tenant)
+ .claim("userId", user.id())
+ .claim("username", user.username())
+ .claim("role", user.role().name());
+
+ if (user.departmentId() != null) {
+ claims.claim("departmentId", user.departmentId());
+ }
+
+ JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
+ return encoder.encode(JwtEncoderParameters.from(header, claims.build())).getTokenValue();
+ }
+
+ public Optional authenticate(String token, String expectedTenant) {
+ if (token == null || token.isBlank() || expectedTenant == null || expectedTenant.isBlank()) {
+ return Optional.empty();
+ }
+
+ try {
+ Jwt jwt = decoder.decode(token);
+ String tenant = jwt.getClaimAsString("tenant");
+ if (!expectedTenant.equalsIgnoreCase(tenant)) {
+ return Optional.empty();
+ }
+
+ Long userId = asLong(jwt.getClaim("userId")).orElseGet(() -> Long.valueOf(jwt.getSubject()));
+ String username = jwt.getClaimAsString("username");
+ Role role = Role.valueOf(jwt.getClaimAsString("role"));
+ Long departmentId = asLong(jwt.getClaim("departmentId")).orElse(null);
+
+ return Optional.of(new AuthenticatedUser(userId, username, role, departmentId));
+ } catch (JwtException | IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+
+ private Optional asLong(Object value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ if (value instanceof Number number) {
+ return Optional.of(number.longValue());
+ }
+ try {
+ return Optional.of(Long.valueOf(String.valueOf(value)));
+ } catch (NumberFormatException e) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java
new file mode 100644
index 0000000..5f6d309
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java
@@ -0,0 +1,6 @@
+package com.magistr.app.config.auth;
+
+import com.magistr.app.model.User;
+
+public record RefreshTokenRotation(User user, String refreshToken) {
+}
diff --git a/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java
new file mode 100644
index 0000000..e4dff9e
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java
@@ -0,0 +1,132 @@
+package com.magistr.app.config.auth;
+
+import com.magistr.app.model.AuthRefreshToken;
+import com.magistr.app.model.User;
+import com.magistr.app.repository.AuthRefreshTokenRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.LocalDateTime;
+import java.util.Base64;
+import java.util.HexFormat;
+import java.util.Optional;
+
+@Service
+public class RefreshTokenService {
+
+ private static final int TOKEN_BYTES = 32;
+ private static final int USER_AGENT_LIMIT = 512;
+ private static final int IP_LIMIT = 64;
+
+ private final SecureRandom secureRandom = new SecureRandom();
+ private final AuthRefreshTokenRepository repository;
+ private final JwtProperties properties;
+
+ public RefreshTokenService(AuthRefreshTokenRepository repository, JwtProperties properties) {
+ this.repository = repository;
+ this.properties = properties;
+ }
+
+ @Transactional
+ public String createSession(User user, String tenant, String userAgent, String ipAddress) {
+ String refreshToken = generateRawToken();
+ AuthRefreshToken token = new AuthRefreshToken();
+ LocalDateTime now = LocalDateTime.now();
+ token.setUser(user);
+ token.setTenant(tenant);
+ token.setTokenHash(hashToken(refreshToken));
+ token.setIssuedAt(now);
+ token.setExpiresAt(now.plus(properties.getRefreshTtl()));
+ token.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
+ token.setIpAddress(limit(ipAddress, IP_LIMIT));
+ repository.save(token);
+ return refreshToken;
+ }
+
+ @Transactional
+ public Optional rotate(String rawToken, String tenant, String userAgent, String ipAddress) {
+ if (rawToken == null || rawToken.isBlank()) {
+ return Optional.empty();
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ String currentHash = hashToken(rawToken);
+ Optional storedOpt = repository.findByTokenHash(currentHash);
+ if (storedOpt.isEmpty()) {
+ return Optional.empty();
+ }
+
+ AuthRefreshToken stored = storedOpt.get();
+ if (!tenant.equalsIgnoreCase(stored.getTenant()) || !stored.isActive(now)) {
+ return Optional.empty();
+ }
+
+ User user = stored.getUser();
+ if (user.isArchivedRecord()) {
+ stored.setRevokedAt(now);
+ repository.save(stored);
+ return Optional.empty();
+ }
+
+ String newRawToken = generateRawToken();
+ String newHash = hashToken(newRawToken);
+ stored.setRevokedAt(now);
+ stored.setRotatedToTokenHash(newHash);
+ repository.save(stored);
+
+ AuthRefreshToken next = new AuthRefreshToken();
+ next.setUser(user);
+ next.setTenant(tenant);
+ next.setTokenHash(newHash);
+ next.setIssuedAt(now);
+ next.setExpiresAt(now.plus(properties.getRefreshTtl()));
+ next.setUserAgent(limit(userAgent, USER_AGENT_LIMIT));
+ next.setIpAddress(limit(ipAddress, IP_LIMIT));
+ repository.save(next);
+
+ return Optional.of(new RefreshTokenRotation(user, newRawToken));
+ }
+
+ @Transactional
+ public void revoke(String rawToken, String tenant) {
+ if (rawToken == null || rawToken.isBlank()) {
+ return;
+ }
+
+ repository.findByTokenHash(hashToken(rawToken))
+ .filter(token -> tenant.equalsIgnoreCase(token.getTenant()))
+ .filter(token -> token.getRevokedAt() == null)
+ .ifPresent(token -> {
+ token.setRevokedAt(LocalDateTime.now());
+ repository.save(token);
+ });
+ }
+
+ String hashToken(String rawToken) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 недоступен", e);
+ }
+ }
+
+ private String generateRawToken() {
+ byte[] bytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private String limit(String value, int maxLength) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength);
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/controller/AuthController.java b/backend/src/main/java/com/magistr/app/controller/AuthController.java
index 5e8abd8..0b5096d 100755
--- a/backend/src/main/java/com/magistr/app/controller/AuthController.java
+++ b/backend/src/main/java/com/magistr/app/controller/AuthController.java
@@ -1,15 +1,24 @@
package com.magistr.app.controller;
+import com.magistr.app.config.auth.AuthContext;
+import com.magistr.app.config.auth.JwtProperties;
+import com.magistr.app.config.auth.JwtTokenService;
+import com.magistr.app.config.auth.RefreshTokenRotation;
+import com.magistr.app.config.auth.RefreshTokenService;
+import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
-import com.magistr.app.config.auth.AuthSessionService;
-import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+import java.time.Duration;
import java.util.Map;
import java.util.Optional;
@@ -19,7 +28,9 @@ public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
- private final AuthSessionService sessionService;
+ private final JwtTokenService jwtTokenService;
+ private final RefreshTokenService refreshTokenService;
+ private final JwtProperties jwtProperties;
private static final Map ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/",
@@ -32,14 +43,18 @@ public class AuthController {
public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
- AuthSessionService sessionService) {
+ JwtTokenService jwtTokenService,
+ RefreshTokenService refreshTokenService,
+ JwtProperties jwtProperties) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
- this.sessionService = sessionService;
+ this.jwtTokenService = jwtTokenService;
+ this.refreshTokenService = refreshTokenService;
+ this.jwtProperties = jwtProperties;
}
@PostMapping("/login")
- public ResponseEntity login(@RequestBody LoginRequest request) {
+ public ResponseEntity login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
Optional userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() ||
@@ -55,12 +70,52 @@ public class AuthController {
.status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
}
- String token = sessionService.createSession(user);
- String roleName = user.getRole().name();
- String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
- Long departmentId = user.getDepartmentId();
+ String tenant = TenantContext.getCurrentTenant();
+ String accessToken = jwtTokenService.createAccessToken(user, tenant);
+ String refreshToken = refreshTokenService.createSession(
+ user,
+ tenant,
+ servletRequest.getHeader("User-Agent"),
+ clientIp(servletRequest)
+ );
- return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
+ return ResponseEntity.ok()
+ .header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString())
+ .body(loginResponse(user, accessToken));
+ }
+
+ @PostMapping("/refresh")
+ public ResponseEntity refresh(HttpServletRequest request) {
+ Optional refreshToken = refreshCookieValue(request);
+ if (refreshToken.isEmpty()) {
+ return unauthorized();
+ }
+
+ Optional rotation = refreshTokenService.rotate(
+ refreshToken.get(),
+ TenantContext.getCurrentTenant(),
+ request.getHeader("User-Agent"),
+ clientIp(request)
+ );
+ if (rotation.isEmpty()) {
+ return unauthorizedWithClearedCookie();
+ }
+
+ User user = rotation.get().user();
+ String accessToken = jwtTokenService.createAccessToken(user, TenantContext.getCurrentTenant());
+ return ResponseEntity.ok()
+ .header(HttpHeaders.SET_COOKIE, refreshCookie(rotation.get().refreshToken()).toString())
+ .body(loginResponse(user, accessToken));
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity