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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ public BaseResponse<Void> logout(HttpServletRequest request,
}

@PostMapping("/reissue")
public BaseResponse<ReissueResponse> reissueTokens(@RequestBody RefreshTokenRequest tokenRequest) {
ReissueResponse response = jwtService.reissueTokens(tokenRequest);
public BaseResponse<ReissueResponse> reissueTokens(@RequestBody RefreshTokenRequest tokenRequest,
@CurrentUserId Long userId) {
ReissueResponse response = jwtService.reissueTokens(tokenRequest, userId);
return BaseResponse.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomAuthenticationException;
import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomJwtException;
import com.WhoIsRoom.WhoIs_Server.domain.auth.handler.exception.CustomAuthenticationEntryPoint;
import com.WhoIsRoom.WhoIs_Server.domain.auth.model.UserPrincipal;
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
import com.WhoIsRoom.WhoIs_Server.domain.auth.util.JwtUtil;
Expand All @@ -14,6 +15,7 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
Expand All @@ -37,71 +39,77 @@
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final JwtService jwtService;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

// 인증을 안해도 되니 토큰이 필요없는 URL들 (에러: 로그인이 필요합니다)
public final static List<String> PASS_URIS = Arrays.asList(
"/api/users/signup", "/api/auth/**"
"/api/users/signup", "/api/auth/login",
"/api/auth/email/send", "/api/auth/email/validation"
);

private static final AntPathMatcher ANT = new AntPathMatcher();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

if(isPassUri(request.getRequestURI())) {
log.info("JWT Filter Passed (pass uri) : {}", request.getRequestURI());
filterChain.doFilter(request, response);
return;
}
try {

// 엑세스 토큰이 없으면 Authentication도 없음 -> EntryPoint (401)
log.info("Request URI: {}", request.getRequestURI()); // 요청 URI 로깅
String accessToken = jwtUtil.extractAccessToken(request)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED));
if (isPassUri(request.getRequestURI())) {
log.info("JWT Filter Passed (pass uri) : {}", request.getRequestURI());
filterChain.doFilter(request, response);
return;
}

// 토큰 유효성 검사
jwtUtil.validateToken(accessToken);
// 엑세스 토큰이 없으면 Authentication도 없음 -> EntryPoint (401)
log.info("Request URI: {}", request.getRequestURI()); // 요청 URI 로깅
String accessToken = jwtUtil.extractAccessToken(request)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED));

// 토큰 타입 검사
if(!"access".equals(jwtUtil.getTokenType(accessToken))) {
throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE);
}
// 토큰 유효성 검사
jwtUtil.validateToken(accessToken);

// 로그아웃 체크
jwtService.checkLogout(accessToken);
// 토큰 타입 검사
if (!"access".equals(jwtUtil.getTokenType(accessToken))) {
throw new CustomJwtException(ErrorCode.INVALID_TOKEN_TYPE);
}

// 권한 리스트 생성
List<GrantedAuthority> authorities = Arrays.asList(new SimpleGrantedAuthority(jwtUtil.getRole(accessToken)));
log.info("Granted Authorities : {}", authorities);
UserPrincipal principal = new UserPrincipal(
jwtUtil.getUserId(accessToken),
jwtUtil.getName(accessToken),
null, // 패스워드는 필요 없음
jwtUtil.getProviderId(accessToken),
authorities
);
log.info("UserPrincipal.userId: {}", principal.getUserId());
log.info("UserPrincipal.nickName: {}", principal.getUsername());
log.info("UserPrincipal.providerId: {}", principal.getProviderId());
log.info("UserPrincipal.role: {}", principal.getAuthorities().stream().findFirst().get().toString());
// 로그아웃 체크
jwtService.checkLogout(accessToken);

Authentication authToken = null;
if ("localhost".equals(principal.getProviderId())) {
// 폼 로그인(자체 회원)
authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
// 권한 리스트 생성
List<GrantedAuthority> authorities = Arrays.asList(new SimpleGrantedAuthority(jwtUtil.getRole(accessToken)));
log.info("Granted Authorities : {}", authorities);
UserPrincipal principal = new UserPrincipal(
jwtUtil.getUserId(accessToken),
jwtUtil.getName(accessToken),
null, // 패스워드는 필요 없음
jwtUtil.getProviderId(accessToken),
authorities
);
log.info("UserPrincipal.userId: {}", principal.getUserId());
log.info("UserPrincipal.nickName: {}", principal.getUsername());
log.info("UserPrincipal.providerId: {}", principal.getProviderId());
log.info("UserPrincipal.role: {}", principal.getAuthorities().stream().findFirst().get().toString());

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("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);
log.info("JWT Filter Success : {}", request.getRequestURI());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
Comment on lines +94 to +108
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

authToken이 null일 때 NPE 발생합니다.

providerId"localhost"가 아니면 authToken은 끝까지 null이고, 곧바로 authToken.getAuthorities() 로깅에서 NullPointerException이 터집니다. 실제 서비스(소셜 로그인 등)에서 곧바로 장애가 나니 null 가드를 추가하거나 기본 토큰을 생성하도록 수정이 필요합니다.

-            Authentication authToken = null;
-            if ("localhost".equals(principal.getProviderId())) {
-                // 폼 로그인(자체 회원)
-                authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
-            }
+            Authentication authToken = null;
+            if ("localhost".equals(principal.getProviderId())) {
+                authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
+            }
+
+            if (authToken == null) {
+                throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
+            }
@@
-            log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());
+            log.info("Authorities in SecurityContext: {}", authToken.getAuthorities());
📝 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
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("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);
log.info("JWT Filter Success : {}", request.getRequestURI());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
Authentication authToken = null;
if ("localhost".equals(principal.getProviderId())) {
authToken = new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
if (authToken == null) {
throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
}
// 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);
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/filter/JwtAuthenticationFilter.java
around lines 94-108, authToken can remain null when principal.getProviderId() !=
"localhost", causing a NullPointerException at authToken.getAuthorities(); fix
by guarding uses of authToken and ensuring a non-null token: either construct
the appropriate OAuth2AuthenticationToken for social providers (use principal,
authorities and the provider/login key) when providerId is not "localhost", or
if you cannot create a token, skip the authority logging and do not call
setAuthentication (log a warning and continue the filter chain). Ensure any
logging or SecurityContextHolder.setAuthentication(...) is only done when
authToken != null.

} catch (CustomAuthenticationException | AuthenticationException e) {
customAuthenticationEntryPoint.commence(request, response, (org.springframework.security.core.AuthenticationException) e);
}
}

private boolean isPassUri(String uri) {
return PASS_URIS.stream().anyMatch(pattern -> ANT.match(pattern, uri));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import com.WhoIsRoom.WhoIs_Server.domain.auth.util.AuthenticationUtil;
import com.WhoIsRoom.WhoIs_Server.domain.auth.util.JwtUtil;
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
import com.WhoIsRoom.WhoIs_Server.global.common.response.BaseErrorResponse;
import com.WhoIsRoom.WhoIs_Server.global.common.response.BaseResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
Expand Down Expand Up @@ -35,7 +34,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo

String providerId = authenticationUtil.getProviderId();
String role = authenticationUtil.getRole();
Long memberId = authenticationUtil.getMemberId();
Long memberId = authenticationUtil.getUserId();
String nickName = authenticationUtil.getUsername();
log.info("[CustomAuthenticationSuccessHandler] providerId={}, role={}, memberId={}", providerId, role, memberId);

Expand All @@ -44,7 +43,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
String refreshToken = jwtUtil.createRefreshToken(memberId, providerId, nickName);

// refresh token 저장
jwtService.storeRefreshToken(refreshToken);
jwtService.storeRefreshToken(refreshToken, memberId);
log.info("[CustomAuthenticationSuccessHandler], refreshToken={}", refreshToken);

LoginResponse data = LoginResponse.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public void logout(HttpServletRequest request, RefreshTokenRequest tokenRequest)
String accessToken = jwtUtil.extractAccessToken(request)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));

log.info("LogOut Access Token: {}", accessToken);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

프로덕션 환경에서 토큰 로깅 주의 필요

액세스 토큰 전체를 로그에 남기는 것은 보안상 위험할 수 있습니다. 프로덕션 환경에서는 토큰의 일부만 마스킹하여 로깅하는 것을 권장합니다.

다음과 같이 마스킹 처리를 고려해보세요:

-log.info("LogOut Access Token: {}", accessToken);
+log.info("LogOut Access Token: {}****", accessToken.substring(0, Math.min(10, accessToken.length())));

또는 프로덕션 프로파일에서는 DEBUG 레벨로 변경:

-log.info("LogOut Access Token: {}", accessToken);
+log.debug("LogOut Access Token: {}", accessToken);
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around line 48, avoid logging the full access token; instead mask it (e.g., show
only the first/last few characters and replace the middle with asterisks) before
passing to log.info, or conditionally log the full token only under a
non-production profile / DEBUG level; implement masking logic and/or wrap the
log call with an environment/profile check so production never writes the
complete token to logs.

⚠️ Potential issue | 🟠 Major

보안 문제: 로그에 액세스 토큰 노출.

액세스 토큰을 로그에 기록하면 로그 파일을 통해 토큰이 유출될 수 있습니다. 프로덕션 환경에서 이는 심각한 보안 위험입니다.

다음 중 하나를 선택하여 적용하세요:

옵션 1 (권장): 토큰 마스킹

-log.info("LogOut Access Token: {}", accessToken);
+log.info("LogOut Access Token: {}...", accessToken.substring(0, Math.min(10, accessToken.length())));

옵션 2: 로그 레벨을 DEBUG로 낮추고 프로덕션에서는 비활성화

-log.info("LogOut Access Token: {}", accessToken);
+log.debug("LogOut Access Token: {}", accessToken);

옵션 3: 완전히 제거

-log.info("LogOut Access Token: {}", accessToken);
📝 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
log.info("LogOut Access Token: {}", accessToken);
log.info("LogOut Access Token: {}...", accessToken.substring(0, Math.min(10, accessToken.length())));
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around line 48, the code logs the raw access token which is a security risk; fix
by either (1) masking the token before logging (e.g., replace middle chars with
asterisks and only show first/last few chars), (2) change the log to DEBUG and
wrap it with a runtime check so it is disabled in production (e.g., only log if
logger.isDebugEnabled()), or (3) remove the log entirely; implement one of these
options consistently and ensure no raw token value is written to logs in
production.


String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
Expand All @@ -56,13 +58,13 @@ public void logout(HttpServletRequest request, RefreshTokenRequest tokenRequest)
invalidAccessToken(accessToken);
}

public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest) {
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
return reissueAndSendTokens(refreshToken);
return reissueAndSendTokens(refreshToken, userId);
Comment on lines +61 to +67
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

토큰 재발급 시 사용자 ID 검증 추가 권장

현재 userId 파라미터를 받고 있지만, 리프레시 토큰에 포함된 사용자 ID와 일치하는지 검증하지 않고 있습니다. 이론적으로 인증된 사용자가 다른 사용자의 토큰을 재발급받을 수 있는 보안 취약점이 존재할 수 있습니다.

다음과 같이 검증 로직을 추가하는 것을 권장합니다:

 public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
     String refreshToken = tokenRequest.getRefreshToken();
     jwtUtil.validateToken(refreshToken);
     if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
         throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
     }
+    
+    // 리프레시 토큰의 userId와 인증된 userId 일치 검증
+    Long tokenUserId = jwtUtil.getUserId(refreshToken);
+    if (!tokenUserId.equals(userId)) {
+        throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
+    }
+    
     return reissueAndSendTokens(refreshToken, userId);
 }
📝 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
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
return reissueAndSendTokens(refreshToken);
return reissueAndSendTokens(refreshToken, userId);
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
// 리프레시 토큰의 userId와 인증된 userId 일치 검증
Long tokenUserId = jwtUtil.getUserId(refreshToken);
if (!tokenUserId.equals(userId)) {
throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
}
return reissueAndSendTokens(refreshToken, userId);
}
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around lines 61 to 67, the method accepts userId but doesn't verify it matches
the user encoded in the refresh token; extract the user id (or subject) from the
validated refresh token (e.g., via jwtUtil.getUserId/getSubject), compare it to
the provided userId, and if they differ throw an appropriate CustomJwtException
(create/use an error code like INVALID_TOKEN_USER); perform this check after
jwtUtil.validateToken(refreshToken) and before calling reissueAndSendTokens to
prevent cross-user token reissuance.

⚠️ Potential issue | 🔴 Critical

userId 검증 누락.

reissueTokens 메서드가 userId 파라미터를 받지만, 이 값이 실제로 refresh token에 포함된 userId와 일치하는지 검증하지 않습니다. 공격자가 다른 사용자의 userId를 전달하여 토큰을 탈취할 수 있는 위험이 있습니다.

