개요
기존의 Nest.js에서 JAVA Spring으로 포팅을 진행하게 되면서, 인증 부분을 맡게 되었다.
Nest에서는 Passport라는 라이브러리를 통해서 OAuth를 간단하게 구현할 수 있었지만, Spring에서 인증-인가와 관련된 부분은 대부분 Spring Security를 통해서 관리되는 편이다.
Spring Security를 적용하기 전에, 우선적으로 Spring Security를 사용하지 않는 방식으로 인증-인가를 구현하고자 하였다.
시큐리티를 쓰지 않은 이유
우선, Spring Security의 러닝 커브가 높았고, 낯설은 부분(필터 체인과 같은)들이 너무나 많았다.
빠르게 구현해야하는 상황에서, 기존의 Guard를 이용한 AOP, 인터셉터와 같은 부분은 어느 정도 알고 있었기에 이와 유사한 방식으로 구현해보고자 하였다.
또한, 그 흐름을 이해하고 있지 않은 상태에서 추상화된 방법을 사용하면, 문제 상황이 발생하였을 때 통제하기가 어려울 것 같다는 의견으로 논의가 모였고, 이에 따라서 Spring AOP를 이용해서 구현하고자 하였다.
이 부분에 대해서 ‘바퀴를 만들고 있는 것 아닌가?’라는 의문점이 있었으나, 한번쯤 바퀴를 만들어 보는 것도 좋다고 생각했다.
관점 지향 프로그래밍(AOP)
AOP(관점 지향 프로그래밍, Aspect Oriented Programming)은 횡단 관심사를 공통되는 측면(Aspect)에 따라 모듈화하여, 코드가 중복되지 않고, 일관되게 로직을 적용할 수 있게끔 하는 프로그래밍 기법이다.
횡단 관심사(Croscutting Concerns)란, 구분되는 다른 구현부에서 서로 공통되게 갖고 있는 부분을 말한다. 예로는 로깅, 인증/인가, 트랜잭션과 같은 부분들이 있다.
스프링에서는 Spring AOP, 더 구체적으로 사용하는 경우에는 Aspectj를 이용하여 AOP를 구현할 수 있다.
Spring AOP vs Aspectj
Spring AOP는 프록시 기반으로 AOP를 구현한다. Spring AOP는 대상 객체(Target)를 프록시 객체로 래핑하여 메서드 호출을 가로채고(Intercept) 부가적인 동작을 수행한다. 반면에 AspectJ는 바이트코드(.class) 조작을 사용하여 AOP를 구현한다. 이러한 방식의 차이로 인해, Spring AOP는 프록시를 사용해야하므로 런타임에서만 가능하지만, AspectJ는 컴파일 시점이나 로드 시점에 클래스 파일을 수정하여 AOP를 적용할 수 있다.
우리의 경우에는 Aspectj를 이용하여 구현하였다.
구현
요구사항은 다음과 같다.
1.
기존의 인증 레벨(유저만, 관리자만, 둘 다 뭐든)을 반영할 수 있어야 한다.
2.
어노테이션을 이용한 유사 Guard 미들웨어(스프링을 따라한 네스트를 따라한 스프링)로 만든다.
3.
OAuth를 통해서 유저의 Profile을 가져오고, 그 Profile을 기준으로 JWT 토큰을 발급, 쿠키에 심어준다.
4.
프론트에서는 헤더에 해당 쿠키의 토큰을 심어서 요청하므로, 백엔드에서 헤더와 그 토큰의 유효성을 확인한다.
코드 구현
우선 1.기존의 인증 레벨을 반영하기 위해서, 다음과 같이 어노테이션을 만들어주었다.
/**
* 인증이 필요한 부분에 붙이는 AOP 어노테이션입니다.
* <br>
* 컨트롤러 메서드에만 사용할 수 있습니다.
* <br>
* {@link AuthAspect}에서 이 어노테이션을 검사합니다.
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthGuard {
Level level() default Level.USER_ONLY;
/**
* 해당 어노테이션의 Level을 이용해서, 필요한 인증의 유무를 명시합니다.
*/
enum Level {
USER_ONLY,
ADMIN_ONLY,
USER_OR_ADMIN
}
}
ABAP
복사
원래는 컨트롤러 클래스에 달아놓는 경우와 메서드에 달아놓는 경우를 나누어서, 메서드인 경우에 우선순위를 두고 적용을 해보려고 하였다.
하지만, Aspectj가 지원하는 PointCut(인터셉팅)의 방식 자체가 ElementType.Method인 경우, 메서드에 있는 어노테이션을 먼저, ElementType.Class인 경우 바이트 코드를 기준으로 먼저 있는 어노테이션을 우선적으로 처리한다.
때문에, 두 개를 동시에 구현하려면 별도의 어노테이션으로 분리한다든지.. 하는 방식으로 해야해서 모든 메서드에 일일히 달아놓는 방식으로 구현하였다.
2.Nest의 Guard와 유사하게끔 하기 위해, @AuthGuard와 AuthAspect를 이용하여 인터셉트 후 로직을 구현하였다.
/**
* {@link AuthGuard} 어노테이션이 붙은 메소드나 클래스에 대해 인증을 검사하는 클래스입니다.
*/
@Aspect
@Component
@RequiredArgsConstructor
public class AuthAspect {
private final TokenValidator tokenValidator;
private final CookieManager cookieManager;
private final JwtProperties jwtProperties;
/**
* {@link AuthGuard} 어노테이션이 붙은 곳을 {@link org.aspectj.lang.annotation.Pointcut}으로 인터셉트합니다.
* <p>
* 해당 포인트 컷이 실행되기 전({@link Before}에 아래 메서드를 실행합니다.
*
* @param authGuard 인터셉트 된 해당 {@link AuthGuard} - Level을 알아낼 수 있습니다.
*/
@Before("@annotation(authGuard))")
public void AuthToken(AuthGuard authGuard) {
/**
* 현재 인터셉트 된 서블릿의 {@link HttpServletRequest}를 가져옵니다.
*/
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
String mainTokenName = jwtProperties.getMainTokenName();
String adminTokenName = jwtProperties.getAdminTokenName();
/**
* {@link AuthGuard}의 레벨에 따라서 토큰의 유무와 유효성을 검사합니다.
* <p>
* ADMIN_ONLY: 관리자 토큰만 유효하면 됩니다.
* USER_ONLY: 유저 토큰만 유효하면 됩니다.
* USER_OR_ADMIN: 유저 토큰 혹은 관리자 토큰이 유효하면 됩니다.
* </p>
*/
switch (authGuard.level()) {
case ADMIN_ONLY:
if (!cookieManager.isCookieExists(request, adminTokenName)
|| !tokenValidator.isTokenValid(request)) {
throw new ControllerException(ExceptionStatus.UNAUTHORIZED_ADMIN);
}
break;
case USER_ONLY:
if (!cookieManager.isCookieExists(request, mainTokenName)
|| !tokenValidator.isTokenValid(request)) {
throw new ControllerException(ExceptionStatus.UNAUTHORIZED_USER);
}
break;
case USER_OR_ADMIN:
if ((!cookieManager.isCookieExists(request, mainTokenName)
&& !cookieManager.isCookieExists(request, adminTokenName))
|| !tokenValidator.isTokenValid(request)) {
throw new ControllerException(ExceptionStatus.UNAUTHORIZED);
}
break;
}
}
}
Java
복사
@Aspect로 AOP임에 대한 어노테이션을 달아놓는다.
이후 @Before, @Around, @After와 같은 어노테이션을 이용하여, 인터셉트하는 메서드의 실행 전후에 따른 처리를 할 수 있다. + 표현식으로 원하는 범위를 설정해 줄 수 있다.
또한, 위 코드에는 없지만 JoinPoint, ProceedingJoinPoint(@Around의 경우)를 이용하여 해당 메서드의 시그니처, 매개변수 등과 같은 정보들을 관리할 수 있다.
이 Aspect의 경우에서는 해당 인증-인가 시의 서블릿 컨텍스트를 가져와서 4. 프론트의 토큰을 담은 요청의 헤더를 까보는 방식으로 인증하게끔 하였다.
Aspect는 인터셉트를 할 뿐 해당 서블릿을 종료하거나 할 수는 없기에, Exception으로 유효하지 않은 케이스들에 대해서 처리되게끔 하였고, 아니라면 유효한 것으로 종료되어 메서드가 실행되게끔 구현하였다.
어노테이션을 이용한 인가 AOP를 위 두 개의 클래스를 이용하여 구현해봤다.
추가적인 Spring Security를 사용하지 않고 OAuth를 인증한 방법은 이후의 글(Spring Security 안쓰고 AOP로 OAuth 구현하기 - 2 )에 더 구체적으로 적겠다.
추가로 궁금했던 점
@AuthGuard에 의해서 인터셉트를 하는 경우에, 그 요청들의 인터셉트가 비동기적으로 여러 개가 이뤄질텐데, 스프링 부트에서는 어떻게 이걸 관리하는 건지에 대해 궁금했었다.
→ 스프링의 싱글톤과 멀티 스레딩