[Spring] 필터와 인터셉터를 활용한 로그인 구현
로직 순서
1. 발급
@Component
public class JwtUtils {
private final SecretKey secretKey;
public JwtUtils(@Value("${spring.jwt.secret}") String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String createJwt(String category, String phoneNumber, String role, Long expiredMs) {
return Jwts.builder()
.claim("category", category)
.claim("phoneNumber", phoneNumber)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
JwtUtil 클래스에서 생성을 담당한다.
secret.yml 혹은 파라미터 스토어에 저장된 jwt 시크릿 키를 바탕으로 해싱하여 작성된다.
액세스 토큰 or 리프레시 토큰인지에 대한 정보를 담은 카테고리, 유저의 전화번호, user or admin으로 구분되는 사용자의 구분자를 담은 상태로 액세스 토큰이 생성된다. 생성 시 지정된 시간을 입력 받아 토큰의 유효시간을 설정한다.
String accessToken =
jwtUtils.createJwt("access_token", member.getPhoneNumber(), member.getRole(), 2 * 24 * 60 * 60 * 1000
이후 별도의 인증 로직을 거친 뒤에 사용자 인증이 성공적으로 이루어졌다면 다음과 같은 액세스 토큰을 발급한 뒤에(현재 리프레시 트콘은 사용하지 않고 있다.) Body에 담아 전송함으로써 프론트의 헤더에 정보가 담겨오도록 설정해준다.
2. 검증
public class AuthContext {
private String phoneNumber;
private String role;
}
@UtilityClass
public class AuthContextHolder {
private static final ThreadLocal<AuthContext> contextHolder = new ThreadLocal<>();
public static void clearContext() {contextHolder.remove();}
public static AuthContext getAuthContext() {
return contextHolder.get();
}
public static void setAuthContext(AuthContext authContext) {
contextHolder.set(authContext);
}
}
유저의 정보에 대해 필요한 내용을 AuthContext 클래스를 통해 객체로 보관한다. 이 후 AuthContextHolder를 통해 각 쓰레드마다 독립적으로 저장하고 관리하게 해준다.
public class ThreadLocalCleanerFilter implements Filter {
@Override
public void destroy() {
Filter.super.destroy();
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
try {
filterChain.doFilter(servletRequest, servletResponse);
} finally {
AuthContextHolder.clearContext();
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
}
다음과 같이 필터가 요청을 가로채서 먼저 처리하게 한다. 다음과 같이 하는 이유는 ThreadLocal에 남아있는 컨텍스트 정보가 있다면 다음 사용자의 요청에서 잘못된 정보들로 처리가 될 수 있기 때문에 doFilter를 통해 요청을 그대로 다음 단계로 전달하지만 이전에 있던 정보를 clear하게 되는 것이다.
public class JwtInterceptor implements HandlerInterceptor {
private final JwtUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
return true;
}
String authorization = request.getHeader("Authorization");
boolean isAuthRequired = checkAnnotation(handler, Auth.class);
boolean isAdminRequired = checkAnnotation(handler, Admin.class);
if (!isAuthRequired && !isAdminRequired) {
if (authorization == null) {
return true;
}
saveContext(authorization);
return true;
}
boolean isAuthenticated = authorizeAndSaveContext(authorization);
if (isAdminRequired) {
if (!isAdminRole(AuthContextHolder.getAuthContext().getRole())) {
throw new RestApiException(AuthErrorCode.UNAUTHORIZED);
}
}
return isAuthenticated;
}
다음과 같은 인터셉터를 통해 필터에서 넘어온 정보를 처리하게 된다. 서비스에 맞는 검사를 진행한다. 위의 코드에서는 OPTIONS라는 요청은 건너뛰게 되고, 이 후 서비스에 맞는 검사를 진행하게 된다. 본 코드에서는 검증 로직이 필요한 곳에 편리한 사용을 위하여 아래와 같은 코드를 통해 작성되었다.
private boolean checkAnnotation(Object handler, Class<? extends Annotation> annotationClass) {
if (handler instanceof ResourceHttpRequestHandler) {
return false;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
return handlerMethod.getMethodAnnotation(annotationClass) != null ||
handlerMethod.getBeanType().getAnnotation(annotationClass) != null;
}
어노테이션이 존재하는지 검사하는 로직이다. 어노테이션을 달아줌으로써 어떠한 권한에 대한 검사가 필요한지 분별하게 된다. 예를 들어 @Auth 어노테이션만 존재하게 된다면 사용자가 로그인이 되어있는지만 검사하면 되고, 이 때에 대한 정보가 AuthContext에 담기게 된다. @Admin 어노테이션이 붙어있는 경우 admin 권한 검사까지 진행하게 되는 것이다.
이를 통해 AuthContext에 사용자의 정보가 세팅되게 되며 효율적으로 검사가 가능하다.
3. 사용
public class AuthMemberService {
/**
* 인증 컨텍스트 반환
*
* @return 인증 컨텍스트
*/
public AuthContext getAuthContext() {
return AuthContextHolder.getAuthContext();
}
/**
* 인증된 멤버의 핸드폰번호를 세션에서 반환
*
* @return 핸드폰 번호 String
*/
public String getMemberPhoneNumber() {
AuthContext authContext = getAuthContext();
if (authContext == null) {
throw new RestApiException(AuthErrorCode.NO_USER_INFO);
}
return authContext.getPhoneNumber();
}
}
최종적으로 다른 서비스 로직에서 이를 사용하게 될 경우 위와 같은 코드를 통해 사용된다.
@Auth 어노테이션이 달려 있는 요청의 경우 해당 쓰레드에 사용자의 정보가 담겨져 컨트롤러로 전달되기 때문에 이를 통해 사용자의 원하는 정보에 대한 처리를 할 수 있게된다.
String memberPhoneNumber = authMemberService.getMemberPhoneNumber();
결과적으로 의존성을 주입 받은 뒤 다음과 같은 메서드 하나로 사용자의 원하는 정보를 사용할 수 있게 된다.
필터와 인터셉터를 통해 구현함으로써 사용자가 유효하지 않은 토큰 혹은 권한을 이용하려고 하는 경우 controller까지 접근하지 않고 빠르게 요청을 처리할 수 있게 되는 것이다.