Java + Spring 에서 NestJS Style Auth Guard 만들기
Spring Security, JWT 만을 사용하기에는 너무 크고 복잡하다. 간단한 Annotation으로 컨트롤러의 인증 인가를 설정하는 코드를 작성해보자.
PassportJava
- 새로운 프로젝트에서 인증, 인가 과정에 Spring Security 를 사용하는 것이 적합하다는 의사결정을 내리기는 했으나, NestJS 를 통해 백엔드를 구현했던 입장에서는 Spring Security의 사용에 다소 불편함이 있었다. 때문에, Java + Spring 에서 NestJS Style (AuthGuard + Passport.js) 으로 인증 / 인가 프레임워크를 제작해보고 Spring Security 와의 장단점을 비교해보고자 했다.
- 내가 구현하고자 하는 시스템(?)의 목표는 다음과 같다.
- NestJS 와 같이 @AuthGuard(Guard) 와 같은 형태로 Controller 의 method 에 대해서 가볍게 Auth 과정을 표현할 수 있을 것.
- AuthGuard 의 경우 Strategy 의 선택을 통해서 Guard 의 작동 방식을 선택할 수 있어 유연성을 가질 수 있을 것. (Strategy pattern 적용)
- Guard 에서 처리한 정보를 이후 Method 에서 사용할 수 있도록 Parameter를 통해서 주입할 수 있도록 할 것.
- 사용자 입장에서는 Guard, Strategy의 정의만으로 쉽게 Auth 과정을 정의할 수 있을 것.
- Passport.js 의 구조와 NestJS 의 구조를 따라했다는 점에서 PassportJava 라는 이름을 붙여보았다.
구조
- Controller의 method를 대상으로 Request 처리 이전에 intercept하여 실행할 로직을 결정하는 시스템이다. 해당 시스템은
@UseGuards
,@Guard
를 통해서Guard
를 선택하여 method에 적용할 수 있으며, 로직의 정의는Strategy
를 통해 수행한다.
@Guard
- Controller 에서 사용자가 request 이전에 인증 / 인가 과정에 사용할
Guard
를 선택할 수 있도록 하는annotation
이다. - 사용법
value = {...<T extends AuthGuard> T.class}
args = {...String}
- 주입할 인자가 없을 경우 입력하지 않고
@Guard(RoleGuard.class)
와 같이 사용해도 된다.
- 주입할 인자가 없을 경우 입력하지 않고
@Guard(value = RoleGuard.class, args = {"ADMIN"})
public ResponseEntity<UserResDto> getById(@PathVariable("id") Long id)
throws NotFoundException {
return ResponseEntity.ok(userService.getById(id));
}
@UseGuards
- Controller 에서 사용자가 request 이전에 인증 / 인가 과정에 사용할
Guard
를 여러 개 선택할 수 있도록 하는annotation
이다.- Guard를 여러개 사용하는 것과 다르지는 않다.
- 사용법
value = {...Guard.class}
args = {...String.class}
@UseGuards({
@Guard(JwtGuard.class),
@Guard(value = RoleGuard.class, args = {"ADMIN"})
})
public ResponseEntity<UserResDto> getById(@PathVariable("id") Long id)
throws NotFoundException {
return ResponseEntity.ok(userService.getById(id));
}
AuthGuard
AuthGuard
는UseGuard
에서 사용할Guard
의 기틀이 되는abstract class
이다.- 해당 클래스는
AuthStrategy
를private member
로 가지고 있으며, 이를 상속하는 모든Guard
객체는super(strategy)
를 통해 해당Guard
가 사용하는Strategy
를 결정한다.AuthStrategy
를 통해서 직접 사용할 수 있으나 다음과 같이 구현한 이유는 아래와 같다.Guard
의 경우 어떤 형태의Auth
과정의Guard
를 적용 함을 의미한다.- Guard의 적용은 결국 Controller 의 method 실행 이전 어떤 동작이 실행됨을 적용하는 것이다.
- 예를 들어, 유저의 로그인 가능 여부를 묻는 인증 과정이라고 하자. 이를
LoginedGuard
라고 하면, 해당 인증 과정에는JWT
를 사용한 전략이 사용될 수도 있고,Session
을 통한 전략이 사용될 수도 있다. 혹은 또 다른 전략이 선택될 수도 있다. - 전략이 변경된 경우에도, 해당
Guard
의 역할은 로그인 검증일 뿐이므로, 바뀌지 않는다. 따라서 단순히 해당 유저를 검증하는 전략만 변경 함으로써 코드의 단 한부분만 변경하여 해당 전략의 변경이 가능하다. - 만약 그렇지 않다면,
@UseGuards({JwtStrategy.class})
라고 작성된 모든 method를 변경해야할 것이다.
- 해당 클래스를 구현하는 객체는
@Component
를 통해Spring bean
으로 등록되어야한다.GuardResolver
에서 해당 객체들을 스캔하여 가지고 있어야한다. - 구현
public abstract class AuthGuard {
private AuthStrategy authStrategy;
/**
* 상속받은 클래스는 반드시 AuthStrategy를 지정해야합니다.
*
* @param authStrategy
*/
public AuthGuard(AuthStrategy authStrategy) {
this.authStrategy = authStrategy;
}
public final void check(HttpServletRequest request, String... args) {
try {
authStrategy.check(request, args);
} catch (CustomException customException) {
throw customException;
} catch (RuntimeException e) {
throw new CustomException(ExceptionStatus.UNAUTHORIZED_USER);
}
}
}
- 사용법
@Component
public class RoleGuard extends AuthGuard {
public RoleGuard(@Autowired RoleStrategy strategy) {
super(strategy);
}
}
AuthStrategy
AuthStrategy
는 사용자가 정의할Strategy
의 기틀이 되는interface
이다.- 보다 유연하게 전략을 선택하여
Guard
를 정의하도록 하였다.
- 보다 유연하게 전략을 선택하여
- 해당
interface
는AuthGuard
에 주입될 객체를 정의하며,implements
하는class
는public void check(HttpServletRequest request, String... args)
를 정의해야한다. - 이후 해당 전략을 사용하기 위해서는
AuthGuard
를 상속한 객체의 constructor에서 사용할 Spring bean strategy를 주입할 수 있도록JwtGuard(@Autowired JwtStrategy strategy)
와 같이 정의해야한다. - 구현
public interface AuthStrategy {
/**
* Check method는 해당 Auth 작업에 대한 전략을 수행합니다. 만약, 검증 과정에서 실패할 경우 Error를 throw 합니다. 해당 Error는
* Exception Handler 에서 처리할 수 있습니다.
*
* @param request
*/
public void check(HttpServletRequest request, String... args);
}
- 사용법
@Component
public class RoleStrategy implements AuthStrategy {
@Override
public void check(HttpServletRequest request, String[] args) {
// role check
}
}
// JwtGuard 의 정의
public class RoleGuard extends AuthGuard {
public RoleGuard(@Autowired RoleStrategy strategy) { super(strategy); }
}
GuardAspect
@UseGuards
를 핸들링하기 위한ApectJ
를 사용하는class
이다.handleUseGuards()
를 통해 해당annotation
을 선언한 메소드가 실행되기 이전에Guard
의Strategy
를 실행한다.GuardAspect
의 경우GuardResolver
를 통해Guard
객체를 찾아낸다.- 구현
@Aspect
@Component
public class GuardAspect {
@Autowired
private GuardResolver guardResolver;
@Before("@annotation(useGuards)")
public void handleUseGuards(UseGuards useGuards) {
final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes())
.getRequest();
final List<Guard> guards = Arrays.stream(useGuards.value()).toList();
for (Guard guard : guards) {
AuthGuard authGuard = guardResolver.getGuard(guard.value().getSimpleName());
authGuard.check(request, guard.args());
}
}
@Before("@annotation(guard)")
public void handleGuard(Guard guard) {
final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes()).getRequest();
final AuthGuard authGuard =
guardResolver.getGuard(guard.value().getSimpleName());
authGuard.check(request, guard.args());
}
}
GuardResolver
Spring Bean
으로 등록된 객체 중AuthGuard
타입의 객체를 찾아 저장하고, 이를 이후에GuardAspect
에 전달하는 역할을 수행한다.- 구현
@Component
public class GuardResolver {
private final Map<String, AuthGuard> guards;
public GuardResolver(List<AuthGuard> guards) {
this.guards = guards.stream()
.collect(
Collectors.toMap(
g -> g.getClass().getSimpleName(),
Function.identity()
));
}
public AuthGuard getGuard(@NonNull String guardName) {
return this.guards.get(guardName);
}
}
UserData
Guard
를 통해서Session
혹은JWT
정보를 가져올 수 있다. 해당 경우를 위한 지원으로HttpServletRequest
객체를 통해서 유저의 데이터를parameter
를 통해 주입하는 역할을 수행하는annotation
이 필요하다.
@UserData
Guard
에서 주입된 정보를 사용하며, 이 정보는HttpServletRequest
의USER_DATA
attribute 를 기본으로 한다. 만약 다른 형태의 attribute 를 사용하고자 하는 경우,@UserData
의value
값을 지정해야한다.- 해당 값은 외부
Configure
을 통한 주입을 추천하며,HttpServletRequest
의attribute
와 겹치지 않도록해야한다.SNAKE_CASE(UPPER CASE)
를 추천한다.
- 해당 값은 외부
예시
- Guard
@Component
public class JwtStrategy implements AuthStrategy {
@Value("jwt.data.name")
private String dataName = "JWT_DATA";
@Override
public void check(HttpServletRequest request, String... args) {
String token = getTokenFromRequest(request);
request.setAttribute(dataName, token);
}
private String getTokenFromRequest(@NonNull HttpServletRequest request) {
// Get Bearer token from authorization
return request.getHeader(HttpHeaders.AUTHORIZATION);
}
}
- UserData 적용
// controller code
@Value("jwt.data.name")
private String jwtDataName;
//...
@GetMapping("/{id}")
public boolean someMethod(
@PathVariable("id") Long id,
@UserData(jwtDataName) String token, // token is injected
@UserData JwtData data // value 없이도 가능. default "USER_DATA" 사용.
) throws NotFoundException {
return ResponseEntity.ok(userService.getById(id, token));
}
UserDataAspect
@UserData
를 핸들링하기 위한ApectJ
를 사용하는class
이다.handleUserData()
를 통해 해당annotation
을 선언한parameter
값에 해당Object
를 주입한다.- 해당 annotation을 사용할 때 반드시 Strategy에서 사용하는 타입과 일치해야한다.
- 구현
@Aspect
@Component
public class UserDataAspect {
/**
* TokenData annotation 을 적용한 Parameter 에 대해서 Request의 TokenData Attribute 를 가져옵니다. Pointcut :
* method(params.., @TokenData(Type data), params..)
* <p>
* TokenData 의 경우 v
*
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("execution(* *(.., @com.ticketwar.ticketwar.auth.pass.UserData (*), ..))")
public Object handleUserData(ProceedingJoinPoint joinPoint)
throws Throwable {
final Signature signature = joinPoint.getSignature();
final MethodSignature methodSignature = (MethodSignature) signature;
final Method method = methodSignature.getMethod();
final Parameter[] parameters = method.getParameters();
final Object args[] = joinPoint.getArgs();
final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder
.currentRequestAttributes()).getRequest();
for (int idx = 0; idx < parameters.length; ++idx) {
UserData userData = parameters[idx].getAnnotation(UserData.class);
if (userData != null) {
args[idx] = request.getAttribute(userData.value());
break;
}
}
return joinPoint.proceed(args);
}
}
JWT 적용하기
JWT 의 경우 auth0:java-jwt
라이브러리를 활용하였다.
(단순한 PassportJava 적용 예시)
JwtConfigure
- Spring Bean 으로, 외부 변수를 주입하기 위함이다.
jwt.data.name
@UserData
에 저장할request
의attribute
이름을 지정함.
jwt.secret
- JWT secret 지정.
@Component
public class JwtConfigure {
@Value("${jwt.data.name:USER_DATA}")
private String dataName;
@Value("${jwt.secret}")
private String secret;
public String getDataName() {
return dataName;
}
public String getSecret() {
return secret;
}
}
JwtUtil
- SpringBean (Configure 사용 및 타 Bean 에서 autowire 사용을 위함.) 으로, JWT 토큰 획득 과정과 관련된 method를 담는다.
verifyTokenFromRequestAuthorizationHeader
HttpServletRequest
로 부터Bearer Token
을 획득하고, 이를JwtData
형태로 변환한다.
- 이후 다른 형태로 JWT 가 필요할 경우, 해당 형태로 가져와서 사용할 수 있도록 method를 추가할 것이다.
@Component
public class JwtUtil {
private final String BEARER = "BEARER ";
@Autowired
private JwtConfigure jwtConfigure;
/**
* Verify Authorization header's bearer token.
* <p>
* if fails, it throws RuntimeException.
*
* @param request
* @return
*/
public JwtData verifyTokenFromRequestAuthorizationHeader(HttpServletRequest request) {
final String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION).substring(BEARER.length());
final Algorithm algorithm = Algorithm.HMAC256(jwtConfigure.getSecret());
final JWTVerifier jwtVerifier = JWT.require(algorithm).build();
final DecodedJWT decodedJWT = jwtVerifier.verify(bearerToken);
return getJwtDataFromDecodedJwt(decodedJWT);
}
private JwtData getJwtDataFromDecodedJwt(DecodedJWT decodedJWT) {
final Claim id = decodedJWT.getClaim("id");
final Claim email = decodedJWT.getClaim("email");
final Claim nickname = decodedJWT.getClaim("nickname");
final Claim role = decodedJWT.getClaim("role");
return JwtData.builder()
.id(id.asLong())
.email(email.asString())
.nickname(nickname.asString())
.role(role.asString())
.build();
}
}
JwtStrategy
@Component
public class JwtStrategy implements AuthStrategy {
@Autowired
private JwtConfigure jwtConfigure;
@Autowired
private JwtUtil jwtUtil;
@Override
public void check(HttpServletRequest request, String... args) {
final JwtData jwtData = jwtUtil.verifyTokenFromRequestAuthorizationHeader(request);
request.setAttribute(jwtConfigure.getDataName(), jwtData);
}
}
JwtGuard
@Component
public class JwtGuard extends AuthGuard {
public JwtGuard(@Autowired JwtStrategy strategy) {
super(strategy);
}
}
개발 과정 중 문제
Spring bean 획득과 new ()를 통한 Object
- AuthGuard 의 설계 초기에는
super(new JwtStrategy())
와 같은 형태를 통해서Strategy
를 주입하였다. Spring 과 상관 없이Strategy
를 제작을 하고자 함이었다.
@Component
public class JwtGuard extends AuthGuard {
public JwtGuard() {
super(new JwtStrategy());
}
}
- 그러나
JwtStrategy
를 만들면서, 실패했다.JwtStrategy
의 경우 필연적으로secret
을 외부로부터 주입받아야하는데, 보통 그 수단으로 Spring의@Value
를 통해서 값을 주입받는다. 그러나, 이는Spring bean
에 의해서만 가능하다. - 따라서 어쩔 수 없이,
@Component
를 선언하고, Spring bean 에 등록을 하였으나,jwt.secret
의 값을 제대로 가져오지 못했다. 위 코드를 보면 알 수 있듯,JwtStrategy
를new
를 통해서 새로 인스턴스를 만들고 주입하기에,Bean
과 관련없는 객체가 주입이되었다. AuthGuard
의constructor
에서 직접 bean 을 찾아 넣는 방식도 시도하였으나, 적절하지도 않으며, 해당 시점에는ApplicationContext
가 완성되지 않아 활용할 수도 없다. (되도록이면 접근하지 않는 것이 맞다고 생각한다.) 이렇게 할 경우, Spring 자체에도 의존성이 생겨버린 상태기 때문에 의미가 없었다.- 애초에 해당 시스템 자체가 Spring에 의존을 하고 있기 때문에, Strategy를 Component로 등록하지 않을 이유는 없었다. 따라서, 항상
AuthStrategy
구현 객체는 Spring Bean 에 등록하고,@Autowired
를 통해서 이를 등록하도록 했다. - 결과적으로는 NestJS + Passport 의 방식에 비하면 조금 복잡하기는 하나,
AuthGuard
의 유연성을 지키고, 몇 줄 안되는 코드로AuthStrategy
를 결정할 수 있는 방식이 되었다. 나름 깔끔하게 사용자가 선언할 수 있어 마음에 든다.
@Component
public class JwtGuard extends AuthGuard {
public JwtGuard(@Autowired JwtStrategy strategy) {
super(strategy);
}
}
@Guard, @UseGuards에서 arguments
@UseGuards({JwtGuard.class, RoleGuard.class})
- 초기에는
@Guard
없이@UseGuards
만으로 구성했다. 또한, 인자를 받지 않았다. JwtGuard
의 경우, JWT verification 만 하고 끝내면 되니, 특별한 인자가 필요없지만,RoleGuard
의 경우 해당 method 의 접근 권한을 설정해야하기 때문에 문제가 발생한다.- 또한 다른 custom guard 를 만들 경우에서도 인자를 받을 수 없어 유연성이 부족하다. 권한마다 매번 Guard를 만들 수는 없다.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UseGuards {
Class[] value();
String[] args();
}
@UseGuards(value = {JwtGuard.class, RoleGuard.class}, args = {null, "ADMIN"})
- 그렇다고, 위와 같이 args를 순서대로 매핑하는 느낌으로 사용하는 것도 바람직하지 않다. 또한 여러 인자를 한 Guard에 전달할 수도 없다.
- 따라서, 각
AuthGuard
는 해당 클래스와 인자를 전달하는@Guard
를 통해서 사용하도록 했다.
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Guard {
Class value();
String[] args() default {};
}
- 이를 통해서
Guard
는 각각의 인자를 받아올 수 있게 되었다. - 또한, 여러개의
Guard
를 순차적으로 실행하는 것도 필요하기에@UseGuards
는 다음과 같이 고쳤다.
/**
* UseGuards
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UseGuards {
Guard[] value();
}
@GetMapping("/{id}")
@UseGuards({
@Guard(JwtGuard.class),
@Guard(value = RoleGuard.class, args = {"ADMIN"})
})
public ResponseEntity<UserResDto> getById(){...}
- 다소 복잡하기는 하나, 필요한 요소들을 모두 표현할 수 있다. 다만, String을 통해서만 인자를 받아올 수 있기 때문에 아쉬운 면이 있다.
UserRole.ADMIN
과 같이 설정한다면 더욱 편리할 것이다. 하지만, annotation에서 Enum의 경우 특정해야만 사용 가능하여, 다양한 형태를 받아야하는 현재로서는 어쩔 수 없었다.
Spring Security 와의 비교
직접 구현 이전에는, Spring security 의 예시만 알고 있기 때문에 직접 개발한 형태의 방식은 어떤 장점과 단점을 가질 지에 대해서 명확하게 정의를 내리기 어려웠다. 직접 구현 이후 Spring security 의 적용에 비해서 가질 수 있는 장단점을 간략하게 생각해보았다.
장점
- 상대적으로 쉽다.
- 이는 자체적으로 구조가 단순하기 때문이다. Spring security 의 경우 다양한 보안 관련 기능을 지원해야하고, 이로 인해서 어쩔 수 없이 복잡한 구조를 가져야한다. (실제로 사용자가 사용하지 않는 기능들 까지 담아야하므로...) 반면, 해당 PassportJava 의 경우 Controller method의 실행 이전 intercept 하여 실행할 내용을 정의하는 것을 지원하는 것 뿐이다. 구조적으로 단순하니, 쉬울 수 밖에 없다.
- 또한, 개발자가 직접 해당 기능에 대한 작동을 정의하기 때문에 상대적으로 그 코드를 작성한 개발자에게 있어서는 이해가 쉬울 것이다. 그러나, 팀 단위로 개발하는 환경을 고려하면 정형화되어 있는 형태로 작성해야하는 생태계 (Spring security)가 오히려 코드와 구조를 이해하는 측면에서는 나을 것 같다.
- 직접 제어 가능하다.
- Spring security 와 같은 형태의 이미 주어진 프레임워크에서는 어쩔 수 없이, 제한된 환경에서 해당 구조에 알맞게 코드를 작성해야한다. 물론 어느정도 유연하게 작성할 수는 있겠으나, 프레임워크가 제공하는 시점 아래에서 코드가 작성되어야하며 디테일한 조정은 불가하다.
- 반면, 이렇게 직접 작성하는 경우 개발자가 원하는 방식으로 모두 제어할 수 있다. "불만이 있으면 직접 뛰던가" 의 예시...? 결국에는 Spring Security 가 등장하기 이전에는 어쩔 수 없이 이렇게 코드를 작성해야한 했을 것이다.
단점
- 다양한 기능 지원의 부재
- "쉽다/단순하다" 에서 비롯되는 어쩔 수 없는 단점이다. 필요한 기능이 있을 경우 개발자가 직접 지원하도록 만들어야한다. 시간적인 측면에서 문제가 있다.
- 안정성
- 거대하고 안정된 커뮤니티에서 만들어진 프레임워크를 사용하는 것에 비하면 안정성에서 확실하지 않다. 이는 이러한 형태의 시스템을 만드는 사람에 따라 다르겠으나, 아무래도 Spring Security 에 비하면 부족함은 사실이다.
- (내 구조의 경우) 여전히 Spring에 의존한다.
- Spring bean 이 되어 해당하는
AuthGuard
를 찾아내고, 적합한Strategy
를 주입한다. 이로 인해 Spring 으로부터 독립된 패키지가 아니다. 다른 Java 의 Framework 에 적용할 수 없다.
- Spring bean 이 되어 해당하는
- 결국에는 Spring Security 형태를 따라갈 수 밖에 없다.
- 이번에 PassportJava를 만들어내면서, Spring security 의 등장에 대해서 생각해보았다.
- Spring Security 이전에는 Spring scope 내에서 처리하기 위해서는 PassportJava 와 같은 형태로 제작을 해야만 했을 것이다.
- 혹은, Spring Security 와 마찬가지로 Servlet 에 직접 접근하여 Filter 를 사용하는 형식을 취할 것이다. 이런 형태의 최종 추상화가 바로 Spring Security 형태이다.
- 개발자가 원하는 형태의 사용자 인증 Process 는 결국 비슷하다. 다양한 형태의 인증 프로세스를 범용적으로 지원하기 위한 고민을 Spring security 에서 나름의 정답을 내놓은 셈이다. 더 나은 프로세스가 존재할 수도 있지만, 결국에는 유사한 형태의 결과가 만들어지곤한다.
- 특별히 더 나은 형태의 구조의 발상이 있지 않다면, 누구나 접근할 수 있으며, 사용법과 구조에 대해 이해가 있는 Spring Security 를 적용하는 것이 생산성 측면에서 더 유리하리라 생각한다.
테스트에서의 문제
- 해당 라이브러리는 AspectJ 를 활용해서 Method가 실행되기 이전 intercept 하여 실행한다.
- 이는 Controller 영역의 테스트에서 문제가 발생한다. Test 를 위한 지원이 포함되어있는 Spring Security 와 달리, 해당 라이브러리는 Test 를 할 때 Intercept 하고, argument 를 주입하는 영역에 대해 개별적으로 mocking을 하거나 하는 방식으로 처리를 해야한다.
@Test
void something() {
// 이런 방식으로 Strategy 의 동작과 Resolver 의 동작을 구성해야함...
BDDMockito.doNothing().when(jwtStrategy).check(any(HttpServletRequest.class));
BDDMockito.given(userIdArgumentResolver.supportsParameter(any())).willReturn(true);
BDDMockito.given(userIdArgumentResolver.resolveArgument(any(), any(), any(), any()))
}