переделал токен
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.magistr.app.config.auth;
|
||||||
|
|
||||||
|
import com.magistr.app.model.User;
|
||||||
|
|
||||||
|
public record RefreshTokenRotation(User user, String refreshToken) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 'Описание';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
docs/API.md
39
docs/API.md
@@ -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`.
|
||||||
|
|
||||||
Важные ограничения:
|
Важные ограничения:
|
||||||
|
|
||||||
|
|||||||
@@ -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-домене.
|
||||||
|
|||||||
@@ -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-поля, история кафедр преподавателей, комментарии дисциплин, календарные учебные графики, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
|
||||||
|
|
||||||
### Накатывание на существующих тенантов
|
### Накатывание на существующих тенантов
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` и перенаправляет на `/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 = '/';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 || '/';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user