From b9921f0eb149abbddc8c42663a87ba1a52e8e342 Mon Sep 17 00:00:00 2001 From: "STGRAM\\gffd9" <29088981l@gmail.com> Date: Thu, 18 Sep 2025 15:23:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refreshToken,=20Blacklist=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../join/global/config/CacheType.java | 11 ++-- .../join/member/security/AuthController.java | 33 ++++++++++-- .../join/member/security/AuthService.java | 5 ++ .../security/JwtAuthenticationFilter.java | 47 +++++++++++++++++ .../join/member/security/SecurityConfig.java | 6 ++- .../join/member/security/TokenController.java | 52 +++++++++++++++++++ .../join/member/token/RefreshTokenStore.java | 38 ++++++++++++++ 7 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/oronaminc/join/member/security/TokenController.java create mode 100644 src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java diff --git a/src/main/java/com/oronaminc/join/global/config/CacheType.java b/src/main/java/com/oronaminc/join/global/config/CacheType.java index 71af6e1..74f0d11 100644 --- a/src/main/java/com/oronaminc/join/global/config/CacheType.java +++ b/src/main/java/com/oronaminc/join/global/config/CacheType.java @@ -5,10 +5,11 @@ @AllArgsConstructor public enum CacheType { ROOM_BY_ID("roomById", 300, 1000), - ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000) - ; - + ROOM_BY_SECRET_CODE("roomBySecretCode", 300, 1000), + REFRESH_LATEST("refreshLatest", 60 * 60 * 24 * 14, 100_000), // 14일 + REFRESH_BLACKLIST("refreshBlacklist", 60 * 60 * 24 * 14, 100_000); public final String cacheName; - public final long expireAfterWrite; - public final long maximumSize; + public final int expireAfterWrite; + public final int maximumSize; + } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthController.java b/src/main/java/com/oronaminc/join/member/security/AuthController.java index 5dbd2de..2b8d12f 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthController.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthController.java @@ -4,19 +4,21 @@ import com.oronaminc.join.member.dto.GuestLoginRequest; import com.oronaminc.join.member.dto.KakaoLoginRequest; import com.oronaminc.join.member.token.AuthTokenResponse; +import com.oronaminc.join.member.token.JwtTokenProvider; import com.oronaminc.join.member.token.JwtUtils; import com.oronaminc.join.member.token.LoginResponse; +import com.oronaminc.join.member.token.RefreshTokenStore; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -31,6 +33,8 @@ public class AuthController { private final AuthService authService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; @Operation( summary = "카카오 로그인" @@ -84,7 +88,30 @@ public Map guestLogin( @PostMapping("/logout") @ResponseStatus(HttpStatus.NO_CONTENT) public void logout(HttpServletRequest request, HttpServletResponse response) { - HttpSession session = request.getSession(); + + String refresh = null; + if(request.getCookies() != null){ + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) refresh = cookie.getValue(); + } + } + + if (refresh != null) { + try { + var body = jwtTokenProvider.parseClaims(refresh); + refreshTokenStore.isBlacklisted(refresh); + refreshTokenStore.saveLatest(body.memberId(), ""); + }catch (Exception ignored){ } + } + + // 쿠키 제거 + ResponseCookie expired = ResponseCookie.from("refreshToken", "") + .httpOnly(true).secure(true).sameSite("None") + .path("/").maxAge(0).build(); + + SecurityContextHolder.clearContext(); + + /*HttpSession session = request.getSession(); if (session != null) { session.invalidate(); } @@ -95,6 +122,6 @@ public void logout(HttpServletRequest request, HttpServletResponse response) { cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setMaxAge(0); - response.addCookie(cookie); + response.addCookie(cookie);*/ } } diff --git a/src/main/java/com/oronaminc/join/member/security/AuthService.java b/src/main/java/com/oronaminc/join/member/security/AuthService.java index dbc5667..6e681db 100644 --- a/src/main/java/com/oronaminc/join/member/security/AuthService.java +++ b/src/main/java/com/oronaminc/join/member/security/AuthService.java @@ -11,6 +11,7 @@ import com.oronaminc.join.member.token.JwtMemberInfo; import com.oronaminc.join.member.token.JwtTokenProvider; import com.oronaminc.join.member.token.LoginResponse; +import com.oronaminc.join.member.token.RefreshTokenStore; import com.oronaminc.join.member.token.TokenPair; import com.oronaminc.join.member.util.MemberMapper; import java.util.Map; @@ -37,6 +38,7 @@ public class AuthService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; private final MemberReader memberReader; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; private final RestTemplate restTemplate = new RestTemplate(); @@ -79,6 +81,7 @@ public LoginResponse loadGuest(GuestLoginRequest guestLoginRequest) { TokenPair tokenPair = jwtTokenProvider.generateTokenPair( new JwtMemberInfo(guest.getId(), guest.getNickname(), guest.getMemberType())); + refreshTokenStore.saveLatest(guest.getId(), tokenPair.refreshToken()); AuthTokenResponse authTokenResponse = new AuthTokenResponse(tokenPair.accessToken(), tokenPair.accessTokenExpiresIn(), guest.getId(), @@ -99,6 +102,8 @@ public LoginResponse kakaoLogin(String code) { TokenPair tokenPair = jwtTokenProvider.generateTokenPair( new JwtMemberInfo(member.getId(), member.getNickname(), member.getMemberType())); + refreshTokenStore.saveLatest(member.getId(), tokenPair.refreshToken()); + AuthTokenResponse authTokenResponse = new AuthTokenResponse(tokenPair.accessToken(), tokenPair.accessTokenExpiresIn(), member.getId(), member.getNickname(), member.getMemberType()); diff --git a/src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java b/src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a2ccc3f --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/security/JwtAuthenticationFilter.java @@ -0,0 +1,47 @@ +package com.oronaminc.join.member.security; + +import com.oronaminc.join.member.token.JwtTokenProvider; +import com.oronaminc.join.member.token.TokenBody; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.HttpHeaders; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + TokenBody body = jwtTokenProvider.parseClaims(token); + var auth = new UsernamePasswordAuthenticationToken( + body.memberId(), + null, + List.of(new SimpleGrantedAuthority(body.role().name())) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + }catch (Exception e){ + + } + } + + filterChain.doFilter(request,response); + } +} diff --git a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java index c37b66f..498a3aa 100644 --- a/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java +++ b/src/main/java/com/oronaminc/join/member/security/SecurityConfig.java @@ -1,5 +1,6 @@ package com.oronaminc.join.member.security; +import com.oronaminc.join.member.token.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,6 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -23,7 +25,7 @@ public class SecurityConfig { private final AuthService authService; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwt) throws Exception { return http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) @@ -55,6 +57,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .oauth2Login(oauth2 -> oauth2.userInfoEndpoint(userInfo -> userInfo .userService(authService))) + .addFilterBefore(new JwtAuthenticationFilter(jwt), + UsernamePasswordAuthenticationFilter.class) .logout(withDefaults()) .build(); } diff --git a/src/main/java/com/oronaminc/join/member/security/TokenController.java b/src/main/java/com/oronaminc/join/member/security/TokenController.java new file mode 100644 index 0000000..e01634c --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/security/TokenController.java @@ -0,0 +1,52 @@ +package com.oronaminc.join.member.security; + +import com.oronaminc.join.member.token.AuthTokenResponse; +import com.oronaminc.join.member.token.JwtMemberInfo; +import com.oronaminc.join.member.token.JwtTokenProvider; +import com.oronaminc.join.member.token.JwtUtils; +import com.oronaminc.join.member.token.RefreshTokenStore; +import com.oronaminc.join.member.token.TokenBody; +import com.oronaminc.join.member.token.TokenPair; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth/token") +@RequiredArgsConstructor +public class TokenController { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; + + @PostMapping("/refresh") + public AuthTokenResponse refresh(HttpServletRequest request, HttpServletResponse response) { + String refresh = extraRefreshCookie(request); + if (refresh == null) throw new IllegalArgumentException("refresh cookie is null"); + + if (refreshTokenStore.isBlacklisted(refresh)) throw new IllegalArgumentException("refresh cookie is blacklisted"); + + TokenBody body = jwtTokenProvider.parseClaims(refresh); + if (!refreshTokenStore.isLatest(body.memberId(), refresh)) throw new IllegalArgumentException("refresh token is invalid"); + + TokenPair tokenPair = jwtTokenProvider.generateTokenPair(new JwtMemberInfo(body.memberId(), + body.nickname(), body.role())); + refreshTokenStore.isBlacklisted(refresh); + refreshTokenStore.saveLatest(body.memberId(), tokenPair.refreshToken()); + JwtUtils.addRefreshTokenCookie(response, tokenPair.refreshToken(), tokenPair.refreshTokenExpiresIn()); + + return new AuthTokenResponse(tokenPair.accessToken(), tokenPair.accessTokenExpiresIn(), body.memberId(), body.nickname(), body.role()); + } + + private String extraRefreshCookie(HttpServletRequest request) { + if (request.getCookies() == null) return null; + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) return cookie.getValue(); + } + return null; + } +} diff --git a/src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java b/src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java new file mode 100644 index 0000000..70586be --- /dev/null +++ b/src/main/java/com/oronaminc/join/member/token/RefreshTokenStore.java @@ -0,0 +1,38 @@ +package com.oronaminc.join.member.token; + +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RefreshTokenStore { + private final CacheManager cacheManager; + + private Cache latest() { + return cacheManager.getCache("refreshLatest"); + } + private Cache blacklist() { + return cacheManager.getCache("refreshBlacklist"); + } + + public void saveLatest(Long memberId, String refreshToken) { + latest().put(key(memberId), refreshToken); + } + + public boolean isLatest(Long memberId, String refreshToken) { + String stored = latest().get(key(memberId), String.class); + return Objects.equals(stored, refreshToken); + } + + public boolean isBlacklisted(String refreshToken) { + Boolean v = blacklist().get(refreshToken, Boolean.class); + return v != null && v; + } + + private String key(Long memberId) { + return "refresh:" + memberId; + } +} From 95e942590f5ead194d9464df175d4995e7d35eb2 Mon Sep 17 00:00:00 2001 From: "STGRAM\\gffd9" <29088981l@gmail.com> Date: Mon, 17 Nov 2025 15:46:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?samesite=20=EC=86=8D=EC=84=B1=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/oronaminc/join/member/token/JwtUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/oronaminc/join/member/token/JwtUtils.java b/src/main/java/com/oronaminc/join/member/token/JwtUtils.java index 693c79f..22fad53 100644 --- a/src/main/java/com/oronaminc/join/member/token/JwtUtils.java +++ b/src/main/java/com/oronaminc/join/member/token/JwtUtils.java @@ -19,7 +19,7 @@ public static void addRefreshTokenCookie(HttpServletResponse response, String re .httpOnly(true) .secure(true) .path("/") - .sameSite("Strict") + .sameSite("None") .maxAge(JwtUtils.toSeconds(expiresIn)) .build();