Spring Security에 JWT를 붙이기 전에 JWT를 먼저 발급해 보겠다.
로그인 시 JWT 발급하기
먼저 JWT에 대한 작업을 할 클래스를 하나 생성한다.
Security와 같은 모듈에서 utils 패키지를 만들어 관리를 할 것이다.
그러기 전에 먼저 의존성을 추가해 준다.
build.gradle (common-authentication 모듈)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' //Spring Security
implementation 'io.jsonwebtoken:jjwt:0.9.1'
}
jjwt 가 java json web. token이라고 한다.
utils/JwtUtil (common-authentication 모듈)
package com.seungh1024.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
public class JwtUtil {
public static String createJwt(String memberEmail, String secretKey, Long expiredMs){
// 원하는 정보를 담기 위해 jwt에서 Claim이라는 공간을 제공해 줌. 여기다 정보를 담을 것임
Claims claims = Jwts.claims();
// 일종의 Map 자료구조와 같음
claims.put("memberEmail",memberEmail);
return Jwts.builder()
.setClaims(claims) // 만들어 놓은 claim을 넣는 것
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
}
JWT는 인증에 필요한 정보들을 담고 암호화시켜 사용하는 토큰이다. 쿠키 세션과의 인증하는 그런 방식에 대한 차이는 크게 다르지 않지만 JWT는 서명된 토큰이라는 차이가 있다. 서명된 토큰은 해당 key를 가진 서버가 정상적인 토큰인지 인증할 수 있다.
JWT는 원하는 정보를 담기 위해 Claim이라는 공간을 제공해 준다. 이 Claim은 일종의 Map 자료구조처럼 키, 밸류쌍으로 정보를 넣을 수 있다.
토큰은 기본적으로 문자열 형식이고 builder() 패턴으로 발급했는데 setClaims()로 정보를 담은 claims를 넣어주고 setIssuedAt()으로 발급한 시간을, setExpiration()으로 유효 기간을 설정한다. 마지막으로 서명을 위해 signWith()를 사용하며 암호화를 할 알고리즘과 비밀키를 넣어준다.
이렇게 간단하게 토큰을 만들었으니 이걸 사용할 로그인 로직에 토큰 발급 기능을 추가해 보자.
AuthApplication (auth-api 모듈)
package com.seungh1024.application;
import com.seungh1024.dto.MemberDto;
import com.seungh1024.member.Member;
import com.seungh1024.repository.MemberRepository;
import com.seungh1024.service.AuthService;
import com.seungh1024.utils.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthApplication {
private final AuthService authService;
private final MemberRepository memberRepository;
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expired}")
private long expiredMs;
public void signup(MemberDto.JoinForm memberDto){
authService.signup(memberDto);
}
public String signin(MemberDto.LoginForm memberDto) {
String memberEmail = memberDto.getMemberEmail();
Member member = memberRepository.findMemberByMemberEmail(memberEmail);
System.out.println(expiredMs);
if (member == null) return null;
return JwtUtil.createJwt(memberEmail, jwtSecret, expiredMs);
}
}
클래스가 왜 Service가 아니지?라는 생각이 들지만 Application과 Service 계층을 분리해 보았고 Application은 트랜잭션이 일어나지 않는 작업들, Service는 트랜잭션이 필요한 작업들만 넣을 것이다.
로그인의 경우 회원을 조회하는 하나의 작업만 있으므로 트랜잭션이 필요 없고 회원이 있다면 토큰을 발급하게 했다.
서명에 필요한 secretKey와 유효기간을 설정할 expiredMs 시간 변수는 환경 변수 파일에 작성하고 @Value를 이용해 불러왔다. expiredMs의 경우 밀리 세컨드 단위로 작성해야 한다. 1분이면 1000 * 60인 것이다.
이제 컨트롤러에 연결하고 토큰이 잘 오는지 로그인을 해 보면
이런 에러를 만날 수 있다.
jdk8 이후에 jdk에서 제외되어서 Converter를 하나 추가해 주면 된다.
build.gradle (common-authentication 모듈)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' //Spring Security
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.1'
}
javax.xml.bind를 추가해 주면 된다.
버전은 어떤 걸 사용할지 몰라서 maven repository에 검색 후 가장 많이 사용하는 것으로 설정했다.
다시 테스트해 보면
토큰이 잘 전달되는 것을 확인할 수 있다.
Filter 추가하기
이제 Filter를 추가해서 요청을 먼저 받아서 인증하고 권한 부여를 해보겠다.
config/JwtFilter (common-authentication 모듈)
package com.seungh1024.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@AllArgsConstructor
public class JwtFilter extends OncePerRequestFilter { // -------1
private String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// Token에서 사용자 정보 꺼내기
String memberEmail = "";
//권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(memberEmail,null, List.of(new SimpleGrantedAuthority("USER"))); // ---------------------2
// request를 넣어 detail을 빌드하고 추가한다.
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken); //----------------------------------------------------3
filterChain.doFilter(request,response); // -----------4
}
}
우선 이 Filter는 Spring Security가 현재 대부분의 요청을 막고 있는데 요청이 들어오면 검문소 역할을 하여 먼저 요청을 본 후에 인증과 권한을 부여하여 통과 여부를 결정할 것이다.
- 1. OncePerRequestFilter
Filter가 아니라 OncePerRequestFilter를 사용한 이유가 있다. Spring Security의 인증, 접근 제어가 Filter로 구현되고 이런 인증, 접근 제어는 RequestDispatcher 클래스에 의해서 다른 서블릿으로 dispatch 된다. 이때, 이동할 서블릿에 도착하기 전에 다시 한번 filter chain을 거치며 필터가 두 번 실행되는 현상이 발생할 수 있다. 이런 문제점을 해결하기 위해 oncePerRequestFilter를 사용하며 모든 서블릿에 대해 일관된 요청을 처리하기 위해 만들어진 필터이다. 해당 추상 클래스를 구현한 필터는 사용자의 한 번의 요청에 한 번만 실행되는 필터를 만들 수 있다. - 2. UsernamePasswordAuthenticationToken
인증된 완료된 UsernamePasswordAuthenticationToken은 인증이 끝나고 SecurityContextholder.getContext()에 등록될 Authentication 객체이다. 그렇기에 인증한 토큰을 등록해 주면 인증이 완료되고 권한이 부여되는 것이다. 우선 UsernamePasswordAuthenticationToken을 생성한다. 사용자의 정보, 증명서, 권한을 인수로 가진다. 사용자 정보를 우선 빈 문자열로 넣었고 추후에 JWT토큰에서 추출하여 넣을 것이다. 증명서는 null, 권한은 DB에 아직 없지만 추후에 생길 수 있으니 USER로 넣었다. 여기서 UsernamePasswordAuthentication은 아래와 같이 pricipal에는 username 등의 신원을, credentials는 password를 의미한다. autorities는 말 그래로 권한이다. credentials에 null을 넣은 이유는 이미 로그인 로직을 통해 인증된 사용자라면 민감한 비밀번호의 경우 authentication 객체에 저장해 놓을 필요가 없다고 생각했다. 마찬가지로 JWT에도 비밀번호는 넣지 않을 것이다.
- 3. SecurityContextHolder
request를 넣어 detail을 빌드하고 추가한 후 SecurityContextHolder.getContext()로 SecurityContext에 접근하고 Authentication에 접근하여 방금 만든 토큰을 등록한다. - 4. filterChain.doFilter()
filterChain에 request, response를 넘겨준다. 넘겨주면 request 객체에 인증이 되었다고 인증 도장이 찍히는 형태이다.
이제 이 필터를 Spring Security에 등록해 주어야 한다.
config/AuthenticationConfig (common-authentication 모듈)
public class AuthenticationConfig {
@Value("${jwt.secret}")
private String secretKey; // -------------------------------------------1
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors().and()
.authorizeHttpRequests()
.requestMatchers("/api/v1/auth/signup","/api/v1/auth/signin").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new JwtFilter(secretKey), UsernamePasswordAuthenticationFilter.class)//----------------------------------------------------2
.build();
}
}
- @Value
이게 잘 될지 걱정이었는데 생각대로 잘 되었다. 해당 환경 변수 파일은 이 모듈을 의존성으로 추가한 auth-api 모듈에 있고 값을 잘 가져올까?라는 생각이 들었다. 하지만 공통 모듈들을 의존성으로 추가하면 jar파일로 추가가 되고 그렇다면 파일만 분리되어 있지 빈으로 등록하고 값을 잘 가져올 것이라고 생각했다. 그리고 생각대로 접근이 잘 되었다.
이후 이것 저것 건드리며 알아보니 jar파일로 추가가 되면서 API모듈에서 ComponentScan을 하는 과정에서 환경 변수가 설정되고 Bean등록을 하기 때문에 사용이 되는 것 같다. 패키지 이름을 다르게 하면 읽히지 않고 ComponentScan에 추가해서 읽게 해줘야 한다.
이 secretKey가 필요한 이유는 JWT토큰을 decode 하여 필요한 정보를 꺼내서 인증 과정을 거쳐야 하기 때문이다. - addFilterBefore({등록할 필터}, {특정 필터})
특정 필터 앞에 등록할 필터를 추가하는 것이다. 이름 그대로 특정 필터가 적용되기 앞서 적용되는 것이다. UsernamePasswordAuthenticationFilter 클래스 앞에 적용시키는 이유는 이 클래스가 Spring Security에서 기본적으로 요청을 막고 아이디 비밀번호를 통해 인증을 하는데 해당 인증하는 과정이 UsernamePasswordAuthenticationFilter에서 일어나기 때문이다. 그러니까 내가 커스텀한 JwtFilter에서 UsernamePasswordAuthenticationToken을 등록하고 인증하여 요청 객체에 넘겨주고 이런 과정들이 UsernamePasswordAuthenticationFilter에서 비슷한 방식으로 일어나는 것이다. 그렇다면 당연히 JwtFilter를 적용시키려면 UsernamePasswordAuthenticationFilter 이전에 요청을 가져와서 내가 적용할 방식으로 인증을 완료하고 인증 객체를 넘겨주면 되는 것이다.
이제 이 필터가 정상적으로 거쳐지는지 확인하기 위해 JwtFilter에서 로그나 프린트를 찍어서 확인해 보면
필터가 정상적으로 등록된 것을 확인할 수 있다.
이제 로그인했을 때 JWT토큰으로 사용자의 정보를 추출하여 인증하는 과정을 추가해 보겠다.
config/JwtFilter (common-authentication 모듈)
@Slf4j
@AllArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("authorization : {}", authorization);
//토큰이 없거나 Bearer 형태로 보내지 않으면 block처리
//권한을 안넣어준 요청,응답 객체를 필터에 넣어줘야함
if(authorization == null || !authorization.startsWith("Bearer ")){
log.error("authorization이 없습니다.");
filterChain.doFilter(request,response);
return;
}
// Token 꺼내기
// "Bearer {token}" 형식이기 때문에 공백으로 split 후 1번째걸 가져와야 token을 가져옴!
String token = authorization.split(" ")[1];
// Token expired 여부
if(JwtUtil.isExpired(token,secretKey)){
log.error("Token이 만료 되었습니다");
filterChain.doFilter(request, response);
return;
}
...
}
우선 JWT를 가져와야 하는데 JWT의 경우 요청 헤더의 AUTHORIZATION에 넣어서 보내준다.
거기서 토큰을 가져오면 이제 토큰 형식, 만료 기간 등을 체크하면 된다.
우선 토큰은 “Bearer {token}”과 같은 형식으로 보내기 때문에 토큰이 있는지, Bearer 형태로 잘 왔는지 검사하고 없다면 바로 리턴을 시킨다. 이때 doFilter()메소드로 인증이 되지 않은 request, response 객체를 보내주어야 한다.
만료 기간은 Bearer을 제거한 순수 토큰을 가져와서 검사한다. JwtUtil 클래스에 메소드를 추가해서 검사했다.
utils/JwtUtil (common-authentication 모듈)
public static boolean isExpired(String token, String secretKey){
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().getExpiration().before(new Date());
}
Jwts.parser()를 이용하여 secretKey를 넣어서 검증하여 파싱 하는 것이다.
parseClaimsJws()에 토큰을 넣어주고 해당 body에 만료 기간 정보가 있다.
이때 before() 메소드를 사용하고 현재 시간을 넣어주어 현재 시간 전이면 true를 반환한다.
이게 현재 시간 전이 true면 만료됐다는 것을 의미한다.
그리고 실제로 만료가 됐는지 확인해 보니
이미 만료됐다고 Exception을 날리고 있었다.
Exception처리를 하려고 보니 authentication 모듈에서 일어나는 것이라 이런 Exception도 공통되는 것은 따로 모듈화 하고 싶어졌다. 우선 인증, 인가가 모두 완료되면 예외처리를 해야겠다.
다음으로 토큰에 저장한 사용자의 정보(여기선 이메일)를 꺼내서 확인해보겠다.
utils/JwtUtil (common-authentication 모듈)
public static String getMemberEmail(String token, String secretKey){
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().get("memberEmail",String.class);
}
isExpired()와 마찬가지로 body에서 가져온다. Claim에 저장한 Key값으로 불러오면 된다.
2번째 인자는 객체의 타입 클래스?를 적으면 된다.
이제 JwtFilter 클래스에 비워져 있던 사용자의 이메일 정보를 넣어준다
config/JwtFilter (common-authentication 모듈)
// Token에서 사용자 정보 꺼내기
String memberEmail = JwtUtil.getMemberEmail(token,secretKey);
여기를 채워주고 나면 UsernamePasswordAuthenticationToken에 memberEmail이 들어가서 Authentication에 해당 사용자 정보가 저장될 것이다.
이제 인증된 Authentication 객체를 가져와서 확인하는 컨트롤러를 만들어 테스트하면 된다.
@GetMapping("/security")
public Response<?> securityTest(Authentication authentication){
String memberEmail = authentication.getName();
return success(memberEmail + " Authentication에서 인증된 이메일 꺼내오기!");
}
이제부터 로그인 후 사용자의 아이디는 authentication.getName() 메소드로 꺼내올 수 있다.
여기서 발생한 문제는 해당 모듈에 Spring Security가 없다는 것이다.
common-authentication 모듈에서 모두 해결하려 했지만 실패했다. 클래스를 만들고 메소드에서 해당 객체를 넘겨주려고 했지만 NullPointerException이 발생했다.
그래서 각 API 모듈에도 Spring Security 의존성을 추가해 주어서 해결했다.
공통으로 빼려고 이렇게 했지만 뭔가 마지막에 각각의 API 모듈의 gradle에 Security 의존성을 추가해 주니 찝찝했다
여튼 테스트해보면
성공! 잘 나온다.
Spring Security + JWT 에러 처리
마지막으로 토큰 관련 에러가 여러 개 있는데 바로 확인할 수 있는 ExpiredJwtException와 SignatureException만 처리를 해보았다.
처음엔 단순하게 @RestControllerAdvice를 쓰려했지만 마음대로 되지 않았다.
이번에도 common-authentication 모듈에서 처리하려 했는데 저 어노테이션이 듣질 않는 것이다.
그래서 호출한 API모듈에서 해야 하나?라는 생각에 거기에 작성해보았지만 마찬가지로 되지 않았다.
생각을 다시 해보니 @ControllerAdvice나 @RestControllerAdvice는 컨트롤러에서부터 발생한 Exception을 처리하는 것 같았다. 이름부터 그렇게 보였다. 그래서 조금 찾아보니
@ControllerAdvice는 비즈니스 로직에서 발생하는 예외 처리에 사용하고 Filter, Interceptor의 기능이 동작하는 곳에서는 예외 처리를 해주지 못한다고 한다.
그래서 찾은 게 AuthenticationEntryPoint와 AccessDeniedHandler이다.
AuthenticationEntryPoint는 401 에러인 잘못된 토큰, 토큰 만료, 지원되지 않는 토큰 등에 대한 처리를 해준다.
AccessDeniedHandler는 권한이 없는 사람이 접근하는 경우에 대해 처리를 해준다.
그리고 얘네를 구현한 구현체를 Spring Security에 연결하면 인증 과정에서 발생한 에러처리가 된다.
exception/JwtErrorCode (common-authentication 모듈)
@Getter
@RequiredArgsConstructor
public enum JwtErrorCode{
TOKEN_EXPIRED_ERROR(HttpStatus.UNAUTHORIZED,401,"토큰이 만료되었습니다"),
TOKEN_SIGNATURE_ERROR(HttpStatus.UNAUTHORIZED,401,"유효하지 않은 토큰입니다."),
TOKEN_NOT_EXIST(HttpStatus.NOT_FOUND,404,"토큰이 존재하지 않습니다."),
;
private final HttpStatus httpStatus;
private final int code;
private final String message;
}
토큰 만료, 유효하지 않은 토큰과 토큰이 없는 경우에 대한 에러만 정의했다.
exception/JwtAuthenticationEntryPoint (common-authentication 모듈)
package com.seungh1024.exception;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seungh1024.ErrorResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException , ServletException{
String exception = (String)request.getAttribute("exception");
if(exception == null){
setResponse(response,JwtErrorCode.TOKEN_NOT_EXIST);
}
else if(exception.equals(JwtErrorCode.TOKEN_EXPIRED_ERROR.name())){
setResponse(response,JwtErrorCode.TOKEN_EXPIRED_ERROR);
}else if(exception.equals(JwtErrorCode.TOKEN_SIGNATURE_ERROR.name())){
setResponse(response,JwtErrorCode.TOKEN_SIGNATURE_ERROR);
}
}
private void setResponse(HttpServletResponse response, JwtErrorCode jwtErrorCode) throws IOException {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json;charset-UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
ErrorResponse errorResponse = ErrorResponse.of(jwtErrorCode.getHttpStatus(),jwtErrorCode.getCode(),jwtErrorCode.getMessage());
String result = new ObjectMapper().writeValueAsString(errorResponse);
response.getWriter().write(result);
}
}
Filter에서 request, response를 넘겨주면 거기서 에러가 발생했을 때 얘네가 동작한다.
요청에 객체를 담아서 보내고 받을 것이기 때문에 “exception”이라는 이름으로 객체를 받아온다.
난 문자열로 객체를 받아서 해당 문자열이 에러에 정의한 이름과 같은 경우 각각의 response를 만들어 응답해 주었다.
setResponse()를 보면 JSP 하던 것처럼 인코딩을 해주고 json에 한글이 들어가기 때문에 content type도 설정했다.
에러 클래스에 들어있는 HttpStatus를 설정하고 common-exception에 있는 공통 에러 응답 클래스를 사용하여 응답 형태를 만들었다.
이후 OBjectMapper(). writeValueAsString()으로 얘를 문자열로 바꿔주었고 response.getWriter().write()로 응답 내용을 전송했다.
이렇게 한 이유는 JSONObject 의존성을 추가해서 생성해도 되는데 생각보다 코드도 길어지고 의존성을 계속 추가하는 게 마음에 안 들어서 contentType을 설정하고 문자열로 반환하니 원하는 모양대로 응답이 갔다.
추가로 여러 문제가 발생할 수 있는 토큰으로 요청을 보내보며 발생하는 예외들을 여기다 추가하면 된다.
그리고 ErrorResponse를 사용했으니 각자 의존성을 추가해 주도록 하자.
exception/JwtFilter (common-authentication)
package com.seungh1024.config;
import com.seungh1024.exception.JwtErrorCode;
import com.seungh1024.utils.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.SignatureException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@AllArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("authorization : {}", authorization);
if(authorization == null || !authorization.startsWith("Bearer ")){
log.error("authorization이 없습니다.");
filterChain.doFilter(request,response);
return;
}
String token = authorization.split(" ")[1];
try{
if(!JwtUtil.isExpired(token,secretKey)){
String memberEmail = JwtUtil.getMemberEmail(token,secretKey);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(memberEmail,null, List.of(new SimpleGrantedAuthority("USER")));
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}catch(ExpiredJwtException e){
request.setAttribute("exception", JwtErrorCode.TOKEN_EXPIRED_ERROR.name());
}catch(SignatureException e) {
request.setAttribute("exception", JwtErrorCode.TOKEN_SIGNATURE_ERROR.name());
}catch (Exception e){
log.error("[Exception] cause: {} , message: {}", NestedExceptionUtils.getMostSpecificCause(e), e.getMessage());
e.printStackTrace();
}
filterChain.doFilter(request,response);
}
}
우선 authorization의 null체크와 Bearer 토큰인지 확인하는 건 try, catch로 처리하지 않았는데 저게 없는 경우 request에 “exception”이라는 이름으로 객체가 담기지 않을 것이고 그럼 exception == null에서 처리가 될 것이다.
authorization이 필요 없는 경우 예외가 발생하지 않고 정상적으로 비즈니스 로직단으로 넘어간다.
이후에 발생하는 예외들은 try, catch로 처리하였다.
유효한 토큰이라면 인증하는 과정을 거치게 하였고, isExpired에서 예외가 발생한다면 Expired, Signature Exception이 발생하여 request에 “exception”객체를 담아주었다.
추가로 다른 Exception에 대해서는 로그를 찍고 어떤 에러인지 확인하기 위해 찍어보았다.
마지막으로 AccessDeniedHandler, AuthenticationEntryPoint를 구현한 구현체를 Spring Security에 연결해 준다.
config/AuthenticationConfig (common-authentication)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
return httpSecurity
.httpBasic().disable()///HttpBasic 인증을 할 수 있는 기능을 비활성화
.csrf().disable()
.cors().and()
.authorizeHttpRequests()
.requestMatchers("/api/v1/auth/signup","/api/v1/auth/signin").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
//추가된 부분
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler())
//추가된 부분 끝
.and()
.addFilterBefore(new JwtFilter(jwtSecret,jwtUtil,redisTemplate), UsernamePasswordAuthenticationFilter.class)
.build();
}
이제 신나게 에러를 발생시켜 보면
토큰이 없는 경우
토큰이 잘못된 경우(토큰 글자 한 두 개를 지워서 보내봄)
토큰이 만료된 경우
예외처리가 잘 된 것 같다.
다음엔 Refresh Token 기능도 붙여봐야겠다.
'Java > Spring boot 프로젝트' 카테고리의 다른 글
@EntityListener로 생성일, 수정일 자동으로 넣기 + 프록시 활용(연관관계 Insert) - 게시판 만들기(13) (0) | 2023.06.29 |
---|---|
Spring Security + JWT 적용하기(3) - 게시판 만들기(12) (0) | 2023.04.15 |
Spring Security + JWT 적용하기(1) - 게시판 만들기(10) (0) | 2023.04.06 |
Entity 모듈 분리(멀티 모듈 프로젝트) - 게시판 만들기(9) (0) | 2023.03.22 |
@Valid를 이용한 객체 유효성 검증 - 게시판 만들기(8) (0) | 2023.03.17 |