Skip to content

[FEAT] 동시 로그인 제한 기능 구현#221

Open
Be-HinD wants to merge 2 commits intomainfrom
feat/duplicate-login-prevention
Open

[FEAT] 동시 로그인 제한 기능 구현#221
Be-HinD wants to merge 2 commits intomainfrom
feat/duplicate-login-prevention

Conversation

@Be-HinD
Copy link
Member

@Be-HinD Be-HinD commented Feb 24, 2026

📝 Pull Request

📌 PR 종류

해당하는 항목에 체크해주세요.

  • 기능 추가 (Feature)
  • 버그 수정 (Fix)
  • 문서 수정 (Docs)
  • 코드 리팩터링 (Refactor)
  • 테스트 추가 (Test)
  • 기타 변경 (Chore)

✨ 변경 내용

핵심 설계: 공유 Session ID

Access Token과 Refresh Token이 로그인 시 동일한 sid(session ID)를 공유합니다.
신규 로그인 시 DB의 current_sessionid가 갱신되어 이전 기기의 세션이 즉시 무효화됩니다.

  • JwtTokenProvider: createSessionTokenPair() 추가 — 두 토큰이 동일한 UUID sid를 공유

  • User: updateCurrentSessionid() 메서드 추가

  • CustomUserDetails: currentSessionid 필드 추가 — 로그인 시 DB에서 로드

  • JwtAuthenticationFilter: 매 요청마다 JWT의 sid와 DB의 current_sessionid 비교, 불일치 시 DuplicateSessionException 발생

  • AuthService.login() / googleLogin(): 로그인 시 공유 sid를 DB에 저장

  • AuthService.refresh(): Refresh Token의 sid를 DB와 비교하여 탈취된 기기의 재발급 시도 차단, 검증 통과 시 새 sid 발급 및 DB 갱신 (Refresh Token Rotation 적용)

  • AuthService.logout(): 로그아웃 시 DB current_sessionid를 null로 초기화

  • AuthController.logout(): @AuthenticationPrincipal 추가, 서비스 로그아웃 호출

  • AuthController.refresh(): 재발급된 Refresh Token을 새 HttpOnly 쿠키로 세팅

  • DuplicateSessionException: 중복 로그인 전용 예외 클래스 추가

  • AppErrorCode: DUPLICATE_LOGIN 에러 코드 추가

  • GlobalExceptionHandler: ExpiredTokenException, InvalidTokenException, DuplicateSessionException 핸들러 추가 — 서비스 레이어에서 발생 시 500 대신 적절한 401 응답 반환

  • JwtAuthenticationFilter.sendJsonError(): 예외 타입별 에러 코드 분리, response.isCommitted() 방어 코드 추가

  • docs/동시_로그인_제한_클라이언트_가이드.md: 프론트엔드 적용 가이드 문서 추가


🔍 관련 이슈

해당 PR이 해결하는 이슈가 있다면 연결해주세요.
예: Closes #12, Fixes #5


🧪 테스트

변경된 기능에 대한 테스트 범위 또는 테스트 결과를 작성해주세요.

  • 유닛 테스트 추가 / 수정
  • 통합 테스트 검증
  • 수동 테스트 완료
    • 기기A 로그인 → 기기B 로그인 → 기기A 요청 시 DUPLICATE_LOGIN 응답 확인
    • 기기A 로그인 → 기기B 로그인 → 기기A에서 refresh 시도 시 DUPLICATE_LOGIN 응답 확인
    • 로그아웃 후 해당 토큰으로 요청 시 DUPLICATE_LOGIN 응답 확인
    • 정상 로그인 → 인증 요청 → refresh → 재요청 흐름 정상 동작 확인

🚨 확인해야 할 사항 (Checklist)

PR을 제출하기 전에 아래 항목들을 확인해주세요.

  • 코드 포매팅 완료
  • 불필요한 파일/코드 제거
  • 로직 검증 완료
  • 프로젝트 빌드 성공
  • 린트/정적 분석 통과 (해당 시)

🙋 기타 참고 사항

프론트엔드 적용 필요

이번 변경으로 프론트엔드에서 반드시 대응이 필요한 항목이 있습니다.
상세 내용은 Wiki에 작성해두었습니다.

항목 내용
신규 에러 코드 DUPLICATE_LOGIN (401) — refresh 재시도 없이 로그인 화면으로 이동
/auth/logout Authorization 헤더 필수로 변경
/auth/refresh Refresh Token이 쿠키로 자동 갱신됨 (응답 바디 변경 없음)

보안 개선 포인트

  • Refresh Token Rotation 적용으로 탈취된 Refresh Token의 재사용 차단
  • 로그아웃 시 서버 측 세션 무효화로 토큰 만료 전 강제 차단 가능

