-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] Jwt를 이용한 로그인 기능 구현 #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
WalkthroughJWT 기반 인증 전반을 도입했습니다. 사용자 가입/로그인/로그아웃/토큰 재발급 API와 DTO, 필터·핸들러·유틸·서비스를 추가하고, Spring Security/Redis/JPA Auditing 설정을 구성했습니다. JWT 라이브러리 버전을 0.12.3으로 올리고, ErrorCode를 대폭 확장했으며, 로컬 DB DDL 전략과 JWT 프로퍼티를 갱신했습니다. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant C as Client
participant F as CustomLoginFilter
participant AM as AuthenticationManager
participant S as CustomAuthenticationSuccessHandler
participant JU as JwtUtil
participant JS as JwtService
participant R as Response
C->>F: POST /login (JSON: email,password)
F->>AM: UsernamePasswordAuthenticationToken
AM-->>S: Authentication 성공
S->>JU: access/refresh 토큰 생성
S->>JS: refresh 토큰 저장(Redis)
S->>R: BaseResponse<LoginResponse> + 헤더(Access/Refresh)
sequenceDiagram
autonumber
participant C as Client
participant JF as JwtAuthenticationFilter
participant JU as JwtUtil
participant JS as JwtService
participant SC as SecurityContext
participant D as Downstream Controller
C->>JF: 보호 자원 요청
alt Pass URI
JF-->>D: 체인 계속
else 인증 필요
JF->>JU: Access 토큰 추출/검증
JF->>JU: 클레임 추출(userId/role/...)
JF->>JS: 로그아웃 여부 확인(블랙리스트)
JF->>SC: Authentication 설정
JF-->>D: 체인 계속
end
sequenceDiagram
autonumber
participant C as Client
participant AC as AuthController
participant JS as JwtService
participant JU as JwtUtil
participant RS as RedisService
participant R as Response
C->>AC: POST /api/auth/logout
AC->>JS: logout(request)
JS->>JU: 헤더에서 토큰 추출
JS->>RS: refresh 삭제 / access 블랙리스트 등록
AC-->>C: BaseResponse<Void>
C->>AC: POST /api/auth/reissue
AC->>JS: reissueTokens(request,response)
JS->>JU: refresh 검증
JS->>JU: 새 access/refresh 생성
JS->>RS: 새 refresh 저장 / 기존 삭제
JS->>R: 응답 헤더에 새 토큰 설정
AC-->>C: BaseResponse<Void>
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 17
🧹 Nitpick comments (28)
src/main/resources/application.yml (2)
22-22: 로컬 DDL 전략을 create로 변경 — 의도/리스크 확인 필요
- create는 기동 시 스키마를 드롭/재생성합니다. 로컬 DB 데이터가 날아가도 괜찮은지 확인 부탁드립니다.
- 테스트 편의가 목적이면 create-drop(종료 시 드롭)도 고려 가능합니다. 데이터 유지가 필요하면 update 또는 마이그레이션 도구(Flyway/Liquibase) 권장.
75-82: JWT 프로퍼티: 시크릿 강도, 만료 단위, CORS/프록시 호환성 점검
- secret: HS256 기준 256비트(최소 32바이트 이상) 강도/회전 전략 검토 필요. 환경변수 값 길이 확인 부탁드립니다.
- expiration 단위: 코드에서 Duration 바인딩(예: 15m)인지, Long(ms)인지 정합성 확인 필요. ENV 값 포맷을 명확히 합의해 주세요.
- 헤더명: Authorization-refresh는 커스텀 헤더이므로 CORS에 Allow/Expose 헤더로 등록되어야 브라우저에서 전송/읽기가 가능합니다. 리버스 프록시가 헤더를 소문자화/필터링하지 않는지도 확인 요청.
- 전달 방식: Refresh 토큰은 HttpOnly+Secure 쿠키 사용이 일반적입니다. 현재 헤더 사용 시 XSS 노출 면에서 리스크가 있으니, 추후 로테이션 구현과 함께 쿠키 전환을 검토해 주세요.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/request/LoginRequest.java (1)
8-14: 보안을 위한 validation 추가를 고려해보세요.로그인 요청에 대한 기본적인 validation(예: email 형식, 필수값 체크)을 추가하면 더 안전할 것 같습니다. 물론 서비스 레이어에서 처리할 수도 있지만, DTO 레벨에서도 기본적인 검증이 있으면 좋겠네요.
다음과 같은 validation을 고려해보세요:
+import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class LoginRequest { + @NotBlank + @Email private String email; + @NotBlank private String password; }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/model/User.java (2)
10-11: 엔티티 전체에 @Setter 노출은 지양도메인 불변성 깨질 여지가 큽니다. 빌더/생성자/필요한 세터만 노출로 축소하는 게 안전합니다.
-@Setter @Getter +@Getter public class User extends BaseEntity {
24-29: role NotNull 추가에 따른 기본값/마이그레이션 고려
- 신규/기존 데이터에 대해
role이 항상 세팅되지 않으면 저장 시 실패합니다.- JPA 레벨 기본값 보완용
@PrePersist를 권장합니다. 닉네임 유니크는 DB 인덱스/대소문자 민감도도 확인 필요합니다.@Builder public class User extends BaseEntity { @@ private Role role; @@ public User(String email, String password, String nickName, Role role) { this.email = email; this.password = password; this.nickName = nickName; this.role = role; } + + @PrePersist + private void applyDefaultRoleIfAbsent() { + if (this.role == null) { + this.role = Role.MEMBER; + } + }검증 제안:
- DB에
users.nickname유니크 인덱스 존재/대소문자 규칙 확인- 기존 레코드에
rolenull 존재 여부 확인Also applies to: 31-37
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/JwtExceptionHandlerFilter.java (1)
21-32: 예외 범위 확장 및 응답 커밋 체크JWT 라이브러리/스프링 시큐리티 예외가 그대로 새어 나갈 수 있습니다. 응답이 이미 커밋된 경우 재작성도 피해야 합니다.
package com.WhoIsRoom.WhoIs_Server.domain.auth.handler.exception; import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomJwtException; import com.WhoIsRoom.WhoIs_Server.global.common.exception.BusinessException; +import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.security.core.AuthenticationException; +import io.jsonwebtoken.JwtException; import java.io.IOException; import static com.WhoIsRoom.WhoIs_Server.domain.auth.util.SecurityErrorResponseUtil.setErrorResponse; @@ try { filterChain.doFilter(request, response); } catch (CustomJwtException e) { - setErrorResponse(response, e.getErrorCode()); + if (!response.isCommitted()) setErrorResponse(response, e.getErrorCode()); } catch (BusinessException e) { - setErrorResponse(response, e.getErrorCode()); + if (!response.isCommitted()) setErrorResponse(response, e.getErrorCode()); + } catch (AuthenticationException | JwtException e) { + if (!response.isCommitted()) setErrorResponse(response, ErrorCode.SECURITY_UNAUTHORIZED); } }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java (1)
21-25: @Valid 누락 — DTO Bean Validation과 연동 필요DTO에 검증 애노테이션을 추가했으면 컨트롤러 파라미터에
@Valid를 붙여야 동작합니다.import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; @@ @PostMapping("/signup") - public BaseResponse<Void> signUp(@RequestBody SignupRequest request) { + public BaseResponse<Void> signUp(@Valid @RequestBody SignupRequest request) { userService.signUp(request); return BaseResponse.ok(null); }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/exception/CustomAuthenticationException.java (1)
8-14: 직렬화 식별자와 cause 포함 생성자 추가 제안직렬화 경고 방지 및 원인 전파를 위해 serialVersionUID와 cause 포함 생성자를 추가해두면 좋습니다.
public class CustomAuthenticationException extends AuthenticationException { + private static final long serialVersionUID = 1L; private final ErrorCode errorCode; public CustomAuthenticationException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } + + public CustomAuthenticationException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java (1)
12-24: ObjectMapper 공개 노출 지양 및 유틸 클래스 형태 보완ObjectMapper를 public static으로 노출하면 외부에서 재구성될 수 있어(스레드 세이프티 약화) 위험합니다. 비공개 정적 상수로 감추고, 유틸 클래스는 생성자를 막아주세요. 응답 버퍼 flush도 권장합니다.
-public class SecurityErrorResponseUtil { - public static final ObjectMapper objectMapper = new ObjectMapper() +public class SecurityErrorResponseUtil { + private SecurityErrorResponseUtil() {} + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() .registerModule(new JavaTimeModule()) .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { response.setStatus(errorCode.getHttpStatus()); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); BaseErrorResponse errorResponse = new BaseErrorResponse(errorCode); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(errorResponse)); + response.getWriter().flush(); } }src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/WebConfig.java (1)
15-20: 필드 네이밍 컨벤션 준수 (lowerCamelCase)필드명이 타입명과 동일하고 대문자로 시작합니다. Java 컨벤션에 맞게 lowerCamelCase로 변경해주세요.
- private final CurrentUserIdArgumentResolver CurrentUserIdArgumentResolver; + private final CurrentUserIdArgumentResolver currentUserIdArgumentResolver; @@ @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { - resolvers.add(CurrentUserIdArgumentResolver); + resolvers.add(currentUserIdArgumentResolver); }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/CustomUserDetailsService.java (1)
21-35: 조회 트랜잭션 readOnly 적용 + 매직 스트링 상수화읽기 전용 트랜잭션으로 마킹하고 "localhost" 매직 스트링을 상수로 추출하면 안전하고 일관된 관리가 가능합니다. JwtAuthenticationFilter에서도 동일 문자열을 비교하니 상수화해 두는 편이 좋습니다.
@RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; + private static final String PROVIDER_LOCAL = "localhost"; @Override - public UserPrincipal loadUserByUsername(String email) throws UsernameNotFoundException { + @Transactional(readOnly = true) + public UserPrincipal loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException(email + ": 해당 이메일의 사용자를 찾을 수 없습니다.")); return new UserPrincipal( user.getId(), user.getNickName(), user.getPassword(), - "localhost", + PROVIDER_LOCAL, Collections.singleton(user.getRole().toAuthority()) ); }추가 import 필요:
import org.springframework.transaction.annotation.Transactional;참고:
JwtAuthenticationFilter에서"localhost".equals(principal.getProviderId())비교를 동일 상수를 참조하도록 정리하면 더 안전합니다.src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java (1)
28-32: 토큰 전달 방식 가이드(헤더 vs HttpOnly 쿠키)현재
JwtService.sendTokens는 헤더에 토큰을 담습니다. XSS 영향면에서 최소화를 원하면 Refresh Token만큼은 HttpOnly+Secure(+SameSite) 쿠키로 전달/보관하는 방식을 권장합니다. 이 경우:
- CORS에서
allowCredentials=true필요- 쿠키 Path/Max-Age 설정
- 클라이언트는 Authorization 헤더로 Access만 취급, Refresh는 자동 전송 쿠키로만 사용
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisService.java (2)
22-29: "false" 매직 스트링 사용 지양 + 중복 조회 제거
- Redis 미존재 값을 "false" 문자열로 돌려주는 패턴은 오동작 여지가 큽니다. 호출부에서 존재/부재를 명확히 구분할 수 있도록 Optional 반환 또는 별도의 exists 메서드 제공을 권장합니다.
- 같은 키를 두 번 조회하고 있어 불필요한 RTT가 발생합니다.
중복 조회만 우선 제거하는 소폭 수정 제안:
- ValueOperations<String, String> values = redisTemplate.opsForValue(); - if (values.get(key) == null) { - return "false"; - } - return (String) values.get(key); + String value = redisTemplate.opsForValue().get(key); + return value == null ? "false" : value;
35-37: checkExistsValue는 매직 스트링 의존 + 활용처 부재값 비교로 존재여부를 추론하지 말고 Redis 키 존재 여부를 직접 확인하세요. API도 더 직관적입니다.
- protected boolean checkExistsValue(String value) { - return !value.equals("false"); - } + public boolean exists(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomJsonAuthenticationFailureHandler.java (1)
31-62: 메시지 문자열 매칭 의존도 축소AuthenticationServiceException 메시지에 대한 substring 매칭은 취약합니다. 가능한 한 구체 타입 매핑을 늘리고(예: DisabledException, LockedException 등 AccountStatusException 파생), CustomLoginFilter에서 던지는 예외도 세분화된 타입으로 분리(메서드/콘텐트타입/파싱)하면 메시지 파싱 없이 안정적으로 매핑할 수 있습니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/CustomLoginFilter.java (1)
65-69: 인증 디테일(클라이언트 IP 등) 세팅 권장AuthenticationManager로 전달 전, WebAuthenticationDetails를 세팅하면 감사/로깅/추적에 유용합니다.
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email.trim(), password); + // 요청 메타데이터 부착 (IP, Session Id 등) + authToken.setDetails(new org.springframework.security.web.authentication.WebAuthenticationDetailsSource() + .buildDetails(request)); + return this.getAuthenticationManager().authenticate(authToken);추가 import:
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
50-63: 토큰 전달 방식 중복(헤더+바디) — 합의 필요현재 헤더에 세팅 + 응답 바디에도 토큰을 내려보냅니다. 보안/캐시/프록시 고려 시 한 가지 방식으로 일원화하는 편이 안전합니다. 팀 컨벤션 확정 후 한쪽을 제거하세요.
src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/RedisConfig.java (2)
22-26: 운영 로그 노출 최소화호스트/포트는 운영 환경에서 DEBUG 수준으로 낮추는 것을 권장합니다. 인프라 메타데이터는 불필요한 노출을 줄이는 편이 안전합니다.
34-43: Hash/Default Serializer 설정 고려현재 key/value만 StringSerializer입니다. 향후 Hash 구조나 다른 자료구조 사용 계획이 있다면 hashKey/hashValue serializer도 명시해 두면 조회 편의성이 올라갑니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/model/UserPrincipal.java (1)
10-41: 직렬화/동등성 구현 고려
- SecurityContext 저장/세션 직렬화 대비 Serializable 구현을 권장합니다.
- equals/hashCode/toString 구현으로 로깅·비교 신뢰성을 높일 수 있습니다.
- 설계상 username에 닉네임을 넣고(email이 아님) 사용 중인데, 혼동 방지를 위해 Javadoc으로 의도 명시를 권장합니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
84-88: 민감 로그 과다 노출 가능성userId, nickName, providerId, role 등 주석용 정보는 DEBUG 수준으로 낮추는 것을 권장합니다. 운영 로그 최소화 측면에서 도움이 됩니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (4)
36-36: 상수로 선언 (static final) + 중복 사용지 통일 권장BEARER_PREFIX는 불변 상수이므로 static final로 올리는 게 관례적입니다.
- public final String BEARER_PREFIX = "Bearer "; + public static final String BEARER_PREFIX = "Bearer ";
5-12: 중복 import 정리io.jsonwebtoken.* 가 두 번 import 되어 있습니다. 하나 제거하세요.
import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; @@ -import io.jsonwebtoken.*;
119-121: getUserNameFromToken는 getName과 중복동일 기능 메서드가 중복입니다. 한쪽만 유지해 혼동 줄이세요. (사용처 없으면 제거 추천)
- public String getUserNameFromToken(String token) { - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("name", String.class); - }
123-133: Bearer 제거 방식 간소화replace 대신 substring이 의도에 명확합니다.
public Optional<String> extractAccessToken(HttpServletRequest request) { return Optional.ofNullable(request.getHeader(ACCESS_HEADER)) .filter(accessToken -> accessToken.startsWith(BEARER_PREFIX)) - .map(accessToken -> accessToken.replace(BEARER_PREFIX, "")); + .map(accessToken -> accessToken.substring(BEARER_PREFIX.length())); } public Optional<String> extractRefreshToken(HttpServletRequest request) { return Optional.ofNullable(request.getHeader(REFRESH_HEADER)) .filter(refreshToken -> refreshToken.startsWith(BEARER_PREFIX)) - .map(refreshToken -> refreshToken.replace(BEARER_PREFIX, "")); + .map(refreshToken -> refreshToken.substring(BEARER_PREFIX.length())); }src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (2)
36-37: 중복 상수 제거BEARER_PREFIX는 JwtUtil에 이미 있습니다. 동일 상수를 재선언하지 말고 재사용하세요.
- private final String BEARER_PREFIX = "Bearer "; + private final String BEARER_PREFIX = JwtUtil.BEARER_PREFIX;
97-101: 브라우저에서 헤더 읽기 불가 이슈 예방: Expose-Headers 추가토큰을 응답 헤더로 전달한다면 CORS 하에서 클라이언트가 읽을 수 있도록 Expose-Headers를 설정해야 합니다.
public void sendTokens(HttpServletResponse response, String accessToken, String refreshToken) { response.setHeader(ACCESS_HEADER, BEARER_PREFIX + accessToken); response.setHeader(REFRESH_HEADER, BEARER_PREFIX + refreshToken); + response.setHeader("Access-Control-Expose-Headers", String.join(", ", ACCESS_HEADER, REFRESH_HEADER)); + response.setHeader("Cache-Control", "no-store"); }src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/response/ErrorCode.java (1)
39-44: JWT 오류코드의 HTTP Status 재검토 (400 → 401 권장)인증 관련 오류는 대체로 401(Unauthorized)가 표준적입니다. 아래 항목들의 httpStatus를 401로 조정 권고드립니다.
- EMPTY_AUTHORIZATION_HEADER(612, HttpStatus.BAD_REQUEST.value(),"Authorization 헤더가 존재하지 않습니다."), - EXPIRED_ACCESS_TOKEN(613, HttpStatus.BAD_REQUEST.value(), "이미 만료된 Access 토큰입니다."), - UNSUPPORTED_TOKEN_TYPE(614, HttpStatus.BAD_REQUEST.value(),"지원되지 않는 토큰 형식입니다."), - MALFORMED_TOKEN_TYPE(615, HttpStatus.BAD_REQUEST.value(),"인증 토큰이 올바르게 구성되지 않았습니다."), - INVALID_SIGNATURE_JWT(616, HttpStatus.BAD_REQUEST.value(), "인증 시그니처가 올바르지 않습니다"), + EMPTY_AUTHORIZATION_HEADER(612, HttpStatus.UNAUTHORIZED.value(),"Authorization 헤더가 존재하지 않습니다."), + EXPIRED_ACCESS_TOKEN(613, HttpStatus.UNAUTHORIZED.value(), "이미 만료된 Access 토큰입니다."), + UNSUPPORTED_TOKEN_TYPE(614, HttpStatus.UNAUTHORIZED.value(),"지원되지 않는 토큰 형식입니다."), + MALFORMED_TOKEN_TYPE(615, HttpStatus.UNAUTHORIZED.value(),"인증 토큰이 올바르게 구성되지 않았습니다."), + INVALID_SIGNATURE_JWT(616, HttpStatus.UNAUTHORIZED.value(), "인증 시그니처가 올바르지 않습니다"),추가로 refresh 만료 코드(EXPIRED_REFRESH_TOKEN) 신설도 고려해보세요.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (35)
build.gradle(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/WhoIsServerApplication.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/request/LoginRequest.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/response/LoginResponse.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/exception/CustomAuthenticationException.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/exception/CustomJwtException.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/CustomLoginFilter.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAccessDeniedHandler.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAuthenticationEntryPoint.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomJsonAuthenticationFailureHandler.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/JwtExceptionHandlerFilter.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/model/UserPrincipal.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/CustomUserDetailsService.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/AuthenticationUtil.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/dto/request/SignupRequest.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/model/Role.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/model/User.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/repository/UserRepository.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisService.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/resolver/CurrentUserId.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/resolver/CurrentUserIdArgumentResolver.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/response/ErrorCode.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/CorsConfig.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/RedisConfig.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/SecurityConfig.java(1 hunks)src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/WebConfig.java(1 hunks)src/main/resources/application.yml(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (16)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(34-109)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java (1)
Slf4j(13-26)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAccessDeniedHandler.java (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
Slf4j(18-134)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAuthenticationEntryPoint.java (1)
Slf4j(17-32)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java (1)
SecurityErrorResponseUtil(12-25)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAuthenticationEntryPoint.java (5)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
Slf4j(18-134)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAccessDeniedHandler.java (1)
Slf4j(19-31)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/JwtExceptionHandlerFilter.java (1)
Slf4j(17-33)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java (1)
SecurityErrorResponseUtil(12-25)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (3)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
Slf4j(18-134)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/AuthenticationUtil.java (1)
Component(12-46)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/response/LoginResponse.java (1)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/request/LoginRequest.java (1)
Getter(8-14)
src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/RedisConfig.java (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
Slf4j(22-65)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/JwtExceptionHandlerFilter.java (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAuthenticationEntryPoint.java (1)
Slf4j(17-32)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java (1)
SecurityErrorResponseUtil(12-25)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/request/LoginRequest.java (1)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/response/LoginResponse.java (1)
Getter(6-11)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java (1)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomJsonAuthenticationFailureHandler.java (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/CustomLoginFilter.java (1)
Slf4j(20-70)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java (1)
SecurityErrorResponseUtil(12-25)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/CustomUserDetailsService.java (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/CustomLoginFilter.java (1)
Slf4j(20-70)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(34-109)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/CustomUserDetailsService.java (1)
Slf4j(16-36)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
Slf4j(18-134)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/AuthenticationUtil.java (1)
Component(12-46)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/controller/UserController.java (1)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java (1)
Slf4j(15-39)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/CustomLoginFilter.java (4)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(34-109)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
Slf4j(22-65)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/CustomUserDetailsService.java (1)
Slf4j(16-36)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomJsonAuthenticationFailureHandler.java (1)
Slf4j(17-63)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (5)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(34-109)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
Slf4j(22-65)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
Slf4j(18-134)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java (1)
Slf4j(14-33)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisService.java (1)
Service(11-39)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (5)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(34-109)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
Slf4j(22-65)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/CustomUserDetailsService.java (1)
Slf4j(16-36)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (1)
Slf4j(17-102)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/AuthenticationUtil.java (1)
Component(12-46)
🔇 Additional comments (13)
src/main/java/com/WhoIsRoom/WhoIs_Server/WhoIsServerApplication.java (1)
5-7: 검증 필요 — AuditorAware 빈 존재 여부 확인@EnableJpaAuditing 적용은 적절. @CreatedBy/@LastModifiedBy 사용 시 AuditorAware 빈(또는 구현)이 필요하며, 사용하지 않으면 현 구성으로 충분합니다.
샌드박스에서 검사 스크립트가 출력 없이 종료되어 확인 불가. 로컬 리포지토리 루트에서 아래 명령 실행 후 출력 결과를 붙여넣기:
rg -nP --type=java -C2 '@CreatedBy|@LastModifiedBy' rg -nP --type=java -C3 '\bAuditorAware\b|@Bean\s*\(\)\s*public\s+AuditorAware|@EnableJpaAuditing\s*\(' rg -nP --type=java -C2 '@DataJpaTest'build.gradle (1)
51-53: JWT 라이브러리 업그레이드 좋습니다!org.json dependency가 CVE-2023-5072 취약점을 해결하기 위해 업그레이드되었고 JDK 17에서의 reflection 문제들도 해결되었습니다. 0.12.3 버전으로 업그레이드하면서 보안성과 안정성이 개선되었네요.
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/resolver/CurrentUserId.java (1)
8-11: 깔끔한 어노테이션 정의입니다.런타임 유지와 파라미터 타겟 설정이 올바르게 되어 있고, 의도가 명확한 네이밍이 좋네요.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAccessDeniedHandler.java (1)
24-30: 일관성 있는 예외 처리 구조 좋습니다.다른 핸들러들과 마찬가지로 SecurityErrorResponseUtil을 사용하여 일관된 에러 응답을 생성하는 구조가 깔끔하네요. 로깅도 적절하게 되어 있고요.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/exception/CustomJwtException.java (1)
8-15: 적절한 커스텀 예외 구조입니다.JwtException을 상속받으면서 ErrorCode를 포함하는 구조가 좋네요. 이렇게 하면 JWT 관련 에러를 일관되게 처리할 수 있을 것 같습니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/repository/UserRepository.java (1)
8-12: 필요한 쿼리 메서드들이 잘 정의되어 있습니다.인증/인가 시스템에 필요한 findByEmail, 중복 체크용 exists 메서드들이 적절하게 구성되어 있네요. JPA 네이밍 컨벤션도 잘 따르고 있습니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/model/Role.java (1)
25-33: fromRole 메서드 로직은 적절하나 호출처 확인 필요
- 로직: "ROLE_" 접두사 제거 후 enum.value와 대소문자 무시 비교 → "ROLE_MEMBER" 형태 파싱에 적합.
- 검증 결과: 저장소 전역 검색에서 선언부만 발견(파일: src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/model/Role.java), 호출처 없음.
- 조치: 메서드가 Spring Security/SecurityContext, JWT, DB 등 외부 입력에서 사용되는지 확인하거나, 내부에서 미사용이면 제거하거나 단위 테스트/문서로 명확히 하세요.
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/resolver/CurrentUserIdArgumentResolver.java (1)
32-35: SecurityContext에서 principal이 String인지 확인이 필요합니다.일반적으로 Spring Security의 Authentication.getPrincipal()은 UserDetails 객체를 반환하는데, 여기서는 String으로 캐스팅하고 있네요. JWT 인증 필터에서 email을 principal로 설정하는 것 같은데, 이 부분이 일관되게 구현되었는지 확인해보면 좋겠습니다.
JWT 인증 필터에서 SecurityContext에 어떤 값을 설정하는지 확인해보겠습니다:
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/dto/response/LoginResponse.java (1)
6-11: LGTM — 응답 DTO 심플하고 충분합니다토큰은 민감하니 로그에 남기지 않도록만 유의하면 될 것 같아요.
혹시 토큰을 헤더로도 발급할 계획이면, CORS
exposedHeaders에Authorization을 추가했는지 확인 부탁드립니다.src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/SecurityErrorResponseUtil.java (1)
18-18: HttpStatus 타입 여부 확인 필요
ErrorCode.getHttpStatus()가HttpStatus라면response.setStatus(errorCode.getHttpStatus().value())로 설정해야 합니다. 현재 반환형이 int인지 확인 부탁드립니다.src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/exception/CustomAuthenticationEntryPoint.java (1)
21-31: LGTM인증 실패 시 표준화된 에러 응답으로 위임하는 흐름이 명확합니다. CustomAuthenticationException 우선 적용도 적절합니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/SecurityConfig.java (1)
50-54: 필터 체인 순서 OK예외 핸들러 → JWT 인증 → UsernamePasswordAuthenticationFilter 순서로 배치되어 예외 캡처 및 인증 흐름에 적합합니다.
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
84-95: Refresh 토큰에 role 미포함 → 재발급 시 Access 토큰에 권한 누락/NPE 위험reissue에서 refresh 토큰으로부터 role을 읽는데, 현 구조상 refresh 토큰에 role이 없어 null이 됩니다. 최소한 role을 포함해 생성하도록 변경을 권고합니다. (구 토큰은 재발급 불가해질 수 있으니 공지 필요)
- public String createRefreshToken(Long userId, String providerId, String name) { + public String createRefreshToken(Long userId, String providerId, String name, String role) { return Jwts.builder() .claim("tokenType", "refresh") .claim("userId", userId) .claim("providerId", providerId) .claim("name", name) + .claim("role", role) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRED_IN)) .signWith(secretKey, Jwts.SIG.HS256) .compact(); }
- 호출부 업데이트 필요:
- CustomAuthenticationSuccessHandler.onAuthenticationSuccess(...)에서 createRefreshToken(memberId, providerId, nickName, role)
- JwtService.reissueAndSendTokens(...) 내 재발급 createRefreshToken(...) 호출도 role 전달
| @PostMapping("/logout") | ||
| public BaseResponse<Void> logout(HttpServletRequest request){ | ||
| jwtService.logout(request); | ||
| return BaseResponse.ok(null); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
로그아웃 플로우 연계(JwtService) 치명적 버그 지적: Redis 키 설계 및 토큰 검증
컨트롤러 자체는 단순 위임이지만, 해당 엔드포인트의 핵심 동작을 담당하는 JwtService 구현에 다음 문제가 있어 실제 로그아웃이 의도대로 동작하지 않을 가능성이 큽니다.
- storeRefreshToken이 고정 키(
"auth:refresh:")로 저장하고, deleteRefreshToken은 토큰 문자열 키로 삭제함. 서로 키가 달라 삭제가 되지 않습니다. - checkLogout에서
value.equals(LOGOUT_VALUE)는 value가 null일 때 NPE 발생 가능."logout".equals(value)로 비교 필요. - logout 시 액세스/리프레시 토큰 유효성 및 타입 검증 부재(특히 refresh는 타입이 "refresh"인지 확인 필요).
개선 방향(요지):
- Refresh 저장 키를 사용자 식별자 기반 또는 토큰 해시 기반으로 일관되게 구성하고, 삭제도 동일 키 스킴 사용.
LOGOUT_VALUE.equals(value)로 NPE 방지.- logout 시
validateToken(access)/validateToken(refresh)호출 및 refresh 타입 확인. - reissue 시에도 refresh 타입 확인 및 회전(rotate) 구현 시 이전 토큰 폐기와 family 추적 고려.
원하시면 JwtService 수정 패치를 같이 제안드릴게요.
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java
around lines 22 to 26, the controller delegates logout to JwtService but
JwtService has multiple critical bugs: ensure refresh tokens are stored and
deleted using the same Redis key scheme (e.g., include userId or token-hash in
the key and use that same key for delete), change null-unsafe comparisons to use
"logout".equals(value) (or LOGOUT_VALUE.equals(value)) to avoid NPEs, and during
logout validate both access and refresh tokens via
validateToken(access)/validateToken(refresh) and verify the refresh token's type
equals "refresh" before deleting; also ensure reissue checks refresh type and
when implementing rotation invalidate the previous refresh token consistently
(delete by the consistent key) and consider tracking token family if rotating.
| Authentication authToken = null; | ||
| if ("localhost".equals(principal.getProviderId())) { | ||
| // 폼 로그인(자체 회원) | ||
| authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities); | ||
| } | ||
| // else { | ||
| // // 소셜 로그인 | ||
| // authToken = new OAuth2AuthenticationToken(principal, authorities, loginProvider); | ||
| // } | ||
| log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication()); | ||
| log.info("Authorities in SecurityContext: {}", authToken.getAuthorities()); | ||
|
|
||
| log.info("JWT Filter Success : {}", request.getRequestURI()); | ||
| SecurityContextHolder.getContext().setAuthentication(authToken); | ||
| filterChain.doFilter(request, response); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
providerId 조건으로 인한 NPE 및 미설정 인증 토큰 위험
providerId가 "localhost"가 아닌 경우 authToken이 null로 남아 NPE가 발생하고, SecurityContext에 인증이 설정되지 않습니다. JWT에서 신뢰된 클레임으로 principal을 구성했으므로 토큰을 일괄 생성·세팅하는 편이 안전합니다. 로그도 세팅 이후로 이동하세요.
- Authentication authToken = null;
- if ("localhost".equals(principal.getProviderId())) {
- // 폼 로그인(자체 회원)
- authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
- }
-// else {
-// // 소셜 로그인
-// authToken = new OAuth2AuthenticationToken(principal, authorities, loginProvider);
-// }
- log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication());
- log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());
-
- log.info("JWT Filter Success : {}", request.getRequestURI());
- SecurityContextHolder.getContext().setAuthentication(authToken);
+ Authentication authToken =
+ new UsernamePasswordAuthenticationToken(principal, null, authorities);
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+ log.info("Authentication set in SecurityContext: {}", SecurityContextHolder.getContext().getAuthentication());
+ log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());
+ log.info("JWT Filter Success : {}", request.getRequestURI());Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java
around lines 89 to 103, authToken can remain null when principal.getProviderId()
is not "localhost", causing NPEs and leaving SecurityContext unauthenticated;
instead always construct a valid Authentication from the JWT-derived principal
(e.g., create a UsernamePasswordAuthenticationToken or the appropriate
OAuth2AuthenticationToken for non-local providers) before logging, move the log
statements after
SecurityContextHolder.getContext().setAuthentication(authToken), and then call
filterChain.doFilter(request, response) so the SecurityContext is consistently
set and logs reflect the actual authentication.
| // refresh token 저장 | ||
| jwtService.storeRefreshToken(refreshToken); | ||
| log.info("[CustomAuthenticationSuccessHandler], refreshToken={}", refreshToken); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
민감정보(토큰) 로그 출력 금지
refreshToken을 로그에 남기면 유출 리스크가 큽니다. 해당 로그는 제거하거나 마스킹하세요.
- log.info("[CustomAuthenticationSuccessHandler], refreshToken={}", refreshToken);📝 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.
| // refresh token 저장 | |
| jwtService.storeRefreshToken(refreshToken); | |
| log.info("[CustomAuthenticationSuccessHandler], refreshToken={}", refreshToken); | |
| // refresh token 저장 | |
| jwtService.storeRefreshToken(refreshToken); |
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java
around lines 46 to 49, the code logs the raw refreshToken which exposes
sensitive credentials; remove the log that prints the full token or replace it
with a non-sensitive masked representation (e.g., log only token metadata or a
short masked fragment like "****...abcd"), and ensure any remaining log
statements never include the full token value; update tests or code paths that
relied on that exact log if necessary.
| // refresh token 저장 | ||
| jwtService.storeRefreshToken(refreshToken); | ||
| log.info("[CustomAuthenticationSuccessHandler], refreshToken={}", refreshToken); | ||
|
|
||
| jwtService.sendTokens(response, accessToken, refreshToken); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
[중요] Refresh Token 저장/삭제 키 스킴 불일치로 인해 삭제·검증 불가
JwtService에서 refresh token을 저장할 때 고정 키(prefix)만 사용하고, 삭제는 토큰 값을 키로 사용하고 있습니다. 이 경우 저장된 값을 절대 삭제할 수 없고(키가 다름), 재발급 시 유효성 검증도 불가능해집니다.
권장 수정안(택1):
- 키=리프레시토큰: "auth:refresh:{refreshToken}" -> 값은 "1" 같은 플래그. 삭제 시 동일 키로 삭제.
- 키=사용자ID: "auth:refresh:{userId}" -> 값=리프레시토큰. 삭제/회전 시 토큰에서 userId 파싱 후 동일 키로 덮어쓰기/삭제.
또한 재발급(reissue) 시 Redis에 해당 refresh token(또는 사용자 키)이 실제 존재하는지 검증해 주세요. 존재하지 않으면 도난/무효 토큰으로 간주하고 거절해야 합니다.
재현/검증용 스크립트:
참고 수정 예시(JwtService 내):
// 저장
redisService.setValues(REFRESH_TOKEN_KEY_PREFIX + refreshToken, "1", Duration.ofMillis(REFRESH_TOKEN_EXPIRED_IN));
// 검증 (재발급 시)
if (!redisService.exists(REFRESH_TOKEN_KEY_PREFIX + refreshToken)) {
throw new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN);
}
// 삭제
redisService.delete(REFRESH_TOKEN_KEY_PREFIX + refreshToken);또는 userId 기반 스킴:
String key = REFRESH_TOKEN_KEY_PREFIX + jwtUtil.getUserId(refreshToken);
redisService.setValues(key, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRED_IN));
// 재발급 시 동일 키에서 일치 여부 확인 후 rotate🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo ">>> JwtService 내 refresh 토큰 저장/삭제/검증 코드 위치 확인"
rg -n -C2 --type=java 'class\s+JwtService|REFRESH_TOKEN_KEY_PREFIX|storeRefreshToken|deleteRefreshToken|reissueTokens|setValues\(|delete\('
echo
echo ">>> 의심 패턴: setValues(REFRESH_TOKEN_KEY_PREFIX, refreshToken, ...)"
rg -n -C2 --type=java 'setValues\(\s*REFRESH_TOKEN_KEY_PREFIX\s*,\s*refreshToken'
echo
echo ">>> 의심 패턴: delete(refreshToken)"
rg -n -C2 --type=java '\.delete\(\s*refreshToken\s*\)'Length of output: 9344
[중요] JwtService의 Refresh Token 저장/삭제 키 스킴 불일치 — 즉시 수정 필요
storeRefreshToken은 REFRESH_TOKEN_KEY_PREFIX(상수)를 키로 하고 refreshToken을 값으로 저장하는 반면, deleteRefreshToken은 refreshToken 자체를 키로 삭제하고 있어 저장한 항목을 삭제·검증할 수 없습니다. (증거: src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java — storeRefreshToken 약 라인66, deleteRefreshToken 라인70-74, reissueTokens 라인52-53; 호출: CustomAuthenticationSuccessHandler.java 라인47)
수정안(택1):
- 옵션 A(간단): 키 = REFRESH_TOKEN_KEY_PREFIX + refreshToken, 값 = "1"
예: redisService.setValues(REFRESH_TOKEN_KEY_PREFIX + refreshToken, "1", Duration.ofMillis(...)); 삭제도 동일 키로 delete(REFRESH_TOKEN_KEY_PREFIX + refreshToken). - 옵션 B(권장): 키 = REFRESH_TOKEN_KEY_PREFIX + userId, 값 = refreshToken
예: key = REFRESH_TOKEN_KEY_PREFIX + jwtUtil.getUserId(refreshToken); redisService.setValues(key, refreshToken, expiry); 재발급 시 동일 키의 값과 비교해 일치하면 rotate/삭제.
추가 조치: reissueTokens에서 Redis에 해당 키(또는 토큰)가 실제 존재하는지 검증하고, 존재하지 않으면 재발급 거부(도난/무효 처리).
수정 대상: JwtService.storeRefreshToken, JwtService.deleteRefreshToken, JwtService.reissueTokens (및 관련 호출부).
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java
around lines 46–51, the code stores a refresh token but JwtService currently
uses inconsistent Redis key schemes (store uses REFRESH_TOKEN_KEY_PREFIX as key
while delete uses the token itself), preventing proper deletion/validation; fix
JwtService.storeRefreshToken, JwtService.deleteRefreshToken, and
JwtService.reissueTokens to use a consistent scheme—preferably Option B: set
Redis key = REFRESH_TOKEN_KEY_PREFIX + userId and value = refreshToken (obtain
userId from the token via jwtUtil), set expiry accordingly, change
deleteRefreshToken to delete REFRESH_TOKEN_KEY_PREFIX + userId, and in
reissueTokens verify the stored value matches the presented refreshToken (reject
if missing or mismatched) before rotating/deleting; update any callers
(including CustomAuthenticationSuccessHandler) only if they rely on old key
semantics.
| public void reissueTokens(HttpServletRequest request, HttpServletResponse response) { | ||
| String refreshToken = jwtUtil.extractRefreshToken(request) | ||
| .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN)); | ||
| jwtUtil.validateToken(refreshToken); | ||
| reissueAndSendTokens(response, refreshToken); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
재발급: 토큰 타입 검증/화이트리스트 검증 누락
- refresh 헤더에 access 토큰을 넣어도 재발급이 되어버립니다.
- Redis에 저장된 refresh 토큰인지 확인이 없습니다(도난 토큰 재사용 위험).
두 검증을 추가하세요.
public void reissueTokens(HttpServletRequest request, HttpServletResponse response) {
String refreshToken = jwtUtil.extractRefreshToken(request)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN));
jwtUtil.validateToken(refreshToken);
+ // 토큰 타입이 refresh인지 확인
+ if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
+ throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE);
+ }
+ // Redis 화이트리스트 확인 (미등록/삭제된 토큰 거부)
+ String stored = redisService.getValues(REFRESH_TOKEN_KEY_PREFIX + refreshToken);
+ if ("false".equals(stored)) {
+ throw new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN);
+ }
reissueAndSendTokens(response, refreshToken);
}📝 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.
| public void reissueTokens(HttpServletRequest request, HttpServletResponse response) { | |
| String refreshToken = jwtUtil.extractRefreshToken(request) | |
| .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN)); | |
| jwtUtil.validateToken(refreshToken); | |
| reissueAndSendTokens(response, refreshToken); | |
| } | |
| public void reissueTokens(HttpServletRequest request, HttpServletResponse response) { | |
| String refreshToken = jwtUtil.extractRefreshToken(request) | |
| .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN)); | |
| jwtUtil.validateToken(refreshToken); | |
| // 토큰 타입이 refresh인지 확인 | |
| if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { | |
| throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE); | |
| } | |
| // Redis 화이트리스트 확인 (미등록/삭제된 토큰 거부) | |
| String stored = redisService.getValues(REFRESH_TOKEN_KEY_PREFIX + refreshToken); | |
| if ("false".equals(stored)) { | |
| throw new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN); | |
| } | |
| reissueAndSendTokens(response, refreshToken); | |
| } |
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around lines 52 to 57, the reissueTokens method currently accepts any token from
the refresh header and does not verify that the token is actually a refresh
token nor that it exists in Redis whitelist; update the method to first validate
the token type (ensure the token's claim/type indicates "refresh" and throw
CustomAuthenticationException with SECURITY_INVALID_REFRESH_TOKEN if not), then
check Redis (or the existing token store) that the provided refresh token is
present and matches the stored value for the user/session (throw
CustomAuthenticationException with an appropriate error code, e.g.,
SECURITY_INVALID_REFRESH_TOKEN or SECURITY_STOLEN_TOKEN, if missing or
mismatched), and only after both checks call reissueAndSendTokens(response,
refreshToken).
| @Transactional | ||
| public void signUp(SignupRequest request) { | ||
| if (userRepository.existsByEmail(request.getEmail())) { | ||
| throw new BusinessException(ErrorCode.USER_DUPLICATE_EMAIL); | ||
| } | ||
| if (userRepository.existsByNickName(request.getNickName())) { | ||
| throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME); | ||
| } | ||
|
|
||
| User user = User.builder() | ||
| .email(request.getEmail()) | ||
| .nickName(request.getNickName()) | ||
| .password(passwordEncoder.encode(request.getPassword())) | ||
| .role(Role.MEMBER) | ||
| .build(); | ||
| userRepository.save(user); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
중복 체크 후 저장(TOCTOU) 경쟁 조건 — 동시 가입 시 중복 허용 가능
existsBy...로 검사 후 save는 경쟁 상태에서 무력화됩니다. DB 유니크 제약은 필수이고, DataIntegrityViolationException을 캐치해 비즈니스 예외로 매핑해 주세요. 입력 정규화(트림/이메일 소문자화)도 함께 권장.
+import org.springframework.dao.DataIntegrityViolationException;
@@
public void signUp(SignupRequest request) {
- if (userRepository.existsByEmail(request.getEmail())) {
+ final String email = request.getEmail().trim().toLowerCase();
+ final String nick = request.getNickName().trim();
+
+ if (userRepository.existsByEmail(email)) {
throw new BusinessException(ErrorCode.USER_DUPLICATE_EMAIL);
}
- if (userRepository.existsByNickName(request.getNickName())) {
+ if (userRepository.existsByNickName(nick)) {
throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME);
}
- User user = User.builder()
- .email(request.getEmail())
- .nickName(request.getNickName())
+ User user = User.builder()
+ .email(email)
+ .nickName(nick)
.password(passwordEncoder.encode(request.getPassword()))
.role(Role.MEMBER)
.build();
- userRepository.save(user);
+ try {
+ userRepository.save(user);
+ } catch (DataIntegrityViolationException e) {
+ // 어떤 제약이 깨졌는지 재확인하여 정확한 코드로 변환
+ if (userRepository.existsByEmail(email)) {
+ throw new BusinessException(ErrorCode.USER_DUPLICATE_EMAIL);
+ }
+ if (userRepository.existsByNickName(nick)) {
+ throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME);
+ }
+ throw e; // 알 수 없는 제약 오류는 상위에서 처리
+ }
}📝 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.
| @Transactional | |
| public void signUp(SignupRequest request) { | |
| if (userRepository.existsByEmail(request.getEmail())) { | |
| throw new BusinessException(ErrorCode.USER_DUPLICATE_EMAIL); | |
| } | |
| if (userRepository.existsByNickName(request.getNickName())) { | |
| throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME); | |
| } | |
| User user = User.builder() | |
| .email(request.getEmail()) | |
| .nickName(request.getNickName()) | |
| .password(passwordEncoder.encode(request.getPassword())) | |
| .role(Role.MEMBER) | |
| .build(); | |
| userRepository.save(user); | |
| } | |
| @Transactional | |
| public void signUp(SignupRequest request) { | |
| final String email = request.getEmail().trim().toLowerCase(); | |
| final String nick = request.getNickName().trim(); | |
| if (userRepository.existsByEmail(email)) { | |
| throw new BusinessException(ErrorCode.USER_DUPLICATE_EMAIL); | |
| } | |
| if (userRepository.existsByNickName(nick)) { | |
| throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME); | |
| } | |
| User user = User.builder() | |
| .email(email) | |
| .nickName(nick) | |
| .password(passwordEncoder.encode(request.getPassword())) | |
| .role(Role.MEMBER) | |
| .build(); | |
| try { | |
| userRepository.save(user); | |
| } catch (DataIntegrityViolationException e) { | |
| // 어떤 제약이 깨졌는지 재확인하여 정확한 코드로 변환 | |
| if (userRepository.existsByEmail(email)) { | |
| throw new BusinessException(ErrorCode.USER_DUPLICATE_EMAIL); | |
| } | |
| if (userRepository.existsByNickName(nick)) { | |
| throw new BusinessException(ErrorCode.USER_DUPLICATE_NICKNAME); | |
| } | |
| throw e; // 알 수 없는 제약 오류는 상위에서 처리 | |
| } | |
| } |
| USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 시용자가 있습니다."), | ||
| USER_DUPLICATE_NICKNAME(202, HttpStatus.BAD_REQUEST.value(), "중복된 닉네임의 사용자가 있습니다."), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
한글 오타 수정
"시용자" → "사용자"
- USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 시용자가 있습니다."),
+ USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 사용자가 있습니다."),📝 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.
| USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 시용자가 있습니다."), | |
| USER_DUPLICATE_NICKNAME(202, HttpStatus.BAD_REQUEST.value(), "중복된 닉네임의 사용자가 있습니다."), | |
| USER_DUPLICATE_EMAIL(201, HttpStatus.BAD_REQUEST.value(), "중복된 이메일의 사용자가 있습니다."), | |
| USER_DUPLICATE_NICKNAME(202, HttpStatus.BAD_REQUEST.value(), "중복된 닉네임의 사용자가 있습니다."), |
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/response/ErrorCode.java
around lines 23 to 24, the Korean message for USER_DUPLICATE_EMAIL contains a
typo ("시용자"); change the string to use the correct word "사용자" so the enum entry
reads with "중복된 이메일의 사용자가 있습니다." keeping all other enum fields and formatting
unchanged.
| @Bean | ||
| public CorsConfigurationSource corsConfigurationSource() { | ||
| CorsConfiguration configuration = new CorsConfiguration(); | ||
| configuration.addAllowedOriginPattern("*"); // 모든 출처 허용 | ||
| configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 | ||
| configuration.addAllowedHeader("*"); // 모든 헤더 허용 | ||
| configuration.setAllowCredentials(true); // 인증 정보 허용 | ||
|
|
||
| UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); | ||
| source.registerCorsConfiguration("/**", configuration); | ||
| return source; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
과도하게 허용적인 CORS(+ credentials) — 운영 환경에선 축소 필요
addAllowedOriginPattern("*")와 setAllowCredentials(true) 조합은 모든 출처에 대해 인증 쿠키/헤더를 허용합니다. 개발엔 편하지만 운영에선 보안 리스크 큼. 허용 도메인을 프로퍼티로 제한하고, preflight 최적화를 위해 maxAge, 필요 시 클라이언트가 읽어야 하는 응답 헤더(Authorization, Set-Cookie 등)를 exposedHeaders에 노출하는 걸 추천합니다.
적용 예시:
package com.WhoIsRoom.WhoIs_Server.global.config;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import java.util.List;
@Configuration
public class CorsConfig {
+ @Value("${app.cors.allowed-origins:*}")
+ private List<String> allowedOrigins;
+
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
- configuration.addAllowedOriginPattern("*"); // 모든 출처 허용
+ if (allowedOrigins != null && !allowedOrigins.isEmpty() && !allowedOrigins.contains("*")) {
+ configuration.setAllowedOrigins(allowedOrigins); // 명시적 허용
+ } else {
+ configuration.addAllowedOriginPattern("*"); // 개발/기본값
+ }
configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용
configuration.addAllowedHeader("*"); // 모든 헤더 허용
configuration.setAllowCredentials(true); // 인증 정보 허용
+ configuration.setMaxAge(3600L); // preflight 캐시
+ configuration.addExposedHeader("Authorization");
+ configuration.addExposedHeader("Set-Cookie");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}📝 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.
| @Bean | |
| public CorsConfigurationSource corsConfigurationSource() { | |
| CorsConfiguration configuration = new CorsConfiguration(); | |
| configuration.addAllowedOriginPattern("*"); // 모든 출처 허용 | |
| configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 | |
| configuration.addAllowedHeader("*"); // 모든 헤더 허용 | |
| configuration.setAllowCredentials(true); // 인증 정보 허용 | |
| UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); | |
| source.registerCorsConfiguration("/**", configuration); | |
| return source; | |
| } | |
| package com.WhoIsRoom.WhoIs_Server.global.config; | |
| import org.springframework.beans.factory.annotation.Value; | |
| import org.springframework.context.annotation.Bean; | |
| import org.springframework.context.annotation.Configuration; | |
| import org.springframework.web.cors.CorsConfiguration; | |
| import org.springframework.web.cors.CorsConfigurationSource; | |
| import org.springframework.web.cors.UrlBasedCorsConfigurationSource; | |
| import java.util.List; | |
| @Configuration | |
| public class CorsConfig { | |
| @Value("${app.cors.allowed-origins:*}") | |
| private List<String> allowedOrigins; | |
| @Bean | |
| public CorsConfigurationSource corsConfigurationSource() { | |
| CorsConfiguration configuration = new CorsConfiguration(); | |
| if (allowedOrigins != null && !allowedOrigins.isEmpty() && !allowedOrigins.contains("*")) { | |
| configuration.setAllowedOrigins(allowedOrigins); // 명시적 허용 | |
| } else { | |
| configuration.addAllowedOriginPattern("*"); // 개발/기본값 | |
| } | |
| configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 | |
| configuration.addAllowedHeader("*"); // 모든 헤더 허용 | |
| configuration.setAllowCredentials(true); // 인증 정보 허용 | |
| configuration.setMaxAge(3600L); // preflight 캐시 | |
| configuration.addExposedHeader("Authorization"); | |
| configuration.addExposedHeader("Set-Cookie"); | |
| UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); | |
| source.registerCorsConfiguration("/**", configuration); | |
| return source; | |
| } | |
| } |
| .cors(cors -> cors.disable()) | ||
| .httpBasic(b -> b.disable()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CORS 비활성화는 토큰 헤더 전달/읽기와 상충
CORS를 disable하면 브라우저 클라이언트에서 헤더 기반 토큰 수신/노출이 막힐 수 있습니다. CORS를 활성화하고 허용/Expose 헤더를 명시하세요.
- .cors(cors -> cors.disable())
+ .cors(cors -> {}) // 별도 CorsConfigurationSource Bean 사용추가로 아래 Bean을 구성하는 것을 권장합니다(동일 파일 하단 또는 별도 @configuration):
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://your-frontend.example.com", "http://localhost:3000"));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","PATCH","OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization", "Refresh")); // ACCESS_HEADER/REFRESH_HEADER 실제 값으로 교체
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/SecurityConfig.java
around lines 45-46, CORS is currently disabled which prevents browser clients
from receiving/exposing header-based tokens; enable and configure CORS instead
of calling cors.disable(): wire a CorsConfigurationSource bean (or add
configuration in this class) that sets allowed origins (frontend URLs and
localhost), allowed methods (GET, POST, PUT, DELETE, PATCH, OPTIONS), allowed
headers (or List.of("*")), exposed headers to include your access/refresh header
names (e.g. "Authorization", "Refresh"), allow credentials true, and register it
for "/**"; then remove cors.disable() and ensure the security config uses the
configured CorsConfigurationSource so header tokens are readable by browsers.
| .requestMatchers("/**").permitAll() | ||
| // 나머지 모든 요청은 인증 필요 | ||
| .anyRequest().authenticated() | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
인가 규칙 오류: 전체 permitAll로 인해 인증이 사실상 무력화됨
.requestMatchers("/**").permitAll() 때문에 anyRequest().authenticated()가 의미 없습니다. 필요한 공개 엔드포인트만 허용하고 나머지는 인증 요구로 바꾸세요.
- .authorizeHttpRequests(authorize -> authorize
- .requestMatchers("/**").permitAll()
- // 나머지 모든 요청은 인증 필요
- .anyRequest().authenticated()
- );
+ .authorizeHttpRequests(authorize -> authorize
+ .requestMatchers("/api/auth/**", "/api/users/signup", "/error", "/actuator/health").permitAll()
+ .anyRequest().authenticated()
+ );📝 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.
| .requestMatchers("/**").permitAll() | |
| // 나머지 모든 요청은 인증 필요 | |
| .anyRequest().authenticated() | |
| ); | |
| .authorizeHttpRequests(authorize -> authorize | |
| .requestMatchers("/api/auth/**", "/api/users/signup", "/error", "/actuator/health").permitAll() | |
| .anyRequest().authenticated() | |
| ); |
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/global/config/SecurityConfig.java
around lines 65 to 68, the rule .requestMatchers("/**").permitAll()
unintentionally allows every request and negates .anyRequest().authenticated();
remove or narrow that matcher to only the explicit public endpoints (e.g.
/login, /signup, /public/**, /health, static resource paths) and place those
permit rules before the final .anyRequest().authenticated() so that only those
specific routes are public and all other requests require authentication.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (2)
41-50: 로그아웃 시 토큰 타입/유효성도 확인 권장헤더만 추출하고 타입/유효성 검증이 없어, 잘못된 헤더에 의해 엉뚱한 토큰이 블랙리스트 처리될 수 있습니다. 방어적으로 access/refresh 각각 검증을 권장합니다.
public void logout(HttpServletRequest request) { String accessToken = jwtUtil.extractAccessToken(request) .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN)); String refreshToken = jwtUtil.extractRefreshToken(request) .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN)); + // 토큰 유효성 및 타입 검증 + jwtUtil.validateToken(accessToken); + if (!"access".equals(jwtUtil.getTokenType(accessToken))) { + throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE); + } + jwtUtil.validateToken(refreshToken); + if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { + throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE); + } + deleteRefreshToken(refreshToken); //access token blacklist 처리 -> 로그아웃한 사용자가 요청 시 access token이 redis에 존재하면 jwtAuthenticationFilter에서 인증처리 거부 invalidAccessToken(accessToken); }
81-84: 블랙리스트 TTL을 잔여 만료시간으로 세팅 권장현재는 설정값(최대 만료)으로 고정해 실제 잔여 만료보다 길게 남을 수 있습니다. 토큰 exp와 현재 시간 차이로 TTL을 계산해 설정하면 Redis 부하를 줄일 수 있습니다. JwtUtil에 만료시각을 반환하는 헬퍼를 추가해 사용하는 방식을 추천합니다.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (5)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/handler/success/CustomAuthenticationSuccessHandler.java (1)
Slf4j(22-65)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java (1)
Slf4j(34-109)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/util/JwtUtil.java (1)
Slf4j(18-134)src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/controller/AuthController.java (1)
Slf4j(14-33)src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisService.java (1)
Service(11-39)
🔇 Additional comments (3)
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java (3)
56-59: 토큰 타입 검증 추가 LGTMrefresh 토큰 타입 검증 추가된 부분은 적절합니다.
52-61: 재발급: Redis 화이트리스트(화이트리스트/세션 저장소) 검증 누락현재는 refresh 헤더에 유효한 형식의 JWT만 있으면 재발급이 진행됩니다. Redis에 저장된 토큰인지 확인이 없어 도난/탈취 토큰으로 무한 재발급이 가능합니다. 저장 시와 동일한 키 규칙으로 Redis에서 존재 여부를 확인하세요.
다음 패치를 적용해 주세요(저장/삭제 키 정합성 패치와 함께 사용 전제):
public void reissueTokens(HttpServletRequest request, HttpServletResponse response) { String refreshToken = jwtUtil.extractRefreshToken(request) .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN)); jwtUtil.validateToken(refreshToken); if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) { throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE); } + // Redis 화이트리스트 확인 (미등록/삭제된 토큰 거부) + String stored = redisService.getValues(REFRESH_TOKEN_KEY_PREFIX + refreshToken); + if ("false".equals(stored)) { + throw new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN); + } reissueAndSendTokens(response, refreshToken); }
70-79: Redis 키 불일치로 삭제 실패 — 실질적 무효화 불가현재 저장은 고정 프리픽스 키에 하고 삭제는 토큰 원문 키로 하여, 삭제가 동작하지 않으며 사용자 간 덮어쓰기 위험이 있습니다. 동일한 복합 키(프리픽스 + 토큰)로 통일하세요.
public void storeRefreshToken(String refreshToken) { - redisService.setValues(REFRESH_TOKEN_KEY_PREFIX, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRED_IN)); + redisService.setValues(REFRESH_TOKEN_KEY_PREFIX + refreshToken, "valid", Duration.ofMillis(REFRESH_TOKEN_EXPIRED_IN)); } private void deleteRefreshToken(String refreshToken){ if(refreshToken == null){ throw new CustomJwtException(ErrorCode.EMPTY_REFRESH_HEADER); } - redisService.delete(refreshToken); + redisService.delete(REFRESH_TOKEN_KEY_PREFIX + refreshToken); }
| private void reissueAndSendTokens(HttpServletResponse response, String refreshToken) { | ||
|
|
||
| // 새로운 Refresh Token 발급 | ||
| String reissuedAccessToken = jwtUtil.createAccessToken(jwtUtil.getUserId(refreshToken), jwtUtil.getProviderId(refreshToken), jwtUtil.getRole(refreshToken), jwtUtil.getName(refreshToken)); | ||
| String reissuedRefreshToken = jwtUtil.createRefreshToken(jwtUtil.getUserId(refreshToken), jwtUtil.getProviderId(refreshToken), jwtUtil.getName(refreshToken)); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
재발급 시 role null 가능 — 새 Access 토큰 권한 누락/예외
refresh 토큰에는 role 클레임이 없어 null이 될 수 있습니다. 이 상태로 Access 토큰을 만들면 이후 인증 필터에서 권한 생성 시 예외가 발생합니다. 즉시 방어코드 추가하고, 구조적으로는 refresh 토큰에 role을 포함하거나 DB 조회로 보완하세요.
private void reissueAndSendTokens(HttpServletResponse response, String refreshToken) {
- // 새로운 Refresh Token 발급
- String reissuedAccessToken = jwtUtil.createAccessToken(jwtUtil.getUserId(refreshToken), jwtUtil.getProviderId(refreshToken), jwtUtil.getRole(refreshToken), jwtUtil.getName(refreshToken));
- String reissuedRefreshToken = jwtUtil.createRefreshToken(jwtUtil.getUserId(refreshToken), jwtUtil.getProviderId(refreshToken), jwtUtil.getName(refreshToken));
+ // 새로운 토큰 생성에 필요한 클레임 추출 및 방어코드
+ Long userId = jwtUtil.getUserId(refreshToken);
+ String providerId = jwtUtil.getProviderId(refreshToken);
+ String name = jwtUtil.getName(refreshToken);
+ String role = jwtUtil.getRole(refreshToken);
+ if (role == null) {
+ // 구조적 개선 전까지는 명시적으로 거부
+ throw new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_REFRESH_TOKEN);
+ }
+
+ String reissuedAccessToken = jwtUtil.createAccessToken(userId, providerId, role, name);
+ String reissuedRefreshToken = jwtUtil.createRefreshToken(userId, providerId, name);구조적 개선(권장) — JwtUtil에 role 포함:
// JwtUtil.java 의 createRefreshToken 시그니처와 구현 변경
public String createRefreshToken(Long userId, String providerId, String name, String role) {
return Jwts.builder()
.claim("tokenType", "refresh")
.claim("userId", userId)
.claim("providerId", providerId)
.claim("name", name)
.claim("role", role) // 추가
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRED_IN))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
// 호출부(CustomAuthenticationSuccessHandler, JwtService)도 role 전달로 업데이트 필요🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around lines 86-91, the code reissues an Access Token using
jwtUtil.getRole(refreshToken) which can be null because the refresh token may
not carry a role; add defensive logic so role is never passed as null: if
jwtUtil.getRole(refreshToken) returns null, load the user's role from the
persistent store (e.g., UserRepository.findById(userId)) and use that value when
calling createAccessToken; as a structural improvement, update
JwtUtil.createRefreshToken and all callers to include role in refresh tokens so
future reissues can read role directly. Ensure null-handling path logs a clear
warning and fails gracefully if role cannot be resolved.
…into feat/#3-Jwt-login
Related issue 🛠
Work Description 📝
CustomLoginFilter를 통한 로그인 기능 구현
Redis를 이용한 로그아웃, 토큰 재발급 기능 구현
JwtAuthentication을 통한 인증 필터 구현
EntryPoint, ExceptionHandlerFilter, FailureHandler, deniedHandler를 통한 예외 처리 구현
회원가입 기능 구현
Resolver를 통한 CurrentUserId 구현
Cors 설정
Screenshot 📸
Uncompleted Tasks 😅
To Reviewers 📢
Summary by CodeRabbit
새 기능
구성
작업