1. Spring Authorization Server 의 Core Model 을 알아보자
1) RegisteredClient
- 인가 서버에 등록된 클라이언트를 의미한다.
- OAuth2.0 또는 OAuth2.1 을 통해 인증 및 권한 부여를 요청하는 클라이언트를 구성하는데 사용된다.
- 예를 들어, 클라이언트가 authorization_code 또는 client_credentials 와 같은 권한 부여 흐름을 시작하려면 먼저 클라이언트를 권한 부여 서버에 등록해야한다.
- 클라이언트 등록시 클라이언트는 고유한 client_id, client_secret 및 고유한 클라이언트 식별자와 연결된 메타 데이터를 할당한다.
- 클라이언트의 주요 목적은 보호된 리소스에 대한 액세스를 요청하는 것으로 클라이언트는 먼저 권한 부여 서버를 인증하고 액세스 토큰과 교환을 요청한다.
▶ 주요 필드 및 설정
public class RegisteredClient implements Serializable {
private String id;
private String clientId;
private Instant clientIdIssuedAt;
private String clientSecret;
private Instant clientSecretExpiresAt;
private String clientName;
private Set<ClientAuthenticationMethod> clientAuthenticationMethods;
private Set<AuthorizationGrantType> authorizationGrantTypes;
private Set<String> redirectUris;
private Set<String> postLogoutRedirectUris;
private Set<String> scopes;
private ClientSettings clientSettings;
private TokenSettings tokenSettings;
...
}
1. 클라이언트ID (ClientId): 클라이언트를 식별하는 고유한 문자열
2. 클라이언트시크릿 (ClientSecret): 클라이언트를 인증하기 위해 사용되는 비밀번호 또는 키. 비밀이 필요한 클라이언트 유형에서 사용됨
3. 클라이언트 인증 방법 (Client Authentication Methods): 클라이언트가 인증 서버에 자신을 인증하는 방법
ex. client_secret_basic, client_secret_post, none, ....
4. 권한 부여 타입 (Authorization Grant Types): 클라이언트가 요청할 수 있는 권한 부여 방식
ex. authoriation_code, client_credentials, refresh_token, ...
5. 리다이렉트 URL (Redirect URIs): 인증 코드 또는 액세스 토큰을 전달받을 클라이언트의 URL 목록
6. 스코프 (Scopes): 클라이언트가 요청할 수 있는 권한의 범위
7. 클라이언트 설정 (Client Settings): PKCE 사용 여부, 리다이렉트 URL 요구 여부 등의 추가 설정
8. 토큰 설정 (Token Settings): 액세스 토큰과 리프레시 토큰의 유효 기간 등의 설정
▶ JAVA 코드로 예제 구성해보기
- AuthorizationServerConfig 에서 간단하게 인메모리 Repository 빈으로 등록하여 설정해볼 수 있다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
@Configuration
public class AuthorizationServerConfig {
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client-id")
.clientSecret("{noop}client-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://example.com/callback")
.scope("read")
.scope("write")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(1))
.refreshTokenTimeToLive(Duration.ofDays(30))
.build())
.clientSettings(ClientSettings.builder()
.requireProofKey(true)
.requireAuthorizationConsent(true)
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
}
2) OAuth2Authorization
Spring Authorization Server 에서 OAuth2.0 및 OpenID Connect 1.0 토큰과 관련된 모든 정보와 상태를 캡슐화 하는 핵심 엔티티이다. 이 객체는 특정 클라이언트 애플리케이션과 사용자 간의 인증 및 권한 부여 과정에서 발생하는 다양한 데이터를 저장하는데 사용한다.
▶ 주요 필드 및 설정
public class OAuth2Authorization implements Serializable {
private String id;
private String registeredClientId;
private String principalName;
private AuthorizationGrantType authorizationGrantType;
private Set<String> authorizedScopes;
private Map<Class<? extends OAuth2Token>, Token<?>> tokens;
private Map<String, Object> attributes;
...
}
1. ID: OAuth2Authorization 객체를 식별하는 고유한 ID
2. 등록된 클라이언트 (Registered Client): 클라이언트 애플리케이션을 나타내는 RegisteredClient 객체
3. Principal 이름: 인증된 사용자를 나타내는 이름
4. Authorization Grant Type: 권한 부여 타입
5. Attributes: 추가적인 속성 정보를 저장하는 맵
6. Tokens: 발급된 토큰 (액세스 토큰, 리프레시 토큰, ID 토큰 등)을 저장하는 정보
해당 포스팅에서는 OAuth2Authorization 객체에 대해서는 따로 다루지 않으니 자세한 부분은 공식문서를 참고해 주세요!
2. 인메모리가 아닌 로컬 DB 로 연동해서 사용하고 싶은데 어떻게 해야할까?
아래 step 을 따라가기 전에 로컬 프로젝트에서 databse 관련 dependency 가 먼저 설정되어있어야한다.
또한, 이 포스팅에서는 Spring Data JPA 를 사용하기 때문에 해당 의존성도 설치해주어야 한다.
스프링 부트는 3.2.2 버전을 사용했다. (설정 참고)
스프링 부트 3.2.2 에서 spring-security-oauth2-authorization-server 버전은 1.2.1 버전이다.
implementation 'org.springframework.security:spring-security-oauth2-authorization-server'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
1) 엔티티 클래스를 만든다.
- ClientEntity.java 클래스를 만든다.
- @GeneratedValue(strategy= GenerationType.IDENTITY) 같은 건 각자 프로젝트에 맞게 쓰면된다.
package com.server.authorization.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "client")
public class ClientEntity {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;
private String clientId;
private Instant clientIdIssuedAt;
private String clientSecret;
private Instant clientSecretExpiresAt;
private String clientName;
@Column(length = 1000)
private String clientAuthenticationMethods;
@Column(length = 1000)
private String authorizationGrantTypes;
@Column(length = 1000)
private String redirectUris;
@Column(length = 1000)
private String postLogoutRedirectUris;
@Column(length = 1000)
private String scopes;
@Column(length = 2000)
private String clientSettings;
@Column(length = 2000)
private String tokenSettings;
}
- 클래스를 만든 뒤에는 yaml 파일 설정을 아래와 같이 해서 자동으로 생성되게 하거나 따로 create table 쿼리를 이용해서 테이블을 만들어주면 된다.
jpa:
hibernate:
ddl-auto: update
show-sql: true
2. ClientRepository 를 만든다.
일반적인 JPA repository 를 만드는 방법과 다르지 않다.
ClientReposiroty 클래스를 만든다.
대신 조회할 때 increment id 대신 고유한 clientId 로 조회하기 때문에 String clientId 를 인자로 가지는 findByClientId 메서드를 만든다.
package com.server.authorization.repository;
import com.server.authorization.entity.ClientEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ClientRepository extends JpaRepository<ClientEntity, String> {
Optional<ClientEntity> findByClientId(String clientId);
}
3. 인가 작업에서 사용하기 위한 RegisteredClient 객체로 변환하는 Repository 를 만든다.
spring-security-oauth2-authorization-server 에서는 RegisteredClientReposiroty 인터페이스가 존재한다.
따라서 해당 인터페이스의 구현체를 만들어서 데이터베이스 테이블에서 조회한 raw 데이터를 인가에 필요한 RegisteredClient 객체로 컨버팅한다.
- RegisteredClientRepository 인터페이스
public interface RegisteredClientRepository {
void save(RegisteredClient registeredClient);
@Nullable
RegisteredClient findById(String id);
@Nullable
RegisteredClient findByClientId(String clientId);
}
여기서 save 인터페이스는 위에서 언급한 JAVA 코드 예제에서 처럼 config 클래스에서 RegisteredClientRepository 빈을 등록하면서 사용할 수 있다.
@Bean
public RegisteredClientRepository registeredClientRepository(JpaRegisteredClientRepository jpaRegisteredClientRepository) {
// 클라이언트 정보를 등록하는 객체를 만든다.
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("clientid")
.clientSecret("{noop}clientsecret")
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://127.0.0.1:8081")
.scope("store")
.scope("order")
.tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofSeconds(31536000)).build())
.build();
jpaRegisteredClientRepository.save(registeredClient); // 저장
return jpaRegisteredClientRepository;
}
- 구현체 JpaRegisteredClientRepository 클래스 만들기
클래스 명은 마음대로 명명해도 된다. 여기서는 JpaRegisteredClientRepository 라고 명명했다.
package com.server.authorization.repository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.server.authorization.entity.ClientEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@Component
public class JpaRegisteredClientRepository implements RegisteredClientRepository {
private final ClientRepository clientRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
public JpaRegisteredClientRepository(ClientRepository clientRepository) {
Assert.notNull(clientRepository, "clientRepository cannot be null");
this.clientRepository = clientRepository;
ClassLoader classLoader = JpaRegisteredClientRepository.class.getClassLoader();
List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);
this.objectMapper.registerModules(securityModules);
this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
}
@Override
public void save(RegisteredClient registeredClient) {
Assert.notNull(registeredClient, "registeredClient cannot be null");
// this.clientRepository.save(toEntity(registeredClient));
}
@Override
public RegisteredClient findById(String id) {
Assert.hasText(id, "id cannot be empty");
return this.clientRepository.findById(id).map(this::toObject).orElse(null);
}
@Override
public RegisteredClient findByClientId(String clientId) {
Assert.hasText(clientId, "clientId cannot be empty");
return this.clientRepository.findByClientId(clientId).map(this::toObject).orElse(null);
}
private RegisteredClient toObject(ClientEntity client) {
Set<String> clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(
client.getClientAuthenticationMethods());
Set<String> authorizationGrantTypes = StringUtils.commaDelimitedListToSet(
client.getAuthorizationGrantTypes());
Set<String> redirectUris = StringUtils.commaDelimitedListToSet(
client.getRedirectUris());
Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(
client.getPostLogoutRedirectUris());
Set<String> clientScopes = StringUtils.commaDelimitedListToSet(
client.getScopes());
RegisteredClient.Builder builder = RegisteredClient.withId(client.getId().toString())
.clientId(client.getClientId())
.clientIdIssuedAt(client.getClientIdIssuedAt())
.clientSecret(client.getClientSecret())
.clientSecretExpiresAt(client.getClientSecretExpiresAt())
.clientName(client.getClientName())
.clientAuthenticationMethods(authenticationMethods ->
clientAuthenticationMethods.forEach(authenticationMethod ->
authenticationMethods.add(resolveClientAuthenticationMethod(authenticationMethod))))
.authorizationGrantTypes((grantTypes) ->
authorizationGrantTypes.forEach(grantType ->
grantTypes.add(resolveAuthorizationGrantType(grantType))))
.redirectUris((uris) -> uris.addAll(redirectUris))
.postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
.scopes((scopes) -> scopes.addAll(clientScopes));
Map<String, Object> clientSettingsMap = parseMap(client.getClientSettings());
builder.clientSettings(ClientSettings.withSettings(clientSettingsMap).build());
Map<String, Object> tokenSettingsMap = parseMap(client.getTokenSettings());
builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build());
return builder.build();
}
private Map<String, Object> parseMap(String data) {
try {
return this.objectMapper.readValue(data, new TypeReference<Map<String, Object>>() {
});
} catch (Exception ex) {
throw new IllegalArgumentException(ex.getMessage(), ex);
}
}
private String writeMap(Map<String, Object> data) {
try {
return this.objectMapper.writeValueAsString(data);
} catch (Exception ex) {
throw new IllegalArgumentException(ex.getMessage(), ex);
}
}
private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) {
if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) {
return AuthorizationGrantType.AUTHORIZATION_CODE;
} else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) {
return AuthorizationGrantType.CLIENT_CREDENTIALS;
} else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {
return AuthorizationGrantType.REFRESH_TOKEN;
}
return new AuthorizationGrantType(authorizationGrantType); // Custom authorization grant type
}
private static ClientAuthenticationMethod resolveClientAuthenticationMethod(String clientAuthenticationMethod) {
if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue().equals(clientAuthenticationMethod)) {
return ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
} else if (ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue().equals(clientAuthenticationMethod)) {
return ClientAuthenticationMethod.CLIENT_SECRET_POST;
} else if (ClientAuthenticationMethod.NONE.getValue().equals(clientAuthenticationMethod)) {
return ClientAuthenticationMethod.NONE;
}
return new ClientAuthenticationMethod(clientAuthenticationMethod); // Custom client authentication method
}
}
위 코드는 예제일뿐 만약에 entity 데이터가 다른 식으로 구성되어있더라도 위 구현체에서 잘 맞게 컨버팅만 해주면 된다.
스프링 공식 홈페이지 예제에서 제공한 로직대로 save 하게 되면 아래와 같이 저장된다.
다른 컬럼들은 우리가 알아보기 쉽게 컬럼에 저장되어있지만 client_settings, token_settings 같은 경우 json 모양의 오브젝트가 String 타입으로 컬럼에 저장되어있다.
- tokens_settings 컬럼 값
{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.access-token-time-to-live":["java.time.Duration",31536000.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",3600.000000000],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.device-code-time-to-live":["java.time.Duration",300.000000000]}
해당 구조를 모르는 사람이 DB 를 컬럼 값을 수정해야한다거나 (토큰만료시간 수정이 제일 유력하다) 기존 security5 에서 권장하던 oauth2 client 의 필드대로 테이블이 구성되어있었다면 (우리가 그랬다...) 마이그레이션 작업을 진행할 때 위와같은 데이터 구조가 불가능 할 수 있다.
따라서 Client 가 될 테이블 컬럼의 타입과 값들은 자유롭게 커스텀해주고 RegisteredClientRepository 의 구현체에서 RegisteredClient 로 컨버팅만 알맞게 해주면 된다.
3. 그래서 어떻게 사용할 수 있는데?
2번 과정만 진행하면 실제로 OAuth2TokenEndpointFilter 에서 해당 client 를 조회하여 사용할 수 없다.
대신 위에서도 계속 언급해 왔듯이 설정 클래스에서 반드시 RegisteredClientRepository 를 빈으로 등록해주어야한다.
- AuthorizationServerConfig.java (설정 코드 참고)
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(
HttpSecurity http,
JwtEncoder jwtEncoder,
JpaRegisteredClientRepository registeredClientRepository,
MemberService memberService,
JwtCustomizer jwtCustomizer
) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.registeredClientRepository(registeredClientRepository);
http
// Accept access tokens for User Info and/or Client Registration
.oauth2ResourceServer(resourceServer -> resourceServer
.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository(JpaRegisteredClientRepository jpaRegisteredClientRepository) {
return jpaRegisteredClientRepository;
}