OAuth 2.0 Resource Server에서 JWT 토큰을 검증하고 권한을 처리하는 방법에 대해 알아보겠습니다. Spring Security를 사용하여 Resource Server를 구성하고, JWT 토큰의 scope와 roles 클레임을 권한으로 변환하는 과정을 살펴볼 것입니다. 또한, 자주 발생하는 SSL 관련 오류와 그 해결 방법에 대해서도 다룰 예정입니다.
Authorization_code vs Client_credentials
OAuth 2.0의 Authorization Code 방식과 Client Credentials 방식은 서로 다른 사용 사례와 흐름을 가지고 있습니다. 두 방식의 주요 차이점은 다음과 같습니다:
Authorization Code 방식:
사용자 개입이 필요한 인증 흐름
웹 애플리케이션이나 모바일 앱에서 주로 사용
리소스 소유자(사용자)의 동의를 얻어 액세스 토큰을 발급
보안성이 높고 refresh token을 사용할 수 있음
Client Credentials 방식:
클라이언트 애플리케이션이 직접 자신의 자격 증명으로 인증
서버 간 통신이나 백그라운드 작업에 주로 사용
사용자 컨텍스트 없이 클라이언트 자체의 권한으로 액세스 토큰을 얻음
간단하지만 사용자 특정 데이터에 접근할 수 없음
OAuth2 Authorization Server Setup
Spring Boot와 Spring Security를 사용하여 OAuth 2.0 Authorization Server를 구현하기 위해서는 다음과 같은 핵심 설정이 필요합니다:
- JWT Token을 이용할 경우 일반적으로 Filter에서 해당 토큰을 검증한뒤 필요한 정보를 찾아서 Authentication 인터페이스 를 전달하여 세팅하게 됩니다. 이 Authentication 을 SpringSecurity 에서 일반적인 형태로 구현해놓은 것이 UserNamePasswordAuthenticationToken 입니다.
public Authentication getAuthentication(String token) throws TimeoutException, ExecutionException, InterruptedException {
LoginUserDetails loginUserDetails = this.makeUserDetailsFromToken(token);
return new UsernamePasswordAuthenticationToken(loginUserDetails, "", loginUserDetails.getAuthorities());
}
<Token정보로부터 사용자정보를 얻어낸 뒤 UsernamePasswordAuthenticationToken 생성>
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 620L;
private final Object principal;
private Object credentials;
<Sring Security에 구현되어 있는 클래스>
2. Test Code 작성법
public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails,
Pageable pageable) {
- Spring 전체 Context를 기동한다면 가능하겠지만 우리는 일반적으로 Controller Layer를 테스트할때 @WebMvcTest를 사용합니다. (테스트 레이어 분리, 범위 한정 등등의 이유로)
- 문제는 이렇게 진행하면 해당 코드를 테스트할때 그냥 호출하게 되면 loginUserDetails에서는 Null 이 발생하게 됩니다.
- 1번에서 설명했던 Authentication정보를 Context에 세팅하는 부분 (Filter) 이 호출되지 않기 때문입니다.
- 그래서 아래와 같이 수동으로 UserDatails를 세팅해주는 InMemoryUserDetailManager 사용을 권장합니다.
@Bean
@Primary
public UserDetailsService userDetailsService() {
List<SimpleGrantedAuthority> userAuth = new ArrayList<>();
userAuth.add(new SimpleGrantedAuthority("ROLE_USER"));
List<SimpleGrantedAuthority> adminAuth = new ArrayList<>();
adminAuth.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
LoginUserDetails loginUserDetails = new LoginUserDetails(1, "", "user","", userAuth);
LoginUserDetails loginAdminDetails = new LoginUserDetails(2, "", "admin", "", adminAuth);
return new InMemoryUserDetailsManager(Arrays.asList(loginUserDetails, loginAdminDetails));
}
- 그리고 테스트 케이스에서는 @WithUserDetails 어노테이션을 사용해서 앞에서 생성했던 UserDetails정보를 통해서 호출할 수 있습니다. (@WithMockUser, @WithAnonymousUser, @WithSecurityContext 등을 적합한 것을 선택해서 사용하면 됩니다.)
- User 클래스를 상속받아서 별도로 구현한 경우에는 NullPointerException 가 발생하는 경우가 있습니다. (Spring Security 버전에 따라 차이가 있는지는 확인해야 할 것 같습니다.)
public class LoginUserDetails extends User {
private Integer userId;
private String userGroup;
public LoginUserDetails(Integer userId, String password, String userName, String userGroup, Collection<? extends GrantedAuthority> authorities) {
super(userName, password, authorities);
this.userId = userId;
this.userGroup = userGroup;
}
.. 기타 로직구현 메소드
- 이유는 다음과 같습니다. User 클래스를 살펴보면 이 역시 UsenamePasswordAuthenticationToken 처럼 Spring Security에서 UserDetails 인터페이스를 일반적으로 구현해놓은 클래스입니다.
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = 620L;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
- 그리고 아까 테스트코드에서 우리가 사용한 InMemoryUserDetailsManager 역시 Spring Security에서 UserDetailsManager를 구현해 놓은 클래스입니다.
public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
protected final Log logger = LogFactory.getLog(this.getClass());
private final Map<String, MutableUserDetails> users = new HashMap();
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private AuthenticationManager authenticationManager;
... 중략 ...
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails user = (UserDetails)this.users.get(username.toLowerCase());
if (user == null) {
throw new UsernameNotFoundException(username);
} else {
return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
}
}
- Spring Security는 바로 이 loadUserByUsername 을 호출해서 UserDetails를 세팅하게 되는데 User 객체를 받게 됩니다.
-바로 여기가 문제의 원인입니다.
- 비지니스 사항때문에 User를 상속받아서 별도로 구현한 LoginUserDetails는 User의 Sub type입니다.
LoginUserDetails는 User가 될 수 있지만, User는 LoginUserDetails가 될 수 없습니다.
- 이러한 내용을 Controller에서 좀 더 명확하게 확인하기 위해서 (errorOnInvalidType=true)를 사용해봅니다.
public @ResponseBody
Page<DataDto> findDataAll (@AuthenticationPrincipal(errorOnInvalidType=true) LoginUserDetails loginUserDetails,
Pageable pageable) {
- 아래와 같은 오류메시지를 확인할 수 있습니다.
Caused by: java.lang.ClassCastException: class org.springframework.security.core.userdetails.User cannot be cast to class ...LoginUserDetails (org.springframework.security.core.userdetails.User and ...LoginUserDetails are in unnamed module of loader 'app')
=> 이부분은 사실 원격요청을 하지 않고 제공되는 공개키, pem을 통해서 로컬에서 검증이 가능하지만 현재 java로는 직접 구현해야 하는 부분이 많고, 자주는 아니지만 공개키는 변경될 수 있기 때문에 키값을 확인하기 위해서는 결국 원격요청이 필요해서 쉽게 가기로 했다.
-- "cognito:groups" 정보를 이용해서 권한을 세팅한다.
B. 기존 토큰이라면
- secretKey를 이용해서 검증하고
- "roles" 정보를 이용해서 권한을 세팅한다.
<정리>
이렇게 만들면 기존의 JWT Token(HS256) 와 신규 Cognito Token (RSA256) 를 모두 소화할 수 있다.
그러나 만들어 놓고보니 소스가 지저분하다. ResourceServer에서 권한을 확인하여 API접근제어를 해야하는데 좋은 구조는 아니다.
- 로직이 복잡하고(구조 특성상 해당모듈을 그대로 copy해가는 방식이 될텐데 유지보수성이...영), secretKey 공유의 문제도 있다.
- 꼭 두 가지 토큰을 소화할 필요가 있을까?
- 기존 방식은 외부 사용자가 고려되지 않은 방식이고, OAuth 2.0은 출발자체가 3rd Party연계를 간편하게 하기 위한 방법이다.
다양한 확장을 위해서 인증은 각각의 방식을 유지하고 서비스를 사용자에 따라서 분리하는 것이 해결책이라는 판단을 내렸다.
- 내부 사용자가 이용하며 Client, Resource Server가 같은 Boundary에 있는 레거시의 경우 기존 토큰을 사용하고
- 외부 사용자가 이용해야 하는 API 서비스는 별도로 분리 구성하며, 이 서비스에서는 OAuth 2.0만을 활용하여 인가/인증을 하도록 한다.
(이부분은 AWS API Gateway내에서 자격증명으로 Cognito를 연계하는 방식과 시너지를 낼 수 있을 것 같다.)
- 이를 위해서 보다 쉽게 Resource Server를 세팅하는 법을 Spring Security에서 제공하고 있는데 다음글에서 좀 더 자세히 적용해보고 서비스를 어떻게 분리하는 것이 보다 효과적일지 살펴본다.
- 특정 ip 목록이 추가될 경우 SpEL처리를 하며, 빈 값이 넘어올 경우 전체 ip에 대해서 허용해주도록 한다.
@ConfigurationProperties(prefix = "1.2.3")
@Getter
@Setter
@ToString
public class Properties {
private SsoProperties ssoProperties;
}
public class SsoProperties {
private List<String> allowedIps = new ArrayList<>();
}
- ip목록의 경우 수시로 바뀔수 있기 때문에 별도 properties를 활용하도록 한다.
<수행결과>
- FilterSecurityInterceptor에 의해서 처리되었으며 처리 결과는 다음과 같다.
localhost주소 127.0.0.1
<주의사항>
- IPv6를 사용하고 있을 경우 ip형식이 다음과 같이 처리가 되기 때문에 허용주소를 "127.0.0.1"로 한 경우 403오류가 발생한다.
IPv6
- IPv6로 주소를 사용하거나 JVM 기동시 -Djava.net.preferIPv4Stack=true 옵션을 사용하면 된다.
- Spring Security 의 경우 Filter Chain 의 형태로 기능을 제공하고 있으며 필요에 따라서 추가/수정/삭제가 가능하다.
<내용>
1. Spring Security - Filter Chain
- Spring 에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller 로 분배된다.
- 이 때 각 Request 에 대해서 공통적으로 처리해야할 필요가 있을 때 DispatcherServlet 이전에 단계가 필요하며 이것이 Filter이다.
Filter 와 Interceptor의 차이
- Spring Security는 FilterChainProxy를 통해서 상세로직을 구현하고 있다.
Spring Security Filters
- 현재 Application에서 사용중인 Filter Chain은 Debug를 통해서 쉽게 확인할 수 있다.
11개의 Filter를 사용중이다.
- FilterChain이름이 의미하듯 Filter는 순서가 매우 중요하다.
2. 우리가 가장 많이 사용하는 UserNamePassword 구조에 대해서 좀 더 살펴보겠다.
(적당한 그림을 찾지 못해서 손으로 그려보았다.)
AuthenticationFilter
- Request가 들어왔을때 Filter를 거치게 되고, 적절한 AuthenticationToken이 존재하지 않는다면 AuthenticationProviders를 거쳐서 생성하게 되며 UserDeatilsService를 입맛에 맞게 구현하여 사용자 정보를 가져오는 부분을 구현할 수 있다.
- Spring Securty에서는 사람들이 가장 많이 사용하는 DB인증을 다음과 같이 미리 구현해 두었다. (오른쪽 파란색 박스)
3. 인증방식 변경 ( JWT Token)
최근에는 MSA구조의 효율성을 높이기 위해서 JWT Token 방식을 많이 사용하고 있다. Request에 대한 인증을 별도 서버를 거치지 않고 검증가능하고 기본로직에 필요한 내용을 담을 수 있어서 편리하다.
현재 서비스에 적용중인 대략적인 구조이다.
JWT Token Filter
- JWT AuthTokenFilter에서 해당 처리를 마친 후 나머지 FilterChain을 수행하는 구조이다.
- Token을 통해서 인증 및 인가를 위한 정보를 생성하여 SecurityContextHolder를 통해서 세팅한다.
- JWT Filter의 내용을 간단히 살펴보면 아래와 같다. (보안상 TokenProvider 로직은 개별구현하는 것을 권장한다.)
@Bean
public DelegatingSecurityContextAsyncTaskExecutor taskExecutor(ThreadPoolTaskExecutor delegate) {
return new DelegatingSecurityContextAsyncTaskExecutor(delegate);
}
2. DelegatingSecurityContextAsyncTaskExecutor 소스를 살펴보면 다음과 같다.
최상위 추상 클래스로 AbstractDelegatingSecurityContextSupport 가 구현되어 있으며 Runnable, Callable에 대하여 wrapping처리를 하게 되며,
abstract class AbstractDelegatingSecurityContextSupport {
private final SecurityContext securityContext;
/**
* Creates a new {@link AbstractDelegatingSecurityContextSupport} that uses the
* specified {@link SecurityContext}.
*
* @param securityContext the {@link SecurityContext} to use for each
* {@link DelegatingSecurityContextRunnable} and each
* {@link DelegatingSecurityContextCallable} or null to default to the current
* {@link SecurityContext}.
*/
AbstractDelegatingSecurityContextSupport(SecurityContext securityContext) {
this.securityContext = securityContext;
}
protected final Runnable wrap(Runnable delegate) {
return DelegatingSecurityContextRunnable.create(delegate, securityContext);
}
protected final <T> Callable<T> wrap(Callable<T> delegate) {
return DelegatingSecurityContextCallable.create(delegate, securityContext);
}
}
DelegatingSecurityContextRunnable 의 run 메소드를 수행할때 delegateSecurityContext를 세팅하여 수행하도록 되어있다.
수행이 완료된 뒤에는 originalSecurityContext로 원복시키고, 현재 Runnable에 세팅되어 있던 값은 null 처리 해야 ThreadPool사용시 문제가 발생하지 않는다.
public final class DelegatingSecurityContextRunnable implements Runnable {
...
@Override
public void run() {
this.originalSecurityContext = SecurityContextHolder.getContext();
try {
SecurityContextHolder.setContext(delegateSecurityContext);
delegate.run();
}
finally {
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
if (emptyContext.equals(originalSecurityContext)) {
SecurityContextHolder.clearContext();
} else {
SecurityContextHolder.setContext(originalSecurityContext);
}
this.originalSecurityContext = null;
}
}
...
<추가 문제상황>
- 해당 작업을 @Scheduled 를 통해서 수행할 경우에도 SecurityContext는 비어있게 된다. 인증정보를 가지고 수행된 것이 아니기 때문!