-
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
Changes from all commits
ee829b0
cc8a4a9
43ffcbd
50e54d0
b33f419
6b0dcae
6ef8264
3c6e9e5
492c37f
1788d85
c447bb8
a3c47cd
cced5c9
b313a02
efa6577
de562bf
4438f94
ad68dd9
bb97c24
89260c8
b8954c7
1535ea5
05d0dab
f28ba2f
78d1c7b
0a7a934
c89a3b0
380265a
e68e049
fe1380e
6e35ccc
d396d42
1885c32
0047f72
10a74c0
cde1ec9
266bc95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
|
|
||
| @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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());
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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에서 처리됨 | ||
| **/ |
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 구현에 다음 문제가 있어 실제 로그아웃이 의도대로 동작하지 않을 가능성이 큽니다.
"auth:refresh:")로 저장하고, deleteRefreshToken은 토큰 문자열 키로 삭제함. 서로 키가 달라 삭제가 되지 않습니다.value.equals(LOGOUT_VALUE)는 value가 null일 때 NPE 발생 가능."logout".equals(value)로 비교 필요.개선 방향(요지):
LOGOUT_VALUE.equals(value)로 NPE 방지.validateToken(access)/validateToken(refresh)호출 및 refresh 타입 확인.원하시면 JwtService 수정 패치를 같이 제안드릴게요.
🤖 Prompt for AI Agents