Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ public enum ErrorCode implements ResponseCode {
AUTH_UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.UNAUTHORIZED, 40105, "지원하지 않는 소셜 로그인입니다."),
AUTH_INVALID_LOGIN_TOKEN_KEY(HttpStatus.UNAUTHORIZED, 40106, "유효하지 않은 로그인 토큰 키입니다."),
AUTH_BLACKLIST_TOKEN(HttpStatus.UNAUTHORIZED, 40107, "블랙리스트에 등록된 토큰입니다."),
AUTH_INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 40108, "인증 처리 중 서버 오류가 발생했습니다."),

JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50100, "JSON 직렬화/역직렬화에 실패했습니다."),
AWS_BUCKET_BASE_URL_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, 50101, "aws s3 bucket base url 설정이 누락되었습니다."),
WEB_DOMAIN_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, 50102, "허용된 웹 도메인 설정이 비어있습니다."),

PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public enum AuthParameters {
COOKIE_ACCESS_TOKEN("access_token"),
COOKIE_TEMP_TOKEN("temp_token"),

REDIRECT_URL_KEY("redirect_url"),
REDIRECT_SESSION_KEY("oauth2_return_to"),

Comment on lines +22 to +24
Copy link

@coderabbitai coderabbitai bot Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

❓ Verification inconclusive

파라미터 키 불일치 가능성(redirect_url vs redirect_uri)

PR 요약/가이드에선 redirect_uri를 예시로 들었습니다. 현재 상수는 redirect_url입니다. 클라이언트와 불일치 시 로그인 플로우가 깨집니다. 호환성 위해 둘 다 지원하는 방안을 추천합니다.

     COOKIE_TEMP_TOKEN("temp_token"),
 
-    REDIRECT_URL_KEY("redirect_url"),
+    REDIRECT_URL_KEY("redirect_url"),
+    REDIRECT_URI_KEY("redirect_uri"),
     REDIRECT_SESSION_KEY("oauth2_return_to"),

추가로 Resolver에서 두 키 중 하나를 우선순위로 읽는 로직을 반영해 주세요.


필수: redirect_uri 파라미터 호환성 추가

PR 예시가 redirect_uri인 반면 코드엔 redirect_url만 있어 클라이언트 불일치 시 OAuth 로그인 흐름이 깨질 수 있습니다. 다음 변경 적용 필요 (파일: src/main/java/konkuk/thip/common/security/constant/AuthParameters.java):

     COOKIE_TEMP_TOKEN("temp_token"),
 
-    REDIRECT_URL_KEY("redirect_url"),
+    REDIRECT_URL_KEY("redirect_url"),
+    REDIRECT_URI_KEY("redirect_uri"),
     REDIRECT_SESSION_KEY("oauth2_return_to"),

Resolver에서 REDIRECT_URI_KEY를 우선 읽고 없으면 REDIRECT_URL_KEY를 사용하도록 우선순위 로직 반영.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/security/constant/AuthParameters.java around
lines 22 to 24, the enum currently only defines REDIRECT_URL_KEY("redirect_url")
which is incompatible with clients sending redirect_uri; add a new enum entry
REDIRECT_URI_KEY("redirect_uri") and then update the OAuth resolver logic to
check for REDIRECT_URI_KEY first and fall back to REDIRECT_URL_KEY when
REDIRECT_URI_KEY is absent so redirect_uri is honored with backward
compatibility.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pr 의 예시가 오타인 거야. FE와 'redirect_url' 을 key로 설정하는 것으로 결정했어.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seongjunnoh 감사합니다! PR 설명의 예시가 오타였다는 점을 명확히 해주셔서 이해했습니다.

FE와 redirect_url을 키로 사용하기로 합의하셨다면 현재 구현이 올바르네요. 제가 제안한 REDIRECT_URI_KEY 추가와 우선순위 로직은 불필요합니다.

현재 REDIRECT_URL_KEY("redirect_url") 구현이 FE와의 합의된 사양에 맞게 잘 되어 있습니다.

🐰✨
╰( ◕ ᗜ ◕ )╯


✏️ Learnings added
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#307
File: src/main/java/konkuk/thip/common/security/constant/AuthParameters.java:22-24
Timestamp: 2025-09-20T09:02:02.821Z
Learning: seongjunnoh는 PR 설명과 실제 구현 간 차이에 대해 명확한 설명을 제공하며, FE와의 협업에서 구체적인 기술 사양(파라미터 키명 등)을 미리 합의하는 방식을 선호한다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#113
File: src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java:38-44
Timestamp: 2025-07-30T14:05:04.945Z
Learning: seongjunnoh는 코드 최적화 제안에 대해 구체적인 기술적 근거와 효율성 차이를 이해하고 싶어하며, 성능 개선 방식에 대한 상세한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#93
File: src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java:49-114
Timestamp: 2025-07-28T16:44:31.224Z
Learning: seongjunnoh는 코드 중복 문제에 대한 리팩토링 제안을 적극적으로 수용하고 함수형 인터페이스를 활용한 해결책을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#285
File: src/main/java/konkuk/thip/room/adapter/out/jpa/RoomStatus.java:1-7
Timestamp: 2025-08-31T05:25:14.845Z
Learning: seongjunnoh는 enum 의존성에 대해 유연한 접근을 선호하며, 도메인→어댑터 레이어 참조와 같은 아키텍처 layering 원칙보다 실용적인 구현을 우선시한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#195
File: src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java:0-0
Timestamp: 2025-08-13T05:22:32.287Z
Learning: seongjunnoh는 데이터 무결성과 중복 방지에 대한 고민이 깊으며, LinkedHashSet을 활용한 중복 제거와 순서 보장을 동시에 달성하는 솔루션을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#305
File: src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java:9-13
Timestamp: 2025-09-17T06:40:49.863Z
Learning: seongjunnoh는 메서드 네이밍 시 구현 세부사항보다 비즈니스 의도를 반영하는 것을 선호하며, 미래 확장성과 아키텍처 변화를 고려한 설계 철학을 가지고 있다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#180
File: src/main/java/konkuk/thip/room/application/service/AttendanceCheckCreateService.java:22-40
Timestamp: 2025-08-14T09:15:31.371Z
Learning: seongjunnoh는 기술적 완벽성보다 실제 비즈니스 시나리오와 사용자 행동 패턴을 우선 고려하며, 발생 가능성이 낮은 엣지 케이스보다는 실용적인 구현을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#278
File: src/main/java/konkuk/thip/TestTokenController.java:0-0
Timestamp: 2025-08-24T09:33:52.982Z
Learning: seongjunnoh는 Spring의 ConditionalOnProperty 동작 원리를 정확히 이해하고 있으며, 보안 이슈에 대해서도 실질적인 위험성을 기준으로 판단하는 실용적 접근을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#278
File: src/main/java/konkuk/thip/common/exception/code/ErrorCode.java:210-211
Timestamp: 2025-08-24T09:40:48.595Z
Learning: seongjunnoh는 HTTP 상태 코드 선택에 대해 기술적 근거와 코드베이스 내 일관성을 중요하게 생각하며, 구체적인 사례 분석을 통한 설명을 선호한다.

Learnt from: seongjunnoh
PR: THIP-TextHip/THIP-Server#112
File: src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java:272-272
Timestamp: 2025-07-30T10:44:34.115Z
Learning: seongjunnoh는 피드 커서 페이지네이션에서 LocalDateTime 단일 커서 방식을 선호하며, 복합 키 기반 커서보다 구현 단순성과 성능을 우선시한다.

;

private final String value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import konkuk.thip.common.exception.AuthException;
import konkuk.thip.common.exception.code.ErrorCode;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
Expand All @@ -15,16 +17,32 @@
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;

public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver){
public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver")
HandlerExceptionResolver resolver) {
this.resolver = resolver;
}

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Exception e = (Exception) request.getAttribute("exception");
if(e == null){
e = authException;
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {

// 필터에서 set한 예외 우선
Exception original = (Exception) request.getAttribute("exception");
if (original == null) {
original = authException;
}

Exception mapped = wrapAsAuthException(original);

resolver.resolveException(request, response, null, mapped);
}

// 모든 예외를 AuthException(401)으로 감싸는 메서드
private Exception wrapAsAuthException(Exception e) {
if (e instanceof AuthException) {
return e;
}
resolver.resolveException(request, response, null, e);
return new AuthException(ErrorCode.AUTH_INTERNAL_SERVER_ERROR, e);
}
Comment on lines +41 to 47
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,33 @@
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import konkuk.thip.common.exception.AuthException;
import konkuk.thip.common.exception.code.ErrorCode;
import konkuk.thip.common.security.oauth2.tokenstorage.LoginTokenStorage;
import konkuk.thip.common.security.util.JwtUtil;
import konkuk.thip.config.properties.WebDomainProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

import static konkuk.thip.common.security.constant.AuthParameters.REDIRECT_HOME_URL;
import static konkuk.thip.common.security.constant.AuthParameters.REDIRECT_SIGNUP_URL;
import static konkuk.thip.common.security.constant.AuthParameters.*;

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

private static final int COOKIE_MAX_AGE = 60 * 60 * 24; // 1일
private final LoginTokenStorage loginTokenStorage;

@Value("${server.web-redirect-url}")
private String webRedirectUrl;
private final WebDomainProperties webDomainProperties;
Comment on lines -29 to +32
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굳굳


private final JwtUtil jwtUtil;

Expand All @@ -38,6 +40,22 @@ public void onAuthenticationSuccess(
Authentication authentication
) throws IOException, ServletException {

// Resolver에서 세션에 저장한 origin을 복원
String webRedirectDomain = null;
if (request.getSession(false) != null) {
webRedirectDomain = (String) request.getSession(false).getAttribute(REDIRECT_SESSION_KEY.getValue());
request.getSession(false).removeAttribute(REDIRECT_SESSION_KEY.getValue()); // 사용했으면 제거(일회성)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿굿

}

// 허용 오리진 검증 및 폴백
if (!webDomainProperties.isAllowed(Objects.toString(webRedirectDomain, ""))) {
List<String> origins = webDomainProperties.getWebDomainUrls();
if (origins == null || origins.isEmpty()) {
throw new AuthException(ErrorCode.WEB_DOMAIN_ORIGIN_EMPTY);
}
webRedirectDomain = origins.get(0);
}

Comment on lines +43 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

리다이렉트 도메인 검증 강화 및 Origin 정규화

현재 문자열 기반 검증/결합은 오리진이 아닌 전체 URL이 들어오거나 경계 케이스(중복 슬래시, 포트/IPv6, 국제화 도메인)에서 취약할 수 있습니다. URI 파싱으로 오리진을 정규화한 뒤 허용 목록과 비교하도록 개선하세요.

+import org.springframework.web.util.UriComponentsBuilder;
+import java.net.URI;
 ...
-        // 허용 오리진 검증 및 폴백
-        if (!webDomainProperties.isAllowed(Objects.toString(webRedirectDomain, ""))) {
+        // 허용 오리진 검증 및 폴백
+        String candidate = Objects.toString(webRedirectDomain, "");
+        if (!candidate.isEmpty()) {
+            try {
+                URI parsed = URI.create(candidate).normalize();
+                candidate = parsed.getScheme() + "://" + parsed.getAuthority();
+            } catch (IllegalArgumentException e) {
+                candidate = "";
+            }
+        }
+        if (!webDomainProperties.isAllowed(candidate)) {
             List<String> origins = webDomainProperties.getWebDomainUrls();
             if (origins == null || origins.isEmpty()) {
                 throw new AuthException(ErrorCode.WEB_DOMAIN_ORIGIN_EMPTY);
             }
-            webRedirectDomain = origins.get(0);
+            candidate = origins.get(0);
         }
+        // 최종 오리진
+        webRedirectDomain = candidate;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Resolver에서 세션에 저장한 origin을 복원
String webRedirectDomain = null;
if (request.getSession(false) != null) {
webRedirectDomain = (String) request.getSession(false).getAttribute(REDIRECT_SESSION_KEY.getValue());
request.getSession(false).removeAttribute(REDIRECT_SESSION_KEY.getValue()); // 사용했으면 제거(일회성)
}
// 허용 오리진 검증 및 폴백
if (!webDomainProperties.isAllowed(Objects.toString(webRedirectDomain, ""))) {
List<String> origins = webDomainProperties.getWebDomainUrls();
if (origins == null || origins.isEmpty()) {
throw new AuthException(ErrorCode.WEB_DOMAIN_ORIGIN_EMPTY);
}
webRedirectDomain = origins.get(0);
}
// Resolver에서 세션에 저장한 origin을 복원
String webRedirectDomain = null;
if (request.getSession(false) != null) {
webRedirectDomain = (String) request.getSession(false).getAttribute(REDIRECT_SESSION_KEY.getValue());
request.getSession(false).removeAttribute(REDIRECT_SESSION_KEY.getValue()); // 사용했으면 제거(일회성)
}
// 허용 오리진 검증 및 폴백
String candidate = Objects.toString(webRedirectDomain, "");
if (!candidate.isEmpty()) {
try {
URI parsed = URI.create(candidate).normalize();
candidate = parsed.getScheme() + "://" + parsed.getAuthority();
} catch (IllegalArgumentException e) {
candidate = "";
}
}
if (!webDomainProperties.isAllowed(candidate)) {
List<String> origins = webDomainProperties.getWebDomainUrls();
if (origins == null || origins.isEmpty()) {
throw new AuthException(ErrorCode.WEB_DOMAIN_ORIGIN_EMPTY);
}
candidate = origins.get(0);
}
// 최종 오리진
webRedirectDomain = candidate;
🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/common/security/oauth2/CustomSuccessHandler.java
around lines 43 to 58, the current plain-string redirect-domain check is
vulnerable to full-URL inputs and edge cases; parse and normalize the stored
redirect value as a URI and extract its origin (scheme + host and explicit port
if non-default) before comparing against the allowed origins. Specifically:
retrieve and remove the session attribute as now, then if non-null attempt to
parse it with java.net.URI (or java.net.URL), build a normalized origin string
(lowercase host, include explicit port when not default for scheme), and only
use that normalized origin for isAllowed checks; also normalize the configured
allowed origins the same way (or pre-normalize when loading properties), and
keep the fallback: if allowed list is empty throw AuthException; finally if
parsing fails, treat as invalid and fall back to the first allowed origin or
throw per current policy.

CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();
LoginUser loginUser = oAuth2User.getLoginUser();

Expand All @@ -48,15 +66,15 @@ public void onAuthenticationSuccess(
String loginTokenKey = UUID.randomUUID().toString();
loginTokenStorage.put(loginTokenKey, TokenType.TEMP, tempToken, Duration.ofMinutes(5)); // ttl 5분

getRedirectStrategy().sendRedirect(request, response, webRedirectUrl + REDIRECT_SIGNUP_URL.getValue() + "?loginTokenKey=" + loginTokenKey);
getRedirectStrategy().sendRedirect(request, response, webRedirectDomain + REDIRECT_SIGNUP_URL.getValue() + "?loginTokenKey=" + loginTokenKey);
} else {
// 기존 유저 - 로그인용 액세스 토큰
String accessToken = jwtUtil.createAccessToken(loginUser.userId());

String loginTokenKey = UUID.randomUUID().toString();
loginTokenStorage.put(loginTokenKey, TokenType.ACCESS, accessToken, Duration.ofMinutes(5)); // ttl 5분

getRedirectStrategy().sendRedirect(request, response, webRedirectUrl + REDIRECT_HOME_URL.getValue() + "?loginTokenKey=" + loginTokenKey);
getRedirectStrategy().sendRedirect(request, response, webRedirectDomain + REDIRECT_HOME_URL.getValue() + "?loginTokenKey=" + loginTokenKey);
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import konkuk.thip.common.exception.BusinessException;
import konkuk.thip.common.exception.code.ErrorCode;
import konkuk.thip.common.security.annotation.Oauth2Id;
import konkuk.thip.common.security.oauth2.LoginTokenStorage;
import konkuk.thip.common.security.oauth2.tokenstorage.LoginTokenStorage;
import konkuk.thip.common.security.oauth2.auth.dto.AuthSetCookieRequest;
import konkuk.thip.common.security.oauth2.auth.dto.AuthSetCookieResponse;
import konkuk.thip.common.security.oauth2.auth.dto.AuthTokenRequest;
Expand Down Expand Up @@ -92,11 +92,11 @@ public BaseResponse<AuthTokenResponse> getToken(
String token;
boolean isNewUser;

if (entry.getType() == ACCESS) {
token = entry.getToken();
if (entry.type() == ACCESS) {
token = entry.token();
isNewUser = false;
} else {
token = entry.getToken();
token = entry.token();
isNewUser = true;
}

Expand Down Expand Up @@ -127,8 +127,8 @@ public BaseResponse<AuthSetCookieResponse> setCookie(
ResponseCookie cookie;
String type;

if (entry.getType() == ACCESS) {
cookie = ResponseCookie.from(COOKIE_ACCESS_TOKEN.getValue(), entry.getToken())
if (entry.type() == ACCESS) {
cookie = ResponseCookie.from(COOKIE_ACCESS_TOKEN.getValue(), entry.token())
.httpOnly(true)
.secure(true)
.sameSite("None")
Expand All @@ -137,7 +137,7 @@ public BaseResponse<AuthSetCookieResponse> setCookie(
.build();
type = ACCESS.getValue();
} else {
cookie = ResponseCookie.from(COOKIE_TEMP_TOKEN.getValue(), entry.getToken())
cookie = ResponseCookie.from(COOKIE_TEMP_TOKEN.getValue(), entry.token())
.httpOnly(true)
.secure(true)
.sameSite("None")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package konkuk.thip.common.security.oauth2.tokenstorage;

import konkuk.thip.common.security.oauth2.TokenType;

import java.time.Duration;

public interface LoginTokenStorage {

void put(String key, TokenType type, String token, Duration ttl);

/**
* 저장된 토큰을 1회용으로 소비 후 삭제한다.
* 존재하지 않으면 null 반환.
*/
Entry consume(String key);

record Entry(TokenType type, String token) {
}
}
Comment on lines +11 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

소비(consume) 원자성 명시 필요

보안상 “1회용”은 get+delete가 원자적이어야 합니다. 인터페이스 주석에 원자성/스레드 안전성 계약을 명문화해 구현체 일관성을 보장하세요.

-    /**
-     * 저장된 토큰을 1회용으로 소비 후 삭제한다.
-     * 존재하지 않으면 null 반환.
-     */
+    /**
+     * 저장된 토큰을 1회용으로 원자적으로(atomic) 소비 후 삭제한다.
+     * - 구현체는 getAndDelete(예: Redis GETDEL) 등으로 동시성 환경에서도 중복 소비가 불가능해야 한다.
+     * - key가 존재하지 않으면 null을 반환한다.
+     * - put의 ttl은 0보다 커야 한다.
+     */
     Entry consume(String key);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 저장된 토큰을 1회용으로 소비 삭제한다.
* 존재하지 않으면 null 반환.
*/
Entry consume(String key);
record Entry(TokenType type, String token) {
}
}
/**
* 저장된 토큰을 1회용으로 원자적으로(atomic) 소비 삭제한다.
* - 구현체는 getAndDelete(: Redis GETDEL) 등으로 동시성 환경에서도 중복 소비가 불가능해야 한다.
* - key가 존재하지 않으면 null을 반환한다.
* - put의 ttl은 0보다 커야 한다.
*/
Entry consume(String key);
record Entry(TokenType type, String token) {
}
}
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/common/security/oauth2/tokenstorage/LoginTokenStorage.java
around lines 11 to 19, the Javadoc for consume(String key) must declare
atomicity and thread-safety: state that consume performs an atomic
get-and-delete (only one caller will receive the token for a given key), that
concurrent calls must not return the same token, and that null is returned when
no token exists; require implementations to guarantee this (e.g., via
synchronization, database transaction, or atomic store operations) and mention
expected behavior on failure/exception. Ensure the contract is explicit so all
implementations preserve one-time-use semantics.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package konkuk.thip.common.security.oauth2.tokenstorage;

import konkuk.thip.common.security.oauth2.TokenType;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;

@Profile("test")
@Component
@RequiredArgsConstructor
public class MemoryLoginTokenStorage implements LoginTokenStorage{
Comment on lines +12 to +15
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다

private record InternalEntry(TokenType type, String token, long expireAtEpochMillis) { }

private final ConcurrentHashMap<String, InternalEntry> store = new ConcurrentHashMap<>();

/**
* 토큰을 메모리에 저장 (TTL 적용)
*/
@Override
public void put(String key, TokenType type, String token, Duration ttl) {
long expiredAt = Instant.now().plus(ttl).toEpochMilli();
store.put(key, new InternalEntry(type, token, expiredAt));
}

/**
* 토큰을 일회성으로 조회 후 제거 (만료 시 null 반환)
*/
@Override
public Entry consume(String key) {
InternalEntry entry = store.remove(key);
if (entry == null) return null;

if (entry.expireAtEpochMillis() < Instant.now().toEpochMilli()) {
return null; // 만료
}

// 외부에는 최소 DTO만 반환 (내부 정보 캡슐화)
return new Entry(entry.type(), entry.token());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package konkuk.thip.common.security.oauth2.tokenstorage;

import konkuk.thip.common.exception.AuthException;
import konkuk.thip.common.exception.code.ErrorCode;
import konkuk.thip.common.security.oauth2.TokenType;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Profile({"!test"})
@Component
@RequiredArgsConstructor
public class RedisLoginTokenStorage implements LoginTokenStorage {
Comment on lines +13 to +16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다


private final RedisTemplate<String, Object> redisTemplate;

private static final String PREFIX = "auth:login-token:";

@Override
public void put(String key, TokenType type, String token, Duration ttl) {
String redisKey = toRedisKey(key);
Entry entry = new Entry(type, token);

redisTemplate.opsForValue().set(redisKey, entry, ttl);
}
Comment on lines +18 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

직렬화 호환성 이슈: Entry 객체를 값으로 저장 시 역직렬화 실패 가능

RedisTemplate<String, Object>의 기본 시리얼라이저 설정에 따라 Entry 레코드가 Map/바이트로 역직렬화되어 instanceof Entry가 실패할 수 있습니다. 또한 JDK 직렬화를 쓰면 Entry가 Serializable이어야 합니다. 구현 간 일관성을 위해 문자열 값으로 저장하는 방식을 권장합니다.

-    public void put(String key, TokenType type, String token, Duration ttl) {
-        String redisKey = toRedisKey(key);
-        Entry entry = new Entry(type, token);
-
-        redisTemplate.opsForValue().set(redisKey, entry, ttl);
-    }
+    public void put(String key, TokenType type, String token, Duration ttl) {
+        String redisKey = toRedisKey(key);
+        String value = type.name() + ":" + token; // 직렬화 독립
+        redisTemplate.opsForValue().set(redisKey, value, ttl);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private final RedisTemplate<String, Object> redisTemplate;
private static final String PREFIX = "auth:login-token:";
@Override
public void put(String key, TokenType type, String token, Duration ttl) {
String redisKey = toRedisKey(key);
Entry entry = new Entry(type, token);
redisTemplate.opsForValue().set(redisKey, entry, ttl);
}
private final RedisTemplate<String, Object> redisTemplate;
private static final String PREFIX = "auth:login-token:";
@Override
public void put(String key, TokenType type, String token, Duration ttl) {
String redisKey = toRedisKey(key);
String value = type.name() + ":" + token; // 직렬화 독립
redisTemplate.opsForValue().set(redisKey, value, ttl);
}


@Override
public Entry consume(String key) {
String redisKey = toRedisKey(key);
Object value = redisTemplate.opsForValue().getAndDelete(redisKey);
if (value == null) {
return null;
}

if (value instanceof Entry entry) {
return entry;
}

throw new AuthException(ErrorCode.JSON_PROCESSING_ERROR);
}

private String toRedisKey(String key) {
return PREFIX + key;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package konkuk.thip.common.security.argument_resolver;
package konkuk.thip.common.security.resolver;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 패키지정리 굿임다


import jakarta.servlet.http.HttpServletRequest;
import konkuk.thip.common.exception.AuthException;
Expand Down
Loading