Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ee829b0
[Feat] #3 UserPrincipal
Shinjongyun Sep 23, 2025
cc8a4a9
[Feat] #3 CustomUserDetailsService
Shinjongyun Sep 23, 2025
43ffcbd
[Feat] #3 UserRepository
Shinjongyun Sep 23, 2025
50e54d0
[Feat] #3 Role enum
Shinjongyun Sep 23, 2025
b33f419
[Feat] #3 JwtService
Shinjongyun Sep 23, 2025
6b0dcae
[Feat] #3 RedisService
Shinjongyun Sep 23, 2025
6ef8264
[Feat] #3 RedisConfig
Shinjongyun Sep 23, 2025
3c6e9e5
[Feat] #3 JwtUtil
Shinjongyun Sep 23, 2025
492c37f
[Feat] #3 JwtAuthenticationFilter
Shinjongyun Sep 23, 2025
1788d85
[Feat] #3 UserPrincipal에 email과 providerId 추가
Shinjongyun Sep 23, 2025
c447bb8
[Feat] #3 CustomAuthenticationException
Shinjongyun Sep 23, 2025
a3c47cd
[Feat] #3 CustomJwtException
Shinjongyun Sep 23, 2025
cced5c9
[Feat] #3 CustomAuthenticationEntryPoint
Shinjongyun Sep 23, 2025
b313a02
[Feat] #3 SecurityErrorResponseUtil
Shinjongyun Sep 23, 2025
efa6577
[Feat] #3 JwtExceptionHandlerFilter
Shinjongyun Sep 23, 2025
de562bf
[Feat] #3 CustomAccessDeniedHandler
Shinjongyun Sep 23, 2025
4438f94
[Feat] #3 CustomAuthenticationSuccessHandler
Shinjongyun Sep 23, 2025
ad68dd9
[Feat] #3 AuthenticationUtil
Shinjongyun Sep 23, 2025
bb97c24
[Fix] #3 SuccessHandler 토큰을 헤더 방식으로 변경
Shinjongyun Sep 23, 2025
89260c8
[Feat] #3 CustomLoginFilter
Shinjongyun Sep 24, 2025
b8954c7
[Feat] #3 CustomJsonAuthenticationFailureHandler
Shinjongyun Sep 24, 2025
1535ea5
[Feat] #3 AuthController
Shinjongyun Sep 24, 2025
05d0dab
[Feat] #3 UserController
Shinjongyun Sep 24, 2025
f28ba2f
[Feat] #3 SignupRequest
Shinjongyun Sep 24, 2025
78d1c7b
[Feat] #3 UserService
Shinjongyun Sep 24, 2025
0a7a934
[Feat] #3 CurrentUserId
Shinjongyun Sep 24, 2025
c89a3b0
[Feat] #3 CurrentUserIdArgumentResolver
Shinjongyun Sep 24, 2025
380265a
[Refact] #3 JwtFilter pass URI ANT 패턴으로 변경
Shinjongyun Sep 24, 2025
e68e049
[Feat] #3 CorsConfig
Shinjongyun Sep 24, 2025
fe1380e
[Feat] #3 WebConfig
Shinjongyun Sep 24, 2025
6e35ccc
[Feat] #3 LoginRequest
Shinjongyun Sep 24, 2025
d396d42
[Feat] #3 PasswordEncoder
Shinjongyun Sep 24, 2025
1885c32
[Feat] #3 LoginResponse
Shinjongyun Sep 24, 2025
0047f72
[Refact] #3 Token payload 최소화
Shinjongyun Sep 24, 2025
10a74c0
[Feat] #3 UserPrincipal에 userName만 있도록 변경
Shinjongyun Sep 24, 2025
cde1ec9
[Feat] #3 재발급시 tokenType 확인하는 기능 추가
Shinjongyun Sep 24, 2025
266bc95
Merge branch 'develop' of https://github.com/WhosInRoom/WhosInServer …
yskim6772 Sep 30, 2025
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
6 changes: 3 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'

// JWT -> 원하는 버전 사용
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class WhoIsServerApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.controller;

import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
import com.WhoIsRoom.WhoIs_Server.global.common.response.BaseResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

private final JwtService jwtService;

@PostMapping("/logout")
public BaseResponse<Void> logout(HttpServletRequest request){
jwtService.logout(request);
return BaseResponse.ok(null);
}
Comment on lines +22 to +26
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

로그아웃 플로우 연계(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.


@PostMapping("/reissue")
public BaseResponse<Void> reissueTokens(HttpServletRequest request, HttpServletResponse response) {
jwtService.reissueTokens(request, response);
return BaseResponse.ok(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LoginRequest {
private String email;
private String password;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.dto.response;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class LoginResponse {
private String accessToken;
private String refreshToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.exception;

import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
import lombok.Getter;
import org.springframework.security.core.AuthenticationException;

@Getter
public class CustomAuthenticationException extends AuthenticationException {
private final ErrorCode errorCode;

public CustomAuthenticationException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.exception;

import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
import io.jsonwebtoken.JwtException;
import lombok.Getter;

@Getter
public class CustomJwtException extends JwtException {
private final ErrorCode errorCode;

public CustomJwtException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.filter;

import com.WhoIsRoom.WhoIs_Server.domain.auth.dto.request.LoginRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;

@Slf4j
public class CustomLoginFilter extends UsernamePasswordAuthenticationFilter {

private final ObjectMapper objectMapper;

public CustomLoginFilter(AuthenticationManager authenticationManager,
ObjectMapper objectMapper,
AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler) {
this.objectMapper = objectMapper;
super.setFilterProcessesUrl("/api/auth/login");
super.setAuthenticationManager(authenticationManager);
super.setAuthenticationSuccessHandler(successHandler);
super.setAuthenticationFailureHandler(failureHandler);
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {

log.info("=== Login Filter (JSON only) 진입 ===");

if (!"POST".equalsIgnoreCase(request.getMethod())) {
throw new AuthenticationServiceException("지원하지 않는 HTTP 메서드입니다.");
}

String contentType = request.getContentType();
if (contentType == null || !contentType.toLowerCase().contains("application/json")) {
throw new AuthenticationServiceException("Content-Type application/json 만 허용됩니다.");
}

LoginRequest login;
try {
login = objectMapper.readValue(request.getInputStream(), LoginRequest.class);
} catch (IOException e) {
throw new AuthenticationServiceException("JSON 파싱 실패", e);
}

String email = login.getEmail();
String password = login.getPassword();

if (email == null || email.isBlank() || password == null || password.isBlank()) {
throw new BadCredentialsException("이메일/비밀번호가 비어있습니다.");
}

UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(email.trim(), password);

return this.getAuthenticationManager().authenticate(authToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.filter;

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.model.UserPrincipal;
import com.WhoIsRoom.WhoIs_Server.domain.auth.service.JwtService;
import com.WhoIsRoom.WhoIs_Server.domain.auth.util.JwtUtil;
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.GenericFilterBean;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.security.sasl.AuthenticationException;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final JwtService jwtService;

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

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;
}

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

// 토큰 유효성 검사
jwtUtil.validateToken(accessToken);

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

// 로그아웃 체크
jwtService.checkLogout(accessToken);

// 권한 리스트 생성
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("JWT Filter Success : {}", request.getRequestURI());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
Comment on lines +89 to +103
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

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.

}

private boolean isPassUri(String uri) {
return PASS_URIS.stream().anyMatch(pattern -> ANT.match(pattern, uri));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.handler.exception;

import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomAuthenticationException;
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.Map;

import static com.WhoIsRoom.WhoIs_Server.domain.auth.util.SecurityErrorResponseUtil.setErrorResponse;

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException{

log.info("=== AccessDeniedHandler 진입 ===");

ErrorCode code = ErrorCode.SECURITY_ACCESS_DENIED;
setErrorResponse(response, code);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.WhoIsRoom.WhoIs_Server.domain.auth.handler.exception;

import com.WhoIsRoom.WhoIs_Server.domain.auth.exception.CustomAuthenticationException;
import com.WhoIsRoom.WhoIs_Server.global.common.response.ErrorCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

import static com.WhoIsRoom.WhoIs_Server.domain.auth.util.SecurityErrorResponseUtil.setErrorResponse;

@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException{
log.info("=== AuthenticationEntryPoint 진입 ===");

ErrorCode code = ErrorCode.SECURITY_UNAUTHORIZED;

if (authException instanceof CustomAuthenticationException e) {
code = e.getErrorCode(); // 커스텀 코드 사용
}
setErrorResponse(response, code);
}
}
/**
* [CustomAuthenticationEntryPoint]
*
* 📌 Spring Security에서 인증(Authentication)에 실패했을 때 호출되는 진입점 클래스입니다.
*
* ✅ 주요 처리 대상:
* - Spring Security 내부에서 발생한 AuthenticationException
* (ex. UsernameNotFoundException, BadCredentialsException, 인증 객체 없음 등)
*
* ✅ 동작 방식:
* - 인증되지 않은 사용자가 보호된 리소스에 접근 시
* - Spring Security의 ExceptionTranslationFilter가 감지
* - 이 EntryPoint의 commence() 메서드가 호출됨
* - 401 Unauthorized 상태 코드와 공통 JSON 에러 응답 반환
*
* ✅ 처리하지 않는 예외:
* - 필터 단계에서 발생한 JwtException 은 ExceptionHandlerFilter에서 처리됨
**/
Loading