Skip to content

[USER] RTR(Refresh Token Rotation) 인증 시스템 구현#32

Merged
s0ooo0k merged 2 commits intoPETTY-HUB:mainfrom
taehyun32:feat/auth
May 23, 2025
Merged

[USER] RTR(Refresh Token Rotation) 인증 시스템 구현#32
s0ooo0k merged 2 commits intoPETTY-HUB:mainfrom
taehyun32:feat/auth

Conversation

@taehyun32
Copy link
Member

@taehyun32 taehyun32 commented May 23, 2025

📜 PR 내용 요약

  • Refresh Token 기반의 새로운 토큰 갱신 시스템 백엔드 구현
  • 로그인 및 OAuth2 성공 시 Access Token과 Refresh Token을 쿠키로 발급하도록 수정
  • Access Token 만료 시 Refresh Token을 사용하여 자동으로 토큰을 갱신하는 프론트엔드 로직 추가
  • 사용자의 Refresh Token 관리 로직 개선 및 쿼리 충돌 해결

⚒️ 작업 및 변경 내용(상세하게)

1. RTR(Refresh Token Rotation) 인증 시스템 백엔드 구현

  • Refresh Token 엔티티 (RefreshToken.java) 추가:
    • 리프레시 토큰 정보를 저장하는 엔티티를 정의했습니다.
    • 사용자 (Users 엔티티와 ManyToOne 연관 관계), 인증 제공자 (provider), 생성 시간 (createdAt), 만료 시간 (expiredAt), 사용 여부 (used) 필드를 포함합니다.
    • 토큰을 사용됨으로 표시하는 markUsed() 메서드와 만료 여부를 확인하는 isExpired() 메서드를 추가했습니다.
  • Refresh Token Repository (RefreshTokenRepository.java) 추가:
    • JpaRepository를 상속받아 RefreshToken 엔티티의 데이터 접근을 담당합니다.
    • 특정 사용자의 활성 토큰 조회, 사용자의 유효한 활성 토큰 조회, ID로 사용되지 않은 토큰 조회 등의 쿼리 메서드를 정의했습니다.
  • Refresh Token Service (RefreshTokenService.java) 추가:
    • 리프레시 토큰의 비즈니스 로직을 처리하는 서비스 클래스입니다.
    • 새 리프레시 토큰 생성 로직 구현: 사용자에 대한 새로운 리프레시 토큰을 생성하고 데이터베이스에 저장합니다. 리프레시 토큰은 7일의 유효 기간을 가집니다. 사용자당 최대 3개의 활성 토큰만 유지하도록 관리하는 로직을 추가했습니다 (가장 오래된 토큰부터 삭제).
    • 액세스 토큰 및 리프레시 토큰 갱신 로직 구현 (RTR 핵심):
      • 제공된 리프레시 토큰 ID로 유효하고 사용되지 않은 토큰을 찾습니다.
      • 토큰 만료 여부를 확인합니다. 만료된 경우 해당 사용자의 모든 토큰을 무효화하고 예외를 발생시킵니다.
      • 재사용 방지를 위해 현재 사용된 리프레시 토큰을 'used' 상태로 표시합니다 (markUsed).
      • 새로운 액세스 토큰 (1시간 유효)을 생성합니다.
      • 새로운 리프레시 토큰을 생성하여 반환합니다. (Rotate)
      • 갱신된 액세스 토큰과 새로운 리프레시 토큰을 담은 RefreshTokenResponseDTO를 반환합니다.
    • 사용자의 모든 리프레시 토큰 무효화 로직 구현: 사용자가 로그아웃하거나 보안상 필요한 경우, 해당 사용자의 모든 활성 토큰을 무효화하는 기능을 제공합니다.
  • RefreshTokenResponseDTO 추가:
    • 액세스 토큰 갱신 응답 시 사용되는 DTO로, 새로운 액세스 토큰과 새로운 리프레시 토큰(UUID 문자열) 필드를 가집니다.
  • CookieUtils 유틸리티 클래스 추가:
    • HttpServletResponse를 통해 HTTP 응답 헤더에 Access Token (jwt)과 Refresh Token (refresh_token) 쿠키를 설정하는 정적 메서드를 제공합니다.
    • 두 토큰 모두 HttpOnly 속성을 가집니다.
    • Access Token 쿠키의 Path는 /이며 만료 시간은 1시간(3600초)입니다.
    • Refresh Token 쿠키의 Path는 /이며 만료 시간은 7일입니다.

2. 인증 플로우 수정 및 RTR 연동

  • LoginFilter 수정:
    • 사용자 로그인 성공 시, 기존의 Access Token만 쿠키에 저장하던 방식에서, Access Token과 새로운 Refresh Token을 모두 생성하여 CookieUtils를 통해 응답 쿠키에 설정하도록 변경했습니다.
    • LoginFilter 생성자에 RefreshTokenServiceUsersRepository 빈을 추가로 주입받도록 수정했습니다.
  • OAuth2SuccessHandler 수정:
    • OAuth2 로그인 성공 시, LoginFilter와 마찬가지로 Access Token과 새로운 Refresh Token을 모두 생성하여 CookieUtils를 통해 응답 쿠키에 설정하도록 변경했습니다.
    • OAuth2SuccessHandler 생성자에 RefreshTokenServiceUsersRepository 빈을 추가로 주입받도록 수정했습니다.
  • JWTFilter 수정:
    • /api/auth/refresh 엔드포인트에 대한 요청은 JWT 검증 필터를 건너뛰도록 예외 처리를 추가했습니다. 이는 리프레시 토큰 갱신 시 Access Token 없이 요청하기 때문입니다.
  • UsersApiController/api/auth/refresh POST 엔드포인트 추가:
    • 클라이언트가 /api/auth/refresh 경로로 POST 요청을 보낼 때 호출되는 API 엔드포인트를 추가했습니다.
    • 요청 쿠키에서 refresh_token 값을 읽어옵니다.
    • RefreshTokenService.refreshAccessToken 메서드를 호출하여 토큰 갱신을 시도하고, 성공 시 CookieUtils를 사용하여 응답 쿠키에 새로운 Access Token과 Refresh Token을 설정합니다.
    • refresh_token이 없거나 유효하지 않은 토큰 형식인 경우, 또는 갱신 중 서비스 예외 발생 시 적절한 오류 응답을 반환합니다.
  • SecurityConfig 수정:
    • /api/auth/refresh 경로에 대한 접근을 인증 없이 허용하도록 설정했습니다.
    • 로그아웃 엔드포인트 (/logout)에서 JSESSIONID 쿠키 외에 jwt 쿠키와 refresh_token 쿠키도 삭제하도록 추가했습니다.

