OAuth2: Authorization-Server (Custom 인증 서버 구축)(2)
지난 포스팅에 이어서, Spring Security에서 제공하는 spring-oauth2-authorization-server 를 나의 Application에 맞게 수정해 볼 예정이다.
Spring Security 에서 제공하는 기본적인 Security Config 라던지, 기본적인 개념은 아래의 글을 참고하는 것을 추천한다.
https://lucas-owner.tistory.com/79
인증 서버 구축하는 이유?
필자가 자체적인 인증서버(Authorization Server) 를 구축하는 이유는, 첫 번째로 일관된 인증 체계이다.
여러 개의 서비스를 구축하고, 운영을 계획 중에 있는데, 모든 서비스에 자체적인 인증체계와 보안을 구축하는 것도 좋은 방법 중 하나일 수도 있지만, 내 서비스들에 맞게 자체 인증서버를 사용하면, 서비스들의 통합을 구현하기에 용이하기 때문이다.(또한 인증, 인가, 보안에 대한 개발시간을 단축시킬 수 있다)
두 번째로는 커스터마이징과, 외부 종속성 삭제이다. 이전 글에서 설명했던 Keyclock 이라던지 이미 완성된 인증서버를 사용할 수 도 있다.
하지만 해당 Tool에 대해서 의존하게 되면 인증 플로우 및 커스텀을 하기 어렵다. 또한 Tool 에 대해서 추가적인 공부에 대한 비용과, 시간 절감을 할 수 있다.
초반에 구축하기 어렵다는 장점이 분명 존재하지만, 추후 고도화를 이어간다면 자체 인증서버를 통한 IP 제한, 그룹화된 보안정책, 다중 팩터 인증, 정책 등 자유롭게 구성이 가능하다는 장점이 있기에 자체 인증서버를 구축하는 것이 분명한 메리트가 있다고 생각했다.
흐름 및 설명
전체적인 흐름은 위 다이어그램과 같다, JWT 검증에 대한 부분을 Resource Server에 위임한다는 것이
기존의 Authorization Server의 흐름에서 변경된 부분이다.
서비스가 많아지게 된다면, "인가"에 대한 부분을 일일이 Auth Server에 질의 하게 된다면, 유저의 API 요청당 Auth Server 에 Validation 을 요구하기에 네트워크 통신 자원을 낭비하지 않고 Auth Server에 부하가 생길 수 있다고 판단했기 때문이다, Auth Server 에 Resource Server를 등록할 때, Auth Server에서 Resource Server로 공개키(public Key)를 제공하여, JWT 키를 검증할 수 있도록 설계했다.
추가 및 수정할 기능
- User 가입 및 인증에 대한 기능(Login, register Page)
- Client Host 등록 및 Security 등록
- RSA Key DB 관리
- JWT Claim Custom
- 위 기능에 따른 Security Configuration 수정
리스트에 있는 기능들을 추가, 수정하여 기존에 하드코딩 되어있던 부분들과 InMemory로 구현되어 있는 부분들을
전부 DB에 저장하여 관리할 예정이고, JWT Validation 또한 Auth Server에서 가능하도록 설정할 예정이다.
각 구현부에 대해서는, 핵심적인 로직에 대해서만 기술할 예정이다.
1. User 가입 및 인증
우선 User 가 로그인, 가입을 할 수 있는 페이지를 우리는 Client에게 제공해야 하기에, thymeleaf 기반으로 페이지를 생성, Controller와 연결을 시켜줬다. (Security에서 제공하는 Default Login Form을 사용할 수 있지만, 가입(regist) 화면을 제공해야 했기에 Login 화면을 추가.)
http://localhost:9999/login 으로 접속했을 때 화면은 다음과 같다.
Spring Security에서 제공하는 기본 Login 화면처럼 보이지만,, 최대한 비슷하게 구현한 내용이다.
Default Login Page의 html 경우 Spring Security docs에서 제공하고 있다.
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html
http://localhost:9999/signup 가입 화면 예시이고, 테스트를 위한 최소한의 입력정보와, 테스트용 field를 추가했다.
가입 로직을 진행할 때, BCryptPasswordEncoder를 사용하여 Password 를 암호화는 필수로 할것이다.
추후에 Client 등록 부분에서도 해당 Encoder 를 사용하여, Secret Key를 암호화할 예정이다.
이때 SecurityConfig.class 에 BCryptPasswordEncoder를 Bean으로 등록하게 된다면, 각 Servcie 클래스들을 DI 하는 과정에서 순환참조 Exception 이 발생하기에 따로 Component로 미리 빼주도록 하겠다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class PasswordEncoderConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
1-2. 로그인 인증
Spring Security를 사용을 해봤다면, Security에서 어떤 인터페이스를 구현해야 하는지 알고 있을 것이다.
우리는 UserDetailsService 인터페이스를 구현하여, DB에서 유저의 정보를 불러오고 인증할 것이다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor
@Slf4j
@Transactional
public class CustomUserService implements UserDetailsService {
private final UserRepository repository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = repository.findByUserName(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUserName())
.password(user.getPassword())
.roles(user.getRole())
.build();
}
}
해당 인터페이스를 implements 함으로써 Login 검증을 수행할 수 있게 된다.
@Bean
public UserDetailsService userDetailsService() {
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
SecurityConfig 클래스의 userDetailsService는 제거해주도록 하자.
(UserDetailsService를 구현했기에 필요 없는 Bean이다.)
2. Client Host 등록 및 Security 설정
OAuth2.0 Authorization Server를 사용하기 위해서는 Client에 대한 정보들을 설정해야 한다.
아래는 Security Sample의 내용이다.
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oidc-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080")
.postLogoutRedirectUri("http://127.0.0.1:8080")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
return new InMemoryRegisteredClientRepository(oidcClient);
}
모든 설정들을 DB에 저장하고 사용할 수 있지만, 나의 서비스에서는 각 클라이언트를 구별할 수 있는 필수적인 항목 몇 개 만을 DB에
저장하도록 하겠다.
우선 SecurityConfig 클래스의 설정부터 보도록 하겠다.
// SecurityConfig.class
@Lazy
private final OAuthClientService oAuthClientService;
// ...
@Bean
public RegisteredClientRepository registeredClientRepository() {
return new RegisteredClientRepository() {
@Override
public void save(RegisteredClient registeredClient) {
}
@Override
public RegisteredClient findById(String id) {
return oAuthClientService.findByIdString(id);
}
@Override
public RegisteredClient findByClientId(String clientId) {
return oAuthClientService.loadClientByClientId(clientId);
}
};
}
OAuthClientService는 Client에 대한 CRUD 및 Security에 등록하기 위한 기능클래스이다.
registeredClientRepository()는 springframework.security.oauth2.server.authorization.client 에서 제공하는
Repository 기능이다. 오버라이드 하여 사용을 해야 하며, save의 경우 RegisterClient 포맷이 아닌, 별도의 DTO 클래스를 통해 진행함으로 오버라이드 메서드를 사용하지 않았다.
이외에 ID, ClientId로 조회하여 Client를 조회 등록하는 부분이 있는데 얼핏 본다면 "하나만 써도 되는 거 아닌가?"라는 생각이 들 수 있지만 메서드 2개를 모두 구현해야 한다. 이유는, 각각의 메서드는 특정시점에 다른 목적을 위해 호출되기 때문이다.
id 조회는 Authorization Server 의 내부 관리 목적이고, ClientId 는 클라이언트 인증을 위해 외부적으로 조회되는 목적이다.
ClientService 의 조회 부분 코드이다. 각각 findById, findByClientId 로 특정 Client 를 조회 후, loadClientByResult() 라는 private Method 를 통해 RegisterdClient 가 필요로 하는 객체로 매핑후 전달해주었다.
이때 DB 에서 조회해온 값을 세팅하는 부분을 본다면, Client 별로 필수로 받아야 하는 데이터가 어떤것인지 알 수 있을것이다.
3. RSA Key
Spring OAuth Authorization Server는 JWT 를 nimbus 라는 Library 로 생성, 검증을 한다.
그리고 이 JWT 를 발급 및 검증에 필요한게 RSA Key 이다.
RSA Key 는 공개키와, 비공개 키로 이루어져 있으며, 아래와 같은 역할을 한다.
- JWT 생성: RSA 비공개키로 서명
- JWT 검증: RSA 공개키로 서명검증
각 Client 마다 RSA Key를 다르게 발급하여 관리하는것이 좋지만, 해당 예제에서는 하나의 RSA Key쌍으로 관리하는것을 예시로 들겠다.
큰 흐름으로 RSA Key 관리 및 사용흐름은 아래와 같다.
- RSA Key 생성
- DB 에 저장(Encoding 하여 진행)
- (실제사용시) DB 조회 RSA Key Decoding
- ImmutableJWKSet<SecurityContext> 에 RSA Key를 이용해 JWKSet 저장.
이전에 Security Sample 에서는 서버 실행시마다 새로운 RSA Key 를 발급하여 Set 하는 방식이었다, 즉 매번 다른 Key 를 생성하기 때문에, 리소스 서버에서도 매번 RSA Key 를 재요청하고 처리하는 과정을 거쳤어야 하는 것이다.
아래의 RsaKey Service Class 를 보도록하자.
해당 Class 는 2개의 public 메서드와, 2개의 private 메서드로 구성되어있는데
public 메서드의 경우 RSA Key 저장, ImmutableJWKSet return 으로 이루어져 있다.
private 메서드의 경우 RSA Key 생성 로직과, Key를 decoding 하는 메서드로 구성되어 있다. 생성된 RSA Key 는 DB 에 저장될때 Base64 로 인코딩 되어 저장되게 되는데 실제 조회 후 사용시 public, private 키는 각각 Key Spec 에 맞춰서 로드 되어야 하기때문에
별도의 메서드로 분리 해 주었다.
이후로는 Security 에서 JWT 를 생성, 검증을 위한 JWKSet 형식에 맞춰 RSA Key 를 Set 해준후에 Return 해주면
nimbus 를 JWT 로직에서 해당 RSA Key를 사용하게 된다.
@Bean
public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException, InvalidKeySpecException {
return rsaKeysService.loadByJwkSet("bomkey");
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
SecurityConfig 클래스에 위와 같이 정의를 해주게 된다면, Security Context 에 저장된 JWKSet(with RSA Key) 을 통해
JWT decoding 을 수행할 수 있게 된다.
4. JWT Claim Custom
일반적으로 jjwt 라이브러리나, java-jwt 를 사용하게 되면 토큰 생성 코드를 일일이 작성하게 되지만, nimbus 의 경우
OAuth2TokenCustomizer 인터페이스의 기본 구현체를 사용하게 되며, JWT 에 필요한 default claim 들이 들어가게된다.
claim 을 추가하고 싶으면 해당 인터페이스를 구현한 Component 로 JWT Claim 에 대한 추가적인 설정을 할 수 있다.
/**
* JWT Customizer
* - Token Type AT Only (Access Token) Customization
* - Add Custom Claim
*/
@Component
public class JwtCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
private String returnClientId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication != null) {
return authentication.getName();
}
return "unknown";
}
@Override
public void customize(JwtEncodingContext context) {
// Token Type AT Only (Access Token) Customization
if(context.getTokenType().getValue().equals("access_token")) {
context.getClaims().claim("user_host", returnClientId());
// Exp
Instant now = Instant.now();
Instant expTime = now.plus(60, ChronoUnit.MINUTES); // 1Hour
context.getClaims().expiresAt(expTime);
}
}
}
간단하게 user의 host 를 JWT Cliam 에 추가해주었고, 토큰의 Expire 설정을 변경해 주었다.
5. SecurityConfig.class 전체 코드
@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class SecurityConfig {
@Lazy
private final OAuthClientService oAuthClientService;
private final RsaKeysService rsaKeysService;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
List<String> originUris = oAuthClientService.getOriginUris();
originUris.forEach(config::addAllowedOrigin);
config.addAllowedHeader("*");
config.addAllowedMethod("POST");
config.addAllowedMethod("GET");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
@Order(1)
public SecurityFilterChain serverFilterChain(HttpSecurity http) throws Exception{
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
http
.cors(Customizer.withDefaults())
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/user/signup", "/user/view/signup", "/signup").permitAll() //signup
.requestMatchers("/client/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger
.anyRequest().authenticated()
)
.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()))
.formLogin(form -> form.loginPage("/login").permitAll());
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
return new RegisteredClientRepository() {
@Override
public void save(RegisteredClient registeredClient) {
}
@Override
public RegisteredClient findById(String id) {
return oAuthClientService.findByIdString(id);
}
@Override
public RegisteredClient findByClientId(String clientId) {
return oAuthClientService.loadClientByClientId(clientId);
}
};
}
@Bean
public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException, InvalidKeySpecException {
return rsaKeysService.loadByJwkSet("bomkey");
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.build();
}
}
여기까지 잘 왔다면, 대부분의 설정은 되어있을것이다.
다만 언급되지 않은 설정들을 본다면 다음과 같다.
- 특정 Client URL들에 대해서 CORS 허용
- 뷰 페이지에 대한 API 허용, swagger 경로에 대한 허용
실제로 인증할때 Client 에서는 Auth Server 로 3번의 요청을 보내게 되는데, CORS 설정을 추가로 해주지 않는다면 Token 발급에 대한 요청이 Reject 되기 때문에 유의해야한다.
.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults()))
위의 설정은 Jwt 토큰을 통해 보호된 API 에 접근하려고 할때, JWT 토큰을 검증 하도록 설정하는 코드인데
아래와 같은 테스트 컨트롤러를 통해, JWT 발급 및 검증을 테스트 하기 위한 장치이다.
기본적으로는 Resource Server 에서 해당 설정을 사용한다.
public class TestController {
@GetMapping("/test")
public String test() {
return "test Success";
}
}
6. 간단한 Test 진행
Test 의 경우 이전글에 설명했던 대로 postman 을 사용하여 테스트를 진행해도 되고, 필자 처럼 실제 Client 와 연동 테스트도 가능하다.(별도 Front Client 필요)
postman 은 포스팅 해두었으니, 간단하게 Client 테스트를 보자면 아래와 같다.
로그인을 통해 Oauth 서버의 로그인 화면에서 로그인을 진행 한다면
- login 페이지 요청
- user id,pw 로 Auth Code 요청
- Auth Code 로 Token 발급 요청
여기서 Token 요청은 1번의 Preflight 요청과, 실제 Token 발급 요청 2번이 수행된것을 확인할 수 있다.
이후 Local Storage 에 저장 로직까지 완료가 된다면
위와 같이 Access Token 은 Local Storage 에 저장되어 사용이 가능하고, Refresh Token 의 경우 HttpOnly 옵션을 사용해 토큰에 저장, 서버에서만 읽을 수 있도록 설정해주었다.
'spring & boot > Spring & Spring Boot' 카테고리의 다른 글
[Spring boot] Spring Boot 라이브러리(Library) 개념 및 만들기(1) (1) | 2024.12.27 |
---|---|
[Spring boot] Spring Boot 3.x^ - Swagger 적용(2) (0) | 2024.11.14 |
[Spring boot] OAuth2: Authorization-Server (인증 서버 구축)(1) (1) | 2024.11.13 |
[Spring Boot] WireMock - API Test (0) | 2024.10.29 |
[Spring Boot]Jasypt 원리 및 사용방법(yml 설정 암복호화, Boot 3.x^) +String Util Class (0) | 2024.08.14 |
댓글