From a1d991997f8142284bedb96c7fcd36cc02235194 Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 13 Oct 2025 01:35:36 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat=20:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B5=AC=ED=98=84=20=20-=20/auth/logout=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EB=B8=94?= =?UTF-8?q?=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20-=20=ED=86=A0=ED=81=B0=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../members/dto/auth/AuthResponseDTO.java | 4 + .../members/service/auth/AuthService.java | 3 + .../service/auth/TokenBlackListService.java | 47 +++++++++++ .../code/status/error/AuthStatus.java | 3 + .../status/success/AuthSuccessStatus.java | 36 ++++++++ .../global/security/CustomLogoutHandler.java | 42 ++++++++++ .../security/JwtAuthenticationFilter.java | 27 +++--- .../security/JwtTokenExpirationTime.java | 16 ++++ .../global/security/JwtTokenProvider.java | 4 +- .../global/security/SecurityAllowOrigins.java | 16 ++++ .../global/security/SecurityConfig.java | 84 ++++++++++--------- 11 files changed, 229 insertions(+), 53 deletions(-) create mode 100644 src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java create mode 100644 src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java create mode 100644 src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java create mode 100644 src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java create mode 100644 src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java diff --git a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java index e8340dd..b6ab51d 100644 --- a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java @@ -1,5 +1,6 @@ package DiffLens.back_end.domain.members.dto.auth; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,7 +14,9 @@ public class AuthResponseDTO { @NoArgsConstructor public static class LoginDto{ + @JsonProperty(value = "access_token") private String accessToken; + @JsonProperty(value = "refresh_token") private String refreshToken; } @@ -24,6 +27,7 @@ public static class LoginDto{ @NoArgsConstructor public static class SignUpDto{ + @JsonProperty(value = "is_success") private Boolean isSuccess; } diff --git a/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java b/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java index 9f822ac..edf1e89 100644 --- a/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java +++ b/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java @@ -10,6 +10,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 로그인과 회원가입 처리 + */ @Service @RequiredArgsConstructor public class AuthService { diff --git a/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java b/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java new file mode 100644 index 0000000..5c3199a --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java @@ -0,0 +1,47 @@ +package DiffLens.back_end.domain.members.service.auth; + +import DiffLens.back_end.global.security.JwtTokenExpirationTime; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class TokenBlackListService { + + private final RedisTemplate redisTemplate; + + private static final String BLACKLIST_PREFIX = "blacklist:"; + + private final long expirationMillis = JwtTokenExpirationTime.ACCESS_TOKEN.getExpirationMillis(); + + /** + * BlackList 내에 토큰 추가합니다. + */ + public void addTokenToList(String token) { + String key = BLACKLIST_PREFIX + token; + redisTemplate.opsForValue().set(key, "BLACKLISTED", expirationMillis, TimeUnit.MILLISECONDS); + } + + /** + * BlackList 내에 토큰이 존재하는지 여부 확인 + * @param token accessToken + * @return 토큰 존재여부 Boolean + */ + public boolean isContainToken(String token) { + String key = BLACKLIST_PREFIX + token; + return redisTemplate.hasKey(key); + } + + /** + * 블랙리스트에서 제거 (필요한 경우만) + */ + public void removeToken(String token) { + String key = BLACKLIST_PREFIX + token; + redisTemplate.delete(key); + } + + +} diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java index 372d9db..e9f16e8 100644 --- a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java @@ -15,6 +15,9 @@ public enum AuthStatus implements BaseErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH401", "존재하지 않는 사용자입니다."), ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "AUTH402", "이미 존재하는 사용자입니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "AUTH403", "올바르지 않은 비밀번호입니다."), + HEADER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH404", "인증 헤더가 존재하지 않습니다."), + EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "AUTH405", "만료된 토큰입니다."), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 토큰입니다."), ERROR_IN_SIGNUP(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH500", "회원가입 중 알 수 없는 오류가 발생했습니다."), diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java new file mode 100644 index 0000000..6b03c32 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/success/AuthSuccessStatus.java @@ -0,0 +1,36 @@ +package DiffLens.back_end.global.responses.code.status.success; + +import DiffLens.back_end.global.responses.code.BaseCode; +import DiffLens.back_end.global.responses.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum AuthSuccessStatus implements BaseCode { + + LOGIN_SUCCESS(HttpStatus.OK, "AUTH200", "로그인 성공입니다."), + LOGOUT_SUCCESS(HttpStatus.OK, "AUTH205", "로그아웃 성공입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder().message(message).code(code).isSuccess(true).build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build(); + } + +} diff --git a/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java b/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java new file mode 100644 index 0000000..16b50d7 --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java @@ -0,0 +1,42 @@ +package DiffLens.back_end.global.security; + +import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomLogoutHandler implements LogoutHandler { + + private final TokenBlackListService tokenBlackListService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + + // 요청 정보에서 토큰 추출 + String headerToken = request.getHeader("Authorization"); + + // 토큰이 없을 경우 처리 + if(headerToken == null) { + throw new ErrorHandler(AuthStatus.HEADER_NOT_FOUND); + } + + // 헤더에서 토큰 추출 + String token = headerToken.substring(7); + + // Redis 내에 토큰이 존재하지 않는 경우 + if (!tokenBlackListService.isContainToken(token)) { + tokenBlackListService.addTokenToList(token); // BlackList 추가 + } + + } + +} diff --git a/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java b/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java index 816217a..478c570 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtAuthenticationFilter.java @@ -1,25 +1,29 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Collections; +@Component +@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; - - public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { - this.jwtTokenProvider = jwtTokenProvider; - } + private final TokenBlackListService blackListService; @Override protected void doFilterInternal( @@ -29,17 +33,15 @@ protected void doFilterInternal( try { if (token != null) { -// System.out.println("Token found: " + token); - // if (blacklistRepository.isBlacklisted(token)) { - // response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - // response.getWriter().write("로그아웃된 토큰입니다."); - // return; - // } + + // token 이 BlackList에 있을 경우 + if (blackListService.isContainToken(token)) { + throw new ErrorHandler(AuthStatus.EXPIRED_TOKEN); + } + if (jwtTokenProvider.validateToken(token)) { Authentication authentication = jwtTokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); -// System.out.println( -// "Authentication set in SecurityContext: " + authentication.getName()); // System.out.println("authentication = " + authentication.getAuthorities()); } else { // System.out.println("Invalid or expired token."); @@ -57,7 +59,6 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication(anonymousAuth); } } catch (Exception ex) { - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write("Internal server error occurred."); } diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java new file mode 100644 index 0000000..cd083ba --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenExpirationTime.java @@ -0,0 +1,16 @@ +package DiffLens.back_end.global.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JwtTokenExpirationTime { + + ACCESS_TOKEN(1000L * 60 * 30), // 30분 + REFRESH_TOKEN(1000L * 60 * 60 * 24 * 14) + ; + + private final long expirationMillis; + +} diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java index b9fd928..cea9b77 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java @@ -38,8 +38,8 @@ public class JwtTokenProvider { @Value("${spring.jwt.secret}") private String secretKey; - private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 15 * 24; - private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 24 * 30; + private static final long ACCESS_TOKEN_EXPIRE_TIME = JwtTokenExpirationTime.ACCESS_TOKEN.getExpirationMillis(); + private static final long REFRESH_TOKEN_EXPIRE_TIME = JwtTokenExpirationTime.REFRESH_TOKEN.getExpirationMillis(); @PostConstruct public void init() { diff --git a/src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java b/src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java new file mode 100644 index 0000000..edce61b --- /dev/null +++ b/src/main/java/DiffLens/back_end/global/security/SecurityAllowOrigins.java @@ -0,0 +1,16 @@ +package DiffLens.back_end.global.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "spring.security") +public class SecurityAllowOrigins { + private List allowedOrigins; +} diff --git a/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java b/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java index 4b950ec..026bb09 100644 --- a/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java +++ b/src/main/java/DiffLens/back_end/global/security/SecurityConfig.java @@ -1,7 +1,11 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.code.status.success.AuthSuccessStatus; +import DiffLens.back_end.global.responses.exception.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -9,6 +13,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -18,19 +23,14 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; - @EnableWebSecurity @Configuration @RequiredArgsConstructor -@ConfigurationProperties(prefix = "spring.security") public class SecurityConfig { - private List allowedOrigins; - - public void setAllowedOrigins(List allowedOrigins) { - this.allowedOrigins = allowedOrigins; - } + private final CustomLogoutHandler customLogoutHandler; + private final SecurityAllowOrigins securityProperties; + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public WebSecurityCustomizer webSecurityCustomizer() { @@ -38,48 +38,57 @@ public WebSecurityCustomizer webSecurityCustomizer() { } @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .headers( - headers -> - headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests( - request -> - request.requestMatchers( - "/images/**", - "/swagger-ui/**", - "/v3/api-docs/**", - "/auth/signup/**", - "/auth/login/**", - "/oauth2/**" - ) - .permitAll() - .anyRequest() - .authenticated() + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(request -> request + .requestMatchers( + "/images/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/auth/signup/**", + "/auth/login/**", + "/oauth2/**" + ).permitAll() + .anyRequest().authenticated() ) - // .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, - // blacklistRepository), UsernamePasswordAuthenticationFilter.class); - .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider), - UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) + .logout(this::configureLogout); return http.build(); } + // 로그아웃 처리 + private void configureLogout(LogoutConfigurer logout) { + + ObjectMapper objectMapper = new ObjectMapper(); + + logout + .logoutUrl("/auth/logout") + .addLogoutHandler(customLogoutHandler) + .logoutSuccessHandler((request, response, authentication) -> { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse apiResponse = ApiResponse.onSuccess(AuthSuccessStatus.LOGOUT_SUCCESS); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + }); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - allowedOrigins.forEach(configuration::addAllowedOriginPattern); - configuration.setAllowCredentials(true); // 인증 정보 포함 허용 + securityProperties.getAllowedOrigins().forEach(configuration::addAllowedOriginPattern); + configuration.setAllowCredentials(true); configuration.addAllowedMethod("*"); - configuration.setMaxAge(3600L); // CORS 요청 캐싱 시간 (1시간) + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); @@ -90,5 +99,4 @@ public CorsConfigurationSource corsConfigurationSource() { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - } From 1b25b808e929a0dfcce1363cd8cfc3c85c5c3623 Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 13 Oct 2025 04:28:16 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat=20:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=20=20-=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20api=20=EA=B5=AC=ED=98=84=20=20=20-=20access=20token?= =?UTF-8?q?=20=EB=A7=8C=EB=A3=8C=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=20=20-=20=EC=83=81=EA=B8=B0=20=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20redis=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../implement/AbstractSocialAuthStrategy.java | 4 +- .../implement/AuthGeneralStrategy.java | 4 +- .../strategy/interfaces/AuthStrategy.java | 2 +- .../members/controller/AuthController.java | 36 +++++++-- .../members/dto/auth/AuthRequestDTO.java | 21 ++--- .../members/dto/auth/AuthResponseDTO.java | 11 +++ .../repository/AccessTokenRepository.java | 46 +++++++++++ .../repository/RefreshTokenRepository.java | 50 ++++++++++-- .../members/service/auth/AuthService.java | 31 ++++++- .../service/auth/TokenBlackListService.java | 19 ++--- .../code/status/error/AuthStatus.java | 3 +- .../global/security/CustomLogoutHandler.java | 11 ++- .../global/security/JwtTokenProvider.java | 80 ++++++++++++++----- 13 files changed, 255 insertions(+), 63 deletions(-) create mode 100644 src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java index 28c0a75..aa3de53 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AbstractSocialAuthStrategy.java @@ -27,7 +27,7 @@ public abstract class AbstractSocialAuthStrategy implements AuthStrategy { protected final JwtTokenProvider jwtTokenProvider; @Override - public Boolean signUp(AuthRequestDTO.SignUpDto request) { + public Boolean signUp(AuthRequestDTO.SignUp request) { // 소셜 로그인은 보통 signUp 단독 호출 필요 없음 return true; } @@ -35,7 +35,7 @@ public Boolean signUp(AuthRequestDTO.SignUpDto request) { @Override public TokenResponseDTO login(Object request) { - AuthRequestDTO.SocialLoginDto body = (AuthRequestDTO.SocialLoginDto) request; + AuthRequestDTO.SocialLogin body = (AuthRequestDTO.SocialLogin) request; String code = body.getCode(); String decoded = URLDecoder.decode(code, StandardCharsets.UTF_8); diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java index de0d3e7..8942bff 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/implement/AuthGeneralStrategy.java @@ -22,7 +22,7 @@ public class AuthGeneralStrategy implements AuthStrategy { private final JwtTokenProvider jwtTokenProvider; @Override - public Boolean signUp(AuthRequestDTO.SignUpDto request) { + public Boolean signUp(AuthRequestDTO.SignUp request) { // 요청 정보 추출 String email = request.getEmail(); @@ -49,7 +49,7 @@ public Boolean signUp(AuthRequestDTO.SignUpDto request) { @Override public TokenResponseDTO login(Object request) { - AuthRequestDTO.LoginDto body = (AuthRequestDTO.LoginDto) request; + AuthRequestDTO.Login body = (AuthRequestDTO.Login) request; // 유저 가져옴 Member member = memberRepository.findByEmail(body.getEmail()) diff --git a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java index a04406e..2576657 100644 --- a/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java +++ b/src/main/java/DiffLens/back_end/domain/members/auth/strategy/interfaces/AuthStrategy.java @@ -10,7 +10,7 @@ public interface AuthStrategy { * @param request 회원가입 시 클라이언트에서 보내는 Request Body * @return 회원가입 성공 여부 */ - public Boolean signUp(AuthRequestDTO.SignUpDto request); + public Boolean signUp(AuthRequestDTO.SignUp request); /** diff --git a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java index 40bc4eb..122ff20 100644 --- a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java +++ b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java @@ -6,12 +6,10 @@ import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -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.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "로그인 관련 API") @RestController @@ -35,7 +33,7 @@ public class AuthController { 로그인 성공 여부를 반환합니다. """) - public ApiResponse signUp(@RequestBody @Valid AuthRequestDTO.SignUpDto request){ + public ApiResponse signUp(@RequestBody @Valid AuthRequestDTO.SignUp request){ AuthResponseDTO.SignUpDto signUpDto = authService.signUp(request); return ApiResponse.onSuccess(signUpDto); } @@ -55,7 +53,7 @@ public ApiResponse signUp(@RequestBody @Valid AuthReq 인증에 필요한 토큰정보를 포함합니다. """) - public ApiResponse localLogin(@RequestBody @Valid AuthRequestDTO.LoginDto request){ + public ApiResponse localLogin(@RequestBody @Valid AuthRequestDTO.Login request){ AuthResponseDTO.LoginDto login = authService.login(request); return ApiResponse.onSuccess(login); } @@ -80,9 +78,33 @@ public ApiResponse localLogin(@RequestBody @Valid Auth """) - public ApiResponse googleLogin(@RequestBody @Valid AuthRequestDTO.SocialLoginDto request){ + public ApiResponse googleLogin(@RequestBody @Valid AuthRequestDTO.SocialLogin request){ AuthResponseDTO.LoginDto login = authService.login(request); return ApiResponse.onSuccess(login); } + @PostMapping("/reissue") + @Operation(summary = "인증 토큰 재발급", + description = """ + + ## 개요 + 기존 토큰 만료 시 해당 API를 호출하여 새로운 인증토큰을 발급 받습니다.
+ 재발급 시 기존 토큰은 만료됩니다. + + ## Request Header + Authorization 헤더에 refresh token 을 담아 호출하세요.
+ ex) Bearer eyJhbGciOi...re7neDrYl9gJM6c + + ## 응답 + AccessToken과 RefreshToken 이 반환됩니다. + - 재발급 시 기존 AccessToken은 사용 불가능합니다. + - Refresh Token은 계정마다 14일 유효합니다. + + """) + public ApiResponse reIssue(HttpServletRequest request) { + AuthResponseDTO.LoginDto loginDto = authService.reIssue(request); + return ApiResponse.onSuccess(loginDto); + } + + } diff --git a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java index bb6bdb0..ef5718b 100644 --- a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java +++ b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthRequestDTO.java @@ -15,7 +15,7 @@ public class AuthRequestDTO { @Getter @NoArgsConstructor @AllArgsConstructor - public static class SignUpDto { + public static class SignUp { @Schema(description = "email 형식으로 입력해야 합니다.") @NotBlank(message = "이메일은 필수 입력 항목입니다.") @@ -42,25 +42,28 @@ public static class SignUpDto { @Getter @NoArgsConstructor @AllArgsConstructor - public static class LoginDto implements LoginRequest { + public static class Login implements LoginRequest { - @NotBlank(message = "이메일은 필수 입력 항목입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") + @Schema(description = "이메일", example = "example@gmail.com") + @NotBlank + @Email private String email; - @NotBlank(message = "비밀번호는 필수 입력 항목입니다.") - @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하로 입력해주세요.") + @Schema(description = "비밀번호", example = "password123") + @NotBlank + @Size(min = 8, max = 20) private String password; - @NotNull(message = "로그인 타입은 필수 항목입니다.") + @Schema(description = "로그인 타입", example = "GENERAL") + @NotNull private LoginType loginType; - } + @Getter @NoArgsConstructor @AllArgsConstructor - public static class SocialLoginDto implements LoginRequest { + public static class SocialLogin implements LoginRequest { @NotBlank(message = "code 는 필수 입력 항목입니다.") private String code; diff --git a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java index b6ab51d..4235d19 100644 --- a/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/members/dto/auth/AuthResponseDTO.java @@ -32,4 +32,15 @@ public static class SignUpDto{ } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ReIssue{ + + @JsonProperty(value = "access_token") + private String accessToken; + + } + } diff --git a/src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java b/src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java new file mode 100644 index 0000000..9d1fae0 --- /dev/null +++ b/src/main/java/DiffLens/back_end/domain/members/repository/AccessTokenRepository.java @@ -0,0 +1,46 @@ +package DiffLens.back_end.domain.members.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class AccessTokenRepository { + + private final StringRedisTemplate redisTemplate; + + private final String ACCESS_TOKEN_KEY = "accessToken:"; + private final String MEMBER_ACCESS_TOKEN_KEY = "memberAccessToken:"; + + public void saveToken(Long memberId, String accessToken, long expirationMillis) { + // 기존 memberAccessToken 삭제 + String oldToken = redisTemplate.opsForValue().get(MEMBER_ACCESS_TOKEN_KEY + memberId); + if (oldToken != null) { + redisTemplate.delete(ACCESS_TOKEN_KEY + oldToken); + } + + // 새 토큰 저장 + redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY + accessToken, memberId.toString(), expirationMillis, TimeUnit.MILLISECONDS); + redisTemplate.opsForValue().set(MEMBER_ACCESS_TOKEN_KEY + memberId, accessToken, expirationMillis, TimeUnit.MILLISECONDS); + } + + public Optional getCurrentToken(Long memberId) { + return Optional.ofNullable(redisTemplate.opsForValue().get(MEMBER_ACCESS_TOKEN_KEY + memberId)); + } + + public void deleteToken(String accessToken) { + String memberId = redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY + accessToken); + if (memberId != null) { + redisTemplate.delete(MEMBER_ACCESS_TOKEN_KEY + memberId); + } + redisTemplate.delete(ACCESS_TOKEN_KEY + accessToken); + } + + public boolean exists(String accessToken) { + return redisTemplate.hasKey(ACCESS_TOKEN_KEY + accessToken); + } +} \ No newline at end of file diff --git a/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java b/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java index 9b01387..beb5c95 100644 --- a/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java +++ b/src/main/java/DiffLens/back_end/domain/members/repository/RefreshTokenRepository.java @@ -4,12 +4,13 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; +import java.util.Optional; import java.util.concurrent.TimeUnit; @Repository public class RefreshTokenRepository { - private final String REFRESH_TOKEN_REDIS_KEY = "refreshToken: "; + private final String REFRESH_TOKEN_REDIS_KEY = "refreshToken:"; private final StringRedisTemplate redisTemplate; @@ -18,18 +19,17 @@ public RefreshTokenRepository(StringRedisTemplate redisTemplate){ this.redisTemplate = redisTemplate; } - public void saveToken(Long memberId, String refreshToken, long expiration){ - String key = REFRESH_TOKEN_REDIS_KEY + refreshToken; - redisTemplate - .opsForValue() - .set(key, memberId.toString(), expiration / 1000, TimeUnit.SECONDS); + public void saveToken(Long memberId, String refreshToken, long expiration) { + String key = REFRESH_TOKEN_REDIS_KEY + refreshToken; + redisTemplate.opsForValue().set(key, memberId.toString(), expiration / 1000, TimeUnit.SECONDS); + redisTemplate.opsForValue().set("member:" + memberId + ":refresh", refreshToken, expiration / 1000, TimeUnit.SECONDS); } // RefreshToken 으로 memberId 가져오기 - public Long getMemberIdByToken(String token){ + public Optional getMemberIdByToken(String token) { String key = REFRESH_TOKEN_REDIS_KEY + token; String memberId = redisTemplate.opsForValue().get(key); - return memberId != null ? Long.parseLong(memberId) : null; + return memberId != null ? Optional.of(Long.parseLong(memberId)) : Optional.empty(); } // RefreshToken 삭제 @@ -43,4 +43,38 @@ public boolean existsRefreshToken(String token){ return redisTemplate.hasKey(key); } + // memberId로 Redis에 저장된 토큰 조회 (단순 스캔, 소규모 시스템에서만 추천) + public String getTokenByMemberId(Long memberId) { + for (String key : redisTemplate.keys(REFRESH_TOKEN_REDIS_KEY + "*")) { + String value = redisTemplate.opsForValue().get(key); + if (value != null && value.equals(memberId.toString())) { + return key.replace(REFRESH_TOKEN_REDIS_KEY, ""); + } + } + return null; + } + + // memberId로 Redis에 저장된 토큰의 남은 만료시간 반환 + public Optional getExpirationByMemberId(Long memberId) { + String token = getTokenByMemberId(memberId); + if (token == null) return Optional.empty(); + Long expiration = getExpirationByToken(token); + return Optional.of(expiration); + } + + public Long getExpirationByToken(String token){ + + String key = REFRESH_TOKEN_REDIS_KEY + token; + return redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + } + + public void deleteByMemberId(Long memberId) { + String refreshToken = redisTemplate.opsForValue().get("member:" + memberId + ":refresh"); + if (refreshToken != null) { + redisTemplate.delete(REFRESH_TOKEN_REDIS_KEY + refreshToken); + redisTemplate.delete("member:" + memberId + ":refresh"); + } + } + + } diff --git a/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java b/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java index edf1e89..778dc8d 100644 --- a/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java +++ b/src/main/java/DiffLens/back_end/domain/members/service/auth/AuthService.java @@ -6,6 +6,10 @@ import DiffLens.back_end.domain.members.dto.auth.AuthResponseDTO; import DiffLens.back_end.domain.members.dto.auth.TokenResponseDTO; import DiffLens.back_end.domain.members.enums.LoginType; +import DiffLens.back_end.global.responses.code.status.error.AuthStatus; +import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; +import DiffLens.back_end.global.security.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,9 +22,10 @@ public class AuthService { private final StrategyFactory strategyFactory; + private final JwtTokenProvider jwtTokenProvider; @Transactional - public AuthResponseDTO.SignUpDto signUp(AuthRequestDTO.SignUpDto request) { + public AuthResponseDTO.SignUpDto signUp(AuthRequestDTO.SignUp request) { // 요청 정보에서 loginType 추출 LoginType loginType = request.getLoginType(); @@ -54,5 +59,29 @@ public AuthResponseDTO.LoginDto login(T .build(); } + // 토큰 재발급 + @Transactional + public AuthResponseDTO.LoginDto reIssue(HttpServletRequest request) { + + // 헤더 추출 + String header = request.getHeader("Authorization"); + + // 헤더 없으면 + if (header == null) + throw new ErrorHandler(AuthStatus.TOKEN_NOT_FOUND); + + // 헤더에서 토큰 추출 + String refreshToken = header.substring(7); + + // 재발급 + TokenResponseDTO tokenDTO = jwtTokenProvider.reissueTokens(refreshToken); + + // 응답 반환 + return AuthResponseDTO.LoginDto.builder() + .accessToken(tokenDTO.getAccessToken()) + .refreshToken(tokenDTO.getRefreshToken()) + .build(); + } + } diff --git a/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java b/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java index 5c3199a..11c4674 100644 --- a/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java +++ b/src/main/java/DiffLens/back_end/domain/members/service/auth/TokenBlackListService.java @@ -1,8 +1,8 @@ package DiffLens.back_end.domain.members.service.auth; -import DiffLens.back_end.global.security.JwtTokenExpirationTime; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DigestUtils; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; @@ -13,16 +13,14 @@ public class TokenBlackListService { private final RedisTemplate redisTemplate; - private static final String BLACKLIST_PREFIX = "blacklist:"; - - private final long expirationMillis = JwtTokenExpirationTime.ACCESS_TOKEN.getExpirationMillis(); + private static final String BLACKLIST_PREFIX = "auth:blacklist:"; /** * BlackList 내에 토큰 추가합니다. */ - public void addTokenToList(String token) { - String key = BLACKLIST_PREFIX + token; - redisTemplate.opsForValue().set(key, "BLACKLISTED", expirationMillis, TimeUnit.MILLISECONDS); + public void addTokenToList(String token, long remainMillis) { + String key = getKey(token); + redisTemplate.opsForValue().set(key, true, remainMillis, TimeUnit.MILLISECONDS); } /** @@ -31,7 +29,7 @@ public void addTokenToList(String token) { * @return 토큰 존재여부 Boolean */ public boolean isContainToken(String token) { - String key = BLACKLIST_PREFIX + token; + String key = getKey(token); return redisTemplate.hasKey(key); } @@ -39,9 +37,12 @@ public boolean isContainToken(String token) { * 블랙리스트에서 제거 (필요한 경우만) */ public void removeToken(String token) { - String key = BLACKLIST_PREFIX + token; + String key = getKey(token); redisTemplate.delete(key); } + private String getKey(String token) { + return BLACKLIST_PREFIX + DigestUtils.sha1DigestAsHex(token); + } } diff --git a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java index e9f16e8..9879599 100644 --- a/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java +++ b/src/main/java/DiffLens/back_end/global/responses/code/status/error/AuthStatus.java @@ -15,9 +15,10 @@ public enum AuthStatus implements BaseErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH401", "존재하지 않는 사용자입니다."), ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "AUTH402", "이미 존재하는 사용자입니다."), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "AUTH403", "올바르지 않은 비밀번호입니다."), - HEADER_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH404", "인증 헤더가 존재하지 않습니다."), + TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH404", "인증 헤더가 존재하지 않습니다."), EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "AUTH405", "만료된 토큰입니다."), INVALID_TOKEN(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "AUTH407", "올바르지 않은 refresh token 입니다."), ERROR_IN_SIGNUP(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH500", "회원가입 중 알 수 없는 오류가 발생했습니다."), diff --git a/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java b/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java index 16b50d7..60681ac 100644 --- a/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java +++ b/src/main/java/DiffLens/back_end/global/security/CustomLogoutHandler.java @@ -1,5 +1,7 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.domain.members.repository.RefreshTokenRepository; +import DiffLens.back_end.domain.members.service.auth.CurrentUserService; import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; import DiffLens.back_end.global.responses.code.status.error.AuthStatus; import DiffLens.back_end.global.responses.exception.handler.ErrorHandler; @@ -17,6 +19,8 @@ public class CustomLogoutHandler implements LogoutHandler { private final TokenBlackListService tokenBlackListService; + private final RefreshTokenRepository refreshTokenRepository; + private final CurrentUserService currentUserService; @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { @@ -26,7 +30,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut // 토큰이 없을 경우 처리 if(headerToken == null) { - throw new ErrorHandler(AuthStatus.HEADER_NOT_FOUND); + throw new ErrorHandler(AuthStatus.TOKEN_NOT_FOUND); } // 헤더에서 토큰 추출 @@ -34,9 +38,12 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut // Redis 내에 토큰이 존재하지 않는 경우 if (!tokenBlackListService.isContainToken(token)) { - tokenBlackListService.addTokenToList(token); // BlackList 추가 + tokenBlackListService.addTokenToList(token, JwtTokenExpirationTime.ACCESS_TOKEN.getExpirationMillis()); // BlackList 추가 } + Long memberId = currentUserService.getCurrentUserId(); + refreshTokenRepository.deleteByMemberId(memberId); + } } diff --git a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java index cea9b77..accc841 100644 --- a/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java +++ b/src/main/java/DiffLens/back_end/global/security/JwtTokenProvider.java @@ -1,5 +1,7 @@ package DiffLens.back_end.global.security; +import DiffLens.back_end.domain.members.repository.AccessTokenRepository; +import DiffLens.back_end.domain.members.service.auth.TokenBlackListService; import DiffLens.back_end.global.responses.code.status.error.AuthStatus; import DiffLens.back_end.domain.members.dto.auth.TokenResponseDTO; import DiffLens.back_end.domain.members.dto.auth.TokenWithRolesResponseDTO; @@ -31,7 +33,9 @@ public class JwtTokenProvider { private final RefreshTokenRepository refreshTokenRepository; + private final AccessTokenRepository accessTokenRepository; private final MemberRepository memberRepository; + private final TokenBlackListService tokenBlackListService; private Key key; @@ -47,34 +51,67 @@ public void init() { } public TokenResponseDTO createToken(Member member) { - Claims claims = Jwts.claims().setSubject(member.getEmail()); - Date now = new Date(); -// List roles = extractRoles(member); - String accessToken = - Jwts.builder() - .setClaims(claims) -// .claim("roles", roles) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE_TIME)) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); + // 항상 새로운 access token 발급 + String accessToken = createAccessToken(member); + String refreshToken; - String refreshToken = - Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME)) - .claim("random", UUID.randomUUID().toString()) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); + Long memberId = member.getId(); + Optional existing = refreshTokenRepository.getExpirationByMemberId(memberId); // memberId로 refresh token 조회 - long expiration = getExpiration(refreshToken); - refreshTokenRepository.saveToken(member.getId(), refreshToken, expiration); + // 새로운 유저이거나 만료가 얼마 남지 않았으면 새로 발급 + if (existing.isEmpty() || existing.get() < (REFRESH_TOKEN_EXPIRE_TIME / 3)){ + refreshToken = createRefreshToken(member); + refreshTokenRepository.saveToken(memberId, refreshToken, getExpiration(refreshToken)); + } else { + refreshToken = refreshTokenRepository.getTokenByMemberId(memberId); + } + + // accessToken Redis 저장 + accessTokenRepository.saveToken(memberId, accessToken, ACCESS_TOKEN_EXPIRE_TIME); return TokenResponseDTO.of(accessToken, refreshToken); } + public TokenResponseDTO reissueTokens(String refreshToken) { + + if (!validateToken(refreshToken)) { + throw new ErrorHandler(AuthStatus.INVALID_TOKEN); + } + + Long memberId = refreshTokenRepository.getMemberIdByToken(refreshToken) // memberId 조회 + .orElseThrow(() -> new ErrorHandler(AuthStatus.REFRESH_TOKEN_NOT_FOUND)); + + Member member = memberRepository.findById(memberId) // member 조회 + .orElseThrow(() -> new ErrorHandler(AuthStatus.USER_NOT_FOUND)); + + Long remainingTime = refreshTokenRepository.getExpirationByMemberId(memberId) // 남은기간 조회 + .orElseThrow(() -> new ErrorHandler(AuthStatus.REFRESH_TOKEN_NOT_FOUND)); + + accessTokenRepository.getCurrentToken(memberId) + .ifPresent(oldToken -> tokenBlackListService.addTokenToList(oldToken, remainingTime)); + + accessTokenRepository.getCurrentToken(memberId) + .ifPresent(accessTokenRepository::deleteToken); + + + String newAccessToken = createAccessToken(member); + accessTokenRepository.saveToken(memberId, newAccessToken, ACCESS_TOKEN_EXPIRE_TIME); + + String newRefreshToken; + + // RefreshToken 남은 기간이 1/3 이하이면 새로 발급 + if (remainingTime < (REFRESH_TOKEN_EXPIRE_TIME / 3)) { + newRefreshToken = createRefreshToken(member); + refreshTokenRepository.saveToken(memberId, newRefreshToken, getExpiration(newRefreshToken)); + } else { + newRefreshToken = refreshToken; + } + + return TokenResponseDTO.of(newAccessToken, newRefreshToken); + } + + public boolean validateToken(String token) { try { Jws claims = @@ -193,6 +230,7 @@ public long getExpiration(String token) { } catch (ExpiredJwtException e) { return 0; } catch (Exception e) { + System.out.println(e.getMessage()); throw new IllegalArgumentException("Invalid JWT Token"); } } From 890d2ee996394a389613f929afe2d9df608810d6 Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 13 Oct 2025 04:54:44 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs=20:=20=EA=B0=9C=EB=B3=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EB=B6=84=EB=A6=AC=20=20=20-=20=EC=95=84=EC=A7=81=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20API=EC=97=90=EC=84=9C=20=EA=B0=9C=EB=B3=84?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EC=A7=80=EB=8A=94=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/search/controller/SearchController.java | 7 +++++++ .../domain/search/dto/SearchResponseDTO.java | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java index 01cbecd..7e7a05f 100644 --- a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java +++ b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java @@ -27,6 +27,13 @@ public ApiResponse refine(@RequestBody @Valid Se return ApiResponse.onSuccess(result); } + @GetMapping("/{searchId}/each-responses") + @Operation(summary = "개별 응답 데이터 ( 미구현 )") + public ApiResponse eachResponses(@PathVariable("searchId") Long searchId, Integer pageNum, Integer size){ + SearchResponseDTO.EachResponses result = new SearchResponseDTO.EachResponses(); + return ApiResponse.onSuccess(result); + } + @GetMapping("/recommended") @Operation(summary = "맞춤 검색 추천 ( 미구현 )") public ApiResponse refine(@RequestBody @Valid SearchRequestDTO.SearchFilters request) { diff --git a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java index 0c20f60..1eadf84 100644 --- a/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java +++ b/src/main/java/DiffLens/back_end/domain/search/dto/SearchResponseDTO.java @@ -64,5 +64,18 @@ public static class AppliedFilter { } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class EachResponses { + + private List keys; + private List> values; + private Integer page; + private Integer size; + + } + } From b5fae529e0ea192ed8aecf47435458d269854f94 Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 13 Oct 2025 04:57:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore=20:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B3=84=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../back_end/domain/library/controller/LibraryController.java | 2 ++ .../back_end/domain/members/controller/AuthController.java | 2 +- .../back_end/domain/panel/controller/PanelController.java | 2 ++ .../back_end/domain/search/controller/SearchController.java | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java index 0b81749..8f40614 100644 --- a/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java +++ b/src/main/java/DiffLens/back_end/domain/library/controller/LibraryController.java @@ -3,11 +3,13 @@ import DiffLens.back_end.domain.library.dto.LibraryResponseDTO; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "라이브러리 API") @RestController @RequestMapping("/libraries") @RequiredArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java index 122ff20..a20b168 100644 --- a/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java +++ b/src/main/java/DiffLens/back_end/domain/members/controller/AuthController.java @@ -11,7 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -@Tag(name = "로그인 관련 API") +@Tag(name = "인증 API") @RestController @RequestMapping("/auth") @RequiredArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java b/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java index dbdfabc..3f16a43 100644 --- a/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java +++ b/src/main/java/DiffLens/back_end/domain/panel/controller/PanelController.java @@ -4,10 +4,12 @@ import DiffLens.back_end.domain.panel.dto.PanelResponseDTO; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +@Tag(name = "패널 API") @RestController @RequestMapping("/panels") @RequiredArgsConstructor diff --git a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java index 7e7a05f..97c7025 100644 --- a/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java +++ b/src/main/java/DiffLens/back_end/domain/search/controller/SearchController.java @@ -4,10 +4,12 @@ import DiffLens.back_end.domain.search.dto.SearchResponseDTO; import DiffLens.back_end.global.responses.exception.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +@Tag(name = "검색 API") @RestController @RequestMapping("/search") @RequiredArgsConstructor