From 87cd4cbdc7f3f4e5f9cbf5e759a50f0d0096731e Mon Sep 17 00:00:00 2001 From: Zuev Date: Wed, 27 May 2026 15:19:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B0=D0=BB=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/config.toml | 2 +- AGENTS.md | 2 +- backend/pom.xml | 13 ++ .../app/config/auth/AuthSessionService.java | 33 ---- .../config/auth/AuthorizationInterceptor.java | 23 ++- .../app/config/auth/JwtProperties.java | 66 ++++++++ .../app/config/auth/JwtTokenService.java | 110 +++++++++++++ .../app/config/auth/RefreshTokenRotation.java | 6 + .../app/config/auth/RefreshTokenService.java | 132 ++++++++++++++++ .../app/controller/AuthController.java | 144 ++++++++++++++++-- .../magistr/app/model/AuthRefreshToken.java | 134 ++++++++++++++++ .../AuthRefreshTokenRepository.java | 11 ++ .../src/main/resources/application.properties | 8 +- .../main/resources/db/migration/V1__init.sql | 30 ++++ .../app/config/auth/JwtTokenServiceTest.java | 104 +++++++++++++ .../config/auth/RefreshTokenServiceTest.java | 115 ++++++++++++++ docs/API.md | 39 ++++- docs/ARCHITECTURE.md | 19 ++- docs/DATABASE.md | 34 ++++- docs/DEVELOPMENT.md | 4 +- docs/FRONTEND.md | 30 ++-- docs/INFRASTRUCTURE.md | 15 ++ frontend/admin/js/api.js | 85 +++++++++-- frontend/admin/js/main.js | 7 +- frontend/admin/settings/js/main.js | 5 +- frontend/script.js | 29 ++-- frontend/student/index.html | 76 ++++++++- frontend/teacher/index.html | 76 ++++++++- 28 files changed, 1227 insertions(+), 125 deletions(-) delete mode 100644 backend/src/main/java/com/magistr/app/config/auth/AuthSessionService.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/JwtProperties.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/JwtTokenService.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/RefreshTokenRotation.java create mode 100644 backend/src/main/java/com/magistr/app/config/auth/RefreshTokenService.java create mode 100644 backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java create mode 100644 backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java create mode 100644 backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java 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> logout(HttpServletRequest request) { + refreshCookieValue(request).ifPresent(token -> + refreshTokenService.revoke(token, TenantContext.getCurrentTenant()) + ); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString()) + .body(Map.of("success", true, "message", "Выход выполнен")); } @GetMapping("/me") @@ -76,4 +131,71 @@ public class AuthController { "departmentId", user.departmentId() )); } + + private LoginResponse loginResponse(User user, String token) { + String roleName = user.getRole().name(); + return new LoginResponse( + true, + "OK", + token, + roleName, + ROLE_REDIRECTS.getOrDefault(roleName, "/"), + user.getDepartmentId(), + user.getId() + ); + } + + private ResponseEntity unauthorized() { + return ResponseEntity + .status(401) + .body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null)); + } + + private ResponseEntity unauthorizedWithClearedCookie() { + return ResponseEntity + .status(401) + .header(HttpHeaders.SET_COOKIE, clearRefreshCookie().toString()) + .body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null)); + } + + private ResponseCookie refreshCookie(String token) { + return ResponseCookie.from(jwtProperties.getRefreshCookieName(), token) + .httpOnly(true) + .secure(jwtProperties.isRefreshCookieSecure()) + .sameSite("Lax") + .path("/api/auth") + .maxAge(jwtProperties.getRefreshTtl()) + .build(); + } + + private ResponseCookie clearRefreshCookie() { + return ResponseCookie.from(jwtProperties.getRefreshCookieName(), "") + .httpOnly(true) + .secure(jwtProperties.isRefreshCookieSecure()) + .sameSite("Lax") + .path("/api/auth") + .maxAge(Duration.ZERO) + .build(); + } + + private Optional refreshCookieValue(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + for (Cookie cookie : cookies) { + if (jwtProperties.getRefreshCookieName().equals(cookie.getName())) { + return Optional.ofNullable(cookie.getValue()).filter(value -> !value.isBlank()); + } + } + return Optional.empty(); + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } } diff --git a/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java b/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java new file mode 100644 index 0000000..ca4137e --- /dev/null +++ b/backend/src/main/java/com/magistr/app/model/AuthRefreshToken.java @@ -0,0 +1,134 @@ +package com.magistr.app.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "auth_refresh_tokens") +public class AuthRefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 100) + private String tenant; + + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + @Column(name = "issued_at", nullable = false) + private LocalDateTime issuedAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "rotated_to_token_hash", length = 64) + private String rotatedToTokenHash; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "ip_address", length = 64) + private String ipAddress; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getTenant() { + return tenant; + } + + public void setTenant(String tenant) { + this.tenant = tenant; + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = tokenHash; + } + + public LocalDateTime getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(LocalDateTime issuedAt) { + this.issuedAt = issuedAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getRevokedAt() { + return revokedAt; + } + + public void setRevokedAt(LocalDateTime revokedAt) { + this.revokedAt = revokedAt; + } + + public String getRotatedToTokenHash() { + return rotatedToTokenHash; + } + + public void setRotatedToTokenHash(String rotatedToTokenHash) { + this.rotatedToTokenHash = rotatedToTokenHash; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + public boolean isActive(LocalDateTime now) { + return revokedAt == null && expiresAt.isAfter(now); + } +} diff --git a/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java new file mode 100644 index 0000000..5d9599b --- /dev/null +++ b/backend/src/main/java/com/magistr/app/repository/AuthRefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.magistr.app.repository; + +import com.magistr.app.model.AuthRefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AuthRefreshTokenRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 1503138..a1d4852 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -14,5 +14,11 @@ spring.jpa.open-in-view=false # Мультитенантность app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} -#logging.level.root=DEBUG +# JWT авторизация +app.jwt.secret=${JWT_SECRET:dev-only-change-this-jwt-secret-32-bytes-minimum} +app.jwt.access-ttl=${JWT_ACCESS_TOKEN_TTL:15m} +app.jwt.refresh-ttl=${JWT_REFRESH_TOKEN_TTL:7d} +app.jwt.refresh-cookie-name=${JWT_REFRESH_COOKIE_NAME:magistr_refresh} +app.jwt.refresh-cookie-secure=${JWT_REFRESH_COOKIE_SECURE:false} +#logging.level.root=DEBUG diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql index 45fb370..5332257 100755 --- a/backend/src/main/resources/db/migration/V1__init.sql +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -91,6 +91,25 @@ VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Ад ('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1) ON CONFLICT (username) DO NOTHING; +CREATE TABLE IF NOT EXISTS auth_refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant VARCHAR(100) NOT NULL, + token_hash VARCHAR(64) UNIQUE NOT NULL, + issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP, + rotated_to_token_hash VARCHAR(64), + user_agent VARCHAR(512), + ip_address VARCHAR(64) +); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_user + ON auth_refresh_tokens(user_id); + +CREATE INDEX IF NOT EXISTS idx_auth_refresh_tokens_tenant_expires + ON auth_refresh_tokens(tenant, expires_at); + CREATE TABLE IF NOT EXISTS teacher_department_assignments ( id BIGSERIAL PRIMARY KEY, teacher_id BIGINT NOT NULL REFERENCES users(id), @@ -950,6 +969,17 @@ COMMENT ON COLUMN users.full_name IS 'ФИО пользователя'; COMMENT ON COLUMN users.job_title IS 'Должность пользователя'; COMMENT ON COLUMN users.department_id IS 'ID кафедры'; +COMMENT ON TABLE auth_refresh_tokens IS 'Отзывные refresh-сессии JWT. Сырые refresh-токены не хранятся, только SHA-256 хэш'; +COMMENT ON COLUMN auth_refresh_tokens.user_id IS 'ID пользователя, которому выдан refresh-токен'; +COMMENT ON COLUMN auth_refresh_tokens.tenant IS 'Тенант, в рамках которого выдан refresh-токен'; +COMMENT ON COLUMN auth_refresh_tokens.token_hash IS 'SHA-256 хэш refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.issued_at IS 'Дата и время выдачи refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.expires_at IS 'Дата и время истечения refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.revoked_at IS 'Дата и время отзыва refresh-токена'; +COMMENT ON COLUMN auth_refresh_tokens.rotated_to_token_hash IS 'Хэш следующего refresh-токена после ротации'; +COMMENT ON COLUMN auth_refresh_tokens.user_agent IS 'User-Agent клиента при выдаче токена'; +COMMENT ON COLUMN auth_refresh_tokens.ip_address IS 'IP-адрес клиента при выдаче токена'; + COMMENT ON COLUMN education_forms.id IS 'ID формы обучения'; COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; COMMENT ON COLUMN education_forms.description IS 'Описание'; diff --git a/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java new file mode 100644 index 0000000..e233672 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/JwtTokenServiceTest.java @@ -0,0 +1,104 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.Role; +import com.magistr.app.model.User; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtTokenServiceTest { + + @Test + void authenticatesValidTokenForSameTenant() { + JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15))); + String token = service.createAccessToken(user(), "magistr"); + + var authenticated = service.authenticate(token, "magistr"); + + assertThat(authenticated).isPresent(); + assertThat(authenticated.get().id()).isEqualTo(10L); + assertThat(authenticated.get().username()).isEqualTo("admin"); + assertThat(authenticated.get().role()).isEqualTo(Role.ADMIN); + assertThat(authenticated.get().departmentId()).isEqualTo(2L); + } + + @Test + void rejectsTokenForDifferentTenant() { + JwtTokenService service = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15))); + String token = service.createAccessToken(user(), "magistr"); + + assertThat(service.authenticate(token, "n8n")).isEmpty(); + } + + @Test + void rejectsTokenWithDifferentSignature() { + JwtTokenService issuer = new JwtTokenService(properties("12345678901234567890123456789012", Duration.ofMinutes(15))); + JwtTokenService verifier = new JwtTokenService(properties("abcdefghijklmnopqrstuvwxyz123456", Duration.ofMinutes(15))); + String token = issuer.createAccessToken(user(), "magistr"); + + assertThat(verifier.authenticate(token, "magistr")).isEmpty(); + } + + @Test + void rejectsExpiredToken() { + String secret = "12345678901234567890123456789012"; + JwtTokenService service = new JwtTokenService(properties(secret, Duration.ofMinutes(15))); + String token = expiredToken(secret); + + assertThat(service.authenticate(token, "magistr")).isEmpty(); + } + + private String expiredToken(String secret) { + try { + Instant now = Instant.now(); + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer("magistr") + .subject("10") + .jwtID("expired") + .issueTime(Date.from(now.minusSeconds(120))) + .expirationTime(Date.from(now.minusSeconds(90))) + .claim("tenant", "magistr") + .claim("userId", 10L) + .claim("username", "admin") + .claim("role", "ADMIN") + .claim("departmentId", 2L) + .build(); + SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims); + jwt.sign(new MACSigner(secret.getBytes(StandardCharsets.UTF_8))); + return jwt.serialize(); + } catch (JOSEException e) { + throw new IllegalStateException(e); + } + } + + private JwtProperties properties(String secret, Duration accessTtl) { + JwtProperties properties = new JwtProperties(); + properties.setSecret(secret); + properties.setAccessTtl(accessTtl); + properties.setRefreshTtl(Duration.ofDays(7)); + return properties; + } + + private User user() { + User user = new User(); + user.setId(10L); + user.setUsername("admin"); + user.setRole(Role.ADMIN); + user.setDepartmentId(2L); + user.setFullName("Администратор"); + user.setJobTitle("Администратор"); + user.setPassword("hash"); + return user; + } +} diff --git a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java new file mode 100644 index 0000000..068f1db --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceTest.java @@ -0,0 +1,115 @@ +package com.magistr.app.config.auth; + +import com.magistr.app.model.AuthRefreshToken; +import com.magistr.app.model.Role; +import com.magistr.app.model.User; +import com.magistr.app.repository.AuthRefreshTokenRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RefreshTokenServiceTest { + + @Test + void storesOnlyTokenHash() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + + String rawToken = service.createSession(user(), "magistr", "Browser", "127.0.0.1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuthRefreshToken.class); + verify(repository).save(captor.capture()); + AuthRefreshToken stored = captor.getValue(); + + assertThat(stored.getTokenHash()).isNotEqualTo(rawToken); + assertThat(stored.getTokenHash()).hasSize(64); + assertThat(stored.getTenant()).isEqualTo("magistr"); + assertThat(stored.getExpiresAt()).isAfter(LocalDateTime.now()); + } + + @Test + void rotatesRefreshTokenAndRejectsReplay() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + String rawToken = "raw-refresh-token"; + AuthRefreshToken stored = activeToken(service.hashToken(rawToken)); + when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored)); + + Optional rotation = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1"); + + assertThat(rotation).isPresent(); + assertThat(rotation.get().refreshToken()).isNotEqualTo(rawToken); + assertThat(stored.getRevokedAt()).isNotNull(); + assertThat(stored.getRotatedToTokenHash()).hasSize(64); + + Optional replay = service.rotate(rawToken, "magistr", "Browser", "127.0.0.1"); + assertThat(replay).isEmpty(); + verify(repository, times(2)).save(any(AuthRefreshToken.class)); + } + + @Test + void rejectsTokenFromDifferentTenant() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + String rawToken = "raw-refresh-token"; + when(repository.findByTokenHash(service.hashToken(rawToken))) + .thenReturn(Optional.of(activeToken(service.hashToken(rawToken)))); + + assertThat(service.rotate(rawToken, "n8n", "Browser", "127.0.0.1")).isEmpty(); + } + + @Test + void logoutRevokesActiveToken() { + AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class); + when(repository.save(any(AuthRefreshToken.class))).thenAnswer(invocation -> invocation.getArgument(0)); + RefreshTokenService service = new RefreshTokenService(repository, properties()); + String rawToken = "raw-refresh-token"; + AuthRefreshToken stored = activeToken(service.hashToken(rawToken)); + when(repository.findByTokenHash(service.hashToken(rawToken))).thenReturn(Optional.of(stored)); + + service.revoke(rawToken, "magistr"); + + assertThat(stored.getRevokedAt()).isNotNull(); + verify(repository).save(stored); + } + + private JwtProperties properties() { + JwtProperties properties = new JwtProperties(); + properties.setRefreshTtl(Duration.ofDays(7)); + return properties; + } + + private AuthRefreshToken activeToken(String hash) { + AuthRefreshToken token = new AuthRefreshToken(); + token.setUser(user()); + token.setTenant("magistr"); + token.setTokenHash(hash); + token.setIssuedAt(LocalDateTime.now().minusMinutes(1)); + token.setExpiresAt(LocalDateTime.now().plusDays(1)); + return token; + } + + private User user() { + User user = new User(); + user.setId(10L); + user.setUsername("admin"); + user.setRole(Role.ADMIN); + user.setDepartmentId(2L); + user.setFullName("Администратор"); + user.setJobTitle("Администратор"); + user.setPassword("hash"); + return user; + } +} diff --git a/docs/API.md b/docs/API.md index 85bd686..a7088c8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -23,7 +23,7 @@ { "success": true, "message": "OK", - "token": "550e8400-e29b-41d4-a716-446655440000", + "token": "eyJhbGciOiJIUzI1NiJ9...", "role": "ADMIN", "redirect": "/admin/", "departmentId": 1, @@ -31,6 +31,8 @@ } ``` +Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена. + **Ошибка (401):** ```json { @@ -44,7 +46,7 @@ } ``` -> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer ` +> После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer `. Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. @@ -59,6 +61,37 @@ Redirect по ролям: | `TEACHER` | `/teacher/` | | `STUDENT` | `/student/` | +### `POST /api/auth/refresh` + +Обновляет access JWT по refresh-cookie. Тело запроса не требуется. + +**Успешный ответ (200):** +```json +{ + "success": true, + "message": "OK", + "token": "eyJhbGciOiJIUzI1NiJ9...", + "role": "ADMIN", + "redirect": "/admin/", + "departmentId": 1, + "userId": 1 +} +``` + +Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается. + +### `POST /api/auth/logout` + +Отзывает текущий refresh-токен и очищает refresh-cookie. + +**Успешный ответ (200):** +```json +{ + "success": true, + "message": "Выход выполнен" +} +``` + ### `GET /api/auth/me` Возвращает текущего пользователя по bearer-токену. @@ -151,7 +184,7 @@ Redirect по ролям: ## Права ролей на API -Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, проходят через bearer-токен и `@RequireRoles`. +Скрытие вкладок во frontend не является защитой. Все `/api/**` запросы, кроме `POST /api/auth/login`, `POST /api/auth/refresh` и `POST /api/auth/logout`, проходят через bearer access JWT и `@RequireRoles`. Важные ограничения: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c5a700..13d7ecb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -131,16 +131,15 @@ sequenceDiagram ## Аутентификация -Система использует простую модель аутентификации без JWT и без полноценного Spring Security: +Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow: -1. Клиент отправляет `POST /api/auth/login` с `username` и `password` -2. Backend проверяет пароль через `BCryptPasswordEncoder` -3. При успехе возвращается: - - UUID-токен (для заголовка `Authorization: Bearer`) - - Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`) - - Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`) -4. Токен хранится в `localStorage` на клиенте +1. Клиент отправляет `POST /api/auth/login` с `username` и `password`. +2. Backend проверяет пароль через `BCryptPasswordEncoder`. +3. При успехе возвращается access JWT для заголовка `Authorization: Bearer ` и устанавливается `HttpOnly` refresh-cookie. +4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш. +5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается. +6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie. -Токены хранятся в `AuthSessionService` в памяти процесса backend. `AuthorizationInterceptor` проверяет bearer-токен для `/api/**`, кроме `POST /api/auth/login`, и применяет аннотацию `@RequireRoles` на контроллерах и методах. Это означает, что UI-роль в `localStorage` больше не является единственной защитой: backend возвращает `401`, если токена нет, и `403`, если роли недостаточно. +Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно. -`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution. +`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 9e3b82d..240625c 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -48,6 +48,17 @@ erDiagram TIMESTAMP created_at TIMESTAMP updated_at } + + auth_refresh_tokens { + BIGSERIAL id PK + BIGINT user_id FK + VARCHAR tenant + VARCHAR token_hash UK + TIMESTAMP issued_at + TIMESTAMP expires_at + TIMESTAMP revoked_at + VARCHAR rotated_to_token_hash + } education_forms { BIGSERIAL id PK @@ -292,6 +303,7 @@ erDiagram student_groups ||--o{ schedule_rule_groups : "group_id" student_groups ||--o{ student_group_calendar_assignments : "group_id" users ||--o{ teacher_subjects : "user_id" + users ||--o{ auth_refresh_tokens : "user_id" users ||--o{ teacher_department_assignments : "teacher_id" departments ||--o{ teacher_department_assignments : "department_id" users ||--o{ teacher_lesson_types : "user_id" @@ -379,6 +391,22 @@ erDiagram > **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. +#### `auth_refresh_tokens` — Refresh-сессии JWT +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | BIGSERIAL PK | ID refresh-сессии | +| `user_id` | BIGINT FK → users (CASCADE) | Пользователь | +| `tenant` | VARCHAR(100) | Тенант, для которого выдан refresh-токен | +| `token_hash` | VARCHAR(64) UNIQUE | SHA-256 хэш refresh-токена | +| `issued_at` | TIMESTAMP | Дата выдачи | +| `expires_at` | TIMESTAMP | Дата истечения | +| `revoked_at` | TIMESTAMP | Дата отзыва, `NULL` для активной сессии | +| `rotated_to_token_hash` | VARCHAR(64) | Хэш следующего refresh-токена после ротации | +| `user_agent` | VARCHAR(512) | User-Agent клиента | +| `ip_address` | VARCHAR(64) | IP-адрес клиента | + +Сырой refresh-токен никогда не хранится в БД. При каждом `POST /api/auth/refresh` старый refresh-токен отзывается, а клиент получает новый refresh-cookie. + ### Учебный процесс #### `education_forms` — Формы обучения @@ -674,17 +702,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня 1. Все миграции находятся в `backend/src/main/resources/db/migration/` 2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) -3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway +3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД. 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline -> Текущая ветка календарного учебного графика является осознанным исключением: `V1__init.sql` переписан как новая базовая схема, а применение предполагает полный сброс БД без переноса старых данных. +> Текущая JWT-правка является осознанным исключением по прямой просьбе пользователя: `V1__init.sql` обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum. ### Текущие миграции | Файл | Описание | |------|----------| -| `V1__init.sql` | Инициализация: справочники, роли, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | +| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии | ### Накатывание на существующих тенантов diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f7727e9..8e7be9d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -222,11 +222,13 @@ public class AbsenceController { ### Правила -1. **Никогда** не изменяйте уже закоммиченные файлы миграций +1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя 2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) 3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... 4. После добавления — перезапустите backend для применения +Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch. + ### Применение ```bash diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index a8aa0f1..7eff0c3 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -171,19 +171,24 @@ frontend/ ## API-клиент (`api.js`) -Все HTTP-запросы проходят через обёртку `apiFetch()`: +Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом: ```javascript -export async function apiFetch(endpoint, method = 'GET', body = null) { +export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) { const response = await fetch(endpoint, { method, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: body ? JSON.stringify(body) : null + headers: getHeaders(body ? 'application/json' : null), + credentials: 'same-origin', + body: body ? JSON.stringify(body) : undefined }); + if (response.status === 401 && retryOnUnauthorized) { + const refreshed = await refreshAccessToken(); + if (refreshed) return apiFetch(endpoint, method, body, false); + clearAuthState(); + window.location.href = '/'; + } + if (!response.ok) { throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); } @@ -200,7 +205,7 @@ export const api = { }; ``` -Токен берётся из `localStorage.getItem('token')`. +При `401` клиент один раз вызывает `POST /api/auth/refresh`, обновляет `localStorage.token` и повторяет исходный запрос. Если refresh неуспешен, auth state очищается и пользователь возвращается на страницу входа. --- @@ -211,11 +216,12 @@ export const api = { 1. Пользователь вводит логин/пароль 2. `script.js` отправляет `POST /api/auth/login` 3. При успехе сохраняет в `localStorage`: - - `token` — UUID-токен + - `token` — access JWT - `role` — роль пользователя - `departmentId` — кафедра пользователя - `userId` — ID пользователя для личного расписания преподавателя -4. Перенаправляет на соответствующий интерфейс: +4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript +5. Перенаправляет на соответствующий интерфейс: - `ADMIN` → `/admin/` - `EDUCATION_OFFICE` → `/admin/#schedule-view` - `DEPARTMENT` → `/admin/#department-workspace` @@ -230,13 +236,13 @@ export const api = { ```javascript export function isAuthenticatedAsAdmin() { const role = localStorage.getItem('role'); - return token && role === 'ADMIN'; + return getToken() && role === 'ADMIN'; } ``` ### Выход -Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`. +Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`. --- diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 6c80225..ad899bd 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -26,8 +26,13 @@ docker network create proxy ```env POSTGRES_USER=myuser POSTGRES_PASSWORD=supersecretpassword +JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes +JWT_ACCESS_TOKEN_TTL=15m +JWT_REFRESH_TOKEN_TTL=7d ``` +`JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене он задаётся через Kubernetes Secret `app-secret`. + ### Dockerfile (Backend) Backend собирается через multi-stage сборку Maven: @@ -58,6 +63,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ | `frontend` | Deployment | Apache httpd | | `tenants-config` | ConfigMap | JSON-список тенантов | +### JWT настройки + +`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie: + +- `JWT_ACCESS_TOKEN_TTL=15m` +- `JWT_REFRESH_TOKEN_TTL=7d` +- `JWT_REFRESH_COOKIE_SECURE=true` + +`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет. + ### ConfigMap для тенантов ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`. diff --git a/frontend/admin/js/api.js b/frontend/admin/js/api.js index 5e96564..5df791b 100755 --- a/frontend/admin/js/api.js +++ b/frontend/admin/js/api.js @@ -1,38 +1,43 @@ -const token = localStorage.getItem('token'); +let refreshPromise = null; + +const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId']; export function getToken() { - return token; + return localStorage.getItem('token'); } export function isAuthenticatedAsAdmin() { const role = localStorage.getItem('role'); - return token && role === 'ADMIN'; + return getToken() && role === 'ADMIN'; } export function isAuthenticatedAsRole(expectedRole) { const role = localStorage.getItem('role'); - return token && role === expectedRole; + return getToken() && role === expectedRole; } export function isAuthenticatedAsAny(roles) { const role = localStorage.getItem('role'); - return token && roles.includes(role); + return getToken() && roles.includes(role); } function getHeaders(contentType = 'application/json') { - const headers = { - 'Authorization': `Bearer ${token}` - }; + const headers = {}; + const token = getToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } if (contentType) { headers['Content-Type'] = contentType; } return headers; } -export async function apiFetch(endpoint, method = 'GET', body = null) { +export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) { const options = { method, - headers: getHeaders(body ? 'application/json' : null) + headers: getHeaders(body ? 'application/json' : null), + credentials: 'same-origin' }; if (body) { @@ -48,6 +53,15 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { data = null; } + if (response.status === 401 && retryOnUnauthorized) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + return apiFetch(endpoint, method, body, false); + } + clearAuthState(); + window.location.href = '/'; + } + if (!response.ok) { throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); } @@ -55,6 +69,57 @@ export async function apiFetch(endpoint, method = 'GET', body = null) { return data; } +export async function refreshAccessToken() { + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'same-origin' + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.token) { + return false; + } + storeAuthState(data); + return true; + })().finally(() => { + refreshPromise = null; + }); + + return refreshPromise; +} + +export async function logout() { + try { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin' + }); + } catch (e) { + console.warn('Не удалось завершить серверную сессию:', e.message); + } finally { + clearAuthState(); + } +} + +export function storeAuthState(data) { + if (data.token) localStorage.setItem('token', data.token); + if (data.role) localStorage.setItem('role', data.role); + if (data.departmentId !== undefined && data.departmentId !== null) { + localStorage.setItem('departmentId', data.departmentId); + } + if (data.userId !== undefined && data.userId !== null) { + localStorage.setItem('userId', data.userId); + } +} + +export function clearAuthState() { + AUTH_KEYS.forEach(key => localStorage.removeItem(key)); +} + export const api = { get: (url) => apiFetch(url, 'GET'), post: (url, body) => apiFetch(url, 'POST', body), diff --git a/frontend/admin/js/main.js b/frontend/admin/js/main.js index 4b73c3d..812fbb2 100755 --- a/frontend/admin/js/main.js +++ b/frontend/admin/js/main.js @@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) { import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message)); } -import { isAuthenticatedAsAny } from './api.js'; +import { isAuthenticatedAsAny, logout } from './api.js'; import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; @@ -143,9 +143,8 @@ document.addEventListener('click', (e) => { }); // Logout -btnLogout.addEventListener('click', () => { - localStorage.removeItem('token'); - localStorage.removeItem('role'); +btnLogout.addEventListener('click', async () => { + await logout(); window.location.href = '/'; }); diff --git a/frontend/admin/settings/js/main.js b/frontend/admin/settings/js/main.js index 3f99b58..9a41f88 100644 --- a/frontend/admin/settings/js/main.js +++ b/frontend/admin/settings/js/main.js @@ -1,10 +1,11 @@ // Основной модуль страницы настроек import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; +import { getToken } from '../../js/api.js'; + // Проверка авторизации -const token = localStorage.getItem('token'); const role = localStorage.getItem('role'); -if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { +if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { window.location.href = '/'; } diff --git a/frontend/script.js b/frontend/script.js index cf3632f..d1455fa 100755 --- a/frontend/script.js +++ b/frontend/script.js @@ -127,23 +127,28 @@ hideAlert(); try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: usernameInput.value.trim(), - password: passwordInput.value, - }), + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + username: usernameInput.value.trim(), + password: passwordInput.value, + }), }); const data = await response.json(); - if (response.ok) { - showAlert('Вход выполнен успешно!', 'success'); + if (response.ok) { + showAlert('Вход выполнен успешно!', 'success'); - if (data.token) localStorage.setItem('token', data.token); - if (data.role) localStorage.setItem('role', data.role); - if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); + localStorage.removeItem('token'); + localStorage.removeItem('role'); + localStorage.removeItem('departmentId'); + localStorage.removeItem('userId'); + if (data.token) localStorage.setItem('token', data.token); + if (data.role) localStorage.setItem('role', data.role); + if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); if (data.userId) localStorage.setItem('userId', data.userId); const redirect = data.redirect || '/'; diff --git a/frontend/student/index.html b/frontend/student/index.html index 34219aa..2108e18 100644 --- a/frontend/student/index.html +++ b/frontend/student/index.html @@ -292,19 +292,18 @@