Merge pull request #23 from SlimusMinus/fix-security

added refresh token
This commit is contained in:
SlimusMinus
2026-08-03 02:52:23 +03:00
committed by GitHub
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 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

View File

@@ -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<AuthResponse> 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<AuthResponse> 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<Map<String, Boolean>> 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<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;
import lombok.Data;
import lombok.ToString;
@Data
public class AuthRequest {
private String login;
@ToString.Exclude
private String password;
}

View File

@@ -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;
}

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
public class CheckLoginDto {
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
@Builder
public class ModerationErrorDetails {
private List<String> labels;
private List<String> blockedWords;
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

@@ -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;
import lombok.Data;
import lombok.ToString;
@Data
public class RegisterRequest {
private String name;
private String login;
@ToString.Exclude
private String password;
}

View File

@@ -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;
}

View File

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

View File

@@ -12,7 +12,6 @@ public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
boolean existsByUserAndPost(User user, Post post);
List<Favorite> findByUser(User user);
void deleteByUserAndPost(User user, Post post);
void deleteByPost(Post post);
void deleteByPostIn(List<Post> posts);
@Query("""
@@ -24,10 +23,10 @@ public interface FavoriteRepository extends JpaRepository<Favorite, Long> {
// 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<Object[]> countLikesByPostIds(List<Long> postIds);
}

View File

@@ -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<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));
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);

View File

@@ -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<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()
.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);

View File

@@ -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<String> 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);

View File

@@ -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,6 +26,7 @@ public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
public UserRegisterDto registerUser(RegisterRequest request) {
if (userRepository.findByLogin(request.getLogin()).isPresent()) {
@@ -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) {
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) {
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(),

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
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
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}