переделал токен
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

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

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;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.config.auth.JwtProperties;
import com.magistr.app.config.auth.JwtTokenService;
import com.magistr.app.config.auth.RefreshTokenRotation;
import com.magistr.app.config.auth.RefreshTokenService;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.dto.LoginRequest;
import com.magistr.app.dto.LoginResponse;
import com.magistr.app.config.auth.AuthSessionService;
import com.magistr.app.config.auth.AuthContext;
import com.magistr.app.model.User;
import com.magistr.app.repository.UserRepository;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
@@ -19,7 +28,9 @@ public class AuthController {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
private final AuthSessionService sessionService;
private final JwtTokenService jwtTokenService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties jwtProperties;
private static final Map<String, String> ROLE_REDIRECTS = Map.of(
"ADMIN", "/admin/",
@@ -32,14 +43,18 @@ public class AuthController {
public AuthController(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder,
AuthSessionService sessionService) {
JwtTokenService jwtTokenService,
RefreshTokenService refreshTokenService,
JwtProperties jwtProperties) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.sessionService = sessionService;
this.jwtTokenService = jwtTokenService;
this.refreshTokenService = refreshTokenService;
this.jwtProperties = jwtProperties;
}
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request, HttpServletRequest servletRequest) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty() ||
@@ -55,12 +70,52 @@ public class AuthController {
.status(401)
.body(new LoginResponse(false, "Пользователь архивирован", null, null, null, null));
}
String token = sessionService.createSession(user);
String roleName = user.getRole().name();
String redirect = ROLE_REDIRECTS.getOrDefault(roleName, "/");
Long departmentId = user.getDepartmentId();
String tenant = TenantContext.getCurrentTenant();
String accessToken = jwtTokenService.createAccessToken(user, tenant);
String refreshToken = refreshTokenService.createSession(
user,
tenant,
servletRequest.getHeader("User-Agent"),
clientIp(servletRequest)
);
return ResponseEntity.ok(new LoginResponse(true, "OK", token, roleName, redirect, departmentId, user.getId()));
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie(refreshToken).toString())
.body(loginResponse(user, accessToken));
}
@PostMapping("/refresh")
public ResponseEntity<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")
@@ -76,4 +131,71 @@ public class AuthController {
"departmentId", user.departmentId()
));
}
private LoginResponse loginResponse(User user, String token) {
String roleName = user.getRole().name();
return new LoginResponse(
true,
"OK",
token,
roleName,
ROLE_REDIRECTS.getOrDefault(roleName, "/"),
user.getDepartmentId(),
user.getId()
);
}
private ResponseEntity<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);
}