переделал токен
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 13s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 11s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m48s

This commit is contained in:
Zuev
2026-05-27 15:19:18 +03:00
parent 52328ae6c0
commit d4ace6aab2
28 changed files with 1227 additions and 125 deletions

View File

@@ -3,7 +3,7 @@
web_search = "live" web_search = "live"
developer_instructions = """ developer_instructions = """
Для проекта /mnt/HDD/magistr/magistr используй корневой AGENTS.md как основной проектный регламент. Для проекта /mnt/HDD/ProjectMagistr/magistr используй корневой AGENTS.md как основной проектный регламент.
Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов. Соблюдай русский язык для ответов, комментариев, UI, ошибок и логов.
При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня. При конфликте проектных docs/skills с AGENTS.md считай AGENTS.md основным проектным источником, кроме инструкций более высокого уровня.
Используй проектные скиллы из .agents/skills, когда задача соответствует их description. Используй проектные скиллы из .agents/skills, когда задача соответствует их description.

View File

@@ -62,7 +62,7 @@ docker compose logs -f backend
## Критические правила для агентов ## Критические правила для агентов
### Flyway миграции ### Flyway миграции
- **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. - **ЗАПРЕЩЕНО** изменять существующие файлы миграций (например, `V1__init.sql`). Это сломает контрольные суммы Flyway. (кроме случаев когда я сам об этом прошу)
- Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/` - Новые миграции: `V{N}__{описание}.sql` в `backend/src/main/resources/db/migration/`
- Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md) - Подробнее — см. [`docs/DATABASE.md`](docs/DATABASE.md)

View File

@@ -50,6 +50,12 @@
<artifactId>spring-security-crypto</artifactId> <artifactId>spring-security-crypto</artifactId>
</dependency> </dependency>
<!-- JWT/JWS support without enabling full Spring Security auto-flow -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- H2 in-memory DB (fallback когда нет настроенных тенантов) --> <!-- H2 in-memory DB (fallback когда нет настроенных тенантов) -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
@@ -63,6 +69,13 @@
<artifactId>opentelemetry-api</artifactId> <artifactId>opentelemetry-api</artifactId>
<version>1.49.0</version> <version>1.49.0</version>
</dependency> </dependency>
<!-- Tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -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<String, AuthenticatedUser> 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<AuthenticatedUser> findByToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(sessions.get(token));
}
}

View File

@@ -15,11 +15,11 @@ import java.util.Map;
@Component @Component
public class AuthorizationInterceptor implements HandlerInterceptor { public class AuthorizationInterceptor implements HandlerInterceptor {
private final AuthSessionService sessionService; private final JwtTokenService jwtTokenService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public AuthorizationInterceptor(AuthSessionService sessionService, ObjectMapper objectMapper) { public AuthorizationInterceptor(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
this.sessionService = sessionService; this.jwtTokenService = jwtTokenService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -28,12 +28,15 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) { if (!request.getRequestURI().startsWith("/api/") || "OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true; return true;
} }
if (request.getRequestURI().equals("/api/auth/login")) { if (isPublicAuthEndpoint(request)) {
return true; return true;
} }
String token = bearerToken(request.getHeader("Authorization")); 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) { if (user == null) {
writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему"); writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "Требуется вход в систему");
return false; return false;
@@ -49,6 +52,16 @@ public class AuthorizationInterceptor implements HandlerInterceptor {
return true; 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 @Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear(); AuthContext.clear();

View File

@@ -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;
}
}

View File

@@ -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<SecurityContext>(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<AuthenticatedUser> 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<Long> 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();
}
}
}

View File

@@ -0,0 +1,6 @@
package com.magistr.app.config.auth;
import com.magistr.app.model.User;
public record RefreshTokenRotation(User user, String refreshToken) {
}

View File

@@ -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<RefreshTokenRotation> 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<AuthRefreshToken> 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);
}
}

View File

@@ -1,15 +1,24 @@
package com.magistr.app.controller; 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.LoginRequest;
import com.magistr.app.dto.LoginResponse; 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.model.User;
import com.magistr.app.repository.UserRepository; 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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -19,7 +28,9 @@ public class AuthController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
private final AuthSessionService sessionService; private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
private static final Map<String, String> ROLE_REDIRECTS = Map.of( private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/", "ADMIN", "/admin/",
@@ -32,14 +43,18 @@ public class AuthController {
public AuthController(UserRepository userRepository, public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder, BCryptPasswordEncoder passwordEncoder,
AuthSessionService sessionService) { JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.sessionService = sessionService; this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) { public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername()); Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() || if (userOpt.isEmpty() ||
@@ -55,12 +70,52 @@ public class AuthController {
.status(401) .status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null)); .body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
} }
String token = sessionService.createSession(user); String tenant = TenantContext.getCurrentTenant();
String roleName = user.getRole().name(); String accessToken = jwtTokenService.createAccessToken(user, tenant);
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/"); String refreshToken = refreshTokenService.createSession(
Long departmentId = user.getDepartmentId(); 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<LoginResponse> refresh(HttpServletRequest request) {
Optional<String> refreshToken = refreshCookieValue(request);
if (refreshToken.isEmpty()) {
return unauthorized();
}
Optional<RefreshTokenRotation> 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<Map<String, Object>> 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") @GetMapping("/me")
@@ -76,4 +131,71 @@ public class AuthController {
"departmentId", user.departmentId() "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<LoginResponse> unauthorized() {
return ResponseEntity
.status(401)
.body(new LoginResponse(false, "Требуется вход в систему", null, null, null, null));
}
private ResponseEntity<LoginResponse> 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<String> 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();
}
} }

View File

@@ -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);
}
}

View File

@@ -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<AuthRefreshToken, Long> {
Optional<AuthRefreshToken> findByTokenHash(String tokenHash);
}

View File

@@ -14,5 +14,11 @@ spring.jpa.open-in-view=false
# Мультитенантность # Мультитенантность
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} 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

View File

@@ -91,6 +91,25 @@ VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN', 'Иванов Ад
('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1) ('просмотр_расписаний', crypt('1234567890', gen_salt('bf', 10)), 'SCHEDULE_VIEWER', 'Оператор просмотра расписаний', 'Просмотр расписаний', 1)
ON CONFLICT (username) DO NOTHING; 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 ( CREATE TABLE IF NOT EXISTS teacher_department_assignments (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id), 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.job_title IS 'Должность пользователя';
COMMENT ON COLUMN users.department_id IS 'ID кафедры'; 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.id IS 'ID формы обучения';
COMMENT ON COLUMN education_forms.name IS 'Название формы обучения'; COMMENT ON COLUMN education_forms.name IS 'Название формы обучения';
COMMENT ON COLUMN education_forms.description IS 'Описание'; COMMENT ON COLUMN education_forms.description IS 'Описание';

View File

@@ -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;
}
}

View File

@@ -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<AuthRefreshToken> 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<RefreshTokenRotation> 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<RefreshTokenRotation> 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;
}
}

View File

@@ -23,7 +23,7 @@
{ {
"success": true, "success": true,
"message": "OK", "message": "OK",
"token": "550e8400-e29b-41d4-a716-446655440000", "token": "eyJhbGciOiJIUzI1NiJ9...",
"role": "ADMIN", "role": "ADMIN",
"redirect": "/admin/", "redirect": "/admin/",
"departmentId": 1, "departmentId": 1,
@@ -31,6 +31,8 @@
} }
``` ```
Ответ также устанавливает `HttpOnly` cookie `magistr_refresh` для обновления access-токена.
**Ошибка (401):** **Ошибка (401):**
```json ```json
{ {
@@ -44,7 +46,7 @@
} }
``` ```
> После получения токена клиент должен передавать его в заголовке: `Authorization: Bearer <token>` > После получения access JWT клиент должен передавать его в заголовке: `Authorization: Bearer <token>`.
Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`. Поддерживаемые роли: `ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`.
@@ -59,6 +61,37 @@ Redirect по ролям:
| `TEACHER` | `/teacher/` | | `TEACHER` | `/teacher/` |
| `STUDENT` | `/student/` | | `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` ### `GET /api/auth/me`
Возвращает текущего пользователя по bearer-токену. Возвращает текущего пользователя по bearer-токену.
@@ -151,7 +184,7 @@ Redirect по ролям:
## Права ролей на API ## Права ролей на 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`.
Важные ограничения: Важные ограничения:

View File

@@ -131,16 +131,15 @@ sequenceDiagram
## Аутентификация ## Аутентификация
Система использует простую модель аутентификации без JWT и без полноценного Spring Security: Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
1. Клиент отправляет `POST /api/auth/login` с `username` и `password` 1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
2. Backend проверяет пароль через `BCryptPasswordEncoder` 2. Backend проверяет пароль через `BCryptPasswordEncoder`.
3. При успехе возвращается: 3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `HttpOnly` refresh-cookie.
- UUID-токен (для заголовка `Authorization: Bearer`) 4. Access JWT хранится в `localStorage`; refresh-токен хранится только в cookie, а в БД сохраняется SHA-256 хэш.
- Роль пользователя (`ADMIN`, `EDUCATION_OFFICE`, `DEPARTMENT`, `SCHEDULE_VIEWER`, `TEACHER`, `STUDENT`) 5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
- Redirect URL (`/admin/`, `/admin/#schedule-view`, `/admin/#department-workspace`, `/teacher/`, `/student/`) 6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
4. Токен хранится в `localStorage` на клиенте
Токены хранятся в `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-домене.

View File

@@ -48,6 +48,17 @@ erDiagram
TIMESTAMP created_at TIMESTAMP created_at
TIMESTAMP updated_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 { education_forms {
BIGSERIAL id PK BIGSERIAL id PK
@@ -292,6 +303,7 @@ erDiagram
student_groups ||--o{ schedule_rule_groups : "group_id" student_groups ||--o{ schedule_rule_groups : "group_id"
student_groups ||--o{ student_group_calendar_assignments : "group_id" student_groups ||--o{ student_group_calendar_assignments : "group_id"
users ||--o{ teacher_subjects : "user_id" users ||--o{ teacher_subjects : "user_id"
users ||--o{ auth_refresh_tokens : "user_id"
users ||--o{ teacher_department_assignments : "teacher_id" users ||--o{ teacher_department_assignments : "teacher_id"
departments ||--o{ teacher_department_assignments : "department_id" departments ||--o{ teacher_department_assignments : "department_id"
users ||--o{ teacher_lesson_types : "user_id" users ||--o{ teacher_lesson_types : "user_id"
@@ -379,6 +391,22 @@ erDiagram
> **Триггер:** `update_users_updated_at` автоматически обновляет `updated_at` при любом `UPDATE`. > **Триггер:** `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` — Формы обучения #### `education_forms` — Формы обучения
@@ -674,17 +702,17 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
1. Все миграции находятся в `backend/src/main/resources/db/migration/` 1. Все миграции находятся в `backend/src/main/resources/db/migration/`
2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`) 2. Формат имени: `V{номер}__{описание}.sql` (напр. `V1__init.sql`, `V2__add_departments.sql`)
3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway 3. **ЗАПРЕЩЕНО** изменять уже закоммиченные файлы миграций — это сломает контрольные суммы Flyway. Исключение допускается только по прямой просьбе пользователя и при полном сбросе tenant-БД.
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`) 4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline 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-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
### Накатывание на существующих тенантов ### Накатывание на существующих тенантов

View File

@@ -222,11 +222,13 @@ public class AbsenceController {
### Правила ### Правила
1. **Никогда** не изменяйте уже закоммиченные файлы миграций 1. **Никогда** не изменяйте уже закоммиченные файлы миграций без прямой просьбы пользователя
2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!) 2. Имя файла: `V{номер}__{описание}.sql` (два подчёркивания!)
3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ... 3. Нумерация строго инкрементальная: `V1`, `V2`, `V3`, ...
4. После добавления — перезапустите backend для применения 4. После добавления — перезапустите backend для применения
Изменение `V1__init.sql` допустимо только как осознанное исключение на этапе разработки. Для уже применённой `V1` требуется полный сброс tenant-схем или удаление истории Flyway перед запуском backend, иначе будет checksum mismatch.
### Применение ### Применение
```bash ```bash

View File

@@ -171,19 +171,24 @@ frontend/
## API-клиент (`api.js`) ## API-клиент (`api.js`)
Все HTTP-запросы проходят через обёртку `apiFetch()`: Все HTTP-запросы проходят через обёртку `apiFetch()`. Access JWT читается из `localStorage` перед каждым запросом:
```javascript ```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, { const response = await fetch(endpoint, {
method, method,
headers: { headers: getHeaders(body ? 'application/json' : null),
'Authorization': `Bearer ${token}`, credentials: 'same-origin',
'Content-Type': 'application/json' body: body ? JSON.stringify(body) : undefined
},
body: body ? JSON.stringify(body) : 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) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); 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. Пользователь вводит логин/пароль 1. Пользователь вводит логин/пароль
2. `script.js` отправляет `POST /api/auth/login` 2. `script.js` отправляет `POST /api/auth/login`
3. При успехе сохраняет в `localStorage`: 3. При успехе сохраняет в `localStorage`:
- `token`UUID-токен - `token`access JWT
- `role` — роль пользователя - `role` — роль пользователя
- `departmentId` — кафедра пользователя - `departmentId` — кафедра пользователя
- `userId` — ID пользователя для личного расписания преподавателя - `userId` — ID пользователя для личного расписания преподавателя
4. Перенаправляет на соответствующий интерфейс: 4. Refresh-токен сохраняется браузером как `HttpOnly` cookie и недоступен JavaScript
5. Перенаправляет на соответствующий интерфейс:
- `ADMIN``/admin/` - `ADMIN``/admin/`
- `EDUCATION_OFFICE``/admin/#schedule-view` - `EDUCATION_OFFICE``/admin/#schedule-view`
- `DEPARTMENT``/admin/#department-workspace` - `DEPARTMENT``/admin/#department-workspace`
@@ -230,13 +236,13 @@ export const api = {
```javascript ```javascript
export function isAuthenticatedAsAdmin() { export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && role === 'ADMIN'; return getToken() && role === 'ADMIN';
} }
``` ```
### Выход ### Выход
Кнопка «Выйти» находится в dropdown-меню «Настройки» в footer боковой панели. Очищает `localStorage` и перенаправляет на `/`. Кнопка «Выйти» вызывает `POST /api/auth/logout`, затем очищает `localStorage` и перенаправляет на `/`.
--- ---

View File

@@ -26,8 +26,13 @@ docker network create proxy
```env ```env
POSTGRES_USER=myuser POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecretpassword 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) ### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven: Backend собирается через multi-stage сборку Maven:
@@ -58,6 +63,16 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
| `frontend` | Deployment | Apache httpd | | `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов | | `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 для тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`. ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.

View File

@@ -1,38 +1,43 @@
const token = localStorage.getItem('token'); let refreshPromise = null;
const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
export function getToken() { export function getToken() {
return token; return localStorage.getItem('token');
} }
export function isAuthenticatedAsAdmin() { export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && role === 'ADMIN'; return getToken() && role === 'ADMIN';
} }
export function isAuthenticatedAsRole(expectedRole) { export function isAuthenticatedAsRole(expectedRole) {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && role === expectedRole; return getToken() && role === expectedRole;
} }
export function isAuthenticatedAsAny(roles) { export function isAuthenticatedAsAny(roles) {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
return token && roles.includes(role); return getToken() && roles.includes(role);
} }
function getHeaders(contentType = 'application/json') { function getHeaders(contentType = 'application/json') {
const headers = { const headers = {};
'Authorization': `Bearer ${token}` const token = getToken();
}; if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (contentType) { if (contentType) {
headers['Content-Type'] = contentType; headers['Content-Type'] = contentType;
} }
return headers; return headers;
} }
export async function apiFetch(endpoint, method = 'GET', body = null) { export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
const options = { const options = {
method, method,
headers: getHeaders(body ? 'application/json' : null) headers: getHeaders(body ? 'application/json' : null),
credentials: 'same-origin'
}; };
if (body) { if (body) {
@@ -48,6 +53,15 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
data = 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) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
} }
@@ -55,6 +69,57 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
return data; 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 = { export const api = {
get: (url) => apiFetch(url, 'GET'), get: (url) => apiFetch(url, 'GET'),
post: (url, body) => apiFetch(url, 'POST', body), post: (url, body) => apiFetch(url, 'POST', body),

View File

@@ -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('./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 { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -143,9 +143,8 @@ document.addEventListener('click', (e) => {
}); });
// Logout // Logout
btnLogout.addEventListener('click', () => { btnLogout.addEventListener('click', async () => {
localStorage.removeItem('token'); await logout();
localStorage.removeItem('role');
window.location.href = '/'; window.location.href = '/';
}); });

View File

@@ -1,10 +1,11 @@
// Основной модуль страницы настроек // Основной модуль страницы настроек
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js'; import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
import { getToken } from '../../js/api.js';
// Проверка авторизации // Проверка авторизации
const token = localStorage.getItem('token');
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) { if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/'; window.location.href = '/';
} }

View File

@@ -127,23 +127,28 @@
hideAlert(); hideAlert();
try { try {
const response = await fetch('/api/auth/login', { const response = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentials: 'same-origin',
username: usernameInput.value.trim(), body: JSON.stringify({
password: passwordInput.value, username: usernameInput.value.trim(),
}), password: passwordInput.value,
}),
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
showAlert('Вход выполнен успешно!', 'success'); showAlert('Вход выполнен успешно!', 'success');
if (data.token) localStorage.setItem('token', data.token); localStorage.removeItem('token');
if (data.role) localStorage.setItem('role', data.role); localStorage.removeItem('role');
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId); 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); if (data.userId) localStorage.setItem('userId', data.userId);
const redirect = data.redirect || '/'; const redirect = data.redirect || '/';

View File

@@ -292,19 +292,18 @@
<script> <script>
(() => { (() => {
const token = localStorage.getItem('token');
const groupSelect = document.getElementById('group-select'); const groupSelect = document.getElementById('group-select');
const grid = document.getElementById('schedule-grid'); const grid = document.getElementById('schedule-grid');
const statusLine = document.getElementById('status-line'); const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label'); const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker'); const datePicker = document.getElementById('date-picker');
let weekStart = startOfWeek(new Date()); let weekStart = startOfWeek(new Date());
let refreshPromise = null;
document.getElementById('logout-link').addEventListener('click', () => { document.getElementById('logout-link').addEventListener('click', async (event) => {
localStorage.removeItem('token'); event.preventDefault();
localStorage.removeItem('role'); await logout();
localStorage.removeItem('departmentId'); window.location.href = '/';
localStorage.removeItem('userId');
}); });
document.getElementById('theme-toggle-local').addEventListener('click', () => { document.getElementById('theme-toggle-local').addEventListener('click', () => {
@@ -444,17 +443,78 @@
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)); return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
} }
async function fetchJson(url) { async function fetchJson(url, retryOnUnauthorized = true) {
const token = localStorage.getItem('token');
const response = await fetch(url, { const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {} headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: 'same-origin'
}); });
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return fetchJson(url, false);
}
clearAuthState();
window.location.href = '/';
}
if (!response.ok) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
} }
return data; return data;
} }
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;
}
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (error) {
console.warn('Не удалось завершить серверную сессию:', error.message);
} finally {
clearAuthState();
}
}
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);
}
}
function clearAuthState() {
['token', 'role', 'departmentId', 'userId'].forEach(key => localStorage.removeItem(key));
}
function showStatus(message, isError) { function showStatus(message, isError) {
statusLine.textContent = message; statusLine.textContent = message;
statusLine.classList.toggle('error', isError); statusLine.classList.toggle('error', isError);

View File

@@ -295,19 +295,18 @@
<script> <script>
(() => { (() => {
const token = localStorage.getItem('token');
const teacherId = localStorage.getItem('userId'); const teacherId = localStorage.getItem('userId');
const grid = document.getElementById('schedule-grid'); const grid = document.getElementById('schedule-grid');
const statusLine = document.getElementById('status-line'); const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label'); const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker'); const datePicker = document.getElementById('date-picker');
let weekStart = startOfWeek(new Date()); let weekStart = startOfWeek(new Date());
let refreshPromise = null;
document.getElementById('logout-link').addEventListener('click', () => { document.getElementById('logout-link').addEventListener('click', async (event) => {
localStorage.removeItem('token'); event.preventDefault();
localStorage.removeItem('role'); await logout();
localStorage.removeItem('departmentId'); window.location.href = '/';
localStorage.removeItem('userId');
}); });
document.getElementById('theme-toggle-local').addEventListener('click', () => { document.getElementById('theme-toggle-local').addEventListener('click', () => {
@@ -471,17 +470,78 @@
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index)); return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
} }
async function fetchJson(url) { async function fetchJson(url, retryOnUnauthorized = true) {
const token = localStorage.getItem('token');
const response = await fetch(url, { const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {} headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: 'same-origin'
}); });
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return fetchJson(url, false);
}
clearAuthState();
window.location.href = '/';
}
if (!response.ok) { if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`); throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
} }
return data; return data;
} }
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;
}
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (error) {
console.warn('Не удалось завершить серверную сессию:', error.message);
} finally {
clearAuthState();
}
}
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);
}
}
function clearAuthState() {
['token', 'role', 'departmentId', 'userId'].forEach(key => localStorage.removeItem(key));
}
function showStatus(message, isError) { function showStatus(message, isError) {
statusLine.textContent = message; statusLine.textContent = message;
statusLine.classList.toggle('error', isError); statusLine.classList.toggle('error', isError);