Summary by CodeRabbit

  • 새로운 기능

    • 세션 기반 토큰 페어(액세스+리프레시+세션ID) 발급 및 사용
    • 명시적 로그아웃 API 추가
  • 버그 수정

    • 리프레시 토큰 회전 적용 및 SID 검증으로 중복 세션 탐지·차단
    • 만료·무효 토큰에 대한 일관된 오류 응답 처리 추가
  • 보안

    • 세션 ID 기반 검증으로 동시 로그인 제한 강화
  • 문서

    • 클라이언트용 동시 로그인 제한 및 토큰 회전 가이드 추가

- JwtTokenProvider: createSessionTokenPair() 추가, access/refresh 토큰이 동일한 sid 공유
- User: updateCurrentSessionid() 메서드 추가
- AuthService: 로그인/재발급 시 sid DB 저장, 로그아웃 시 sid 초기화
- AuthService.refresh(): refreshToken sid 검증으로 탈취된 기기의 재발급 시도 차단 (Refresh Token Rotation)
- JwtAuthenticationFilter: sid 불일치 시 DuplicateSessionException 발생, sendJsonError 예외 타입별 에러코드 분리, isCommitted 방어 코드 추가
- AppErrorCode: DUPLICATE_LOGIN 에러코드 추가
- DuplicateSessionException: 신규 예외 클래스 추가
- GlobalExceptionHandler: 보안 예외(ExpiredToken, InvalidToken, DuplicateSession) 핸들러 추가
- CustomUserDetails: currentSessionid 필드 추가
- RefreshResponse: refreshToken 필드 추가 (@JsonIgnore)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Be-HinD Be-HinD self-assigned this Feb 24, 2026
@Be-HinD Be-HinD added the ✨enhancement New feature or request label Feb 24, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Walkthrough

세션 기반 토큰 페어(SessionTokenPair)를 도입해 로그인·구글 로그인·토큰 갱신 흐름에서 SID를 포함한 토큰을 발급·회전하며, 사용자 엔티티의 currentSessionid로 세션 일치 검증·회전·무효화(logout)를 수행하고 중복 로그인 예외를 도입했습니다.

Changes

Cohort / File(s) Summary
JWT & 토큰 유틸리티
src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java
SessionTokenPair 레코드 추가, access/refresh 통합 생성 createSessionTokenPair 도입, 토큰에 sid 클레임 포함, getSidFromToken 추가. 기존 createAccess/Refresh 제거.
인증 서비스
src/main/java/team/wego/wegobackend/auth/application/AuthService.java
로그인/googleLogin/refresh에서 SessionTokenPair 사용으로 변경, refresh에서 SID 검증 및 토큰 회전 적용, logout(Long userId) 추가, 주요 메서드에 @Transactional 적용.
컨트롤러 및 DTO
src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java, src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java, src/main/java/team/wego/wegobackend/auth/application/dto/response/RefreshResponse.java
RefreshResponse에 refreshToken 필드 추가(@JsonIgnore), 컨트롤러 시그니처 변경(@AuthenticationPrincipal 주입, HttpServletResponse 파라미터 추가), refresh 시 쿠키 재설정 및 logout에 인증된 사용자 사용.
인증 필터 및 예외 처리
src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java, src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java, src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java, src/main/java/team/wego/wegobackend/common/security/exception/DuplicateSessionException.java
액세스 토큰 검증 후 토큰 SID와 DB 세션 비교해 중복 세션 감지 및 DuplicateSessionException 발생, AppErrorCode에 DUPLICATE_LOGIN 추가, 토큰 관련 예외(Expired/Invalid)용 핸들러 추가, 에러 응답 생성 로직 통합.
도메인 모델 & 시큐리티 세부
src/main/java/team/wego/wegobackend/user/domain/User.java, src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java
User 엔티티에 currentSessionid 컬럼 추가 및 업데이트 메서드 추가. CustomUserDetails에 currentSessionid 필드 및 초기화 추가.
문서(클라이언트 가이드)
docs/동시_로그인_제한_클라이언트_가이드.md
동시 로그인 제한 및 토큰 회전/에러 처리(Axios 인터셉터 예시 포함) 관련 클라이언트 동작 가이드 신규 추가.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant AuthController
    participant AuthService
    participant JwtTokenProvider
    participant UserRepo as User Repository
    participant Database

    rect rgba(100,150,200,0.5)
    Note over Client,Database: Refresh token rotation 및 세션 검증 흐름
    Client->>AuthController: POST /auth/refresh (refreshToken cookie)
    AuthController->>AuthService: refresh(refreshToken)
    AuthService->>JwtTokenProvider: getSidFromToken(refreshToken)
    JwtTokenProvider-->>AuthService: sid
    AuthService->>UserRepo: findById(userId)
    UserRepo->>Database: SELECT user (currentSessionid)
    Database-->>UserRepo: user
    UserRepo-->>AuthService: User
    alt sid == user.currentSessionid
        AuthService->>JwtTokenProvider: createSessionTokenPair(userId, email, role)
        JwtTokenProvider->>JwtTokenProvider: generate new sessionId (UUID)
        JwtTokenProvider-->>AuthService: SessionTokenPair(access, refresh, sessionId)
        AuthService->>UserRepo: updateCurrentSessionid(new sessionId)
        UserRepo->>Database: UPDATE user.current_sessionid
        Database-->>UserRepo: OK
        AuthService-->>AuthController: RefreshResponse(accessToken, refreshToken)
        AuthController->>Client: 200 OK + Set-Cookie(refreshToken)
    else sid != user.currentSessionid
        AuthService-->>AuthController: throw AppException(DUPLICATE_LOGIN)
        AuthController-->>Client: 401 DUPLICATE_LOGIN
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • [FEAT] 동시 로그인 제한 기능 #220 — 본 PR은 사용자 current_sessionid 필드 추가, JWT에 sid 포함, 필터에서 SID 검증, 로그인/갱신/로그아웃 시 세션 ID 회전/삭제 등 해당 이슈의 요구사항을 코드 수준에서 구현합니다.

Possibly related PRs

Poem

🐰 세션을 살피는 토끼가 말하네,
토큰은 돌고 새 SID가 생기네,
한 기기만 머무르라 명하니,
로그아웃에선 흔적을 지우고,
보안의 밭엔 평화가 깃드네 🥕✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.03% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive 이 PR의 변경사항은 연결된 이슈들(#12 환경설정, #5 배포환경)과 직접적인 관련성이 없습니다. 동시 로그인 제한 기능은 별도의 기능 구현입니다. 이 PR이 정말 이슈 #12, #5와 관련되어 있는지 확인하거나, 올바른 이슈를 링크해야 합니다. 현재 PR의 실제 목적을 반영하는 이슈가 있는지 검토하세요.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 주요 변경사항을 명확하게 요약하고 있습니다: 동시 로그인 제한 기능 구현. 제목은 간결하고 전체 변경셋의 핵심을 잘 반영합니다.
Out of Scope Changes check ✅ Passed 모든 변경사항은 동시 로그인 제한 기능 구현이라는 PR 목표와 일치합니다. SessionTokenPair, SID 검증, DuplicateSessionException 등 모든 변경이 이 기능의 범위 내입니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/duplicate-login-prevention

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java (1)

30-33: ⚠️ Potential issue | 🟠 Major

getAuthorities()null을 반환하므로 Spring Security 인증 토큰에 권한이 설정되지 않습니다.

생성자에서 this.authorities를 정상적으로 초기화하고 있지만(라인 26-27), getAuthorities() 오버라이드 메서드가 null을 반환합니다(라인 31-33). @Getter 애너테이션이 있더라도 명시적 오버라이드 메서드가 우선 적용됩니다. JwtAuthenticationFilter의 라인 70에서 userDetails.getAuthorities()가 호출되어 UsernamePasswordAuthenticationToken 생성자에 전달되므로, 인증 객체가 권한 정보를 갖지 않게 되어 권한 검사에 실패합니다.

수정 제안
     `@Override`
     public Collection<? extends GrantedAuthority> getAuthorities() {
-        return null;
+        return authorities;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java`
around lines 30 - 33, The getAuthorities() override in CustomUserDetails
currently returns null, so authorities set in the constructor (this.authorities)
are never exposed to Spring Security; update the getAuthorities() method in
class CustomUserDetails to return the instance field (this.authorities) instead
of null so JwtAuthenticationFilter's call to userDetails.getAuthorities()
supplies the proper GrantedAuthority collection when creating the
UsernamePasswordAuthenticationToken.
src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java (1)

100-114: ⚠️ Potential issue | 🟠 Major

로그아웃 시 @AuthenticationPrincipal 의존으로 인해 이미 세션이 무효화된 사용자는 로그아웃 API를 호출할 수 없습니다.

다른 기기에서 로그인하여 세션이 무효화된 사용자는 JwtAuthenticationFilter에서 DuplicateSessionException이 발생하여 이 엔드포인트에 도달하지 못합니다. 현재 클라이언트 가이드 문서(docs/채팅서비스_클라이언트_가이드문서.md)에는 이 DUPLICATE_LOGIN 에러 처리 방법이 명시되어 있지 않습니다. 의도된 동작이라면 클라이언트 가이드 문서에 이 시나리오의 처리 방법(예: 로컬 상태 클리어, 토큰 폐기)이 명시되어야 합니다.

🧹 Nitpick comments (2)
src/main/java/team/wego/wegobackend/user/domain/User.java (1)

88-89: 컬럼 길이 제약 추가를 권장합니다.

current_sessionidlength 제약이 없어 기본값 255가 적용됩니다. UUID 문자열(36자)만 저장하므로 명시적 길이를 지정하면 스키마 의도가 명확해집니다.

♻️ 수정 제안
-    `@Column`(name = "current_sessionid", nullable = true)
+    `@Column`(name = "current_sessionid", length = 36, nullable = true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/team/wego/wegobackend/user/domain/User.java` around lines 88 -
89, The `@Column` for field currentSessionid lacks an explicit length so the
schema defaults to 255; update the `@Column` annotation on the
User.currentSessionid field to include a length suitable for a UUID (e.g.,
length = 36) so the database column size reflects the intended content and
documents the intent.
src/main/java/team/wego/wegobackend/auth/application/AuthService.java (1)

28-28: UserNotFoundException 클래스가 두 패키지에 중복 존재합니다.

team.wego.wegobackend.auth.exception.UserNotFoundExceptionteam.wego.wegobackend.user.exception.UserNotFoundException이 동일한 구현을 가지고 있습니다. 현재 codebase에서 auth 패키지 버전이 8곳, user 패키지 버전이 4곳에서 사용 중이므로, 하나로 통합하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/team/wego/wegobackend/auth/application/AuthService.java` at
line 28, There are two identical UserNotFoundException classes; remove
duplication by keeping a single canonical class (e.g.,
team.wego.wegobackend.user.exception.UserNotFoundException), delete the
duplicate in team.wego.wegobackend.auth.exception, then update all imports and
references that currently use
team.wego.wegobackend.auth.exception.UserNotFoundException (including
AuthService.java and the ~8 auth usages) to point to
team.wego.wegobackend.user.exception.UserNotFoundException (including the 4
existing user usages); run a build to fix remaining import/compiler errors and
adjust any package-private visibility if needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/team/wego/wegobackend/auth/application/AuthService.java`:
- Around line 216-220: The comparison of refresh SID is unsafe and can throw
NullPointerException because jwtTokenProvider.getSidFromToken(refreshToken) may
return null and user.getCurrentSessionid() may also be null; replace the direct
equals check with a null-safe comparison (e.g., use Objects.equals(refreshSid,
user.getCurrentSessionid()) or explicitly handle nulls) and keep the existing
behavior of throwing new AppException(AppErrorCode.DUPLICATE_LOGIN) when the
SIDs do not match; refer to jwtTokenProvider.getSidFromToken, the local variable
refreshSid, user.getCurrentSessionid, and the
AppException(AppErrorCode.DUPLICATE_LOGIN) throw site.

In
`@src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java`:
- Around line 42-58: In GlobalExceptionHandler, remove the unnecessary
`@ExceptionHandler` methods handleDuplicateSession(DuplicateSessionException) and
handleInvalidToken(InvalidTokenException) since those exceptions are handled in
the JWT filter and never reach this controller advice; keep
handleExpiredToken(ExpiredTokenException) because AuthService.refresh() can
throw it. Delete the two methods (and any now-unused imports) and run a build to
ensure no remaining references; if any tests or code reference those handlers,
update callers to rely on the filter or existing AppException usage (e.g.,
AuthService's AppException(AppErrorCode.DUPLICATE_LOGIN)) instead.

In
`@src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java`:
- Around line 86-88: getSidFromToken currently returns null when the "sid" claim
is missing which can cause NPEs in JwtAuthenticationFilter; change
getSidFromToken in JwtTokenProvider to be null-safe by wrapping the claim
retrieval with Optional (e.g., return
Optional.ofNullable(getClaims(token).get("sid", String.class))) or explicitly
checking containsKey/get returning null and returning Optional.empty()/a safe
default, and update the caller JwtAuthenticationFilter to handle
Optional<String> (or the chosen safe return) instead of assuming a non-null sid.

In
`@src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java`:
- Around line 61-67: The comparison between tokenSid and currentSessionid can
throw NPE because getSidFromToken(jwt) may return null (and currentSessionid can
also be null); update the check in JwtAuthenticationFilter to use a null-safe
comparison (e.g., Objects.equals(tokenSid, currentSessionid)) instead of
tokenSid.equals(...), add the java.util.Objects import if missing, and keep the
existing behavior of throwing DuplicateSessionException when the two values are
not equal.

---

Outside diff comments:
In `@src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java`:
- Around line 30-33: The getAuthorities() override in CustomUserDetails
currently returns null, so authorities set in the constructor (this.authorities)
are never exposed to Spring Security; update the getAuthorities() method in
class CustomUserDetails to return the instance field (this.authorities) instead
of null so JwtAuthenticationFilter's call to userDetails.getAuthorities()
supplies the proper GrantedAuthority collection when creating the
UsernamePasswordAuthenticationToken.

---

Nitpick comments:
In `@src/main/java/team/wego/wegobackend/auth/application/AuthService.java`:
- Line 28: There are two identical UserNotFoundException classes; remove
duplication by keeping a single canonical class (e.g.,
team.wego.wegobackend.user.exception.UserNotFoundException), delete the
duplicate in team.wego.wegobackend.auth.exception, then update all imports and
references that currently use
team.wego.wegobackend.auth.exception.UserNotFoundException (including
AuthService.java and the ~8 auth usages) to point to
team.wego.wegobackend.user.exception.UserNotFoundException (including the 4
existing user usages); run a build to fix remaining import/compiler errors and
adjust any package-private visibility if needed.

In `@src/main/java/team/wego/wegobackend/user/domain/User.java`:
- Around line 88-89: The `@Column` for field currentSessionid lacks an explicit
length so the schema defaults to 255; update the `@Column` annotation on the
User.currentSessionid field to include a length suitable for a UUID (e.g.,
length = 36) so the database column size reflects the intended content and
documents the intent.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ef9f446 and 10e9f12.

📒 Files selected for processing (11)
  • src/main/java/team/wego/wegobackend/auth/application/AuthService.java
  • src/main/java/team/wego/wegobackend/auth/application/dto/response/RefreshResponse.java
  • src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java
  • src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java
  • src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java
  • src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java
  • src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java
  • src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java
  • src/main/java/team/wego/wegobackend/common/security/exception/DuplicateSessionException.java
  • src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java
  • src/main/java/team/wego/wegobackend/user/domain/User.java

Comment on lines 42 to 58
@ExceptionHandler(ExpiredTokenException.class)
public ResponseEntity<ErrorResponse> handleExpiredToken(ExpiredTokenException ex,
HttpServletRequest request) {
return handleApp(new AppException(AppErrorCode.EXPIRED_TOKEN), request);
}

@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ErrorResponse> handleInvalidToken(InvalidTokenException ex,
HttpServletRequest request) {
return handleApp(new AppException(AppErrorCode.INVALID_TOKEN), request);
}

@ExceptionHandler(DuplicateSessionException.class)
public ResponseEntity<ErrorResponse> handleDuplicateSession(DuplicateSessionException ex,
HttpServletRequest request) {
return handleApp(new AppException(AppErrorCode.DUPLICATE_LOGIN), request);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for DuplicateSessionException being thrown
rg -n "throw new DuplicateSessionException" --type=java -C3
echo "---"
# Search for DUPLICATE_LOGIN usage
rg -n "DUPLICATE_LOGIN" --type=java -C2
echo "---"
# Search for ExpiredTokenException throws in services
rg -n "throw new ExpiredTokenException" --type=java -C2
echo "---"
# Search for InvalidTokenException throws in services
rg -n "throw new InvalidTokenException" --type=java -C2

Repository: WeGo-Together/WeGo_BackEnd

Length of output: 6594


필터에서 발생하는 예외는 @RestControllerAdvice에서 처리되지 않습니다.

JwtAuthenticationFilter(서블릿 필터)에서 발생하는 ExpiredTokenException, InvalidTokenException, DuplicateSessionException은 Spring MVC 디스패치 체인 밖이므로 이 핸들러에 도달하지 않습니다. 필터는 자체 예외 처리 로직(getErrorCodeFromException())으로 처리합니다.

다만 handleExpiredTokenAuthService.refresh()에서 서비스 레이어에서 ExpiredTokenException이 발생할 수 있으므로 필요합니다. 반면 handleDuplicateSession은 실질적 사용처가 없습니다: DuplicateSessionException은 필터에서만 발생하고(이미 필터가 처리), 서비스 레이어(AuthService.java:219)에서는 AppException(AppErrorCode.DUPLICATE_LOGIN)을 사용하므로 이 핸들러를 거치지 않습니다. handleInvalidToken도 현재 모든 발생 지점이 필터나 JWT 제공자에 있어 GlobalExceptionHandler까지 도달하지 않습니다.

불필요한 핸들러(handleDuplicateSession, handleInvalidToken)는 제거하는 것이 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java`
around lines 42 - 58, In GlobalExceptionHandler, remove the unnecessary
`@ExceptionHandler` methods handleDuplicateSession(DuplicateSessionException) and
handleInvalidToken(InvalidTokenException) since those exceptions are handled in
the JWT filter and never reach this controller advice; keep
handleExpiredToken(ExpiredTokenException) because AuthService.refresh() can
throw it. Delete the two methods (and any now-unused imports) and run a build to
ensure no remaining references; if any tests or code reference those handlers,
update callers to rely on the filter or existing AppException usage (e.g.,
AuthService's AppException(AppErrorCode.DUPLICATE_LOGIN)) instead.

Comment on lines +86 to +88
public String getSidFromToken(String token) {
return getClaims(token).get("sid", String.class);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

sid 클레임 미존재 시 null 반환 가능성 고려 필요

getClaims(token).get("sid", String.class)는 해당 클레임이 없을 경우 null을 반환합니다. 이 기능 배포 이전에 발급된 토큰에는 sid 클레임이 없으므로, 호출자(JwtAuthenticationFilter)에서 NPE가 발생할 수 있습니다. 필터 쪽 리뷰 코멘트에서 이미 언급한 null-safe 처리로 대응 가능합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java`
around lines 86 - 88, getSidFromToken currently returns null when the "sid"
claim is missing which can cause NPEs in JwtAuthenticationFilter; change
getSidFromToken in JwtTokenProvider to be null-safe by wrapping the claim
retrieval with Optional (e.g., return
Optional.ofNullable(getClaims(token).get("sid", String.class))) or explicitly
checking containsKey/get returning null and returning Optional.empty()/a safe
default, and update the caller JwtAuthenticationFilter to handle
Optional<String> (or the chosen safe return) instead of assuming a non-null sid.

- AuthService.refresh(): refreshSid null 체크 추가
- JwtAuthenticationFilter: tokenSid null 체크 추가
- GlobalExceptionHandler: dead code인 handleDuplicateSession 제거
  (DuplicateSessionException은 필터 전용, 서비스 레이어는 AppException 사용)
- docs: 동시 로그인 제한 클라이언트 가이드 문서 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/team/wego/wegobackend/auth/application/AuthService.java (1)

200-229: ⚠️ Potential issue | 🟠 Major

동시 refresh 요청 시 TOCTOU 경합 조건이 발생할 수 있습니다.

@Transactional의 기본 격리 수준(READ_COMMITTED)에서, 동일한 refresh 토큰으로 두 요청이 동시에 들어오면 다음 흐름이 발생합니다:

  1. 두 트랜잭션 모두 user.getCurrentSessionid() = "sid-A" 읽기 → 검증 통과
  2. 트랜잭션 A: sid-B 발급 → updateCurrentSessionid("sid-B") → 커밋
  3. 트랜잭션 B: sid-C 발급 → updateCurrentSessionid("sid-C") → 커밋
  4. 결과: DB에는 sid-C만 유효 → 트랜잭션 A가 반환한 토큰은 즉시 무효화됨

보안 취약점이라기보다는 UX 신뢰성 문제이지만, 정상 사용자가 별다른 이유 없이 재인증을 요구받을 수 있습니다.

UserRepository에 비관적 락(pessimistic lock) 쿼리를 추가하거나 엔티티에 @Version 낙관적 락을 적용하는 방식으로 해결할 수 있습니다.

🔒 비관적 락 적용 예시

UserRepository에 락 쿼리 추가:

+ `@Lock`(LockModeType.PESSIMISTIC_WRITE)
+ `@Query`("SELECT u FROM User u WHERE u.email = :email")
+ Optional<User> findByEmailWithLock(`@Param`("email") String email);

AuthService.refresh()에서 변경:

- User user = userRepository.findByEmail(email)
+ User user = userRepository.findByEmailWithLock(email)
          .orElseThrow(UserNotFoundException::new);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/team/wego/wegobackend/auth/application/AuthService.java` around
lines 200 - 229, The refresh flow in AuthService.refresh has a TOCTOU race when
concurrent refreshes read and update user.currentSessionid; fix by serializing
access to the User row: add a repository method (e.g.,
UserRepository.findByEmailForUpdate or findByEmailWithPessimisticLock) that
loads the User with a PESSIMISTIC_WRITE lock, then in AuthService.refresh
replace the current userRepository.findByEmail(...) call with that locked loader
inside the `@Transactional` method so only one transaction can validate/update the
session id at a time; alternatively, add an `@Version` field to the User entity
and handle OptimisticLockException by retrying the refresh once, but prefer the
pessimistic-lock repository method for immediate correctness.
♻️ Duplicate comments (2)
src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java (1)

61-67: ⚠️ Potential issue | 🟡 Minor

이전 리뷰의 null 체크 반영 — 단, null tokenSid 시 발생하는 예외 코드가 의미론적으로 부정확

tokenSid == null 조건 추가로 NPE는 방지되었으나, sid 클레임이 없는 토큰(레거시 토큰 포함)은 실질적으로 유효하지 않은 토큰임에도 DUPLICATE_LOGIN 에러 코드로 응답됩니다. 클라이언트가 이를 "다른 기기에서 로그인됨"으로 오해할 수 있습니다.

sid가 없는 경우는 InvalidTokenException으로 분리하는 것을 권장합니다.

🛠️ 개선 제안
-               if (tokenSid == null || !tokenSid.equals(currentSessionid)) {
-                   throw new DuplicateSessionException();
-               }
+               if (tokenSid == null) {
+                   throw new InvalidTokenException();
+               }
+               if (!tokenSid.equals(currentSessionid)) {
+                   throw new DuplicateSessionException();
+               }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java`
around lines 61 - 67, The current check in JwtAuthenticationFilter uses tokenSid
== null to throw DuplicateSessionException which mislabels tokens missing the
sid claim; change the logic so that when jwtTokenProvider.getSidFromToken(jwt)
returns null you throw an InvalidTokenException (or create one if missing) to
indicate a malformed/invalid token, and only throw DuplicateSessionException
when tokenSid is non-null but does not equal userDetails.getCurrentSessionid();
update any related error codes/messages accordingly.
src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java (1)

47-51: handleInvalidToken은 여전히 도달 불가능한 핸들러입니다.

InvalidTokenExceptionJwtAuthenticationFilter(서블릿 필터 레이어)에서만 던져집니다. DispatcherServlet 앞단에서 발생한 예외는 @ControllerAdvice로 처리할 수 없습니다. 이번 커밋에서 handleDuplicateSession은 제거되었지만 handleInvalidToken은 아직 남아 있습니다. 제거를 권장합니다.

♻️ 제안: 도달 불가능한 핸들러 제거
-    `@ExceptionHandler`(InvalidTokenException.class)
-    public ResponseEntity<ErrorResponse> handleInvalidToken(InvalidTokenException ex,
-            HttpServletRequest request) {
-        return handleApp(new AppException(AppErrorCode.INVALID_TOKEN), request);
-    }
-

import team.wego.wegobackend.common.security.exception.InvalidTokenException; 임포트도 함께 제거하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java`
around lines 47 - 51, Remove the unreachable exception handler method
handleInvalidToken(...) from GlobalExceptionHandler and also remove its unused
import team.wego.wegobackend.common.security.exception.InvalidTokenException;
since InvalidTokenException is thrown from JwtAuthenticationFilter (a servlet
filter before DispatcherServlet) and cannot be handled by `@ControllerAdvice`,
delete the handleInvalidToken method and the corresponding import to keep the
class clean.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/동시_로그인_제한_클라이언트_가이드.md`:
- Around line 133-146: The logout function is using await but is not declared
async; update the function declaration for logout to be async (i.e., make
function logout() -> async function logout()) so the await on axios.post works
without a SyntaxError, and keep the existing try/catch/finally behavior that
calls clearLocalToken() and redirectToLogin().
- Around line 165-187: The response interceptor destructures error.response
without checking for its existence which crashes on network errors; update the
axios.interceptors.response.use error handler to first guard for undefined
error.response (e.g., if (!error.response) return Promise.reject(error) or
handle network errors appropriately) before accessing status/data, then proceed
with the existing logic that uses status, data, clearLocalToken(),
redirectToLogin(), and originalRequest.
- Around line 100-105: Add a language specifier to the fenced code block (or
convert it to an indented block) so the markdown linter MD040 is satisfied:
change the triple-backtick fence that surrounds the sequence starting with "기기A
사용 중" and ending with "→ 401 DUPLICATE_LOGIN 반환" to use ```text (or reformat as
an indented block) so the block is explicitly marked as plain text.

---

Outside diff comments:
In `@src/main/java/team/wego/wegobackend/auth/application/AuthService.java`:
- Around line 200-229: The refresh flow in AuthService.refresh has a TOCTOU race
when concurrent refreshes read and update user.currentSessionid; fix by
serializing access to the User row: add a repository method (e.g.,
UserRepository.findByEmailForUpdate or findByEmailWithPessimisticLock) that
loads the User with a PESSIMISTIC_WRITE lock, then in AuthService.refresh
replace the current userRepository.findByEmail(...) call with that locked loader
inside the `@Transactional` method so only one transaction can validate/update the
session id at a time; alternatively, add an `@Version` field to the User entity
and handle OptimisticLockException by retrying the refresh once, but prefer the
pessimistic-lock repository method for immediate correctness.

---

Duplicate comments:
In
`@src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java`:
- Around line 47-51: Remove the unreachable exception handler method
handleInvalidToken(...) from GlobalExceptionHandler and also remove its unused
import team.wego.wegobackend.common.security.exception.InvalidTokenException;
since InvalidTokenException is thrown from JwtAuthenticationFilter (a servlet
filter before DispatcherServlet) and cannot be handled by `@ControllerAdvice`,
delete the handleInvalidToken method and the corresponding import to keep the
class clean.

In
`@src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java`:
- Around line 61-67: The current check in JwtAuthenticationFilter uses tokenSid
== null to throw DuplicateSessionException which mislabels tokens missing the
sid claim; change the logic so that when jwtTokenProvider.getSidFromToken(jwt)
returns null you throw an InvalidTokenException (or create one if missing) to
indicate a malformed/invalid token, and only throw DuplicateSessionException
when tokenSid is non-null but does not equal userDetails.getCurrentSessionid();
update any related error codes/messages accordingly.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 10e9f12 and b315a50.

📒 Files selected for processing (4)
  • docs/동시_로그인_제한_클라이언트_가이드.md
  • src/main/java/team/wego/wegobackend/auth/application/AuthService.java
  • src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java
  • src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java

Comment on lines +100 to +105
```
기기A 사용 중
→ 기기B에서 동일 계정으로 로그인 (기기A 세션 무효화)
→ 기기A에서 refresh 시도
→ 401 DUPLICATE_LOGIN 반환
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

코드 블록에 언어 지정자가 없습니다.

마크다운 린터(MD040)가 경고한 항목입니다. 일반 텍스트 시퀀스이므로 text를 지정하거나, 해당 내용을 들여쓰기 블록으로 표현하는 것이 좋습니다.

✏️ 수정 제안
-```
+```text
 기기A 사용 중
   → 기기B에서 동일 계정으로 로그인 (기기A 세션 무효화)
   → 기기A에서 refresh 시도
   → 401 DUPLICATE_LOGIN 반환
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 100-100: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/동시_로그인_제한_클라이언트_가이드.md` around lines 100 - 105, Add a language specifier
to the fenced code block (or convert it to an indented block) so the markdown
linter MD040 is satisfied: change the triple-backtick fence that surrounds the
sequence starting with "기기A 사용 중" and ending with "→ 401 DUPLICATE_LOGIN 반환" to
use ```text (or reformat as an indented block) so the block is explicitly marked
as plain text.

Comment on lines +133 to +146
```javascript
function logout() {
try {
await axios.post('/api/v1/auth/logout', null, {
headers: { Authorization: `Bearer ${accessToken}` }
});
} catch (e) {
// 401이어도 로컬 상태는 반드시 초기화
} finally {
clearLocalToken();
redirectToLogin();
}
}
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

logout 함수에 async 키워드가 누락되었습니다.

await를 사용하고 있으므로 함수를 async로 선언해야 합니다. 현재 코드를 그대로 복사하면 SyntaxError가 발생합니다.

✏️ 수정 제안
-function logout() {
+async function logout() {
   try {
     await axios.post('/api/v1/auth/logout', null, {
📝 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
```javascript
function logout() {
try {
await axios.post('/api/v1/auth/logout', null, {
headers: { Authorization: `Bearer ${accessToken}` }
});
} catch (e) {
// 401이어도 로컬 상태는 반드시 초기화
} finally {
clearLocalToken();
redirectToLogin();
}
}
```
async function logout() {
try {
await axios.post('/api/v1/auth/logout', null, {
headers: { Authorization: `Bearer ${accessToken}` }
});
} catch (e) {
// 401이어도 로컬 상태는 반드시 초기화
} finally {
clearLocalToken();
redirectToLogin();
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/동시_로그인_제한_클라이언트_가이드.md` around lines 133 - 146, The logout function is
using await but is not declared async; update the function declaration for
logout to be async (i.e., make function logout() -> async function logout()) so
the await on axios.post works without a SyntaxError, and keep the existing
try/catch/finally behavior that calls clearLocalToken() and redirectToLogin().

Comment on lines +165 to +187
axios.interceptors.response.use(
(response) => response,
async (error) => {
const { status, data } = error.response;
const originalRequest = error.config;

if (status !== 401) return Promise.reject(error);

const errorCode = data?.errorCode;

// 재시도 없이 로그인으로 보내야 하는 케이스
if (
errorCode === 'DUPLICATE_LOGIN' ||
errorCode === 'INVALID_TOKEN' ||
errorCode === 'NOT_FOUND_TOKEN'
) {
if (errorCode === 'DUPLICATE_LOGIN') {
alert('다른 기기에서 로그인되었습니다.');
}
clearLocalToken();
redirectToLogin();
return Promise.reject(error);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

error.responseundefined일 때 인터셉터가 크래시될 수 있습니다.

네트워크 오류(타임아웃, 연결 거부 등)의 경우 error.responseundefined이므로 구조 분해 할당 시 TypeError가 발생합니다.

✏️ 수정 제안
-    const { status, data } = error.response;
-    const originalRequest = error.config;
-
-    if (status !== 401) return Promise.reject(error);
+    const originalRequest = error.config;
+
+    if (!error.response) return Promise.reject(error);
+    const { status, data } = error.response;
+
+    if (status !== 401) return Promise.reject(error);
📝 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
axios.interceptors.response.use(
(response) => response,
async (error) => {
const { status, data } = error.response;
const originalRequest = error.config;
if (status !== 401) return Promise.reject(error);
const errorCode = data?.errorCode;
// 재시도 없이 로그인으로 보내야 하는 케이스
if (
errorCode === 'DUPLICATE_LOGIN' ||
errorCode === 'INVALID_TOKEN' ||
errorCode === 'NOT_FOUND_TOKEN'
) {
if (errorCode === 'DUPLICATE_LOGIN') {
alert('다른 기기에서 로그인되었습니다.');
}
clearLocalToken();
redirectToLogin();
return Promise.reject(error);
}
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (!error.response) return Promise.reject(error);
const { status, data } = error.response;
if (status !== 401) return Promise.reject(error);
const errorCode = data?.errorCode;
// 재시도 없이 로그인으로 보내야 하는 케이스
if (
errorCode === 'DUPLICATE_LOGIN' ||
errorCode === 'INVALID_TOKEN' ||
errorCode === 'NOT_FOUND_TOKEN'
) {
if (errorCode === 'DUPLICATE_LOGIN') {
alert('다른 기기에서 로그인되었습니다.');
}
clearLocalToken();
redirectToLogin();
return Promise.reject(error);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/동시_로그인_제한_클라이언트_가이드.md` around lines 165 - 187, The response interceptor
destructures error.response without checking for its existence which crashes on
network errors; update the axios.interceptors.response.use error handler to
first guard for undefined error.response (e.g., if (!error.response) return
Promise.reject(error) or handle network errors appropriately) before accessing
status/data, then proceed with the existing logic that uses status, data,
clearLocalToken(), redirectToLogin(), and originalRequest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨enhancement New feature or request

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant