[Spring] JWT 인증 구현과 예외 처리 (토큰만료)

이수민·2023년 1월 26일
1

spring

목록 보기
6/12

JWT란

JWT(Json Web Token)은 클라이언트와 서버 간의 사용자 인증을 위해 사용되는 토큰이다.
자세한 내용은 이전 포스팅을 참고해주길 바란다.

Spring에서 인증 구현하기

스프링 부트에서 인증 기능을 구현하기 위해 Spring SecurityJWT를 사용할 것이다.

Spring Security 사용

Spring Security : 스프링 서버에 필요한 인증 및 인가를 위해 많은 기능을 제공하는 프레임워크이다.

1. build.gradle 추가

dependencies {
	...
	// Spring Security
	implementation 'org.springframework.boot:spring-boot-starter-security'
    ...
}

2. WebSecurityConfig 파일

  • Spring security 5.7버전 이상부터 WebSecurityConfigurerAdapter가 deprecated 되었다.
  • 때문에 Spring boot 버전을 2.6.x로 변경하고 진행했다. ( Spring security 5.7 아래를 사용하는 버전)
plugins {
	id 'java'
	id 'org.springframework.boot' version '2.6.11' //<-3.0.1
	id 'io.spring.dependency-management' version '1.1.0'
}

hello.springmvc.first.Config.WebSecurityConfig.java

@RequiredArgsConstructor
@EnableWebSecurity  //Spring Security 설정 활성화
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final ObjectMapper objectMapper;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    //암호화에 필요한 PasswordEncoder Bean 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    //authenticationManager Bean 등록
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override public void configure(WebSecurity web) throws Exception {
    	// 허용하는 url 등록
        web.ignoring().antMatchers("/", "/first/**", "/swagger-ui/**", "/swagger-resources/**", "/v2/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                //세션 사용 안함
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                //URL 관리
                .authorizeRequests()
                .antMatchers().permitAll() //허용 url
                .anyRequest().authenticated()
                .and()

                // JwtAuthenticationFilter를 먼저 적용
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

                .exceptionHandling()
                // 인증예외 발생시 수행 메서드
                .authenticationEntryPoint(((request, response, authException) -> {
                	// 이 부부은 토큰 만료의 경우이며 뒷부분에 설명하도록 하겠다.
                    if(request.getAttribute("exception") == ErrorCode.TOKEN_EXPIRED){
                        response.setStatus(HttpStatus.UNAUTHORIZED.value());
                        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                        objectMapper.writeValue(
                                response.getOutputStream(),
                                ErrorResponse.of(ErrorCode.TOKEN_EXPIRED)
                        );
                    }
                    response.setStatus(HttpStatus.UNAUTHORIZED.value());
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    objectMapper.writeValue(
                            response.getOutputStream(),
                            ErrorResponse.of(ErrorCode.FAIL_AUTHENTICATION)
                    );
                }))
                // 인가예외 발생시 수행 메서드
                .accessDeniedHandler(((request, response, accessDeniedException) -> {
                    response.setStatus(HttpStatus.FORBIDDEN.value());
                    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    objectMapper.writeValue(
                            response.getOutputStream(),
                            ErrorResponse.of(ErrorCode.FAIL_AUTHORIZATION)
                    );
                }));
    }
}
  • @EnableWebSecurity : 웹보안 활성화를 위한 애노테이션이다. 스프링 시큐리티 지원을 가능하게 한다.

  • authenticationEntryPoint : 인증예외 발생시 수행되는 메서드이다. 해당 코드에서는 조건문을 통해 토큰이 만료된 경우와 인증실패의 경우로 나누어 처리한다.

  • accessDeniedHandler : 인가예외 발생시 수행되는 메서드이다.

  • ErrorResponse.of : 예외처리 포스팅에서 생성한 예외처리 메서드이다.

  • ErrorCode.~ : 예외처리 포스팅에서 생성한 에러코드 Enum 파일이다.

JWT 관련 예외 처리

1. ErrorCode 추가

이전에 생성해둔 ErrorCode Enum 파일에 JWT 관련 에러코드를 추가하자.

hello.springmvc.first.Exceptions.ErrorCode.java

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "INVALID INPUT VALUE"),
    FAIL_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "FAIL AUTHENTICATION"), // 로그인X 유저의 요청 OR 토큰 불일(?)
    TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "TOKEN EXPIRED"), // 엑세스 토큰 만료
    FAIL_AUTHORIZATION(HttpStatus.FORBIDDEN, "FAIL AUTHORIZATION"), // 권한 없는 요청
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL SERVER ERROR"); // 예상치 못한 에러

    private final HttpStatus status;
    private final String message;
}

2. BusinessException 생성

  • throw할 에러들을 정의하기 위해 상속할 Exception Class를 하나 생성하자.
  • RuntimeException을 상속 받는다.

hello.springmvc.first.Exceptions.BusinessException.java

public class BusinessException extends RuntimeException {

    private final ErrorCode errorCode;

    public BusinessException(String message, ErrorCode errorCode) {
        super(message);
        this.errorCode = errorCode;
    }

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

3. JwtException 생성

  • JwtException을 throw하기 위해 생성한다.
  • 앞서 만들어 둔 BusinessException을 상속 받자.
    • 다른 에러들도 추가하고 싶다면 이 방법대로 클래스를 하나씩 생성하여 추가하면 된다.

hello.springmvc.first.Exceptions.JwtException.java

public class JwtException extends BusinessException {

    public JwtException() {
        super(ErrorCode.JWT_EXCEPTION);
    }
}

JWT 설정

1. build.gradle 추가

dependencies {
	...
    // jwut
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
    ...
}

2. JwtTokenProvider (토큰 처리 로직)

  • 토큰 관련 메서드들을 정의할 클래스이다.
  • 토큰 생성, 토큰 페이로드 반환, 토큰 검증 등의 로직을 처리한다.

hello.springmvc.first.JWT.JwtTokenProvider.java

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
	// secret key
    private String secretKey = "secretKey";

    // 토큰 유효시간 1분 (테스트)
    private long tokenValidTime = 1 * 60 * 1000L;

    // JWT 토큰 생성
    private String createToken(final long payload, final String secretKey, final Long tokenValidTime) {
        return Jwts.builder()
                .setSubject(String.valueOf(payload))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .setExpiration(new Date(System.currentTimeMillis() + tokenValidTime))
                .compact();
    }

    public String createAccessToken(final long payload) {
        return createToken(payload, secretKey, tokenValidTime);
    }
	
    // 토큰으로부터 페이로드 가져오기
    public Long getAccessTokenPayload(String accessToken) {
        try {
            var claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(accessToken)
                    .getBody();

            return Long.parseLong(claims.getSubject());
        } catch (ExpiredJwtException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            throw new JwtException();
        }
    }

	// 토큰 검증 (정의해 둔 ErrorCode 반환 -> 에러 구분 위해)
    public ErrorCode validateToken(String accessToken) {
        try {
            var claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(accessToken);
            if(!claims.getBody().getExpiration().before(new Date()) == false) {
                return ErrorCode.FAIL_AUTHENTICATION;
            }
            return null;
        } catch (ExpiredJwtException e){
            return ErrorCode.TOKEN_EXPIRED;
        } catch (Exception e) {
            return ErrorCode.FAIL_AUTHENTICATION;
        }
    }
}
  • validateToken : null 또는 두 가지 에러를 반환한다.

    • null : 토큰 검증 성공

    • ErrorCode.FAIL_AUTHENTICATION : 토큰 검증 실패 (인증 실패=401)

    • ErrorCode.TOKEN_EXPIRED : 토큰 만료 (인증 실패=401)

3. CustomUserDetailService (유저 정보 반환)

  • 토큰으로부터 얻은 Payload (유저 정보)를 통해 유저 정보를 가져오는 클래스이다.
  • 해당하는 유저가 없다면 NotFoundException을 Throw한다.

hello.springmvc.first.JWT.CustomUserDetailService.java

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

    private final FirstMapper firstMapper;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        FirstDTO findUser = Optional.ofNullable(firstMapper.selectUserById(Integer.valueOf(userId))).orElseThrow(() -> new NotFoundException());
        return UserPrincipal.create(findUser);
    }
}
  • 토큰에 넣을 페이로드로 사용자 ID를 사용했으므로 파라미터는 userId이다.

4. UserPrincipal (UserDetails)

  • UserDetails를 상속받는 클래스이다.

hello.springmvc.first.JWT.UserPrincipal.java

public class UserPrincipal implements UserDetails {

    private final Integer userId;
    private final Collection<? extends GrantedAuthority> authorities;

    public UserPrincipal(Integer userId, Collection<? extends GrantedAuthority> authorities) {
        this.userId = userId;
        this.authorities = authorities;
    }

    public static UserPrincipal create(FirstDTO user) {
        var authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getName()));
        return new UserPrincipal(user.getId(), authorities);
    }

    public Integer getUserId() {
        return userId;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return null;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

5. JwtAuthenticationFilter (인증 필터)

  • JWT 토큰JwtTokenProvider을 이용해 인증처리를 진행하는 필터이다.
  • OncePerRequestFilter를 상속받아 작성했다.
  • 해당 필터에서 토큰의 유효성 검사가 진행된다.

hello.springmvc.first.JWT.JwtAuthenticationFilter.java

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String PREFIX_BEARER = "Bearer ";
    private final UserDetailsService userDetailsService;
    private final JwtTokenProvider jwtProvider;
    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        var accessToken = resolveToken(request);
        ErrorCode errorCode = jwtProvider.validateToken(accessToken);

        if (accessToken != null && errorCode==null) {
            try {
                UserDetails userDetails = userDetailsService.loadUserByUsername(String.valueOf(jwtProvider.getAccessTokenPayload(accessToken)));
                SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(userDetails.getUsername(), "", userDetails.getAuthorities()));
            } catch (Exception e) {
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                objectMapper.writeValue(
                        response.getOutputStream(),
                        ErrorResponse.of(ErrorCode.FAIL_AUTHENTICATION)
                );
                return;
            }
        }

        request.setAttribute("exception", errorCode);
        filterChain.doFilter(request, response);
    }

	// 토큰 가져오기
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(PREFIX_BEARER)) {
            return bearerToken.substring(7);
        }

        return null;
    }
}
  • doFilterInternal : jwt 토큰의 인증정보를 SecurityContext에 저장하는 역할을 수행한다.
  • filterChain.doFilter : 다음 Filter를 실행하기 위한 코드. 마지막 필터라면 필터 실행 후 리소스를 반환한다.

* 토큰 만료의 경우를 분리한 이유

  • 기존의 방식에서는 토큰 불일치와 토큰 만료의 경우 모두 401 (FAIL AUTHENTICATION) 에러를 반환했다.
  • 이는 클라이언트 입장에서 어떤 이유의 에러인지 알 수 없다는 단점이 있다.
  • 때문에 아래와 같이 응답을 분리했다.
    • 토큰 불일치 : 401 + "FAIL AUTHENTICATION"
    • 토큰 만료 : 401 + "TOKEN EXPIRED"

* JwtAuthenticationFilter에서 에러처리를 하지 않은 이유

  • FilterDispatch Servlet 앞단에 존재한다. 스프링의 관할이 아니다.
  • 때문에 Filter에서 던진 예외는 뒤에 존재하는 Exception Handler에서 처리할 수 없다.
  • 때문에 앞서 설정한 WebSecurityConfig 파일의 exceptionHandling() 부분에서 처리를 해주었다.
    • exceptionHandling : 예외처리 기능 작동
      • .authenticationEntryPoint : 인증 실패 시 처리
      • .accessDeniedHandler : 인가 실패 시 처리

WebSecurityConfig의 exceptionHandling 부분

.exceptionHandling()
// 인증 실패
.authenticationEntryPoint(((request, response, authException) -> {
	// request.setAttribute로 저장했던 ErrorCode를 통해 구분한다.
    // 토큰 만료의 경우
    if(request.getAttribute("exception") == ErrorCode.TOKEN_EXPIRED){
    	response.setStatus(HttpStatus.UNAUTHORIZED.value());
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		objectMapper.writeValue(
			response.getOutputStream(),
            ErrorResponse.of(ErrorCode.TOKEN_EXPIRED)
        );
	}
    // 토큰 불일치의 경우
	response.setStatus(HttpStatus.UNAUTHORIZED.value());
	response.setContentType(MediaType.APPLICATION_JSON_VALUE);
	objectMapper.writeValue(
		response.getOutputStream(),
		ErrorResponse.of(ErrorCode.FAIL_AUTHENTICATION)
    );
}))
// 인가 실패
.accessDeniedHandler(((request, response, accessDeniedException) -> {
	response.setStatus(HttpStatus.FORBIDDEN.value());
	response.setContentType(MediaType.APPLICATION_JSON_VALUE);
	objectMapper.writeValue(
		response.getOutputStream(),
		ErrorResponse.of(ErrorCode.FAIL_AUTHORIZATION)
	);
}));

레퍼런스

https://velog.io/@do_ng_iill/Spring-Security-%EA%B0%84%EB%8B%A8%ED%95%9C-%EC%82%AC%EC%9A%A9%EB%B0%A9%EB%B2%95

https://velog.io/@hellonayeon/spring-boot-jwt-expire-exception

profile
BE 개발자를 꿈꾸는 학생입니다🐣

0개의 댓글