Skip to content
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

✨ Jwt 인증 필터 #25

Merged
merged 26 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
928deec
feat: 403 에러 핸들러 작성
psychology50 Mar 28, 2024
ad717e0
feat: 401 에러 핸들러 작성
psychology50 Mar 28, 2024
b7d0a7f
feat: security config에 인증, 인가 필터 bean 등록
psychology50 Mar 28, 2024
df46329
fix: 인증, 인가 필터 로그 레벨 조정 error -> warn
psychology50 Mar 28, 2024
8e7cda6
feat: jwt 예외 필터 작성
psychology50 Mar 28, 2024
6b697c3
feat: forbedden token entity 정의
psychology50 Mar 28, 2024
d3e3dc9
feat: forbedden token repository 작성
psychology50 Mar 28, 2024
e2f5ed5
feat: forbidden token service 작성
psychology50 Mar 28, 2024
c593881
feat: jwt 인증 필터 추가
psychology50 Mar 28, 2024
ab0d109
fix: user details service 구현제 주입 -> 인터페이스 주입
psychology50 Mar 28, 2024
ad56836
chore: security filter config 설정
psychology50 Mar 28, 2024
93ebf8f
chore: jwt security config 설정
psychology50 Mar 28, 2024
600adf2
chore: security config 커스텀 예외 핸들러 설정
psychology50 Mar 28, 2024
4d58d72
feat: security user to string() 재정의
psychology50 Mar 28, 2024
062f39c
chore: security config 설정
psychology50 Mar 28, 2024
c5417e8
fix: access denied exception import 경로 수정
psychology50 Mar 28, 2024
d4df416
fix: token 파싱 에러 해결
psychology50 Mar 28, 2024
4347adf
style: 예외 로그 위치 수정
psychology50 Mar 28, 2024
fb1e118
feat: global exception handler no-resource-found-exception 핸들링
psychology50 Mar 28, 2024
66a1642
fix: security config 불필요한 의존성 주입 제거
psychology50 Mar 28, 2024
adccdbd
feat: refresh api 개방
psychology50 Mar 28, 2024
b398af8
fix: refresh token annotaion 빈 이름 수정
psychology50 Mar 28, 2024
5aceafc
feat: refresh token 탈취 예외 추가
psychology50 Mar 28, 2024
b77f717
fix: refresh token 탈취 시나리오 핸들링
psychology50 Mar 28, 2024
b7b56c3
fix: taken way token reason code 403의 이유 코드로 변경
psychology50 Mar 28, 2024
a4c9100
fix: jwt 인증 필터 내 메서드 명시적 final 매개변수 제거
psychology50 Mar 28, 2024
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 @@ -2,6 +2,7 @@

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto;
import kr.co.pennyway.api.apis.auth.dto.SignInReq;
import kr.co.pennyway.api.apis.auth.dto.SignUpReq;
Expand All @@ -17,10 +18,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
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 org.springframework.web.bind.annotation.*;

import java.time.Duration;
import java.util.Map;
Expand Down Expand Up @@ -69,6 +67,13 @@ public ResponseEntity<?> signIn(@RequestBody @Validated SignInReq.General reques
return createAuthenticatedResponse(authUseCase.signIn(request));
}

@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.")
@GetMapping("/refresh")
@PreAuthorize("isAnonymous()")
public ResponseEntity<?> refresh(@CookieValue("refreshToken") @Valid String refreshToken) {
return createAuthenticatedResponse(authUseCase.refresh(refreshToken));
}

private ResponseEntity<?> createAuthenticatedResponse(Pair<Long, Jwts> userInfo) {
ResponseCookie cookie = cookieUtil.createCookie("refreshToken", userInfo.getValue().refreshToken(), Duration.ofDays(7).toSeconds());
return ResponseEntity.ok()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@
import kr.co.pennyway.api.common.security.jwt.Jwts;
import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaim;
import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaim;
import kr.co.pennyway.api.common.security.jwt.refresh.RefreshTokenClaimKeys;
import kr.co.pennyway.common.annotation.Helper;
import kr.co.pennyway.domain.common.redis.refresh.RefreshToken;
import kr.co.pennyway.domain.common.redis.refresh.RefreshTokenService;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.infra.common.exception.JwtErrorCode;
import kr.co.pennyway.infra.common.exception.JwtErrorException;
import kr.co.pennyway.infra.common.jwt.JwtProvider;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Pair;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;

@Slf4j
@Helper
Expand Down Expand Up @@ -47,6 +52,25 @@ public Jwts createToken(User user) {
return Jwts.of(accessToken, refreshToken);
}

public Pair<Long, Jwts> refresh(String refreshToken) {
Map<String, ?> claims = refreshTokenProvider.getJwtClaimsFromToken(refreshToken).getClaims();

Long userId = Long.parseLong((String) claims.get(RefreshTokenClaimKeys.USER_ID.getValue()));
String role = (String) claims.get(RefreshTokenClaimKeys.ROLE.getValue());

String newAccessToken = accessTokenProvider.generateToken(AccessTokenClaim.of(userId, role));
RefreshToken newRefreshToken;
try {
newRefreshToken = refreshTokenService.refresh(userId, refreshToken, refreshTokenProvider.generateToken(RefreshTokenClaim.of(userId, role)));
} catch (IllegalArgumentException e) {
throw new JwtErrorException(JwtErrorCode.EXPIRED_TOKEN);
} catch (IllegalStateException e) {
throw new JwtErrorException(JwtErrorCode.TAKEN_AWAY_TOKEN);
}

return Pair.of(userId, Jwts.of(newAccessToken, newRefreshToken.getToken()));
}

private long toSeconds(LocalDateTime expiryTime) {
return Duration.between(LocalDateTime.now(), expiryTime).getSeconds();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ public Pair<Long, Jwts> signIn(SignInReq.General request) {
return Pair.of(user.getId(), jwtAuthHelper.createToken(user));
}

public Pair<Long, Jwts> refresh(String refreshToken) {
return jwtAuthHelper.refresh(refreshToken);
}

private Pair<Boolean, String> checkOauthUserNotGeneralSignUp(String phone) {
Pair<Boolean, String> isGeneralSignUpAllowed = userSyncMapper.isGeneralSignUpAllowed(phone);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Qualifier("accessTokenStrategy")
@Qualifier("refreshTokenStrategy")
public @interface RefreshTokenStrategy {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import kr.co.pennyway.api.common.response.ErrorResponse;
import kr.co.pennyway.common.exception.CausedBy;
import kr.co.pennyway.common.exception.GlobalErrorException;
import kr.co.pennyway.common.exception.ReasonCode;
import kr.co.pennyway.common.exception.StatusCode;
Expand All @@ -11,6 +12,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
Expand All @@ -20,8 +22,8 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.nio.file.AccessDeniedException;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -54,7 +56,9 @@ protected ResponseEntity<ErrorResponse> handleGlobalErrorException(GlobalErrorEx
@ExceptionHandler(AccessDeniedException.class)
protected ErrorResponse handleAccessDeniedException(AccessDeniedException e) {
log.warn("handleAccessDeniedException : {}", e.getMessage());
return ErrorResponse.of(String.valueOf(StatusCode.FORBIDDEN.getCode()), e.getMessage());
CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN);

return ErrorResponse.of(causedBy.getCode(), causedBy.getReason());
}

/**
Expand Down Expand Up @@ -156,6 +160,20 @@ protected ErrorResponse handleNoHandlerFoundException(NoHandlerFoundException e)
return ErrorResponse.of(code, e.getMessage());
}

/**
* 존재하지 않는 URL 호출 시
*
* @see NoHandlerFoundException
*/
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NoResourceFoundException.class)
protected ErrorResponse handleNoResourceFoundException(NoResourceFoundException e) {
log.warn("handleNoResourceFoundException : {}", e.getMessage());

String code = String.valueOf(StatusCode.NOT_FOUND.getCode() * 10 + ReasonCode.INVALID_URL_OR_ENDPOINT.getCode());
return ErrorResponse.of(code, e.getMessage());
}

/**
* API 호출 시 데이터를 반환할 수 없는 경우
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,14 @@ public boolean isCredentialsNonExpired() {
public boolean isEnabled() {
throw new UnsupportedOperationException();
}

@Override
public String toString() {
return "SecurityUserDetails{" +
"userId=" + userId +
", username='" + username + '\'' +
", authorities=" + authorities +
", accountNonLocked=" + accountNonLocked +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package kr.co.pennyway.api.common.security.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.pennyway.api.common.security.jwt.access.AccessTokenClaimKeys;
import kr.co.pennyway.domain.common.redis.forbidden.ForbiddenTokenService;
import kr.co.pennyway.infra.common.exception.JwtErrorCode;
import kr.co.pennyway.infra.common.exception.JwtErrorException;
import kr.co.pennyway.infra.common.jwt.JwtClaims;
import kr.co.pennyway.infra.common.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
* JWT 인증 필터 <br/>
* 만약, 유효한 액세스 토큰과 리프레시 토큰이 모두 없다면 익명 사용자로 간주한다. <br/>
* 인증된 유저는 SecurityContextHolder에 SecurityUser를 등록하며, Controller에서 @AuthenticationPrincipal 어노테이션을 통해 접근할 수 있다.
*
* <pre>
* {@code
* @GetMapping("/user")
* public ResponseEntity<User> getUser(@AuthenticationPrincipal SecurityUser user) {
* Long userId = user.getId();
* ...
* }
* }
* </pre>
*
* @see org.springframework.security.core.annotation.AuthenticationPrincipal
*/
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailService;
private final ForbiddenTokenService forbiddenTokenService;

private final JwtProvider accessTokenProvider;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
if (isAnonymousRequest(request)) {
filterChain.doFilter(request, response);
return;
}

String accessToken = resolveAccessToken(request, response);

UserDetails userDetails = getUserDetails(accessToken);
authenticateUser(userDetails, request);
filterChain.doFilter(request, response);
}

/**
* AccessToken과 RefreshToken이 모두 없는 경우, 익명 사용자로 간주한다.
*/
private boolean isAnonymousRequest(HttpServletRequest request) {
String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION);
String refreshToken = request.getHeader(HttpHeaders.SET_COOKIE);

return accessToken == null && refreshToken == null;
}

/**
* @throws ServletException : Authorization 헤더가 없거나, 금지된 토큰이거나, 토큰이 만료된 경우 예외 발생
*/
private String resolveAccessToken(HttpServletRequest request, HttpServletResponse response) throws ServletException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

String token = accessTokenProvider.resolveToken(authHeader);

if (!StringUtils.hasText(token)) {
handleAuthException(JwtErrorCode.EMPTY_ACCESS_TOKEN);
}

if (forbiddenTokenService.isForbidden(token)) {
handleAuthException(JwtErrorCode.FORBIDDEN_ACCESS_TOKEN);
}

if (accessTokenProvider.isTokenExpired(token)) {
handleAuthException(JwtErrorCode.EXPIRED_TOKEN);
}

return token;
}

/**
* UserDetailsService를 통해 SecurityUser를 가져오는 메서드
*/
private UserDetails getUserDetails(final String accessToken) {
jinlee1703 marked this conversation as resolved.
Show resolved Hide resolved
JwtClaims claims = accessTokenProvider.getJwtClaimsFromToken(accessToken);
String userId = (String) claims.getClaims().get(AccessTokenClaimKeys.USER_ID.getValue());

return userDetailService.loadUserByUsername(userId);
}

/**
* SecurityContextHolder에 SecurityUser를 등록하는 메서드
*/
private void authenticateUser(UserDetails userDetails, HttpServletRequest request) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);

authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("Authenticated user: {}", userDetails.getUsername());
}

/**
* 인증 예외가 발생했을 때, 로그를 남기고 예외를 던지는 메서드
*/
private void handleAuthException(JwtErrorCode errorCode) throws ServletException {
log.warn("AuthErrorException(code={}, message={})", errorCode.name(), errorCode.getExplainError());
JwtErrorException exception = new JwtErrorException(errorCode);
throw new ServletException(exception);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package kr.co.pennyway.api.common.security.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.pennyway.api.common.response.ErrorResponse;
import kr.co.pennyway.infra.common.exception.JwtErrorException;
import kr.co.pennyway.infra.common.util.JwtErrorCodeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (Exception e) {
log.warn("Exception caught in JwtExceptionFilter: {}", e.getMessage());
JwtErrorException exception = JwtErrorCodeUtil.determineAuthErrorException(e);

sendAuthError(response, exception);
}
}

private void sendAuthError(HttpServletResponse response, JwtErrorException e) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(e.getErrorCode().getStatusCode().getCode());

ErrorResponse errorResponse = ErrorResponse.of(e.causedBy().getCode(), e.causedBy().getReason());
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package kr.co.pennyway.api.common.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import kr.co.pennyway.api.common.response.ErrorResponse;
import kr.co.pennyway.common.exception.CausedBy;
import kr.co.pennyway.common.exception.ReasonCode;
import kr.co.pennyway.common.exception.StatusCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.warn("handle error: {}", accessDeniedException.getMessage());
CausedBy causedBy = CausedBy.of(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN);

response.setContentType("application/json;charset=UTF-8");
response.setStatus(causedBy.statusCode().getCode());
ErrorResponse errorResponse = ErrorResponse.of(causedBy.getCode(), causedBy.getReason());
objectMapper.writeValue(response.getWriter(), errorResponse);
}
}
Loading
Loading