다음 diff를 적용하여 userId 검증을 추가하세요:

 public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
     String refreshToken = tokenRequest.getRefreshToken();
     jwtUtil.validateToken(refreshToken);
     if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
         throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
     }
+    
+    // userId 검증: refresh token의 userId와 파라미터로 받은 userId가 일치하는지 확인
+    Long tokenUserId = jwtUtil.getUserId(refreshToken);
+    if (!tokenUserId.equals(userId)) {
+        throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
+    }
+    
     return reissueAndSendTokens(refreshToken, userId);
 }
📝 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
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
return reissueAndSendTokens(refreshToken);
return reissueAndSendTokens(refreshToken, userId);
public ReissueResponse reissueTokens(RefreshTokenRequest tokenRequest, Long userId) {
String refreshToken = tokenRequest.getRefreshToken();
jwtUtil.validateToken(refreshToken);
if (!"refresh".equals(jwtUtil.getTokenType(refreshToken))) {
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
// userId 검증: refresh token의 userId와 파라미터로 받은 userId가 일치하는지 확인
Long tokenUserId = jwtUtil.getUserId(refreshToken);
if (!tokenUserId.equals(userId)) {
throw new CustomAuthenticationException(ErrorCode.SECURITY_UNAUTHORIZED);
}
return reissueAndSendTokens(refreshToken, userId);
}
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/auth/service/JwtService.java
around lines 61 to 67, the method accepts a userId but does not verify it
matches the user encoded in the refresh token; extract the user id (subject)
from the validated refresh token using jwtUtil (e.g., jwtUtil.getUserId or
parsing the token subject), compare it against the userId parameter, and if they
differ throw a CustomJwtException with an appropriate error code (e.g.,
ErrorCode.INVALID_REFRESH_TOKEN_OWNER); then proceed to call
reissueAndSendTokens only after this ownership check passes.

}

public void checkLogout(String accessToken) {
Expand All @@ -72,30 +74,30 @@ public void checkLogout(String accessToken) {
}
}

public void storeRefreshToken(String refreshToken) {
redisService.setValues(REFRESH_TOKEN_KEY_PREFIX, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRED_IN));
public void storeRefreshToken(String refreshToken, Long userId) {
redisService.setValues(REFRESH_TOKEN_KEY_PREFIX+refreshToken, String.valueOf(userId), Duration.ofMillis(REFRESH_TOKEN_EXPIRED_IN));
}

private void deleteRefreshToken(String refreshToken){
if(refreshToken == null){
throw new CustomJwtException(ErrorCode.INVALID_REFRESH_TYPE);
}
redisService.delete(refreshToken);
redisService.delete(REFRESH_TOKEN_KEY_PREFIX+refreshToken);
}