3. RTR 프론트엔드 (HTML) 구현

  • index.html 수정:
    • 사용자 정보 조회 (/api/users/me) 시 401 Unauthorized 응답을 받으면 (Access Token 만료 시), /api/auth/refresh 엔드포인트로 자동 토큰 갱신을 시도하는 JavaScript 로직을 추가했습니다.
    • 토큰 갱신에 성공하면 (refreshResponse.ok) checkLoginStatus 함수를 재귀적으로 다시 호출하여 새로운 Access Token으로 사용자 정보를 정상적으로 조회하도록 했습니다.
    • 토큰 갱신 실패 시 (refreshResponse.ok가 아니거나 예외 발생 시) 로그인 메뉴를 표시하도록 했습니다.

4. @Modifying 쿼리 충돌 해결

  • RefreshTokenService.refreshAccessTokeninvalidateUserTokens 메서드 수정:
    • 기존에 @Modifying 쿼리를 사용하여 사용자의 모든 활성 토큰을 무효화하던 로직을 변경했습니다.
    • 변경 내용: @Modifying 쿼리의 잠재적 충돌 문제를 피하기 위해, 먼저 refreshTokenRepository.findActiveTokensByUser를 사용하여 무효화할 활성 토큰 목록을 조회한 후, 조회된 엔티티 목록을 refreshTokenRepository.deleteAll 메서드를 사용하여 JPQL DELETE 쿼리 대신 엔티티 단위 삭제로 변경하여 해결했습니다. 이로써 동시성 문제 발생 가능성을 줄였습니다.

📚 기타 참고 사항

  • RTR 구현의 핵심은 사용된 리프레시 토큰을 즉시 무효화하여 토큰 하이재킹 공격 시 재사용을 방지하는 것입니다.

  • Refresh Token은 Access Token보다 긴 유효 기간 (7일)을 가집니다. Access Token은 1시간 유효합니다.

  • 사용자당 최대 3개의 활성 리프레시 토큰만 유지됩니다.

  • 로그아웃 시 발급받은 jwtrefresh_token 쿠키가 모두 삭제됩니다.

  • 프론트엔드에서 Access Token 만료 시 Refresh Token으로 자동 갱신을 시도하여 사용자 경험을 개선했습니다.

  • @Modifying 쿼리 사용 시 JPA/Hibernate의 영속성 컨텍스트 문제로 인한 잠재적 충돌 가능성이 있어, 엔티티를 먼저 조회 후 삭제하는 방식으로 우회 처리했습니다.

  • 🔧 프론트엔드 RTR 구현 방법 (다른 페이지 적용 시 참고)

    // 401 에러 발생 시 자동 토큰 갱신 로직
    async function checkLoginStatus() {
        try {
            const response = await fetch('/api');
            
            if (response.ok) {
                // 성공: 로그인 상태 표시
                const data = await response.json();
                // ... 사용자 정보 표시 로직
            } else if (response.status === 401) {
                // 토큰 만료: 자동 갱신 시도
                try {
                    const refreshResponse = await fetch('/api/auth/refresh', { 
                        method: 'POST' 
                    });
                    
                    if (refreshResponse.ok) {
                        // 토큰 갱신 성공 → 원래 요청 재시도
                        return await checkLoginStatus();
                    } else {
                        // 리프레시 토큰도 만료 → 로그인 필요
                        showLoginMenu();
                    }
                } catch (refreshError) {
                    showLoginMenu();
                }
            } else {
                showLoginMenu();
            }
        } catch (error) {
            showLoginMenu();
        }
    }

@coderabbitai
Copy link

coderabbitai bot commented May 23, 2025

Summary by CodeRabbit

  • 신규 기능

    • 리프레시 토큰 기반의 자동 로그인 연장 기능이 추가되었습니다. 액세스 토큰 만료 시, 자동으로 리프레시 토큰을 사용해 로그인을 연장합니다.
    • /api/auth/refresh 엔드포인트가 추가되어, 리프레시 토큰을 통한 액세스 토큰 재발급이 가능합니다.
  • 버그 수정

    • 로그인 및 인증 관련 쿠키 처리 방식이 개선되어, 로그아웃 시 모든 관련 쿠키가 삭제됩니다.
  • UI/UX 개선

    • 로그인 상태 확인 로직이 개선되어, 액세스 토큰 만료 시 자동으로 로그인 상태를 갱신합니다.
    • 로그인 시 토큰을 더 이상 localStorage에 저장하지 않고, 보안 쿠키로 관리합니다.
  • 기타

    • 리프레시 토큰 관리 및 발급 로직이 추가되어, 최대 3개의 활성화된 리프레시 토큰만 유지됩니다.

Walkthrough

이번 변경에서는 리프레시 토큰 기반 인증 및 자동 토큰 갱신 기능이 추가되었습니다. 새로운 엔티티, 서비스, 리포지토리, DTO, 유틸리티가 도입되었으며, 기존 보안 설정 및 필터, 컨트롤러, OAuth2 핸들러, 프론트엔드 코드가 리프레시 토큰 처리 로직을 반영하도록 수정되었습니다.

Changes

파일/경로 변경 요약
.../config/SecurityConfig.java
.../jwt/LoginFilter.java
리프레시 토큰 서비스 및 유저 리포지토리 의존성 추가, /api/auth/refresh 허용, 로그아웃 시 쿠키 삭제 확장, 필터 생성자 및 인증 성공 시 쿠키 처리 로직 변경
.../users/controller/UsersApiController.java 리프레시 토큰 서비스 의존성 주입, /api/auth/refresh 엔드포인트 추가, 쿠키에서 리프레시 토큰 추출 및 검증, 토큰 갱신 및 쿠키 재설정 로직 구현
.../users/dto/RefreshTokenResponseDTO.java 액세스 토큰 및 리프레시 토큰을 담는 DTO 신설, Lombok 적용
.../users/entity/RefreshToken.java 리프레시 토큰 JPA 엔티티 신설, 만료/사용 여부 및 도메인 메서드 추가
.../users/jwt/JWTFilter.java /api/auth/refresh 요청 시 JWT 검증 우회 처리 추가
.../users/jwt/JWTUtil.java 요청에서 리프레시 토큰 쿠키 추출 메서드 추가
.../users/oauth2/OAuth2SuccessHandler.java OAuth2 로그인 성공 시 리프레시 토큰 발급 및 쿠키 처리 로직 추가
.../users/repository/RefreshTokenRepository.java 리프레시 토큰 엔티티용 JPA 리포지토리 신설, 다양한 조회 메서드 구현
.../users/service/RefreshTokenService.java 리프레시 토큰 생성, 갱신, 무효화 서비스 신설, 토큰 개수 제한 및 만료 처리 포함
.../users/util/CookieUtils.java 액세스/리프레시 토큰 쿠키 일괄 설정 유틸리티 신설
.../resources/templates/index.html JS에서 401 발생 시 /api/auth/refresh로 토큰 갱신 후 재시도 로직 추가
.../resources/templates/login.html 로그인 시 Authorization 헤더에서 토큰 추출 및 localStorage 저장 코드 제거

Sequence Diagram(s)

sequenceDiagram
    participant 브라우저
    participant 서버(UsersApiController)
    participant RefreshTokenService
    participant CookieUtils

    브라우저->>서버(UsersApiController): POST /api/auth/refresh (refresh_token 쿠키 포함)
    서버(UsersApiController)->>RefreshTokenService: refreshAccessToken(refreshTokenId)
    RefreshTokenService->>RefreshTokenService: 토큰 유효성/만료/사용 여부 확인
    RefreshTokenService->>RefreshTokenService: 새 access/refresh 토큰 생성, 기존 토큰 사용 처리
    RefreshTokenService-->>서버(UsersApiController): RefreshTokenResponseDTO 반환
    서버(UsersApiController)->>CookieUtils: setTokenCookies(response, access, refresh)
    CookieUtils-->>서버(UsersApiController): 쿠키 설정
    서버(UsersApiController)-->>브라우저: 200 OK (쿠키 포함)
Loading
sequenceDiagram
    participant 브라우저
    participant 서버(LoginFilter)
    participant UsersRepository
    participant RefreshTokenService
    participant CookieUtils

    브라우저->>서버(LoginFilter): 로그인 요청
    서버(LoginFilter)->>UsersRepository: 사용자 조회
    서버(LoginFilter)->>RefreshTokenService: createRefreshToken(user)
    RefreshTokenService-->>서버(LoginFilter): refreshTokenId 반환
    서버(LoginFilter)->>CookieUtils: setTokenCookies(response, access, refresh)
    CookieUtils-->>서버(LoginFilter): 쿠키 설정
    서버(LoginFilter)-->>브라우저: 로그인 성공 (쿠키 포함)
Loading

Poem

🐰
리프레시 토큰 hop! hop!
쿠키 속에 담아 띄우네
만료되어도 걱정 마,
새 토큰 받아 다시 run!
토끼처럼 민첩하게
인증의 숲을 뛰어넘자!
🥕

Note

⚡️ AI Code Reviews for VS Code, Cursor, Windsurf

CodeRabbit now has a plugin for VS Code, Cursor and Windsurf. This brings AI code reviews directly in the code editor. Each commit is reviewed immediately, finding bugs before the PR is raised. Seamless context handoff to your AI code agent ensures that you can easily incorporate review feedback.
Learn more here.

✨ Finishing Touches
  • 📝 Generate Docstrings

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@taehyun32
Copy link
Member Author

@CodeRabbit review

@coderabbitai
Copy link

coderabbitai bot commented May 23, 2025

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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: 8

🧹 Nitpick comments (7)
src/main/resources/templates/login.html (1)

128-130: 주석 처리된 코드를 정리해주세요.

로그인 성공 시 JWT 토큰을 localStorage에 저장하던 로직이 제거되어 HTTP-only 쿠키 기반 토큰 관리로 전환된 것은 보안상 훌륭한 개선입니다. 하지만 130번째 줄의 주석 처리된 중괄호는 정리가 필요합니다.

다음과 같이 정리해주세요:

            if (response.ok) {
                window.location.href = '/';
-                // }
            } else {
src/main/java/io/github/petty/users/repository/RefreshTokenRepository.java (1)

20-25: 데이터베이스 성능 최적화를 위한 인덱스 추가 권장

쿼리 메서드들이 잘 구성되어 있지만, 성능 최적화를 위해 다음 인덱스들을 고려해보세요:

  1. findActiveTokensByProviderAndUsername - 여러 테이블 조인과 필터링이 발생
  2. findActiveTokensByUser - 시간 범위 조건과 정렬이 포함

RefreshToken 엔티티에 다음 인덱스들을 추가하는 것을 권장합니다:

@Entity
@Table(indexes = {
    @Index(name = "idx_refresh_token_user_used_expired", 
           columnList = "user_id, used, expiredAt"),
    @Index(name = "idx_refresh_token_used_created", 
           columnList = "used, createdAt")
})
public class RefreshToken {
    // ...
}

또한 정기적인 만료된 토큰 정리를 위한 배치 작업도 고려해보세요.

src/main/java/io/github/petty/users/entity/RefreshToken.java (1)

45-47: 만료 검사 로직이 명확하게 구현됨

현재 구현이 직관적이고 효과적입니다. 만약 테스트 용이성을 더 높이고 싶다면 서비스 레벨에서 Clock을 주입받는 방식도 고려할 수 있지만, 현재 구현도 충분히 실용적입니다.

src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java (2)

35-37: 토큰 만료 시간을 설정 파일로 관리하는 것이 좋습니다.

현재 액세스 토큰 만료 시간이 하드코딩되어 있습니다. 환경별로 다른 값을 사용할 수 있도록 설정 파일로 이동하는 것을 고려해보세요.

설정 파일로 토큰 만료 시간을 이동하는 리팩토링을 도와드릴까요?


40-40: 메서드명과 파라미터가 일치하지 않습니다.

findByUsername() 메서드에 이메일을 전달하고 있습니다. 메서드명이 혼란을 줄 수 있으므로 findByEmail()로 변경하거나, 적절한 메서드를 사용하는 것이 좋습니다.

src/main/java/io/github/petty/users/service/RefreshTokenService.java (1)

35-35: MAX_TOKENS_PER_USER를 클래스 상수로 정의하는 것이 좋습니다.

메서드 내부에 정의된 상수를 클래스 레벨로 이동하면 가독성과 유지보수성이 향상됩니다.

다음과 같이 수정하세요:

@Service
@RequiredArgsConstructor
public class RefreshTokenService {
    private static final Logger logger = LoggerFactory.getLogger(RefreshTokenService.class);
+   private static final int MAX_TOKENS_PER_USER = 3;
    private final RefreshTokenRepository refreshTokenRepository;
    private final JWTUtil jwtUtil;

    // ... 

    @Transactional
    public UUID createRefreshToken(Users user) {
        // ...
-       final int MAX_TOKENS_PER_USER = 3;
        if (activeTokens.size() >= MAX_TOKENS_PER_USER) {
src/main/java/io/github/petty/users/controller/UsersApiController.java (1)

77-104: 리프레시 토큰 엔드포인트가 보안을 고려하여 잘 구현되었습니다.

구현의 강점:

  • @CookieValue를 통한 HTTP-only 쿠키에서 토큰 추출로 XSS 방어
  • required = false 설정으로 null 체크 가능
  • UUID 형식 검증과 적절한 예외 처리
  • 서비스 레이어 활용으로 관심사 분리
  • 일관된 응답 형식과 적절한 HTTP 상태 코드

보안 고려사항도 잘 반영되어 있고, 에러 처리도 세분화되어 있어 클라이언트에서 적절히 대응할 수 있습니다.

다만 다음 개선사항을 고려해보시기 바랍니다:

 @PostMapping("/auth/refresh")
 public ResponseEntity<?> refreshToken(
         @CookieValue(value = "refresh_token", required = false) String refreshToken,
         HttpServletResponse response) {
+    // 로깅 추가 고려
+    log.debug("리프레시 토큰 갱신 요청 수신");
     
     if (refreshToken == null) {
+        log.warn("리프레시 토큰이 제공되지 않음");
         return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                 .body(Map.of("error", "리프레시 토큰이 없습니다."));
     }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 751c265 and 767bd20.

📒 Files selected for processing (13)
  • src/main/java/io/github/petty/config/SecurityConfig.java (4 hunks)
  • src/main/java/io/github/petty/users/controller/UsersApiController.java (3 hunks)
  • src/main/java/io/github/petty/users/dto/RefreshTokenResponseDTO.java (1 hunks)
  • src/main/java/io/github/petty/users/entity/RefreshToken.java (1 hunks)
  • src/main/java/io/github/petty/users/jwt/JWTFilter.java (1 hunks)
  • src/main/java/io/github/petty/users/jwt/JWTUtil.java (2 hunks)
  • src/main/java/io/github/petty/users/jwt/LoginFilter.java (3 hunks)
  • src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java (2 hunks)
  • src/main/java/io/github/petty/users/repository/RefreshTokenRepository.java (1 hunks)
  • src/main/java/io/github/petty/users/service/RefreshTokenService.java (1 hunks)
  • src/main/java/io/github/petty/users/util/CookieUtils.java (1 hunks)
  • src/main/resources/templates/index.html (1 hunks)
  • src/main/resources/templates/login.html (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (4)
src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java (2)
src/main/java/io/github/petty/users/util/CookieUtils.java (1)
  • CookieUtils (8-31)
src/main/java/io/github/petty/users/jwt/JWTUtil.java (1)
  • Component (17-66)
src/main/java/io/github/petty/users/entity/RefreshToken.java (1)
src/main/java/io/github/petty/users/dto/RefreshTokenResponseDTO.java (1)
  • Getter (7-13)
src/main/java/io/github/petty/users/controller/UsersApiController.java (1)
src/main/java/io/github/petty/users/util/CookieUtils.java (1)
  • CookieUtils (8-31)
src/main/java/io/github/petty/users/jwt/LoginFilter.java (1)
src/main/java/io/github/petty/users/util/CookieUtils.java (1)
  • CookieUtils (8-31)
🪛 ast-grep (0.38.1)
src/main/java/io/github/petty/users/util/CookieUtils.java

[warning] 21-21: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(jwtCookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(cookie-missing-samesite-java)


[warning] 28-28: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(refreshCookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(cookie-missing-samesite-java)


[warning] 21-21: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'.
Context: response.addCookie(jwtCookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(cookie-missing-secure-flag-java)


[warning] 28-28: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'.
Context: response.addCookie(refreshCookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(cookie-missing-secure-flag-java)

🔇 Additional comments (13)
src/main/java/io/github/petty/users/jwt/JWTUtil.java (1)

56-65: 리프레시 토큰 추출 메서드가 잘 구현되었습니다.

새로 추가된 extractRefreshToken 메서드는 쿠키에서 리프레시 토큰을 안전하게 추출합니다. null 체크와 쿠키 순회 로직이 적절히 구현되어 RTR 인증 시스템을 잘 지원합니다.

src/main/java/io/github/petty/users/jwt/JWTFilter.java (1)

32-36: 리프레시 엔드포인트에 대한 JWT 검증 건너뛰기 로직이 올바르게 구현되었습니다.

/api/auth/refresh 엔드포인트는 액세스 토큰이 만료되었을 때 사용되는 것이므로 JWT 검증을 건너뛰는 것이 논리적으로 올바릅니다. 리프레시 토큰의 유효성은 RefreshTokenService에서 별도로 검증되므로 보안상 문제가 없습니다.

src/main/java/io/github/petty/users/dto/RefreshTokenResponseDTO.java (1)

1-13: 간단하고 명확한 DTO 구현입니다.

리프레시 토큰 응답을 위한 DTO가 깔끔하게 구현되었습니다. Lombok 어노테이션을 적절히 사용하여 보일러플레이트 코드를 줄였고, 필요한 필드들이 잘 정의되었습니다.

src/main/resources/templates/index.html (1)

86-127: 전반적으로 잘 구현된 토큰 갱신 로직

RTR 구현이 프론트엔드에서 투명하게 처리되어 사용자 경험이 향상되었습니다. async/await 패턴의 일관된 사용과 적절한 에러 처리가 돋보입니다.

src/main/java/io/github/petty/users/jwt/LoginFilter.java (1)

31-32: 의존성 주입이 적절히 구현됨

RefreshTokenService와 UsersRepository 의존성이 깔끔하게 추가되었고, 생성자 매개변수 순서도 논리적입니다.

Also applies to: 38-38, 41-42

src/main/java/io/github/petty/users/repository/RefreshTokenRepository.java (1)

16-29: 잘 설계된 리포지토리 인터페이스

Spring Data JPA 컨벤션을 잘 따르고 있고, 메서드 이름이 명확하여 용도를 쉽게 파악할 수 있습니다. 쿼리 구성도 논리적이고 RTR 요구사항을 잘 충족합니다.

src/main/java/io/github/petty/users/entity/RefreshToken.java (2)

13-37: 잘 설계된 엔티티 구조

UUID를 기본키로 사용한 것은 보안상 좋은 선택이며, ManyToOne 관계에서 지연 로딩 설정과 적절한 제약조건들이 잘 구성되어 있습니다. Lombok 어노테이션 사용으로 보일러플레이트 코드도 효과적으로 줄였습니다.


40-42: 적절한 도메인 로직 캡슐화

토큰 사용 상태를 변경하는 로직이 엔티티에 적절히 캡슐화되어 있습니다.

src/main/java/io/github/petty/config/SecurityConfig.java (2)

66-66: 리프레시 토큰 엔드포인트 접근 권한 설정이 적절합니다.

/api/auth/refresh 엔드포인트를 인증 없이 접근 가능하도록 설정한 것이 적절합니다. 이를 통해 액세스 토큰이 만료되었을 때도 리프레시 토큰으로 갱신할 수 있습니다.


78-78: 로그아웃 시 리프레시 토큰 쿠키 삭제가 적절히 추가되었습니다.

로그아웃 시 refresh_token 쿠키를 삭제하도록 설정한 것이 보안상 적절합니다.

src/main/java/io/github/petty/users/service/RefreshTokenService.java (1)

27-54: 리프레시 토큰 생성 로직이 잘 구현되었습니다.

RTR(Refresh Token Rotation) 구현과 사용자당 토큰 개수 제한(3개)이 적절히 구현되었습니다. 이는 보안을 강화하고 토큰 관리를 효율적으로 만듭니다.

src/main/java/io/github/petty/users/controller/UsersApiController.java (2)

4-4: 새로운 import문들이 적절하게 추가되었습니다.

리프레시 토큰 기능 구현에 필요한 모든 클래스들이 올바르게 import되었습니다:

  • RefreshTokenResponseDTO: 토큰 갱신 응답 DTO
  • RefreshTokenService: 리프레시 토큰 비즈니스 로직 서비스
  • CookieUtils: 쿠키 설정 유틸리티
  • 서블릿 관련 클래스들과 UUID: 쿠키 처리 및 토큰 ID 처리용

Also applies to: 8-12, 22-22


30-30: RefreshTokenService 의존성 주입이 올바르게 구현되었습니다.

생성자 기반 의존성 주입을 통해 RefreshTokenService가 적절히 추가되었으며, 기존 의존성들과 일관된 방식으로 구현되었습니다.

Also applies to: 33-33, 36-36

Comment on lines +98 to +118
} else if (response.status === 401) {
// 엑세스 토큰 만료: 자동 갱신 시도
console.log('액세스 토큰 만료, 리프레시 토큰으로 갱신 시도');

try {
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST'
});

if (refreshResponse.ok) {
console.log('토큰 갱신 성공! 원래 요청 재시도');
// 토큰 갱신 성공 → 원래 요청 재시도
return await checkLoginStatus();
} else {
console.log('리프레시 토큰도 만료, 로그인 필요');
showLoginMenu();
}
} catch (refreshError) {
console.error('토큰 갱신 실패:', refreshError);
showLoginMenu();
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

토큰 갱신 로직에 재시도 제한 및 오류 처리 개선 권장

현재 구현은 토큰 갱신 후 재귀 호출로 원래 요청을 재시도하는데, 잠재적인 문제점들이 있습니다:

  1. 무한 루프 위험: 갱신 엔드포인트에서 예상치 못한 401이 반복될 경우 무한 재귀가 발생할 수 있습니다
  2. 네트워크 오류 처리 부족: 네트워크 연결 문제 시 적절한 사용자 피드백이 없습니다

다음과 같이 개선하는 것을 권장합니다:

+let refreshAttempted = false;

 async function checkLoginStatus() {
     try {
         const response = await fetch('/api/users/me');

         if (response.ok) {
             // 성공: 로그인 상태 표시
+            refreshAttempted = false; // 성공 시 플래그 리셋
             const data = await response.json();
             document.getElementById('userMenu').style.display = 'inline';
             document.getElementById('loginMenu').style.display = 'none';
             document.getElementById('userInfo').style.display = 'block';
             document.getElementById('username').textContent = data.username;
             document.getElementById('role').textContent = data.role;
-        } else if (response.status === 401) {
+        } else if (response.status === 401 && !refreshAttempted) {
+            refreshAttempted = true;
             // 액세스 토큰 만료: 자동 갱신 시도
             console.log('액세스 토큰 만료, 리프레시 토큰으로 갱신 시도');

             try {
                 const refreshResponse = await fetch('/api/auth/refresh', {
                     method: 'POST'
                 });

                 if (refreshResponse.ok) {
                     console.log('토큰 갱신 성공! 원래 요청 재시도');
                     // 토큰 갱신 성공 → 원래 요청 재시도
                     return await checkLoginStatus();
                 } else {
                     console.log('리프레시 토큰도 만료, 로그인 필요');
                     showLoginMenu();
                 }
             } catch (refreshError) {
                 console.error('토큰 갱신 실패:', refreshError);
+                alert('네트워크 연결을 확인해주세요.');
                 showLoginMenu();
             }
         } else {
             // 다른 에러
             showLoginMenu();
         }
     } catch (error) {
         console.error('사용자 정보 조회 실패:', error);
+        alert('서버와의 연결에 문제가 발생했습니다.');
         showLoginMenu();
     }
 }
📝 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
} else if (response.status === 401) {
// 엑세스 토큰 만료: 자동 갱신 시도
console.log('액세스 토큰 만료, 리프레시 토큰으로 갱신 시도');
try {
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST'
});
if (refreshResponse.ok) {
console.log('토큰 갱신 성공! 원래 요청 재시도');
// 토큰 갱신 성공 → 원래 요청 재시도
return await checkLoginStatus();
} else {
console.log('리프레시 토큰도 만료, 로그인 필요');
showLoginMenu();
}
} catch (refreshError) {
console.error('토큰 갱신 실패:', refreshError);
showLoginMenu();
}
let refreshAttempted = false;
async function checkLoginStatus() {
try {
const response = await fetch('/api/users/me');
if (response.ok) {
// 성공: 로그인 상태 표시
refreshAttempted = false; // 성공 시 플래그 리셋
const data = await response.json();
document.getElementById('userMenu').style.display = 'inline';
document.getElementById('loginMenu').style.display = 'none';
document.getElementById('userInfo').style.display = 'block';
document.getElementById('username').textContent = data.username;
document.getElementById('role').textContent = data.role;
} else if (response.status === 401 && !refreshAttempted) {
refreshAttempted = true;
// 액세스 토큰 만료: 자동 갱신 시도
console.log('액세스 토큰 만료, 리프레시 토큰으로 갱신 시도');
try {
const refreshResponse = await fetch('/api/auth/refresh', {
method: 'POST'
});
if (refreshResponse.ok) {
console.log('토큰 갱신 성공! 원래 요청 재시도');
// 토큰 갱신 성공 → 원래 요청 재시도
return await checkLoginStatus();
} else {
console.log('리프레시 토큰도 만료, 로그인 필요');
showLoginMenu();
}
} catch (refreshError) {
console.error('토큰 갱신 실패:', refreshError);
alert('네트워크 연결을 확인해주세요.');
showLoginMenu();
}
} else {
// 다른 에러
showLoginMenu();
}
} catch (error) {
console.error('사용자 정보 조회 실패:', error);
alert('서버와의 연결에 문제가 발생했습니다.');
showLoginMenu();
}
}
🤖 Prompt for AI Agents
In src/main/resources/templates/index.html around lines 98 to 118, the token
refresh logic retries the original request recursively without limiting retries,
risking infinite loops if the refresh endpoint returns 401 repeatedly, and lacks
user feedback on network errors. To fix this, implement a retry limit counter to
prevent infinite recursion and add user-friendly error handling for network
failures during token refresh, ensuring appropriate messages or UI updates
inform the user of the issue.

Comment on lines +78 to 89
// 액세스 토큰 생성
String token = jwtUtil.createJwt(username, role, 3600000L); // expirationTime

// JWT 토큰을 쿠키에 저장
Cookie jwtCookie = new Cookie("jwt", token);
jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어)
jwtCookie.setPath("/"); // 쿠키의 유효 경로
// jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략)
int maxAgeSeconds = (int) (3600000L / 1000); // 만료 시간을 초 단위로 변환
jwtCookie.setMaxAge(maxAgeSeconds); // 쿠키의 만료 시간 설정
response.addCookie(jwtCookie);
// 사용자 조회
Users user = usersRepository.findByUsername(username);

// 리프레시 토큰 생성
UUID refreshToken = refreshTokenService.createRefreshToken(user);

// response.addHeader("Authorization", "Bearer " + token);
// 쿠키 설정 코드
CookieUtils.setTokenCookies(response, token, refreshToken);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

사용자 조회 시 null 체크 및 예외 처리 추가 필요

현재 구현에서 usersRepository.findByUsername(username)의 결과에 대한 null 체크가 없습니다. 인증이 성공했다면 사용자가 존재해야 하지만, 동시성 문제나 예상치 못한 상황에서 안전성을 위해 체크가 필요합니다.

다음과 같이 개선하는 것을 권장합니다:

 // 사용자 조회
 Users user = usersRepository.findByUsername(username);
+if (user == null) {
+    throw new RuntimeException("인증된 사용자를 찾을 수 없습니다: " + username);
+}

 // 리프레시 토큰 생성
 UUID refreshToken = refreshTokenService.createRefreshToken(user);

 // 쿠키 설정 코드
 CookieUtils.setTokenCookies(response, token, refreshToken);

또는 Optional을 반환하는 메서드로 변경하는 것을 고려해보세요:

-Users user = usersRepository.findByUsername(username);
+Users user = usersRepository.findByUsername(username)
+    .orElseThrow(() -> new RuntimeException("인증된 사용자를 찾을 수 없습니다: " + username));
📝 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
// 액세스 토큰 생성
String token = jwtUtil.createJwt(username, role, 3600000L); // expirationTime
// JWT 토큰을 쿠키에 저장
Cookie jwtCookie = new Cookie("jwt", token);
jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어)
jwtCookie.setPath("/"); // 쿠키의 유효 경로
// jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략)
int maxAgeSeconds = (int) (3600000L / 1000); // 만료 시간을 초 단위로 변환
jwtCookie.setMaxAge(maxAgeSeconds); // 쿠키의 만료 시간 설정
response.addCookie(jwtCookie);
// 사용자 조회
Users user = usersRepository.findByUsername(username);
// 리프레시 토큰 생성
UUID refreshToken = refreshTokenService.createRefreshToken(user);
// response.addHeader("Authorization", "Bearer " + token);
// 쿠키 설정 코드
CookieUtils.setTokenCookies(response, token, refreshToken);
}
// 액세스 토큰 생성
String token = jwtUtil.createJwt(username, role, 3600000L); // expirationTime
// 사용자 조회
Users user = usersRepository.findByUsername(username);
if (user == null) {
throw new RuntimeException("인증된 사용자를 찾을 수 없습니다: " + username);
}
// 리프레시 토큰 생성
UUID refreshToken = refreshTokenService.createRefreshToken(user);
// 쿠키 설정 코드
CookieUtils.setTokenCookies(response, token, refreshToken);
}
🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/jwt/LoginFilter.java around lines 78 to
89, add a null check after retrieving the user with
usersRepository.findByUsername(username). If the user is null, throw an
appropriate exception or handle the error to prevent null pointer issues.
Alternatively, modify the repository method to return an Optional and handle the
absence of the user accordingly before proceeding with refresh token creation
and cookie setting.

Comment on lines +39 to 41
// 사용자 조회
Users user = usersRepository.findByUsername(oAuth2User.getEmail());

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

사용자 조회 결과에 대한 null 체크가 필요합니다.

usersRepository.findByUsername()이 null을 반환할 수 있으므로, 리프레시 토큰 생성 전에 사용자 존재 여부를 확인해야 합니다.

다음과 같이 수정하세요:

        // 사용자 조회
        Users user = usersRepository.findByUsername(oAuth2User.getEmail());
+       if (user == null) {
+           throw new IllegalStateException("사용자를 찾을 수 없습니다: " + oAuth2User.getEmail());
+       }
📝 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
// 사용자 조회
Users user = usersRepository.findByUsername(oAuth2User.getEmail());
// 사용자 조회
Users user = usersRepository.findByUsername(oAuth2User.getEmail());
if (user == null) {
throw new IllegalStateException("사용자를 찾을 수 없습니다: " + oAuth2User.getEmail());
}
🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/oauth2/OAuth2SuccessHandler.java around
lines 39 to 41, add a null check after calling
usersRepository.findByUsername(oAuth2User.getEmail()) to verify if the user
exists before proceeding with refresh token creation. If the user is null,
handle this case appropriately, such as by throwing an exception or returning an
error response, to prevent null pointer exceptions later in the code.

Comment on lines +15 to +30
public static void setTokenCookies(HttpServletResponse response, String accessToken, UUID refreshToken) {
// 액세스 토큰을 쿠키에 저장
Cookie jwtCookie = new Cookie("jwt", accessToken);
jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어)
jwtCookie.setPath("/"); // 쿠키의 유효 경로
// jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략)
jwtCookie.setMaxAge(3600);// 쿠키의 만료 시간 설정 (1시간)
response.addCookie(jwtCookie);

// 리프레시 토큰을 쿠키에 저장
Cookie refreshCookie = new Cookie("refresh_token", refreshToken.toString());
refreshCookie.setHttpOnly(true);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(7 * 24 * 60 * 60); // 7일
response.addCookie(refreshCookie);
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

환경별로 Secure 플래그를 설정할 수 있도록 개선이 필요합니다.

프로덕션 환경에서는 Secure 플래그가 필수적입니다. 환경 변수나 프로필을 통해 동적으로 설정하는 것이 좋습니다.

다음과 같이 환경별 설정을 추가할 수 있습니다:

public static void setTokenCookies(HttpServletResponse response, String accessToken, UUID refreshToken, boolean isProduction) {
    // 액세스 토큰 쿠키
    Cookie jwtCookie = new Cookie("jwt", accessToken);
    jwtCookie.setHttpOnly(true);
    jwtCookie.setPath("/");
    jwtCookie.setAttribute("SameSite", "Strict");
    if (isProduction) {
        jwtCookie.setSecure(true);
    }
    jwtCookie.setMaxAge(3600);
    response.addCookie(jwtCookie);
    
    // 리프레시 토큰 쿠키
    Cookie refreshCookie = new Cookie("refresh_token", refreshToken.toString());
    refreshCookie.setHttpOnly(true);
    refreshCookie.setPath("/");
    refreshCookie.setAttribute("SameSite", "Strict");
    if (isProduction) {
        refreshCookie.setSecure(true);
    }
    refreshCookie.setMaxAge(7 * 24 * 60 * 60);
    response.addCookie(refreshCookie);
}
🧰 Tools
🪛 ast-grep (0.38.1)

[warning] 21-21: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(jwtCookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(cookie-missing-samesite-java)


[warning] 28-28: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(refreshCookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(cookie-missing-samesite-java)


[warning] 21-21: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'.
Context: response.addCookie(jwtCookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(cookie-missing-secure-flag-java)


[warning] 28-28: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'.
Context: response.addCookie(refreshCookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(cookie-missing-secure-flag-java)

🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/util/CookieUtils.java around lines 15 to
30, improve the setTokenCookies method to accept a boolean parameter indicating
if the environment is production. Use this parameter to conditionally set the
Secure flag on both cookies. Also, add the SameSite attribute with value
"Strict" to enhance security. This allows the Secure flag to be enabled only in
production while keeping it disabled in development or localhost environments.

Comment on lines +16 to +22
// 액세스 토큰을 쿠키에 저장
Cookie jwtCookie = new Cookie("jwt", accessToken);
jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어)
jwtCookie.setPath("/"); // 쿠키의 유효 경로
// jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략)
jwtCookie.setMaxAge(3600);// 쿠키의 만료 시간 설정 (1시간)
response.addCookie(jwtCookie);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

CSRF 보호를 위해 SameSite 속성을 추가해야 합니다.

현재 쿠키에 SameSite 속성이 설정되지 않아 CSRF 공격에 취약할 수 있습니다.

다음과 같이 수정하세요:

        Cookie jwtCookie = new Cookie("jwt", accessToken);
        jwtCookie.setHttpOnly(true); // JavaScript 접근 방지 (XSS 방어)
        jwtCookie.setPath("/"); // 쿠키의 유효 경로
+       jwtCookie.setAttribute("SameSite", "Strict"); // CSRF 보호
        // jwtCookie.setSecure(true); // HTTPS 환경에서만 전송 (로컬호스트에서는 생략)
        jwtCookie.setMaxAge(3600);// 쿠키의 만료 시간 설정 (1시간)

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

🧰 Tools
🪛 ast-grep (0.38.1)

[warning] 21-21: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(jwtCookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(cookie-missing-samesite-java)


[warning] 21-21: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'.
Context: response.addCookie(jwtCookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(cookie-missing-secure-flag-java)

🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/util/CookieUtils.java around lines 16 to
22, the cookie being set lacks the SameSite attribute, which is important for
CSRF protection. Modify the code to include the SameSite attribute by either
using a method to set it if available or by adding the attribute manually to the
Set-Cookie header. Ensure the SameSite attribute is set to "Strict" or "Lax"
based on your security requirements to mitigate CSRF risks.

Comment on lines +24 to +29
// 리프레시 토큰을 쿠키에 저장
Cookie refreshCookie = new Cookie("refresh_token", refreshToken.toString());
refreshCookie.setHttpOnly(true);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(7 * 24 * 60 * 60); // 7일
response.addCookie(refreshCookie);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

리프레시 토큰 쿠키에도 SameSite 속성을 추가해야 합니다.

리프레시 토큰 쿠키도 CSRF 공격으로부터 보호되어야 합니다.

다음과 같이 수정하세요:

        Cookie refreshCookie = new Cookie("refresh_token", refreshToken.toString());
        refreshCookie.setHttpOnly(true);
        refreshCookie.setPath("/");
+       refreshCookie.setAttribute("SameSite", "Strict"); // CSRF 보호
        refreshCookie.setMaxAge(7 * 24 * 60 * 60); // 7일

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

🧰 Tools
🪛 ast-grep (0.38.1)

[warning] 28-28: The application does not appear to verify inbound requests which can lead to a Cross-site request forgery (CSRF) vulnerability. If the application uses cookie-based authentication, an attacker can trick users into sending authenticated HTTP requests without their knowledge from any arbitrary domain they visit. To prevent this vulnerability start by identifying if the framework or library leveraged has built-in features or offers plugins for CSRF protection. CSRF tokens should be unique and securely random. The Synchronizer Token or Double Submit Cookie patterns with defense-in-depth mechanisms such as the sameSite cookie flag can help prevent CSRF. For more information, see: [Cross-site request forgery prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Req\ uest_Forgery_Prevention_Cheat_Sheet.html).
Context: response.addCookie(refreshCookie);
Note: [CWE-352] Cross-Site Request Forgery (CSRF). [REFERENCES]
- https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application

(cookie-missing-samesite-java)


[warning] 28-28: A cookie was detected without setting the 'secure' flag. The 'secure' flag for cookies prevents the client from transmitting the cookie over insecure channels such as HTTP. Set the 'secure' flag by calling '.setSecure(true);'.
Context: response.addCookie(refreshCookie);
Note: [CWE-614] Sensitive Cookie in HTTPS Session Without 'Secure' Attribute. [REFERENCES]
- https://owasp.org/www-community/controls/SecureCookieAttribute

(cookie-missing-secure-flag-java)

🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/util/CookieUtils.java around lines 24 to
29, the refresh token cookie is missing the SameSite attribute, which is
important for CSRF protection. Modify the code to set the SameSite attribute on
the refreshCookie, typically by using response headers or a cookie attribute
setter if available, to enforce SameSite=Lax or SameSite=Strict as appropriate
for your security requirements.

Comment on lines +59 to +60
RefreshToken refreshToken = refreshTokenRepository.findByIdAndUsedIsFalse(refreshTokenId)
.orElseThrow(() -> new RuntimeException("유효하지 않은 리프레시 토큰입니다."));
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

RuntimeException 대신 커스텀 예외를 사용하는 것이 좋습니다.

구체적인 예외 타입을 사용하면 예외 처리가 더 명확해지고 API 응답을 적절히 제어할 수 있습니다.

다음과 같은 커스텀 예외 클래스를 생성하는 것을 제안합니다:

public class InvalidRefreshTokenException extends RuntimeException {
    public InvalidRefreshTokenException(String message) {
        super(message);
    }
}

public class ExpiredRefreshTokenException extends RuntimeException {
    public ExpiredRefreshTokenException(String message) {
        super(message);
    }
}

커스텀 예외 클래스 생성과 적용을 도와드릴까요?

Also applies to: 69-69

🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/service/RefreshTokenService.java around
lines 59-60 and line 69, replace the generic RuntimeException with a custom
exception for clearer error handling. Create two custom exception classes,
InvalidRefreshTokenException and ExpiredRefreshTokenException, each extending
RuntimeException with a constructor accepting a message. Then, update the code
to throw InvalidRefreshTokenException when the refresh token is invalid and
ExpiredRefreshTokenException when the token is expired, improving exception
specificity and API response control.

Comment on lines +56 to +88
// 액세스 토큰 및 리프레시 토큰 갱신
@Transactional
public RefreshTokenResponseDTO refreshAccessToken(UUID refreshTokenId) {
RefreshToken refreshToken = refreshTokenRepository.findByIdAndUsedIsFalse(refreshTokenId)
.orElseThrow(() -> new RuntimeException("유효하지 않은 리프레시 토큰입니다."));

// 토큰 만료 체크
if (refreshToken.isExpired()) {
List<RefreshToken> userTokens = refreshTokenRepository.findActiveTokensByUser(
refreshToken.getUser(), LocalDateTime.now());
refreshTokenRepository.deleteAll(userTokens);

logger.warn("만료된 리프레시 토큰: 사용자={}", refreshToken.getUser().getUsername());
throw new RuntimeException("만료된 리프레시 토큰입니다.");
}

// 토큰 사용 처리
refreshToken.markUsed();
refreshTokenRepository.save(refreshToken); // 즉시 저장

// 새 액세스 토큰 생성
Users user = refreshToken.getUser();
String newAccessToken = jwtUtil.createJwt(
user.getUsername(),
user.getRole(),
3600000L); // 1시간

// 새 리프레시 토큰 생성
UUID newRefreshToken = createRefreshToken(user);

logger.debug("토큰 리프레시 성공: 사용자={}", user.getUsername());
return new RefreshTokenResponseDTO(newAccessToken, newRefreshToken.toString());
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

리프레시 토큰 갱신 시 사용자 계정 상태를 확인해야 합니다.

현재 구현에서는 사용자가 비활성화되었거나 삭제된 경우에도 토큰 갱신이 가능합니다. 보안을 강화하기 위해 사용자 상태 검증이 필요합니다.

다음과 같이 사용자 상태 확인을 추가하세요:

        // 토큰 사용 처리
        refreshToken.markUsed();
        refreshTokenRepository.save(refreshToken); // 즉시 저장

        // 새 액세스 토큰 생성
        Users user = refreshToken.getUser();
+       
+       // 사용자 계정 상태 확인
+       if (!user.isEnabled() || user.isDeleted()) {
+           throw new RuntimeException("비활성화되거나 삭제된 사용자입니다.");
+       }
+       
        String newAccessToken = jwtUtil.createJwt(

참고: 위 코드는 Users 엔티티에 isEnabled(), isDeleted() 메서드가 있다고 가정합니다. 실제 구현에 맞게 조정이 필요할 수 있습니다.

📝 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
// 액세스 토큰 및 리프레시 토큰 갱신
@Transactional
public RefreshTokenResponseDTO refreshAccessToken(UUID refreshTokenId) {
RefreshToken refreshToken = refreshTokenRepository.findByIdAndUsedIsFalse(refreshTokenId)
.orElseThrow(() -> new RuntimeException("유효하지 않은 리프레시 토큰입니다."));
// 토큰 만료 체크
if (refreshToken.isExpired()) {
List<RefreshToken> userTokens = refreshTokenRepository.findActiveTokensByUser(
refreshToken.getUser(), LocalDateTime.now());
refreshTokenRepository.deleteAll(userTokens);
logger.warn("만료된 리프레시 토큰: 사용자={}", refreshToken.getUser().getUsername());
throw new RuntimeException("만료된 리프레시 토큰입니다.");
}
// 토큰 사용 처리
refreshToken.markUsed();
refreshTokenRepository.save(refreshToken); // 즉시 저장
// 새 액세스 토큰 생성
Users user = refreshToken.getUser();
String newAccessToken = jwtUtil.createJwt(
user.getUsername(),
user.getRole(),
3600000L); // 1시간
// 새 리프레시 토큰 생성
UUID newRefreshToken = createRefreshToken(user);
logger.debug("토큰 리프레시 성공: 사용자={}", user.getUsername());
return new RefreshTokenResponseDTO(newAccessToken, newRefreshToken.toString());
}
// 토큰 사용 처리
refreshToken.markUsed();
refreshTokenRepository.save(refreshToken); // 즉시 저장
// 새 액세스 토큰 생성
Users user = refreshToken.getUser();
// 사용자 계정 상태 확인
if (!user.isEnabled() || user.isDeleted()) {
throw new RuntimeException("비활성화되거나 삭제된 사용자입니다.");
}
String newAccessToken = jwtUtil.createJwt(
user.getUsername(),
user.getRole(),
3600000L); // 1시간
// 새 리프레시 토큰 생성
UUID newRefreshToken = createRefreshToken(user);
logger.debug("토큰 리프레시 성공: 사용자={}", user.getUsername());
return new RefreshTokenResponseDTO(newAccessToken, newRefreshToken.toString());
🤖 Prompt for AI Agents
In src/main/java/io/github/petty/users/service/RefreshTokenService.java between
lines 56 and 88, add a check after retrieving the user from the refresh token to
verify the user's account status. Specifically, call user.isEnabled() and
user.isDeleted() (or equivalent methods) to ensure the user is active and not
deleted. If the user is disabled or deleted, throw a RuntimeException to prevent
token refresh. This validation should be placed before marking the refresh token
as used and generating new tokens.

@23MinL
Copy link
Contributor

23MinL commented May 23, 2025

🎈 코드 리뷰

전반적인 구현 평가 👍

1. RTR 구현이 전반적으로 잘 되어있어요! 특히 다음 부분들이 인상적입니다

  • Refresh Token Rotation 개념을 정확히 이해하고 구현
  • HTTP-only 쿠키를 사용한 보안적 접근
  • 프론트엔드와 백엔드의 통합이 매끄러움
  • 사용자당 토큰 개수 제한(3개)으로 리소스 관리

2. RTR 시스템의 각 시나리오에 대한 테스트 필요

  • 정상적인 토큰 갱신
  • 만료된 토큰 처리
  • 이미 사용된 토큰 처리
  • 사용자당 토큰 개수 제한 테스트

결론 ✨

  • 전체적으로 RTR 구현이 잘 되어있고, 보안을 고려한 설계가 돋보입니다! 위에서 제안한 개선사항들을 적용하면 더욱 견고한 인증 시스템이 될 것 같습니다.
  • 이 정도 수준의 보안 구현을 해내신 것은 정말 훌륭합니다!
  • 코드 리뷰로도 좋은 보안에 대한 고려한 점을 파악할 수 있는 코드였습니다.

작업하시느라 고생하셨습니다.🚀

@s0ooo0k s0ooo0k merged commit 48d4bbb into PETTY-HUB:main May 23, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants