added refresh token

This commit is contained in:
SlimusMinus
2026-08-03 02:43:46 +03:00
parent d3a231f3fc
commit 7660128d96
21 changed files with 309 additions and 138 deletions

View File

@@ -6,13 +6,14 @@ import com.krylov.refound.config.MinioProperties;
import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration; import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.retry.annotation.EnableRetry; import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication( @SpringBootApplication(
scanBasePackages = "com.krylov.refound", scanBasePackages = "com.krylov.refound",
exclude = { S3AutoConfiguration.class } exclude = { S3AutoConfiguration.class, UserDetailsServiceAutoConfiguration.class }
)@EnableScheduling )@EnableScheduling
@EnableConfigurationProperties({MinioProperties.class, AiProperties.class, AiRetryProperties.class}) @EnableConfigurationProperties({MinioProperties.class, AiProperties.class, AiRetryProperties.class})
@EnableRetry @EnableRetry

View File

@@ -3,6 +3,7 @@ package com.krylov.refound.controller;
import com.krylov.refound.dto.AuthRequest; import com.krylov.refound.dto.AuthRequest;
import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.AuthResponse;
import com.krylov.refound.dto.CheckLoginDto; import com.krylov.refound.dto.CheckLoginDto;
import com.krylov.refound.dto.RefreshRequest;
import com.krylov.refound.dto.RegisterRequest; import com.krylov.refound.dto.RegisterRequest;
import com.krylov.refound.dto.UserRegisterDto; import com.krylov.refound.dto.UserRegisterDto;
import com.krylov.refound.service.AuthService; import com.krylov.refound.service.AuthService;
@@ -10,9 +11,11 @@ import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -24,26 +27,35 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) { public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
log.info("register user {}", request); log.info("register user, login={}", request.getLogin());
UserRegisterDto userRegisterDto = authService.registerUser(request); UserRegisterDto userRegisterDto = authService.registerUser(request);
return ResponseEntity.ok( return ResponseEntity.ok(new AuthResponse(userRegisterDto.token(), userRegisterDto.refreshToken(), userRegisterDto.userDto()));
new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto()));
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<?> login(@RequestBody AuthRequest request) { public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest request) {
log.info("user {} logging", request.getLogin()); log.info("user {} logging in", request.getLogin());
UserRegisterDto userRegisterDto = authService.loginUser(request); UserRegisterDto userRegisterDto = authService.loginUser(request);
return ResponseEntity.ok( return ResponseEntity.ok(new AuthResponse(userRegisterDto.token(), userRegisterDto.refreshToken(), userRegisterDto.userDto()));
new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto()));
} }
@PostMapping("/check-login") @GetMapping("/check-login")
public ResponseEntity<?> checkLogin(@RequestBody CheckLoginDto request) { public ResponseEntity<Map<String, Boolean>> checkLogin(@RequestParam String login) {
log.info("checkLogin for {}", request.getLogin()); log.info("checkLogin for {}", login);
boolean exists = authService.existsByLogin(request); boolean exists = authService.existsByLogin(new CheckLoginDto(login));
return ResponseEntity.ok(Map.of("exists", exists)); return ResponseEntity.ok(Map.of("exists", exists));
} }
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest request) {
return ResponseEntity.ok(authService.refresh(request));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody RefreshRequest request) {
authService.logout(request);
return ResponseEntity.noContent().build();
}
} }

View File

@@ -1,9 +1,11 @@
package com.krylov.refound.dto; package com.krylov.refound.dto;
import lombok.Data; import lombok.Data;
import lombok.ToString;
@Data @Data
public class AuthRequest { public class AuthRequest {
private String login; private String login;
@ToString.Exclude
private String password; private String password;
} }

View File

@@ -1,13 +1,12 @@
package com.krylov.refound.dto; package com.krylov.refound.dto;
import com.krylov.refound.entity.User;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@Data @Data
@AllArgsConstructor @AllArgsConstructor
public class AuthResponse { public class AuthResponse {
private String token; private String token;
private String refreshToken;
private UserDto user; private UserDto user;
} }

View File

@@ -1,9 +0,0 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class ChatMessage {
private Long receiverId;
private String content;
}

