저번 글에서 JWT를 활용해 로그인 API에서 access, refresh token을 발급하고,
재발급 API로 access token 및 refresh token을 재발급하는 과정을 보았습니다.
이번 글에서는 Spring Security를 통해 발급된 Access token을 인증하는 과정을 적어보도록 하겠습니다.
사용되는 클래스가 많아 혼란이 올 수도 있으니,
인증/인가 단계를 거치며 호출되는 메소드 순서대로 각 클래스에 대한 설명을 적어보겠습니다.
전체적인 흐름은 아래와 같습니다. 아래 그림을 보며 글을 같이 읽는다면 이해에 도움이 될 것입니다.
이 글은 2개의 파트로 나뉘어져 있습니다.
그 전에, 간단히 Spring Security와 사용되는 용어에 대해 잠시 알아보겠습니다.
Spring Security 는 Spring 기반의 애플리케이션에서 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하며 Security 관련 기능을 쉽게 구현할 수 있도록 도와주는 프레임워크입니다.
Spring Security는 일련의(연결된) 필터들을 가지고 있으며, 요청(request)은, 인증(Authentication)과 권한부여(Authorization)를 위해 이 필터들을 통과하게 됩니다 .이 필터를 통과하는 과정은, 해당 요청과 관련된 인증 필터(인증 메커니즘/모델에 기반한 관련 인증 필터)를 찾을 때 까지 지속됩니다.
기본용어
전체적인 인증 로직을 간략히 소개해보겠습니다.
먼저 Spring Security 설정을 하기 위해 SecurityConfig 를 작성합니다.
@Configuration과 @EnableWebSecurity를 선언하여 Security 관련 설정과 빈들을 활성화 시켜주고,
JWT와 API 서버로 사용하기 위한 설정과, 인증이 필요한 URL들을 정의하고 JwtAuthenticationFilter를 추가하도록 설정하였습니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenHelper tokenHelper; //1.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable() //http basic 인증방법 비활성화
.formLogin().disable() //form login 비활성화
.csrf().disable() //csrf 관련 정책 비활성화
//세션 관리정책 설정, 세션을 유지하지 않도록 설정
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//2. 아래 url 에 대해 누구나 접근 허용
.antMatchers(HttpMethod.POST, "/auth/**").permitAll() //가입 프로세스
.antMatchers(HttpMethod.GET, "/auth/**").permitAll() //가입 프로세스
.antMatchers(HttpMethod.GET, "/ping").permitAll() //서버 alive 체크
//3. 그 외 나머지 API들은 로그인이 필요한 API들로 접근 권한 체크 활성화
.antMatchers("/post/**").access("@memberGuard.check()")
.antMatchers("/member/**").access("@memberGuard.check()")
.antMatchers("/comment/**").access("@memberGuard.check()")
.antMatchers("/home/**").access("@memberGuard.check()")
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()) //4.
.and()
.addFilterBefore(new JwtAuthenticationFilter(tokenHelper), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
먼저, 토큰과 함께 요청이 들어올 시 인증을 위해 거치는 JwtAuthenticationFilter를 살펴보겠습니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { //1
private final TokenHelper tokenHelper; //2
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { //3
Optional<String> t = extractToken(request); //4. resolve Access Token
if (!(t.isEmpty())) {
Authentication authentication = tokenHelper.validateToken(request, t.get()); //5. 토큰 검증 - 유효 여부 확인 & 인증된 인증 객체 생성 후 반환
SecurityContextHolder.getContext().setAuthentication(authentication); // 6. SecurityContextHolder 에 인증 객체 저장
} else {
request.setAttribute("exception", "토큰 헤더가 잘못되었습니다. Authorization 을 넣어주세요.");
}
//다음 filterchain을 실행하며, 마지막 filterchain인 경우 Dispatcher Servlet이 실행된다.
chain.doFilter(request, response);
}
private Optional<String> extractToken(ServletRequest request) {
return Optional.ofNullable(((HttpServletRequest) request).getHeader("Authorization"));
}
}
<주석 및 흐름 설명>
Optional<String> t = extractToken(request);
request.getHeader():
return a String containing the value of the requested header, or null if the request does not have a header of that name.Authentication authentication = tokenHelper.validateToken(request, t.get())
; SecurityContextHolder.getContext().setAuthentication(authentication);
이제 5번의 과정들을 수행하는 tokenHelper의 validateToken() 메소드를 보겠습니다.
JwtAuthenticationFilter에 필요한 TokenHelper를 살펴보겠습니다.
Access token을 검증하고 인증된 객체인 CustomAuthenticationToken을 반환해주는 TokenHelper의 validateToken() 메소드를 가지고 있습니다.
지난 시간에 토큰을 만드는 메소드들을 추가했었는데, 이 부분은 생략하겠습니다.
@Service
@RequiredArgsConstructor
public class TokenHelper {
private final JwtHandler jwtHandler;
private final RedisService redisService;
**private final CustomUserDetailsService customUserDetailsService;**
@Value("${jwt.max-age.access}")
private Long accessTokenMaxAgeSeconds
@Value("${jwt.max-age.refresh}")
private Long refreshTokenMaxAgeSeconds;
@Value("${jwt.key.access}")
private String accessKey;
@Value("${jwt.key.refresh}")
private String refreshKey;
private static final String ROLE_TYPES = "ROLE_TYPES";
private static final String MEMBER_ID = "MEMBER_ID";
//토큰 생성 관련 메소드 - 2편에서 구현 완료. 여기서는 생략
public String createAccessToken(PrivateClaims privateClaims) {...}
public String createRefreshToken(PrivateClaims privateClaims, String email) {...}
public Optional<PrivateClaims> parseRefreshToken(String token, String email) {...}
private PrivateClaims convert(Claims claims) {...}
@Getter
@AllArgsConstructor
public static class PrivateClaims {
private String memberId;
private UserRole roleTypes;
}
/**
* validate ACCESS Token. JwtAuthenticationFilter의 doFilter() 에서 쓰인다.
*
*/
public Authentication validateToken(HttpServletRequest request, String token) throws BadRequestException {
String exception = "exception";
try {
Jwts.parser().setSigningKey(accessKey.getBytes()).parseClaimsJws(jwtHandler.untype(token));
return getAuthentication(token); //loadByUsername으로 반환된 CustomUserDetails을 Authentication 형식인 CustomAuthenticationToken으로 반환
} catch (BadRequestException e) {
request.setAttribute(exception, "토큰을 입력해주세요. (앞에 'Bearer ' 포함)");
} catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
request.setAttribute(exception, "잘못된 토큰입니다.");
} catch (ExpiredJwtException e) {
request.setAttribute(exception, "토큰이 만료되었습니다.");
} catch (IllegalArgumentException e) {
request.setAttribute(exception, "토큰을 입력해주세요.");
}
return null;
}
/**
* loadUserByUsername 으로 CustomUserDetail 반환
* 인증된 객체인 CustomAuthenticationToken 형식으로 만들어 반환
*/
private Authentication getAuthentication(String token) {
CustomUserDetails userDetails = customUserDetailsService.loadUserByUsername(token);
return new CustomAuthenticationToken(userDetails, userDetails.getAuthorities()); //(principal, authorities)
}
}
새로운 의존성인 CustomUserDetailsService 이 추가되었습니다.
밑에서 자세하게 살펴보도록 하고, validateToken() 메소드의 흐름을 자세히 알아보겠습니다.
우선, 최종 인증된 객체인 Authentication 을 구현하는 CustomAuthenticationToken 을 먼저 살펴보겠습니다.
CustomAuthenticationToken은 CustomUserDetailsService를 이용하여 조회된 사용자의 정보 CustomUserDetails와 유저 권한인 authorities를 UserRole 형태로 저장해줍니다.
public class CustomAuthenticationToken extends AbstractAuthenticationToken {
private CustomUserDetails principal;
public CustomAuthenticationToken(String type, CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
setAuthenticated(true);
}
@Override
public CustomUserDetails getPrincipal() {
return principal;
}
@Override
public Object getCredentials() {
throw new UnsupportedOperationException();
}
}
Spring Security에서 제공해주는 추상 클래스 AbstractAuthenticationToken(Base class for Authentication objects)을 상속받아서, 사용자를 인증하는데 필요한 최소한의 정보를 기억하도록 하였습니다.
Principal에 CustomUserDetails, authorities에 권한 정보로 enum인 UserRole을 넣어주었습니다.
Authentication.setAuthenticated(true)
로 인증 여부를 true로 설정해주었습니다. 이렇게 생성된 CustomAuthenticationToken은 인증이 된 Authentication 객체를 나타냅니다.
허용되지 않는 동작은 예외를 발생시켜주었습니다.
이제 CustomAuthenticationToken의 Principal로 넣어줄 CustomUserDetails을 살펴보겠습니다.
CustomUserDetails는 사용자의 정보(memberId)와 권한/authorities(UserRole)을 담고 있습니다.
@Getter
public class CustomUserDetails implements UserDetails {
private final String memberId;
private final Set<GrantedAuthority> authorities;
public CustomUserDetails(String memberId, SimpleGrantedAuthority authorities){
this.memberId = memberId;
this.authorities = Set.of(authorities);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
throw new UnsupportedOperationException();
}
@Override
public String getUsername() {
return memberId;
}
@Override
public boolean isAccountNonExpired() {
throw new UnsupportedOperationException();
}
@Override
public boolean isAccountNonLocked() {
throw new UnsupportedOperationException();
}
@Override
public boolean isCredentialsNonExpired() {
throw new UnsupportedOperationException();
}
@Override
public boolean isEnabled() {
throw new UnsupportedOperationException();
}
}
Spring Security에서 제공해주는 UserDetails 인터페이스를 구현한 클래스입니다.
UserDetails에 대해 제공되는 javadoc을 읽어보면,
“Provides core user information. Implementations are not used directly by Spring Security for security purposes. They simply store user information which is later encapsulated into Authentication objects. This allows non-security related user information (such as email addresses, telephone numbers etc) to be stored in a convenient location.”
라고 써져있습니다.
우리는 Authentication object를 CustomAuthenticationToken으로 구현합니다.
그리고 그 CustomAuthenticationToken을 만들 때 principal로 CustomUserDetails 를 넣어줍니다.
javadoc의 설명대로, 우리는 나중에 Authentication object로 encapsulate 될 유저 정보(userId, authorities) 들을 넣어줄 뿐입니다.
사용자의 접근을 제어하기 위해 최소한으로 필요한 memberId와 권한 등급 정도만 필드로 선언했습니다.
나머지 오버라이드된 메소드들은, 실제로 사용하거나 사용할 수 있는, 또는 유효한 메소드가 아니므로 호출 시 예외를 발생시키도록 하였습니다.
CustomUserDetails 설명
우리는 JWT 토큰을 발급할 때 JWT body 안에 claim으로 PrivateClaims 객체를 넣어줍니다.
그리고 validateToken()을 할 때 우리는 토큰을 파싱해보며 우리가 발급했던 토큰인지, 만료되지는 않았는지, 제대로 된 토큰이 들어왔는지 등 토큰의 유효성을 검증했습니다.
이처럼 조작되지 않은 토큰이라는 것을 확인했다면, 이 토큰을 가지고 요청하는 유저에게는 인증을 해줘야 할 것입니다. 이에 우리는 memberId 와 권한(authorities)을 담은 CustomUserDetails 형태를 반환해줄 것입니다.
JWT에 담긴 PrivateClaims는 String type 인 memberId 와, enum 타입인 UserRole 을 가지고 있습니다.
이에 사용자의 정보/권한을 담는 CustomUserDetails을 만들기 위해 우리는 JWT에서 PrivateClaims 을 추출해, 그 안의 memberId 와 UserRole을 가지고 memberId와 UserRole.toString() 형태의 권한인 SimpleGrantedAuthority를 넣은 CustomUserDetails를 만들어줍니다.
CustomUserDetails는 권한 등급을 GrantedAuthority 인터페이스 타입으로 받게 되는데, 이의 간단한 구현체인 SimpleGrantedAuthority를 이용하였습니다. 권한 등급은 String 타입으로 인식하기 때문에, Enum 타입인 UserRole을 String으로 변환해주었습니다. 아래는 javadoc 설명입니다.
이제 CustomUserDetails 를 반환하는 loadUserByUsername() 을 구현하는 CustomUserDetailsService 클래스를 살펴보겠습니다.
@Component
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final JwtHandler jwtHandler;
@Override
public CustomUserDetails loadUserByUsername(String parsedToken) throws UsernameNotFoundException { //1
return convertTokenToUserDetail(parsedToken);
}
/**
* JWT 에서 Claim 추출 후 UserDetail 반환
* @param parsedToken
*/
private CustomUserDetails convertTokenToUserDetail(String parsedToken) { //2
Optional<TokenHelper.PrivateClaims> privateClaims = jwtHandler.createPrivateClaim(parsedToken);
return new CustomUserDetails(privateClaims.get().getMemberId(), new SimpleGrantedAuthority(privateClaims.get().getRoleTypes().toString()));
}
}
Spring Security에서 제공해주는 UserDetailsService를 구현하는 CustomUserDetailsService는 인증된 사용자의 정보를 CustomUserDetails로 반환해줍니다.
JWT에서 PrivateClaims를 만드는 createPrivateClaim() 메소드를 가지고 있는 클래스입니다.
@Component
@RequiredArgsConstructor
public class JwtHandler {
private String type = "Bearer";
private final RedisService redisService;
@Value("${jwt.key.access}") // parse 할때 필요
private String accessKey;
@Value("${jwt.key.refresh}")
private String refreshKey;
//생략. 2편에서 구현
public String createToken(String key, Map<String, Object> privateClaims, long maxAgeSeconds) {...} // PrivateClaims으로 토큰 생성
public Optional<Claims> checkRefreshToken(String key, String refreshToken, String email) throws BaseException {...}// refresh Token 재발급할떄 validate
/**
* ===== 토큰 parse 후 privateClaim 으로 변환 =====
* PrivateClaim에 memberId와 UserRole 을 가지고있다.
*/
public Optional<TokenHelper.PrivateClaims> createPrivateClaim(String token) {
Optional<Claims> claims1 = parseAccessToken(accessKey, token); //토큰 파싱
return claims1.map(claims -> convertClaim(claims)); //
}
/**
* ㄴ 토큰 parse : getBody() : Returns the JWT body, either a String or a Claims instance. Claim 반환
*/
public Optional<Claims> parseAccessToken(String key, String token) {
try {
return Optional.of(Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(untype(token)).getBody());
} catch (JwtException e) {
return Optional.empty();
}
}
/**
* ㄴ Claim -> privateClaim 변환
*/
private TokenHelper.PrivateClaims convertClaim(Claims claims) {
return new TokenHelper.PrivateClaims(claims.get("MEMBER_ID", String.class), UserRole.valueOf(claims.get("ROLE_TYPES", String.class)));
}
}
저번 편에서 우리는 토큰을 생성할 때 body에 우리가 직접 정의한 PrivateClaims을 넣어주었습니다. 이에 토큰을 파싱해 안에 있는 PrivateClaims을 반환해줄 수 있습니다.
PrivateClaims는 String type 인 memberId 와, enum 타입인 UserRole 을 가지고 있습니다.
이렇게 차례대로 doFilter()의 validateToken() 을 호출했을 시 호출되는 메소드들과 클래스들에 대해 알아보았습니다. 이제 반환된 CustomAuthenticationToken 객체는 SecurityContextHolder에 저장이 되었을 것입니다.
이제 저장된 인증 객체를 꺼내와 요청과 접근 정책에 따라 검사하는 부분을 살펴보겠습니다.
SecurityContext에 담긴 Authentication 구현체로 요청과 접근 정책에 따른 검사를 진행하는 부분을 살펴보겠습니다. 이에 필요한 MemberGuard 와 AuthHelper 2가지 클래스를 보겠습니다.
처음 설정한 SecurityConfig 에서 우리는 로그인이 필요한 API들에 대해 접근 권한 체크를 활성화해주었습니다.
이는 MemberGuard의 check()메소드를 통해 검사를 하며, MemberGuard.check() 의 반환 결과가 true라면 요청을 수행할 수 있도록 해주었습니다.
MemberGuard는, 사용자의 접근 권한, 즉 UserRole 뿐만아니라 요청하는 자원에 대한 소유주인지에 대해서도 검증할 수 있습니다. 하지만, 이는 Database에 접근을 필요로 합니다. 이에 따른 장단점이 있는데, 이는 밑에서 더 자세히 서술하도록 하겠습니다.
결론적으로, 지금은 자원 소유주 검증 로직이나 권한 별로 인가 로직을 따로 시큐리티 단에서 다루고 있진 않습니다.
권한 별로 인가 로직을 설정해주는 비즈니스 로직을 가진 API가 따로 없기 때문입니다.
이에 회원가입을 제외한 모든 API를 검사하는 MemberGuard의 hasAuthority() 는 어떤 UserRole이던 role을 가지고 있다면 인증/인가를 해주도록 구현하였습니다.
그렇기에 지금은 MemberGuard의 역할이 크게 와닿지 않지만, 나중에 권한 별 인가 로직이 필요하게 된다면 이를 통해 쉽게 도입할 수 있을 것 입니다.
이제 MemberGuard의 메소드들을 살펴보겠습니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class MemberGuard {
private final AuthHelper authHelper;
public boolean check() {
return authHelper.isAuthenticated() && hasAuthority(); //인증,인가. 지금으로썬 인가 로직은 없어서 모든 Role을 다 열어놓음
}
private boolean hasAuthority() {
Set<UserRole> memberRoles = authHelper.extractMemberRoles();
return memberRoles.contains(UserRole.USER) || memberRoles.contains(UserRole.ADMIN) || memberRoles.contains(UserRole.KSA) || memberRoles.contains(UserRole.FRESHMAN) ;
}
}
이러한 검사 작업을 도와주기 위해 작성된 AuthHelper 클래스는 아래와 같습니다.
SecurityContextHolder에 저장되어있는 사용자의 인증 정보를 추출하기 위해 도움을 줄 클래스입니다.
@Component
@Slf4j
public class AuthHelper {
// return true if the token has been authenticated and the AbstractSecurityInterceptor does not need to present the token to the AuthenticationManager again for re-authentication.
public boolean isAuthenticated() { //1.
return getAuthentication() instanceof CustomAuthenticationToken && getAuthentication().isAuthenticated(); //Authentication.isAuthenticated()
}
//obtains the currently authenticated authentication request token
private Authentication getAuthentication() { //2.
return SecurityContextHolder.getContext().getAuthentication();
}
public Set<UserRole> extractMemberRoles() { //3.
return getUserDetails().getAuthorities()
.stream()
.map(authority -> authority.getAuthority())
.map(strAuth -> UserRole.valueOf(String.valueOf(strAuth)))
.collect(Collectors.toSet());
}
private CustomUserDetails getUserDetails() { //4.
return (CustomUserDetails) getAuthentication().getPrincipal();
}
}
isAuthenticated(): getAuthentication() 을 통해 받아온 Authentication 객체가 CustomAuthenticationToken 의 구현체인지 확인하고, Authentication.isAuthenticated() 로 인증된 토큰인지 확인해 true/false로 반환합니다.
a. 인증되지 않은 사용자여도 Spring Security에서 등록해준 필터에 의해 AnonymousAuthenticationToken을 발급받게 되기 때문에, getAuthentication()의 반환 값이 우리가 직접 정의한 CustomAuthenticationToken일 때에만 인증된 것으로 판별해주었습니다.
getAuthentication(): Authenticated 된 CustomUserDetail (principal) 을 가지고있는 Authentication 구현체(CustomAuthenticationToken)를 컨텍스트에서 가져와 반환합니다.
즉, 현재 시큐리티 컨텍스트에 담긴 인증된 CustomAuthenticationToken을 가져옵니다.
Authentication.getAuthentication() 호출 : Obtains the currently authenticated authentication request token.
extractMemberRoles(): getUserDetails() 를 호출해 CustomUserDetails를 받아옵니다.
받아온 CustomUserDetails에서 authorities 를 추출해 UserRole 형태로 반환합니다.
getUserDetails(): 2번의 getAuthentication() 를 호출해 현재 컨텍스트에 담긴 객체인CustomAuthenticationToken의 principal, 즉 CustomUserDetails 를 받아옵니다.
참고) 위에서 CustomUserDetails를 만들때, 아래와 같이 memberId와 권한인 authorities를 담아 UserDetails를 구현했었습니다.
public class CustomUserDetails implements UserDetails {
private final String memberId;
private final Set<GrantedAuthority> authorities;
public CustomUserDetails(String memberId, SimpleGrantedAuthority authorities) {
this.memberId = memberId;
this.authorities = Set.of(authorities);
}
그리고 이렇게 만든 CustomUserDetails을 principal로 가지고 있는 CustomAuthenticationToken 을 만들었습니다.
public class CustomAuthenticationToken extends AbstractAuthenticationToken { //implements Authentication
private CustomUserDetails principal;
public CustomAuthenticationToken(CustomUserDetails principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal; //CustomUserDetails
setAuthenticated(true);
}
스프링에서 제공하는 Authentication 에 대한 문서를 읽어보면, 다음과 같은 설명이 있습니다.
Once the request has been authenticated, the Authentication will usually be stored in a thread-local SecurityContext managed by the SecurityContextHolder by the authentication mechanism which is being used. An explicit authentication can be achieved, without using one of Spring Security's authentication mechanisms, by creating an Authentication instance and using the code:
SecurityContextHolder.getContext().setAuthentication(anAuthentication);
우리는 JwtAuthenticationFilter 에서 위의 코드로 explicit authentication을 구현해 thread-local한 SecurityContext에 등록주었습니다.
이에 같은 스레드를 공유하고 있다면, 어떤 위치에서 사용하든지 저장해둔 Authentication 데이터를 공유할 수 있습니다.
AuthHelper는 그렇게 저장된 사용자 정보(Authentication)를 통해, 우리가 필요한 요청자의 id나 인증 여부, 권한 등을 추출하는데 도움을 주게 됩니다.
이제 마지막으로, 검증에 실패 시 호출되는 CustomAuthenticationEntryPoint 에 대해 알아보겠습니다.
AuthenticationEntryPoint는 인증되지 않은 사용자가 요청을 했을 때 작동되는 핸들러입니다.
이러한 핸들러의 작동은 컨트롤러 계층에 도달하기 전에 수행되기 때문에, 여기에서 예외가 발생한다해도, 우리가Exception을 편리하게 다루기 위해 사용하는 BaseException 으로 이 예외를 처리할 수 없습니다.
Security단에서 예외가 발생해도 Spring의 DispatcherServlet까지 닿을 수가 없기 때문입니다.
따라서 여기에서는 스프링에서 제공해주는 응답 방식을 이용할 수 없습니다. 그렇기에 그냥 여기에서 각각의 상황에 맞게 직접 응답을 작성하였습니다.
CustomAuthenticationEntryPoint 을 알아보기 전에,
이전에 TokenHelper의 validatToken() 메소드를 작성할 때 아래와 같이 request.setAttribute()로 각 에러마다 해당 예외가 어떤 종류의 예외인지 구분할 수 있도록 에러 코드를 넣어주었습니다.
public Authentication validateToken(HttpServletRequest request, String token) throws BadRequestException {
String exception = "exception";
try {
Jwts.parser().setSigningKey(accessKey.getBytes()).parseClaimsJws(jwtHandler.untype(token));
return getAuthentication(token); //loadByUsername으로 반환된 CustomUserDetails을 Authentication 형식인 CustomAuthenticationToken으로 반환
} catch (BadRequestException e) {
request.setAttribute(exception, "토큰을 입력해주세요. (앞에 'Bearer ' 포함)");
} catch (MalformedJwtException | SignatureException | UnsupportedJwtException e) {
request.setAttribute(exception, "잘못된 토큰입니다.");
} catch (ExpiredJwtException e) {
request.setAttribute(exception, "토큰이 만료되었습니다.");
} catch (IllegalArgumentException e) {
request.setAttribute(exception, "토큰을 입력해주세요.");
}
return null;
}
위 코드에서 예외가 발생하면 AuthenticationEntryPoint로 갑니다. 그러므로 우리는 CustomAuthenticationEntryPoint를 구현해서 나머지 코드를 작성하면 됩니다.
CustomAuthenticationEntryPoint로 넘어간 뒤에도 동일한 Request가 있으므로 위에서 처리했던 Attribute를 가져와서 응답 값을 설정해주었습니다.
그리고 setResponse()라는 메소드를 이용해서 Response에 원하는 형태의 에러 메세지를 출력할 수 있도록 하였습니다.
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint{
private static final ObjectMappermapper= new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String exception = (String) request.getAttribute("exception");
setResponse(response);
BasicResponse exceptionDto = new BasicResponse(exception, HttpStatus.FORBIDDEN);
response.getWriter().print(convertObjectToJson(exceptionDto));
}
private void setResponse(HttpServletResponse response) {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
}
public String convertObjectToJson(Object object) throws JsonProcessingException {
if (object == null) {
return null;
}
returnmapper.writeValueAsString(object);
}
}
이렇게 길었던 Spring Security 적용기가 3편으로 마무리 되었습니다.
생각보다 customize 할 수 있는 부분이 많고, Spring Security 자체가 방대한 프레임워크라 이해하고 적용하는데 많은 시간이 걸렸던 것 같습니다.
제가 적용한 방법만이 정답이 아니며, 각자의 상황을 잘 분석해 적용하는 것이 좋을 것 같습니다.
감사합니다.