added refresh token
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.krylov.refound.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class ChatMessage {
|
|
||||||
private Long receiverId;
|
|
||||||
private String content;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package com.krylov.refound.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class CreateChatRequest {
|
|
||||||
private Long postId; // опционально, с каким объявлением связан чат
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,24 +7,24 @@ 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;
|
||||||
private String description;
|
private String description;
|
||||||
private String category;
|
private String category;
|
||||||
private String city;
|
private String city;
|
||||||
private String district;
|
private String district;
|
||||||
private PostType type;
|
private PostType type;
|
||||||
private PostStatus status;
|
private PostStatus status;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private String userEmail;
|
private String userEmail;
|
||||||
private String phone;
|
private String phone;
|
||||||
private List<String> images;
|
private List<String> images;
|
||||||
private boolean isFavorite;
|
private boolean isFavorite;
|
||||||
private Long userId;
|
private Long userId;
|
||||||
private Long likeCount;
|
private Long likeCount;
|
||||||
private Role userRole;
|
private Role userRole;
|
||||||
private String rulesAccepted;
|
private String rulesAccepted;
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/main/java/com/krylov/refound/dto/RefreshRequest.java
Normal file
8
src/main/java/com/krylov/refound/dto/RefreshRequest.java
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package com.krylov.refound.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class RefreshRequest {
|
||||||
|
private String refreshToken;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ package com.krylov.refound.enums;
|
|||||||
|
|
||||||
public enum PostStatus {
|
public enum PostStatus {
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
CLOSED
|
CLOSED,
|
||||||
|
MODERATION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user