View File

@@ -5,5 +5,8 @@ import lombok.Data;
@Data @Data
public class CheckLoginDto { public class CheckLoginDto {
private String login; private String login;
private String password;
public CheckLoginDto(String login) {
this.login = login;
}
} }

View File

@@ -1,8 +0,0 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class CreateChatRequest {
private Long postId; // опционально, с каким объявлением связан чат
}

View File

@@ -9,10 +9,7 @@ import lombok.Setter;
@Setter @Setter
@Builder @Builder
public class ModerationErrorDetails { public class ModerationErrorDetails {
private List<String> labels; private List<String> labels;
private List<String> blockedWords; private List<String> blockedWords;
private Double score; private Double score;
} }

View File

@@ -1,29 +0,0 @@
package com.krylov.refound.dto;
import com.krylov.refound.enums.PostType;
import lombok.Data;
@Data
public class PostFilter {
private PostType type;
private String city;
private String category;
private String district;
private String search;
private Double lat;
private Double lng;
private Double radius; // в км
@Override
public String toString() {
return "type=" + type +
",city=" + city +
",category=" + category +
",district=" + district +
",search=" + search +
",lat=" + lat +
",lng=" + lng +
",radius=" + radius;
}
}

View File

@@ -7,8 +7,8 @@ import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import lombok.Data; import lombok.Data;
@Data @Data
public class PostResponse { public class PostResponse {
private Long id; private Long id;
private String title; private String title;
@@ -27,4 +27,4 @@ import lombok.Data;
private Long likeCount; private Long likeCount;
private Role userRole; private Role userRole;
private String rulesAccepted; private String rulesAccepted;
} }

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.dto;
import lombok.Data;
@Data
public class RefreshRequest {
private String refreshToken;
}

View File

@@ -1,10 +1,12 @@
package com.krylov.refound.dto; package com.krylov.refound.dto;
import lombok.Data; import lombok.Data;
import lombok.ToString;
@Data @Data
public class RegisterRequest { public class RegisterRequest {
private String name; private String name;
private String login; private String login;
@ToString.Exclude
private String password; private String password;
} }

View File

@@ -1,13 +1,5 @@
package com.krylov.refound.dto; package com.krylov.refound.dto;
import lombok.Getter; public record UserRegisterDto(UserDto userDto, String token, String refreshToken) {
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@RequiredArgsConstructor
public class UserRegisterDto {
private final UserDto userDto;
private final String token;
} }

View File

@@ -2,5 +2,6 @@ package com.krylov.refound.enums;
public enum PostStatus { public enum PostStatus {
ACTIVE, ACTIVE,
CLOSED CLOSED,
MODERATION
} }

View File

@@ -12,7 +12,6 @@ public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
boolean existsByUserAndPost(User user, Post post); boolean existsByUserAndPost(User user, Post post);
List<Favorite> findByUser(User user); List<Favorite> findByUser(User user);
void deleteByUserAndPost(User user, Post post); void deleteByUserAndPost(User user, Post post);
void deleteByPost(Post post);
void deleteByPostIn(List<Post> posts); void deleteByPostIn(List<Post> posts);
@Query(""" @Query("""
@@ -24,10 +23,10 @@ public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
// ADD THIS METHOD: // ADD THIS METHOD:
@Query(""" @Query("""
SELECT f.post.id, COUNT(f.id) SELECT f.post.id, COUNT(f.id)\s
FROM Favorite f FROM Favorite f\s
WHERE f.post.id IN :postIds WHERE f.post.id IN :postIds\s
GROUP BY f.post.id GROUP BY f.post.id
""") \s""")
List<Object[]> countLikesByPostIds(List<Long> postIds); List<Object[]> countLikesByPostIds(List<Long> postIds);
} }

View File