private void invalidAccessToken(String accessToken) {
public void invalidAccessToken(String accessToken) {
redisService.setValues(accessToken, LOGOUT_VALUE,
Duration.ofMillis(ACCESS_TOKEN_EXPIRED_IN));
}

private ReissueResponse reissueAndSendTokens(String refreshToken) {
private ReissueResponse reissueAndSendTokens(String refreshToken, Long userId) {

// 새로운 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));

// 새로운 Refresh Token을 DB나 Redis에 저장
storeRefreshToken(reissuedRefreshToken);
storeRefreshToken(reissuedRefreshToken, userId);

// 기존 Refresh Token 폐기 (DB나 Redis에서 삭제)
deleteRefreshToken(refreshToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public String getRole() {
return grantedAuthority.getAuthority();
}

public Long getMemberId() {
public Long getUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
return principal.getUserId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.WhoIsRoom.WhoIs_Server.domain.user.service.UserService;
import com.WhoIsRoom.WhoIs_Server.global.common.resolver.CurrentUserId;
import com.WhoIsRoom.WhoIs_Server.global.common.response.BaseResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
Expand Down Expand Up @@ -44,4 +45,11 @@ public BaseResponse<Void> updatePassword(@CurrentUserId Long userId,
userService.updateMyPassword(userId, request);
return BaseResponse.ok(null);
}

@PostMapping("/delete/account")
public BaseResponse<Void> deleteAccount(HttpServletRequest request,
@CurrentUserId Long userId) {
userService.deleteAccount(request, userId);
return BaseResponse.ok(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request.MailRequest;
import com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request.PasswordRequest;
import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomAuthenticationException;
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.MailService;
import com.WhoIsRoom.WhoIs_Server.domain.auth.util.JwtUtil;
import com.WhoIsRoom.WhoIs_Server.domain.club.model.Club;
import com.WhoIsRoom.WhoIs_Server.domain.club.repository.ClubRepository;
import com.WhoIsRoom.WhoIs_Server.domain.member.model.Member;
Expand All @@ -15,12 +18,15 @@
import com.WhoIsRoom.WhoIs_Server.domain.user.repository.UserRepository;
import com.WhoIsRoom.WhoIs_Server.global.common.exception.BusinessException;
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.net.http.HttpRequest;
import java.util.*;
import java.util.stream.Collectors;

Expand All @@ -33,6 +39,8 @@ public class UserService {
private final MailService mailService;
private final MemberRepository memberRepository;
private final ClubRepository clubRepository;
private final JwtUtil jwtUtil;
private final JwtService jwtService;

@Transactional
public void signUp(SignupRequest request) {
Expand Down Expand Up @@ -169,4 +177,19 @@ private void validateClubExistence(Set<Long> clubIds) {
throw new BusinessException(ErrorCode.CLUB_NOT_FOUND);
}
}

public void deleteAccount (HttpServletRequest request, Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

userRepository.delete(user);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Member 엔티티 cascade 설정 확인이 필요합니다.

User 삭제 시 연관된 Member 레코드들이 올바르게 cascade 삭제되는지 확인해주세요. 만약 cascade 설정이 없다면 foreign key 제약 위반이 발생할 수 있습니다.

다음 스크립트를 실행하여 User 엔티티의 Member 관계 cascade 설정을 확인하세요:


🏁 Script executed:

#!/bin/bash
# User 엔티티에서 Member와의 관계 설정 확인

# User 엔티티 파일에서 @OneToMany 어노테이션과 cascade 설정 찾기
rg -A 5 '@OneToMany.*Member' --type java -g '**/User.java'

# Member 엔티티에서 User와의 관계 설정 확인
rg -A 3 '@ManyToOne.*User' --type java -g '**/Member.java'

Length of output: 0


User 엔티티와 Member 엔티티 간 cascade 설정을 아직 확인하지 못했습니다. 전체 코드베이스에서 @onetomany@manytoone 어노테이션을 검색해보세요:


🏁 Script executed:

#!/bin/bash
# 엔티티 관계 설정 재검증

# OneToMany 어노테이션 전체 검색
rg -C3 "@OneToMany" --type java

# ManyToOne 어노테이션 전체 검색
rg -C3 "@ManyToOne" --type java

Length of output: 1104


Member 관계에 대한 cascade 삭제 설정 추가 필요
User.java에 Member와의 @onetomany(mappedBy="user") 매핑이 없거나 cascade 옵션이 누락되어 있습니다. cascade = CascadeType.REMOVE 또는 orphanRemoval = true 를 추가하거나, User 삭제 전에 Member를 직접 삭제하도록 수정하세요.

🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java
around line 185, the service calls userRepository.delete(user) but the
User↔Member relationship lacks cascade delete/orphan removal, so associated
Member entities are left orphaned; either update the User entity's
@OneToMany(mappedBy="user") to include cascade = CascadeType.REMOVE or
orphanRemoval = true (and ensure fetch/type semantics are correct), or modify
this service to explicitly delete all Member entries for the user (e.g.,
memberRepository.deleteByUser(user) or iterating and deleting members) before
invoking userRepository.delete(user).


String accessToken = jwtUtil.extractAccessToken(request)
.orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
if (accessToken != null) {
jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
}

SecurityContextHolder.clearContext();
}
Comment on lines +181 to +194
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

회원 탈퇴 로직에 트랜잭션 처리 및 순서 개선 필요

현재 구현에는 몇 가지 개선이 필요한 부분이 있습니다:

  1. 트랜잭션 누락: 사용자 삭제와 토큰 무효화가 원자적으로 처리되지 않아, 중간에 실패 시 불일치 상태가 발생할 수 있습니다.
  2. 삭제 순서: 사용자를 먼저 삭제하고 토큰을 무효화하는 순서는, 토큰 무효화가 실패할 경우 유효한 토큰이 남게 됩니다. 순서를 바꾸는 것이 안전합니다.
  3. 불필요한 null 체크 (Line 189): orElseThrow로 이미 예외가 발생하므로 이후 null 체크는 불필요합니다.
  4. 연관 엔티티: Member 엔티티의 cascade 설정을 확인하여 orphan 레코드가 남지 않는지 검증 필요합니다.

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

+@Transactional
 public void deleteAccount (HttpServletRequest request, Long userId) {
     User user = userRepository.findById(userId)
             .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

-    userRepository.delete(user);
-
     String accessToken = jwtUtil.extractAccessToken(request)
             .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
-    if (accessToken != null) {
-        jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
-    }
+    jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
+    
+    userRepository.delete(user);

     SecurityContextHolder.clearContext();
 }

Member 엔티티의 cascade 설정을 확인하려면 다음 스크립트를 실행하세요:


🏁 Script executed:

#!/bin/bash
# Member 엔티티에서 User와의 관계 설정 확인
ast-grep --pattern $'@ManyToOne$$$
User $$$'

# User 엔티티에서 Member와의 관계 cascade 설정 확인  
ast-grep --pattern $'@OneToMany$$$
cascade$$$
Member$$$'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# User 엔티티의 @OneToMany에서 Member와 cascade 옵션 확인
rg -nP --type java '@OneToMany\s*\([^)]*cascade\s*=\s*[^)]*\)' -C2

# Member 엔티티의 @ManyToOne에서 User 참조 확인
rg -nP --type java '@ManyToOne\s*\([^)]*\)\s*private\s+User' -C2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# 엔티티 파일 위치 확인
user_file=$(fd -e java User.java | head -n1)
member_file=$(fd -e java Member.java | head -n1)
echo ">>> User 엔티티: $user_file"
grep -n "@OneToMany" -C2 "$user_file"
echo ">>> Member 엔티티: $member_file"
grep -n "@ManyToOne" -C2 "$member_file"

Length of output: 449


삭제 로직 수정: 트랜잭션·순서·null·cascade 처리

  • 메서드에 @Transactional 추가해 사용자 삭제와 토큰 무효화를 원자적으로 처리
  • orElseThrow 뒤의 if (accessToken != null) 제거
  • 토큰 무효화(jwtService.invalidAccessToken)를 사용자 삭제(userRepository.delete) 앞에 호출
  • UserMember 연관에 cascade = REMOVE 또는 orphanRemoval = true 설정(또는 명시적 자식 삭제) 적용 검토
🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java
around lines 181 to 194, make the deleteAccount method transactional, move
extraction/invalidations of the access token before calling
userRepository.delete, remove the redundant if (accessToken != null) check
(since extractAccessToken already throws), and ensure
jwtService.invalidAccessToken(accessToken) is invoked while still in the
transaction so token invalidation and user deletion happen atomically;
additionally review the User→Member mapping and add cascade = REMOVE or
orphanRemoval = true (or perform explicit child deletions) so related child
entities are removed when the user is deleted, and leave
SecurityContextHolder.clearContext() at the end of the method.

⚠️ Potential issue | 🟠 Major

계정 삭제 로직에 여러 개선이 필요합니다.

다음 사항들을 검토해주세요:

  1. @transactional 어노테이션 누락: 사용자 삭제와 토큰 무효화가 원자적으로 처리되지 않습니다. 토큰 무효화가 실패하면 사용자는 이미 삭제된 상태가 됩니다.

  2. 작업 순서 문제: 사용자를 먼저 삭제(line 185)한 후 토큰을 추출(line 187-188)하고 있습니다. 토큰 추출이 실패하면 사용자는 이미 삭제되었지만 토큰은 여전히 유효한 상태가 됩니다. 토큰 검증을 먼저 수행하세요.

  3. 불필요한 null 체크: Line 189의 if (accessToken != null)은 불필요합니다. Line 187-188에서 orElseThrow를 사용하므로 accessToken은 항상 non-null입니다.

  4. Refresh Token 미처리: Access token만 무효화하고 있습니다. Refresh token도 Redis에서 삭제해야 합니다. 그렇지 않으면 사용자가 삭제된 후에도 refresh token으로 새로운 access token을 발급받을 수 있습니다.

  5. Member 엔티티 처리: User 삭제 시 Member 엔티티들의 cascade 처리가 올바르게 설정되어 있는지 확인이 필요합니다.

다음과 같이 리팩토링을 권장합니다:

+@Transactional
 public void deleteAccount (HttpServletRequest request, Long userId) {
+    // 1. 토큰 검증 먼저 수행
+    String accessToken = jwtUtil.extractAccessToken(request)
+            .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
+    
+    // 2. 사용자 조회 및 삭제
     User user = userRepository.findById(userId)
             .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
-
     userRepository.delete(user);
-
-    String accessToken = jwtUtil.extractAccessToken(request)
-            .orElseThrow(() -> new CustomAuthenticationException(ErrorCode.SECURITY_INVALID_ACCESS_TOKEN));
-    if (accessToken != null) {
-        jwtService.invalidAccessToken(accessToken); // Redis에 blacklist 저장
-    }
-
+    
+    // 3. 토큰 무효화
+    jwtService.invalidAccessToken(accessToken);
+    
+    // 4. Refresh token도 삭제 (userId로 조회하여 삭제하는 메서드 필요)
+    // jwtService.deleteRefreshTokenByUserId(userId);
+    
+    // 5. Security Context 정리
     SecurityContextHolder.clearContext();
 }

참고: Refresh token 삭제를 위해 JwtService에 userId 기반 삭제 메서드 추가가 필요할 수 있습니다.

🤖 Prompt for AI Agents
In src/main/java/com/WhoIsRoom/WhoIs_Server/domain/user/service/UserService.java
around lines 181-194, the deleteAccount method must be made atomic and handle
tokens before deleting the user: annotate the method with @Transactional, first
extract/validate the access token from the request (throw on failure), call
jwtService to blacklist the access token and remove any refresh token(s) for the
user (add a jwtService method that deletes refresh tokens by userId if
necessary), then delete the User entity and finally clear the SecurityContext;
remove the redundant null-check on accessToken; also verify cascade or
orphanRemoval settings for related Member entities so deleting User
removes/handles Member rows correctly.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.WhoIsRoom.WhoIs_Server.global.common.redis;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Profile("local")
@RequiredArgsConstructor
public class RedisInitializer {

private final RedisTemplate<String,String> redisTemplate;

@PostConstruct
public void clearRedis() {
redisTemplate.getConnectionFactory()
.getConnection()
.flushDb(); // 선택한 DB(예: database: 0)만 초기화
log.info("✅ Redis DB 초기화 완료");
}
Comment on lines +18 to +24
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

연결 자원 해제를 추가해주세요.

getConnection()으로 얻은 커넥션을 닫지 않고 flushDb()만 호출하면 커넥션 풀이 반환되지 않아 로컬에서도 누수 위험이 있습니다. try-with-resources로 커넥션을 관리하도록 바꿔주세요.

-import jakarta.annotation.PostConstruct;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.context.annotation.Profile;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.stereotype.Component;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Profile;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
@@
     @PostConstruct
     public void clearRedis() {
-        redisTemplate.getConnectionFactory()
-                .getConnection()
-                .flushDb(); // 선택한 DB(예: database: 0)만 초기화
+        try (RedisConnection connection = redisTemplate.getConnectionFactory().getConnection()) {
+            connection.flushDb(); // 선택한 DB(예: database: 0)만 초기화
+        }
         log.info("✅ Redis DB 초기화 완료");
     }
📝 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
@PostConstruct
public void clearRedis() {
redisTemplate.getConnectionFactory()
.getConnection()
.flushDb(); // 선택한 DB(예: database: 0)만 초기화
log.info("✅ Redis DB 초기화 완료");
}
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@PostConstruct
public void clearRedis() {
try (RedisConnection connection = redisTemplate.getConnectionFactory().getConnection()) {
connection.flushDb(); // 선택한 DB(예: database: 0)만 초기화
}
log.info("✅ Redis DB 초기화 완료");
}
🤖 Prompt for AI Agents
In
src/main/java/com/WhoIsRoom/WhoIs_Server/global/common/redis/RedisInitializer.java
around lines 18 to 24, the Redis connection obtained via
redisTemplate.getConnectionFactory().getConnection() is not closed after
flushDb(), which can leak connections; change the method to acquire the
connection in a try-with-resources block (try (var connection =
redisTemplate.getConnectionFactory().getConnection()) { connection.flushDb(); })
so the connection is always closed/returned to the pool, and optionally handle
or log any exceptions thrown during flush.

}