본문 바로가기

Spring Boot

Interceptor를 활용한 JWT 토큰 검증

인증, 인가를 위해 매 요청마다 JWT를 검사해야 한다.

하지만, 이 과정을 비즈니스 로직 내부로 가져오기엔 무리가 있다.

 

  1. 인증, 인가는 비즈니스 로직의 책임이 아니다. 비즈니스 로직을 구현하는 객체는 자신의 책임에만 집중해야 한다.
  2. 유지보수성이 좋지 않다. Util Class로 분리해도, 결국 Service들은 해당 Util Class에 의존해야 하기 때문이다. 즉, Class 간 결합도가 높아진다.

 

인증, 인가를 비즈니스 로직에서 빼내보자.

Controller에 요청이 도착하기 전에 인증, 인가가 수행할 것이다.

Controller 앞에서 구현하면 다음과 같은 이점을 가질 수 있다.

 

  1. 모든 요청이 Controller에 도달하기 전에 인증과 인가가 처리되므로, 인증되지 않은 접근이나 권한이 없는 요청을 사전에 차단할 수 있다.
  2. 부적절한 인증, 인가 정보를 가지고 있는 요청이 비즈니스 로직 내부로 들어오기 전에 해당 요청을 거부할 수 있으므로, 효율적이다.

 

요청이 Controller에 도달하기 전 검사하는 수단으로, SpringInterceptor를 사용해보자.

 

Intercept는 “가로채다” 라는 의미를 가진다. Spring은 Http Request, Response가 Controller로 전달되기 전, 후 해당 Http Request, Response를 가로채서 특정 작업을 수행할 수 있는 Interceptor를 제공한다. 

 

Interceptor는 세 가지 메소드를 가진다.

1. preHandle
    - DispatcherServlet이 URI에 대한 Controller를 찾기 전에 실행된다.
    - 컨트롤러 실행 이전에 처리해야 할 작업이 있는경우 혹은 요청정보를 가공하거나 추가하는경우 사용한다.
2. postHandle
    - 핸들러(Controller)가 실행은 완료 되었지만 아직 View가 생성되기 이전에 호출된다.
3. afterCompletion
    - 모든 View에서 최종 결과를 생성하는 일을 포함한 모든 작업이 완료된 후에 실행된다.

 

여기서 preHandle만 사용할 것이다.

 


 

 

 

1. JwtTokenProvider 작성


jwt token 관련 작업을 해 주는 provider를 작성한다.

기능은 다음과 같다. 

1. 정보(userId)를 받아 해당 정보를 Payload에 담는 jwt token 발급
2. jwt token을 받아 복호화해 정보(userId)를 반환

 

@Component
public class JwtTokenProvider {

    private final SecretKey key;
    private final long validityInMilliseconds;

    public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") final String secretKey,
                            @Value("${security.jwt.token.expire-length}") final long validityInMilliseconds) {
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
        this.validityInMilliseconds = validityInMilliseconds;
    }

    public String createToken(final String payload) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + validityInMilliseconds);

        return Jwts.builder()
                .setSubject(payload)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public String getPayload(final String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        } catch (JwtException e) {
            throw new InvalidTokenException(ErrorCode.TOKEN_INVALID, e.getMessage());
        }
    }
}

 

createToken()은 저장할 payload(userId가 될 것이다)를 받아와 만료 시간, 발급 시간, Key와 알고리즘과 함께 jwt token을 발급해 리턴한다.

getPayload()는 jwt token을 받아 해당 토큰을 검증하고, payload(userId가 될 것이다) 를 리턴한다.

 

 

 

 

 

2. AuthenticationContext 작성


AuthenticationContext는 요청 전체에서 유저를 저장할 Context이다. HTTP 요청이 들어와서 나갈 때 까지, 해당 요청을 보낸 User 객체를 저장하고 있는 객체라는 것이다.

 

@Setter
@Getter
@Component
@RequestScope
public class AuthenticationContext {
    private User principal;
}

 

@RequestScope를 통해 해당 Bean은 요청이 들어올 때 생성되고, 요청이 끝나면 제거된다. Request가 끝나면 User 정보를 들고 있을 필요가 없기 때문이다.

 

 

 

 

3. AuthenticationExtractor 작성


Request에서 jwt token을 추출하는 역할을 하는 클래스이다. 필자는 Request에서 Bearer 방식을 통해 cookie에 jwt token을 저장할 것이다. JwtEncoder는 Cookie에서 JWT 토큰을 파싱하는 역할을 한다.

 

public class AuthenticationExtractor {
    private static final String TOKEN_COOKIE_NAME = "AccessToken";

    public static String extract(final HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (TOKEN_COOKIE_NAME.equals(cookie.getName())) {
                    return JwtEncoder.decodeJwtBearerToken(cookie.getValue());
                }
            }
        }
        throw new UnathorizedException(ErrorCode.TOKEN_NOT_FOUND);
    }
}

 

cookie에서 AccessToken을 찾아 jwt token을 추출해 반환한다.

 

 

 

4. AuthenticationInterceptor 작성


@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {

    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationContext authenticationContext;
    private final UserRepository userRepository;

    @Override
    public boolean preHandle(final HttpServletRequest request,
                             final HttpServletResponse response,
                             final Object handler) {
        String accessToken = AuthenticationExtractor.extract(request);
        UUID userId = UUID.fromString(jwtTokenProvider.getPayload(accessToken));
        User user = findExistingUser(userId);
        authenticationContext.setPrincipal(user);
        return true;
    }

    private User findExistingUser(final UUID userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND));
    }
}

 

이제 지금까지 만든 것들을 적용할 시간이다.

preHandle을 통해 요청이 Controller에 들어오기 전, jwt token을 추출하고 유저를 검증한다.

그리고 나서 AuthenticationContext에 User를 저장한다.

 

 

 

 

5. AuthenticatedUser 어노테이션 작성


AuthenticatedUser 어노테이션 작성@AuthenticatedUser 어노테이션을 통해 파라메터에서 유저를 얻어 올 것이다.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticatedUser {
}

 

 

 

 

6. AuthenticatedUserArgumentResolver 작성


@AuthenticatedUser의 기능을 구현하는 AuthenticatedUserArgumentResolver를 작성한다.

AuthenticationContextprincipal(User)를 반환할 것이다.

 

@Component
@RequiredArgsConstructor
public class AuthenticatedUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthenticationContext authenticationContext;

    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthenticatedUser.class);
    }

    @Override
    public User resolveArgument(final MethodParameter parameter,
                                final ModelAndViewContainer mavContainer,
                                final NativeWebRequest webRequest,
                                final WebDataBinderFactory binderFactory) {
        return authenticationContext.getPrincipal();
    }
}

 

 

 

 

 

7. AuthenticatedUserArgumentResolver 작성


위에서 만든 Interceptor와 AuthenticatedUserArgumentResolver를 등록시키는 config를 작성한다.

 

@Configuration
@RequiredArgsConstructor
public class AuthenticationConfig implements WebMvcConfigurer {

    private final AuthenticationInterceptor authenticationInterceptor;
    private final AuthenticatedUserArgumentResolver authenticatedUserArgumentResolver;

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor)
                .addPathPatterns("/test");// 테스트용
    }

    @Override
    public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(authenticatedUserArgumentResolver);
    }
}

 

 

 

 

8. 적용


다음과 같이 사용할 수 있다. @AuthenticatedUser 어노테이션을 통해 인증된 User를 사용할 수 있다.

@GetMapping("/test")
public ResponseEntity<ResponseDto<Void>> test(@AuthenticatedUser User user) {
    return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, user.getName()), HttpStatus.OK);
}