From 7660128d96eb41ea2cf18511fa4b44b5fb70cd10 Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Mon, 3 Aug 2026 02:43:46 +0300 Subject: [PATCH] added refresh token --- .../krylov/refound/ReFoundApplication.java | 3 +- .../refound/controller/AuthController.java | 36 ++++--- .../com/krylov/refound/dto/AuthRequest.java | 2 + .../com/krylov/refound/dto/AuthResponse.java | 3 +- .../com/krylov/refound/dto/ChatMessage.java | 9 -- .../com/krylov/refound/dto/CheckLoginDto.java | 5 +- .../krylov/refound/dto/CreateChatRequest.java | 8 -- .../refound/dto/ModerationErrorDetails.java | 3 - .../com/krylov/refound/dto/PostFilter.java | 29 ------ .../com/krylov/refound/dto/PostResponse.java | 40 ++++---- .../krylov/refound/dto/RefreshRequest.java | 8 ++ .../krylov/refound/dto/RegisterRequest.java | 2 + .../krylov/refound/dto/UserRegisterDto.java | 10 +- .../com/krylov/refound/enums/PostStatus.java | 3 +- .../repository/FavoriteRepository.java | 9 +- .../security/JwtAuthenticationFilter.java | 13 ++- .../krylov/refound/security/JwtService.java | 34 +++++-- .../refound/security/SecurityConfig.java | 63 ++++++++++-- .../krylov/refound/service/AuthService.java | 45 +++++++-- .../refound/service/RefreshTokenService.java | 97 +++++++++++++++++++ src/main/resources/application.yaml | 25 ++--- 21 files changed, 309 insertions(+), 138 deletions(-) delete mode 100644 src/main/java/com/krylov/refound/dto/ChatMessage.java delete mode 100644 src/main/java/com/krylov/refound/dto/CreateChatRequest.java delete mode 100644 src/main/java/com/krylov/refound/dto/PostFilter.java create mode 100644 src/main/java/com/krylov/refound/dto/RefreshRequest.java create mode 100644 src/main/java/com/krylov/refound/service/RefreshTokenService.java diff --git a/src/main/java/com/krylov/refound/ReFoundApplication.java b/src/main/java/com/krylov/refound/ReFoundApplication.java index e1acad3..93c6dce 100644 --- a/src/main/java/com/krylov/refound/ReFoundApplication.java +++ b/src/main/java/com/krylov/refound/ReFoundApplication.java @@ -6,13 +6,14 @@ import com.krylov.refound.config.MinioProperties; import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication( scanBasePackages = "com.krylov.refound", - exclude = { S3AutoConfiguration.class } + exclude = { S3AutoConfiguration.class, UserDetailsServiceAutoConfiguration.class } )@EnableScheduling @EnableConfigurationProperties({MinioProperties.class, AiProperties.class, AiRetryProperties.class}) @EnableRetry diff --git a/src/main/java/com/krylov/refound/controller/AuthController.java b/src/main/java/com/krylov/refound/controller/AuthController.java index 8d0a586..3ba5568 100644 --- a/src/main/java/com/krylov/refound/controller/AuthController.java +++ b/src/main/java/com/krylov/refound/controller/AuthController.java @@ -3,6 +3,7 @@ package com.krylov.refound.controller; import com.krylov.refound.dto.AuthRequest; import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.CheckLoginDto; +import com.krylov.refound.dto.RefreshRequest; import com.krylov.refound.dto.RegisterRequest; import com.krylov.refound.dto.UserRegisterDto; import com.krylov.refound.service.AuthService; @@ -10,9 +11,11 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -24,26 +27,35 @@ public class AuthController { private final AuthService authService; @PostMapping("/register") - public ResponseEntity register(@RequestBody RegisterRequest request) { - log.info("register user {}", request); + public ResponseEntity register(@RequestBody RegisterRequest request) { + log.info("register user, login={}", request.getLogin()); UserRegisterDto userRegisterDto = authService.registerUser(request); - return ResponseEntity.ok( - new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto())); + return ResponseEntity.ok(new AuthResponse(userRegisterDto.token(), userRegisterDto.refreshToken(), userRegisterDto.userDto())); } @PostMapping("/login") - public ResponseEntity login(@RequestBody AuthRequest request) { - log.info("user {} logging", request.getLogin()); + public ResponseEntity login(@RequestBody AuthRequest request) { + log.info("user {} logging in", request.getLogin()); UserRegisterDto userRegisterDto = authService.loginUser(request); - return ResponseEntity.ok( - new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto())); + return ResponseEntity.ok(new AuthResponse(userRegisterDto.token(), userRegisterDto.refreshToken(), userRegisterDto.userDto())); } - @PostMapping("/check-login") - public ResponseEntity checkLogin(@RequestBody CheckLoginDto request) { - log.info("checkLogin for {}", request.getLogin()); - boolean exists = authService.existsByLogin(request); + @GetMapping("/check-login") + public ResponseEntity> checkLogin(@RequestParam String login) { + log.info("checkLogin for {}", login); + boolean exists = authService.existsByLogin(new CheckLoginDto(login)); return ResponseEntity.ok(Map.of("exists", exists)); } + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestBody RefreshRequest request) { + return ResponseEntity.ok(authService.refresh(request)); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestBody RefreshRequest request) { + authService.logout(request); + return ResponseEntity.noContent().build(); + } + } diff --git a/src/main/java/com/krylov/refound/dto/AuthRequest.java b/src/main/java/com/krylov/refound/dto/AuthRequest.java index 1b0d047..71c7564 100644 --- a/src/main/java/com/krylov/refound/dto/AuthRequest.java +++ b/src/main/java/com/krylov/refound/dto/AuthRequest.java @@ -1,9 +1,11 @@ package com.krylov.refound.dto; import lombok.Data; +import lombok.ToString; @Data public class AuthRequest { private String login; + @ToString.Exclude private String password; } diff --git a/src/main/java/com/krylov/refound/dto/AuthResponse.java b/src/main/java/com/krylov/refound/dto/AuthResponse.java index f2633ad..69ea889 100644 --- a/src/main/java/com/krylov/refound/dto/AuthResponse.java +++ b/src/main/java/com/krylov/refound/dto/AuthResponse.java @@ -1,13 +1,12 @@ package com.krylov.refound.dto; -import com.krylov.refound.entity.User; import lombok.AllArgsConstructor; import lombok.Data; @Data @AllArgsConstructor public class AuthResponse { - private String token; + private String refreshToken; private UserDto user; } diff --git a/src/main/java/com/krylov/refound/dto/ChatMessage.java b/src/main/java/com/krylov/refound/dto/ChatMessage.java deleted file mode 100644 index 2c52ea3..0000000 --- a/src/main/java/com/krylov/refound/dto/ChatMessage.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.krylov.refound.dto; - -import lombok.Data; - -@Data -public class ChatMessage { - private Long receiverId; - private String content; -} diff --git a/src/main/java/com/krylov/refound/dto/CheckLoginDto.java b/src/main/java/com/krylov/refound/dto/CheckLoginDto.java index 33cfef4..1117af4 100644 --- a/src/main/java/com/krylov/refound/dto/CheckLoginDto.java +++ b/src/main/java/com/krylov/refound/dto/CheckLoginDto.java @@ -5,5 +5,8 @@ import lombok.Data; @Data public class CheckLoginDto { private String login; - private String password; + + public CheckLoginDto(String login) { + this.login = login; + } } diff --git a/src/main/java/com/krylov/refound/dto/CreateChatRequest.java b/src/main/java/com/krylov/refound/dto/CreateChatRequest.java deleted file mode 100644 index 514e083..0000000 --- a/src/main/java/com/krylov/refound/dto/CreateChatRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.krylov.refound.dto; - -import lombok.Data; - -@Data -public class CreateChatRequest { - private Long postId; // опционально, с каким объявлением связан чат -} diff --git a/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java b/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java index f8c72d1..0410b47 100644 --- a/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java +++ b/src/main/java/com/krylov/refound/dto/ModerationErrorDetails.java @@ -9,10 +9,7 @@ import lombok.Setter; @Setter @Builder public class ModerationErrorDetails { - private List labels; - private List blockedWords; - private Double score; } diff --git a/src/main/java/com/krylov/refound/dto/PostFilter.java b/src/main/java/com/krylov/refound/dto/PostFilter.java deleted file mode 100644 index 3345ab2..0000000 --- a/src/main/java/com/krylov/refound/dto/PostFilter.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/java/com/krylov/refound/dto/PostResponse.java b/src/main/java/com/krylov/refound/dto/PostResponse.java index cb9cc12..cfed066 100644 --- a/src/main/java/com/krylov/refound/dto/PostResponse.java +++ b/src/main/java/com/krylov/refound/dto/PostResponse.java @@ -7,24 +7,24 @@ import java.time.LocalDateTime; import java.util.List; import lombok.Data; - @Data - public class PostResponse { +@Data +public class PostResponse { - private Long id; - private String title; - private String description; - private String category; - private String city; - private String district; - private PostType type; - private PostStatus status; - private LocalDateTime createdAt; - private String userEmail; - private String phone; - private List images; - private boolean isFavorite; - private Long userId; - private Long likeCount; - private Role userRole; - private String rulesAccepted; - } + private Long id; + private String title; + private String description; + private String category; + private String city; + private String district; + private PostType type; + private PostStatus status; + private LocalDateTime createdAt; + private String userEmail; + private String phone; + private List images; + private boolean isFavorite; + private Long userId; + private Long likeCount; + private Role userRole; + private String rulesAccepted; +} diff --git a/src/main/java/com/krylov/refound/dto/RefreshRequest.java b/src/main/java/com/krylov/refound/dto/RefreshRequest.java new file mode 100644 index 0000000..440fe87 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/RefreshRequest.java @@ -0,0 +1,8 @@ +package com.krylov.refound.dto; + +import lombok.Data; + +@Data +public class RefreshRequest { + private String refreshToken; +} diff --git a/src/main/java/com/krylov/refound/dto/RegisterRequest.java b/src/main/java/com/krylov/refound/dto/RegisterRequest.java index 4aafdb3..e0ade0c 100644 --- a/src/main/java/com/krylov/refound/dto/RegisterRequest.java +++ b/src/main/java/com/krylov/refound/dto/RegisterRequest.java @@ -1,10 +1,12 @@ package com.krylov.refound.dto; import lombok.Data; +import lombok.ToString; @Data public class RegisterRequest { private String name; private String login; + @ToString.Exclude private String password; } diff --git a/src/main/java/com/krylov/refound/dto/UserRegisterDto.java b/src/main/java/com/krylov/refound/dto/UserRegisterDto.java index 8086f60..f1c9ff8 100644 --- a/src/main/java/com/krylov/refound/dto/UserRegisterDto.java +++ b/src/main/java/com/krylov/refound/dto/UserRegisterDto.java @@ -1,13 +1,5 @@ package com.krylov.refound.dto; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.Setter; +public record UserRegisterDto(UserDto userDto, String token, String refreshToken) { -@Getter -@Setter -@RequiredArgsConstructor -public class UserRegisterDto { - private final UserDto userDto; - private final String token; } diff --git a/src/main/java/com/krylov/refound/enums/PostStatus.java b/src/main/java/com/krylov/refound/enums/PostStatus.java index 6b51d00..48d52ba 100644 --- a/src/main/java/com/krylov/refound/enums/PostStatus.java +++ b/src/main/java/com/krylov/refound/enums/PostStatus.java @@ -2,5 +2,6 @@ package com.krylov.refound.enums; public enum PostStatus { ACTIVE, - CLOSED + CLOSED, + MODERATION } diff --git a/src/main/java/com/krylov/refound/repository/FavoriteRepository.java b/src/main/java/com/krylov/refound/repository/FavoriteRepository.java index df23b18..e08ff34 100644 --- a/src/main/java/com/krylov/refound/repository/FavoriteRepository.java +++ b/src/main/java/com/krylov/refound/repository/FavoriteRepository.java @@ -12,7 +12,6 @@ public interface FavoriteRepository extends JpaRepository { boolean existsByUserAndPost(User user, Post post); List findByUser(User user); void deleteByUserAndPost(User user, Post post); - void deleteByPost(Post post); void deleteByPostIn(List posts); @Query(""" @@ -24,10 +23,10 @@ public interface FavoriteRepository extends JpaRepository { // ADD THIS METHOD: @Query(""" - SELECT f.post.id, COUNT(f.id) - FROM Favorite f - WHERE f.post.id IN :postIds + SELECT f.post.id, COUNT(f.id)\s + FROM Favorite f\s + WHERE f.post.id IN :postIds\s GROUP BY f.post.id - """) + \s""") List countLikesByPostIds(List postIds); } diff --git a/src/main/java/com/krylov/refound/security/JwtAuthenticationFilter.java b/src/main/java/com/krylov/refound/security/JwtAuthenticationFilter.java index 7d7489f..ccde5d0 100644 --- a/src/main/java/com/krylov/refound/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/krylov/refound/security/JwtAuthenticationFilter.java @@ -6,9 +6,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; @@ -36,13 +39,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { try { if (jwtService.isTokenValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) { String login = jwtService.extractLogin(token); + String role = jwtService.extractRole(token); - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(login, null, new ArrayList<>()); + List authorities = role != null + ? List.of(new SimpleGrantedAuthority("ROLE_" + role)) + : List.of(); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(login, null, authorities); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } catch (Exception e) { - // Token invalid — continue as unauthenticated + log.warn("Token invalid — continue as unauthenticated: {}", e.getMessage()); } filterChain.doFilter(request, response); diff --git a/src/main/java/com/krylov/refound/security/JwtService.java b/src/main/java/com/krylov/refound/security/JwtService.java index 56130f5..546cc5e 100644 --- a/src/main/java/com/krylov/refound/security/JwtService.java +++ b/src/main/java/com/krylov/refound/security/JwtService.java @@ -15,20 +15,38 @@ import org.springframework.stereotype.Service; @Service public class JwtService { + private static final String ROLE_CLAIM = "role"; + @Value("${jwt.secret}") private String secret; - @Value("${jwt.expiration}") - private long expiration; + @Value("${jwt.access-expiration}") + private long accessExpiration; - public String generateToken(String login) { + @Value("${jwt.refresh-expiration}") + private long refreshExpiration; + + public String generateAccessToken(String login, String role) { Map claims = new HashMap<>(); - return generateToken(claims, login); + claims.put(ROLE_CLAIM, role); + return buildToken(claims, login, accessExpiration); } - public String generateToken(Map 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 claims, String login, long expiration) { return Jwts.builder() - .claims(extraClaims) + .claims(claims) .subject(login) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expiration)) @@ -40,6 +58,10 @@ public class JwtService { 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) { try { String login = extractLogin(token); diff --git a/src/main/java/com/krylov/refound/security/SecurityConfig.java b/src/main/java/com/krylov/refound/security/SecurityConfig.java index 31667ec..5a7a89d 100644 --- a/src/main/java/com/krylov/refound/security/SecurityConfig.java +++ b/src/main/java/com/krylov/refound/security/SecurityConfig.java @@ -1,10 +1,17 @@ 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 lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; 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.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -24,22 +31,42 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Value("${app.cors.allowed-origins}") + private String allowedOrigins; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) // токен только в Authorization header, cookie не используется — CSRF не актуален .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(this::handleUnauthorized) + .accessDeniedHandler(this::handleForbidden) + ) .authorizeHttpRequests(auth -> auth + // публичные эндпоинты .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/test/**").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("/api/v1/reviews/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").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() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -47,15 +74,33 @@ public class SecurityConfig { 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 public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOriginPatterns(List.of("*")); + List origins = Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); - config.setAllowedMethods(List.of( - "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" - )); + config.setAllowedOrigins(origins); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); diff --git a/src/main/java/com/krylov/refound/service/AuthService.java b/src/main/java/com/krylov/refound/service/AuthService.java index 8b2b0a3..c0f3983 100644 --- a/src/main/java/com/krylov/refound/service/AuthService.java +++ b/src/main/java/com/krylov/refound/service/AuthService.java @@ -1,7 +1,9 @@ package com.krylov.refound.service; import com.krylov.refound.dto.AuthRequest; +import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.CheckLoginDto; +import com.krylov.refound.dto.RefreshRequest; import com.krylov.refound.dto.RegisterRequest; import com.krylov.refound.dto.UserDto; import com.krylov.refound.dto.UserRegisterDto; @@ -12,6 +14,7 @@ import com.krylov.refound.repository.UserRepository; import com.krylov.refound.security.JwtService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -23,8 +26,9 @@ public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; + private final RefreshTokenService refreshTokenService; - public UserRegisterDto registerUser(RegisterRequest request){ + public UserRegisterDto registerUser(RegisterRequest request) { if (userRepository.findByLogin(request.getLogin()).isPresent()) { log.warn("registration failed: login {} already taken", request.getLogin()); throw new LoginAlreadyExistsException("Логин уже занят"); @@ -36,31 +40,54 @@ public class AuthService { 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); - 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); 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("Неверный логин или пароль"); } - 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); - 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()); } + 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) { return new UserDto( user.getId(), diff --git a/src/main/java/com/krylov/refound/service/RefreshTokenService.java b/src/main/java/com/krylov/refound/service/RefreshTokenService.java new file mode 100644 index 0000000..6663ebb --- /dev/null +++ b/src/main/java/com/krylov/refound/service/RefreshTokenService.java @@ -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 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]); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8fbcb71..1c3139c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -30,27 +30,28 @@ spring: change-log: classpath:db/changelog/db.changelog-master.yaml minio: - endpoint: http://localhost:9010 - bucket: refound-images - access-key: 59SWvKyRv7VZh4XT0p0H - secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K - region: us-east-1 # MinIO не проверяет регион, но SDK требует значение + endpoint: ${MINIO_ENDPOINT:http://localhost:9010} + bucket: ${MINIO_BUCKET:refound-images} + access-key: ${MINIO_ACCESS_KEY} + secret-key: ${MINIO_SECRET_KEY} + region: ${MINIO_REGION:us-east-1} ai: - url: http://localhost:8000 + url: ${AI_SERVICE_URL:http://localhost:8000} timeout: 5 - retry: max-attempts: 3 delay: 500 multiplier: 2 -file: - upload-dir: uploads/ - cache: ttl: 300 jwt: - secret: mySuperSecretKeyForJwtTokenGenerationThatIsLongEnough2024!ReFound - expiration: 86400000 # 24 hours in milliseconds \ No newline at end of file + secret: ${JWT_SECRET:mySuperSecretKeyForJwtTokenGenerationThatIsLongEnough2024!ReFound} + 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} \ No newline at end of file