@@ -6,9 +6,12 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -36,13 +39,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
try { try {
if (jwtService.isTokenValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) { if (jwtService.isTokenValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
String login = jwtService.extractLogin(token); String login = jwtService.extractLogin(token);
String role = jwtService.extractRole(token);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(login, null, new ArrayList<>()); List<GrantedAuthority> authorities = role != null
? List.of(new SimpleGrantedAuthority("ROLE_" + role))
: List.of();
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(login, null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
} }
} catch (Exception e) { } catch (Exception e) {
// Token invalid — continue as unauthenticated log.warn("Token invalid — continue as unauthenticated: {}", e.getMessage());
} }
filterChain.doFilter(request, response); filterChain.doFilter(request, response);

View File

@@ -15,20 +15,38 @@ import org.springframework.stereotype.Service;
@Service @Service
public class JwtService { public class JwtService {
private static final String ROLE_CLAIM = "role";
@Value("${jwt.secret}") @Value("${jwt.secret}")
private String secret; private String secret;
@Value("${jwt.expiration}") @Value("${jwt.access-expiration}")
private long expiration; private long accessExpiration;
public String generateToken(String login) { @Value("${jwt.refresh-expiration}")
private long refreshExpiration;
public String generateAccessToken(String login, String role) {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
return generateToken(claims, login); claims.put(ROLE_CLAIM, role);
return buildToken(claims, login, accessExpiration);
} }
public String generateToken(Map<String, Object> extraClaims, String login) { /**
* Refresh-токен не несёт роли и прав — он нужен только для того,
* чтобы получить новый access-токен, а не для прямого доступа к API.
*/
public String generateRefreshToken(String login) {
return buildToken(new HashMap<>(), login, refreshExpiration);
}
public long getRefreshExpirationMillis() {
return refreshExpiration;
}
private String buildToken(Map<String, Object> claims, String login, long expiration) {
return Jwts.builder() return Jwts.builder()
.claims(extraClaims) .claims(claims)
.subject(login) .subject(login)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiration)) .expiration(new Date(System.currentTimeMillis() + expiration))
@@ -40,6 +58,10 @@ public class JwtService {
return extractClaim(token, Claims::getSubject); return extractClaim(token, Claims::getSubject);
} }
public String extractRole(String token) {
return extractClaim(token, claims -> claims.get(ROLE_CLAIM, String.class));
}
public boolean isTokenValid(String token) { public boolean isTokenValid(String token) {
try { try {
String login = extractLogin(token); String login = extractLogin(token);

View File

@@ -1,10 +1,17 @@
package com.krylov.refound.security; package com.krylov.refound.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -24,22 +31,42 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${app.cors.allowed-origins}")
private String allowedOrigins;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) // токен только в Authorization header, cookie не используется — CSRF не актуален
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(this::handleUnauthorized)
.accessDeniedHandler(this::handleForbidden)
)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// публичные эндпоинты
.requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/test/**").permitAll() .requestMatchers("/api/v1/test/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/files/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/files/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers("/api/v1/reviews/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll()
.requestMatchers("/files/**", "/uploads/**").permitAll() .requestMatchers("/files/**", "/uploads/**").permitAll()
.requestMatchers("/ws/**").permitAll() .requestMatchers("/ws/**").permitAll() // авторизация чата — на уровне STOMP CONNECT interceptor, см. заметку ниже
// изменяющие операции — только для авторизованных
.requestMatchers(HttpMethod.POST, "/api/v1/files/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v1/files/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/v1/reviews/**").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/v1/reviews/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v1/reviews/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/v1/posts/**").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v1/posts/**").authenticated()
// пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты
// .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -47,15 +74,33 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
private void handleUnauthorized(HttpServletRequest request, HttpServletResponse response,
org.springframework.security.core.AuthenticationException authException)
throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Unauthorized\"}");
}
private void handleForbidden(HttpServletRequest request, HttpServletResponse response,
org.springframework.security.access.AccessDeniedException accessDeniedException)
throws IOException {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Forbidden\"}");
}
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*")); List<String> origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
config.setAllowedMethods(List.of( config.setAllowedOrigins(origins);
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
));
config.setAllowedHeaders(List.of("*")); config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); config.setAllowCredentials(true);
config.setMaxAge(3600L); config.setMaxAge(3600L);

View File

@@ -1,7 +1,9 @@
package com.krylov.refound.service; package com.krylov.refound.service;
import com.krylov.refound.dto.AuthRequest; import com.krylov.refound.dto.AuthRequest;
import com.krylov.refound.dto.AuthResponse;
import com.krylov.refound.dto.CheckLoginDto; import com.krylov.refound.dto.CheckLoginDto;
import com.krylov.refound.dto.RefreshRequest;
import com.krylov.refound.dto.RegisterRequest; import com.krylov.refound.dto.RegisterRequest;
import com.krylov.refound.dto.UserDto; import com.krylov.refound.dto.UserDto;
import com.krylov.refound.dto.UserRegisterDto; import com.krylov.refound.dto.UserRegisterDto;
@@ -12,6 +14,7 @@ import com.krylov.refound.repository.UserRepository;
import com.krylov.refound.security.JwtService; import com.krylov.refound.security.JwtService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,8 +26,9 @@ public class AuthService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtService jwtService; private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
public UserRegisterDto registerUser(RegisterRequest request){ public UserRegisterDto registerUser(RegisterRequest request) {
if (userRepository.findByLogin(request.getLogin()).isPresent()) { if (userRepository.findByLogin(request.getLogin()).isPresent()) {
log.warn("registration failed: login {} already taken", request.getLogin()); log.warn("registration failed: login {} already taken", request.getLogin());
throw new LoginAlreadyExistsException("Логин уже занят"); throw new LoginAlreadyExistsException("Логин уже занят");
@@ -36,31 +40,54 @@ public class AuthService {
userRepository.save(user); userRepository.save(user);
String token = jwtService.generateToken(user.getLogin()); String accessToken = jwtService.generateAccessToken(user.getLogin(), user.getRole().name());
String refreshToken = refreshTokenService.issue(user.getLogin());
log.info("Refresh token returned = {}", refreshToken);
UserDto userDto = getUserDto(user); UserDto userDto = getUserDto(user);
return new UserRegisterDto(userDto, token); return new UserRegisterDto(userDto, accessToken, refreshToken);
} }
public UserRegisterDto loginUser(AuthRequest request){ public UserRegisterDto loginUser(AuthRequest request) {
User user = userRepository.findByLogin(request.getLogin()).orElse(null); User user = userRepository.findByLogin(request.getLogin()).orElse(null);
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
log.warn("login for user {} or password is incorrect", request.getLogin()); log.warn("login failed: login or password incorrect for {}", request.getLogin());
throw new InvalidCredentialsException("Неверный логин или пароль"); throw new InvalidCredentialsException("Неверный логин или пароль");
} }
String token = jwtService.generateToken(user.getLogin()); String accessToken = jwtService.generateAccessToken(user.getLogin(), user.getRole().name());
String refreshToken = refreshTokenService.issue(user.getLogin());
log.info("Refresh token returned = {}", refreshToken);
UserDto userDto = getUserDto(user); UserDto userDto = getUserDto(user);
return new UserRegisterDto(userDto, token); return new UserRegisterDto(userDto, accessToken, refreshToken);
} }
public boolean existsByLogin(CheckLoginDto request){ public boolean existsByLogin(CheckLoginDto request) {
return userRepository.existsByLogin(request.getLogin()); return userRepository.existsByLogin(request.getLogin());
} }
public AuthResponse refresh(RefreshRequest request) {
String login = refreshTokenService.validateAndGetLogin(request.getRefreshToken())
.orElseThrow(() -> new BadCredentialsException("Invalid or expired refresh token"));
User user = userRepository.findByLogin(login)
.orElseThrow(() -> new BadCredentialsException("User not found"));
// ротация: старый refresh-токен отзываем, выдаём новую пару
refreshTokenService.revoke(request.getRefreshToken());
String newAccessToken = jwtService.generateAccessToken(user.getLogin(), user.getRole().name());
String newRefreshToken = refreshTokenService.issue(user.getLogin());
log.info("Refresh token returned = {}", newRefreshToken);
return new AuthResponse(newAccessToken, newRefreshToken, getUserDto(user));
}
public void logout(RefreshRequest request) {
refreshTokenService.revoke(request.getRefreshToken());
}
private static UserDto getUserDto(User user) { private static UserDto getUserDto(User user) {
return new UserDto( return new UserDto(
user.getId(), user.getId(),

View File

@@ -0,0 +1,97 @@
package com.krylov.refound.service;
import com.krylov.refound.security.JwtService;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private static final String KEY_PREFIX = "refresh_token:";
private final StringRedisTemplate redisTemplate;
private final JwtService jwtService;
/**
* Создаёт refresh-токен, сохраняет его в Redis с TTL и привязкой к логину.
* В качестве ключа используем не сам JWT, а отдельный UUID —
* так тело токена не обязано быть валидным JWT для проверки существования в Redis,
* упрощает revoke без необходимости парсить токен.
*/
public String issue(String login) {
String tokenId = UUID.randomUUID().toString();
String refreshJwt = jwtService.generateRefreshToken(login);
log.info("Saving refresh token: key={}, login={}",
KEY_PREFIX + tokenId,
login);
redisTemplate.opsForValue().set(
KEY_PREFIX + tokenId,
login,
Duration.ofMillis(jwtService.getRefreshExpirationMillis())
);
// Кодируем tokenId в сам JWT через отдельный claim, чтобы при refresh
// можно было найти запись в Redis по этому id, а не по всему телу токена
return tokenId + "." + refreshJwt;
}
/**
* Проверяет refresh-токен: валиден ли JWT и существует ли запись в Redis.
* Возвращает login, если всё ок, иначе — пусто.
*/
public java.util.Optional<String> validateAndGetLogin(String rawToken) {
log.info("Incoming refresh token = {}", rawToken);
String[] parts = rawToken.split("\\.", 2);
log.info("parts.length = {}", parts.length);
if (parts.length == 2) {
log.info("tokenId={}", parts[0]);
log.info("jwt={}", parts[1]);
}
if (parts.length != 2) {
return Optional.empty();
}
boolean valid = jwtService.isTokenValid(parts[1]);
log.info("jwt valid={}", valid);
if (!valid) {
return Optional.empty();
}
String tokenId = parts[0];
String jwt = parts[1];
if (!jwtService.isTokenValid(jwt)) {
return java.util.Optional.empty();
}
String storedLogin = redisTemplate.opsForValue().get(KEY_PREFIX + tokenId);
String jwtLogin = jwtService.extractLogin(jwt);
if (storedLogin == null || !storedLogin.equals(jwtLogin)) {
return java.util.Optional.empty();
}
return java.util.Optional.of(jwtLogin);
}
/** Ротация: старый токен удаляется, выпускается новый. */
public void revoke(String rawToken) {
String[] parts = rawToken.split("\\.", 2);
if (parts.length == 2) {
redisTemplate.delete(KEY_PREFIX + parts[0]);
}
}
}

View File

@@ -30,27 +30,28 @@ spring:
change-log: classpath:db/changelog/db.changelog-master.yaml change-log: classpath:db/changelog/db.changelog-master.yaml
minio: minio:
endpoint: http://localhost:9010 endpoint: ${MINIO_ENDPOINT:http://localhost:9010}
bucket: refound-images bucket: ${MINIO_BUCKET:refound-images}
access-key: 59SWvKyRv7VZh4XT0p0H access-key: ${MINIO_ACCESS_KEY}
secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K secret-key: ${MINIO_SECRET_KEY}
region: us-east-1 # MinIO не проверяет регион, но SDK требует значение region: ${MINIO_REGION:us-east-1}
ai: ai:
url: http://localhost:8000 url: ${AI_SERVICE_URL:http://localhost:8000}
timeout: 5 timeout: 5
retry: retry:
max-attempts: 3 max-attempts: 3
delay: 500 delay: 500
multiplier: 2 multiplier: 2
file:
upload-dir: uploads/
cache: cache:
ttl: 300 ttl: 300
jwt: jwt:
secret: mySuperSecretKeyForJwtTokenGenerationThatIsLongEnough2024!ReFound secret: ${JWT_SECRET:mySuperSecretKeyForJwtTokenGenerationThatIsLongEnough2024!ReFound}
expiration: 86400000 # 24 hours in milliseconds access-expiration: ${JWT_ACCESS_EXPIRATION:900000} # 15 минут
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7 дней
app:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:https://refound.example.com,http://localhost:3000}