Spring Security Oauth2 Part 12
Spring Authorization Server
KeyCloak과 같이 OAuth2, OIDC 기반의 인증방식을 제공하는 Authorization Server을 구현하는 프레임워크이다. Provider 설정, Token 생성, 등의 작업을 처리한다.
Configuration
build.gradle
dependencies {
//spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
//spring authorization server
implementation 'org.springframework.security:spring-security-oauth2-authorization-server:1.0.2'
//thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
//spring web
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
//lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
위의 authorization-server 관련 dependency를 추가하게 되면 아래와 같이 OAuthAuthorizationServerConfiguration class을 활용하여 Spring Authorization Server을 구성할 수 있다.
import을 통한 Authorization Server 구성
OAuth2AuthorizationServerConfiguration을 import 하는 것만으로도 Authorization Server 관련 설정을 수행할 수 있다.
@Configuration
@Import(OAuth2AuthorizationServerConfiguration.class)
public class AuthorizationServerConfig1 {
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://localhost:9000")
.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oauth2-client-app")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://127.0.0.1:8081/login/oauth2/code/oauth2-client-app")
.redirectUri("http://127.0.0.1:8081")
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(registeredClient);
return registeredClientRepository;
}
}
applyDefaultSecurity의 활용한 구성
OAuth2AuthorizationServerConfiguration 내부에는 applyDefaultSecurity 메소드가 존재하는 데, 이를 활용하여 구성하는 것도 가능하다.
@Configuration
public class AuthorizationServerConfig2 {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(httpSecurity);
return httpSecurity.build();
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://localhost:9000")
.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oauth2-client-app")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://127.0.0.1:8081/login/oauth2/code/oauth2-client-app")
.redirectUri("http://127.0.0.1:8081")
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(registeredClient);
return registeredClientRepository;
}
}
직접 OAuth2AuthorizationServerConfigurer 호출을 통한 구성
OAuth2AuthorizationServerConfigurer을 직접 호출하여 authorizationService, TokenGenerator, 각종 endpoint을 세밀하게 처리하는 것이 가능하다.
Configuration
public class AuthorizationServerConfig3 {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
httpSecurity.apply(authorizationServerConfigurer);
return httpSecurity.build();
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://localhost:9000")
.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oauth2-client-app")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://127.0.0.1:8081/login/oauth2/code/oauth2-client-app")
.redirectUri("http://127.0.0.1:8081")
.scope(OidcScopes.OPENID)
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(registeredClient);
return registeredClientRepository;
}
}
jwtDecoder 관련 설정
jwt 기반의 access token을 처리하기 때문에, JwtDecoder을 통한 Token 검증을 수행한다. 따라서, JwtDecoder를 Bean 객체로 등록하여 처리될 수 있도록 한다.
//JwtDecoder
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
//JWKSource
@Bean
public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
RSAKey rsaKey = generateRSA();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}
//RSAKey 생성
private RSAKey generateRSA() throws NoSuchAlgorithmException {
KeyPair keyPair = generateRSAKey();
RSAPrivateKey rsaPrivateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAPublicKey rsaPublicKJey = (RSAPublicKey) keyPair.getPublic();
return new RSAKey.Builder(rsaPublicKJey)
.privateKey(rsaPrivateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
//KeyPair 생성
private KeyPair generateRSAKey() throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
return keyPairGenerator.generateKeyPair();
}
Domain Classes
OAuth2AuthorizationServerConfigurer
Authorization Server 관련 설정을 처리하는 configurer으로 아래의 5가지 Configurer을 포함한다.
Configurer | Description |
---|---|
OAuth2ClientAuthenticationConfigurer | 클라이언트 인증 엔드포인트 설정 |
OAuth2AuthorizationEndpointConfigurer | 권한 부여 엔드 포인트 설정 |
OAuth2TokenEndpointConfigurer | 토큰 엔드포인트 설정 |
OAuth2TokenRevocationConfigurer | 토큰 취소 엔드포인트 설정 |
OidcConfigurer | OIDC 엔드포인트 설정 |
아래의 메소드를 확인해보면 configurer들을 처리하는 것을 확인할 수 있다.
private Map<Class<? extends AbstractOAuth2Configurer>, AbstractOAuth2Configurer> createConfigurers() {
Map<Class<? extends AbstractOAuth2Configurer>, AbstractOAuth2Configurer> configurers = new LinkedHashMap<>();
configurers.put(OAuth2ClientAuthenticationConfigurer.class, new OAuth2ClientAuthenticationConfigurer(this::postProcess));
configurers.put(OAuth2AuthorizationServerMetadataEndpointConfigurer.class, new OAuth2AuthorizationServerMetadataEndpointConfigurer(this::postProcess));
configurers.put(OAuth2AuthorizationEndpointConfigurer.class, new OAuth2AuthorizationEndpointConfigurer(this::postProcess));
configurers.put(OAuth2TokenEndpointConfigurer.class, new OAuth2TokenEndpointConfigurer(this::postProcess));
configurers.put(OAuth2TokenIntrospectionEndpointConfigurer.class, new OAuth2TokenIntrospectionEndpointConfigurer(this::postProcess));
configurers.put(OAuth2TokenRevocationEndpointConfigurer.class, new OAuth2TokenRevocationEndpointConfigurer(this::postProcess));
return configurers;
}
위의 Configurer 외에도 아래와 같이 여러 Configurer 들이 존재한다.
AuthorizationServerContext
Provider 설정에 대한 정보를 저장하고 있는 객체로, Spring Authorization Server을 구동하기 위한 각종 정보를 포함한다.
AuthorizationServerContext
//AuthorizationServerContext
public interface AuthorizationServerContext {
String getIssuer();
AuthorizationServerSettings getAuthorizationServerSettings();
}
//AuthorizationServerSettings
public final class AuthorizationServerSettings extends AbstractSettings {
private AuthorizationServerSettings(Map<String, Object> settings) {
super(settings);
}
public String getIssuer() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.ISSUER);
}
public String getAuthorizationEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT);
}
public String getTokenEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.TOKEN_ENDPOINT);
}
public String getJwkSetEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.JWK_SET_ENDPOINT);
}
public String getTokenRevocationEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.TOKEN_REVOCATION_ENDPOINT);
}
public String getTokenIntrospectionEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.TOKEN_INTROSPECTION_ENDPOINT);
}
public String getOidcClientRegistrationEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_CLIENT_REGISTRATION_ENDPOINT);
}
public String getOidcUserInfoEndpoint() {
return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_USER_INFO_ENDPOINT);
}
public static Builder builder() {
return new Builder()
.authorizationEndpoint("/oauth2/authorize")
.tokenEndpoint("/oauth2/token")
.jwkSetEndpoint("/oauth2/jwks")
.tokenRevocationEndpoint("/oauth2/revoke")
.tokenIntrospectionEndpoint("/oauth2/introspect")
.oidcClientRegistrationEndpoint("/connect/register")
.oidcUserInfoEndpoint("/userinfo");
}
AuthorizationServerContextFilter
AuthorizationServerContext을 처리하는 filter로 AuthorizationServerContext을 생성해서 AuthoriationServerContextHolder에 저장하여 Spring application 전반에 걸쳐 사용될 수 있도록 한다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
AuthorizationServerContext authorizationServerContext =
new DefaultAuthorizationServerContext(
() -> resolveIssuer(this.authorizationServerSettings, request),
this.authorizationServerSettings);
AuthorizationServerContextHolder.setContext(authorizationServerContext);
filterChain.doFilter(request, response);
} finally {
AuthorizationServerContextHolder.resetContext();
}
}
RegisteredClient
RegisteredClient
RegisteredClient 클래스는 인가 서버에 등록된 클라이언트에 대한 메타정보를 포함하는 클래스이다. 아래와 같이 clientId, clientSecret, authorizationGrantType 등 client에 관한 다양한 정보를 포함하고 있다.
public class RegisteredClient implements Serializable {
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
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> scopes;
private ClientSettings clientSettings;
private TokenSettings tokenSettings;
}
RegisteredClientRepository
Authorization Server에는 여러 client들이 등록되는데, InMemory 방식 혹은 JDBC 방식을 활용하여 registeredclient을 저장할 수 있다.
//InMemoryRegisteredClientRepository
public final class InMemoryRegisteredClientRepository implements RegisteredClientRepository {
private final Map<String, RegisteredClient> idRegistrationMap;
private final Map<String, RegisteredClient> clientIdRegistrationMap;
...
}
//JdbcRegisteredClientRepository
public class JdbcRegisteredClientRepository implements RegisteredClientRepository {
// @formatter:off
private static final String COLUMN_NAMES = "id, "
+ "client_id, "
+ "client_id_issued_at, "
+ "client_secret, "
+ "client_secret_expires_at, "
+ "client_name, "
+ "client_authentication_methods, "
+ "authorization_grant_types, "
+ "redirect_uris, "
+ "scopes, "
+ "client_settings,"
+ "token_settings";
// @formatter:on
private static final String TABLE_NAME = "oauth2_registered_client";
private static final String PK_FILTER = "id = ?";
private static final String LOAD_REGISTERED_CLIENT_SQL = "SELECT " + COLUMN_NAMES + " FROM " + TABLE_NAME + " WHERE ";
// @formatter:off
private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME
+ "(" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
// @formatter:on
// @formatter:off
private static final String UPDATE_REGISTERED_CLIENT_SQL = "UPDATE " + TABLE_NAME
+ " SET client_name = ?, client_authentication_methods = ?, authorization_grant_types = ?,"
+ " redirect_uris = ?, scopes = ?, client_settings = ?, token_settings = ?"
+ " WHERE " + PK_FILTER;
// @formatter:on
private static final String COUNT_REGISTERED_CLIENT_SQL = "SELECT COUNT(*) FROM " + TABLE_NAME + " WHERE ";
private final JdbcOperations jdbcOperations;
private RowMapper<RegisteredClient> registeredClientRowMapper;
private Function<RegisteredClient, List<SqlParameterValue>> registeredClientParametersMapper;
...
}
위의 RegisteredClient을 조회해보면 아래와 같이 client의 정보를 포함하고 있는 것을 확인할 수 있다.
{
"id": "778a4813-078a-4091-ac33-408a8dad3316",
"clientId": "oauth2-client-app1",
"clientIdIssuedAt": null,
"clientSecret": "{noop}secret1",
"clientSecretExpiresAt": "+1000000000-12-31T23:59:59.999999999Z",
"clientName": "oauth2-client-app1",
"clientAuthenticationMethods": [
{
"value": "none"
},
{
"value": "client_secret_basic"
}
],
"authorizationGrantTypes": [
{
"value": "refresh_token"
},
{
"value": "client_credentials"
},
{
"value": "authorization_code"
}
],
"redirectUris": [
"http://127.0.0.1:8081"
],
"scopes": [
"read",
"openid",
"profile",
"write",
"email"
],
"clientSettings": {
"settings": {
"settings.client.require-proof-key": false,
"settings.client.require-authorization-consent": true
},
"tokenEndpointAuthenticationSigningAlgorithm": null,
"jwkSetUrl": null,
"requireProofKey": false,
"requireAuthorizationConsent": true
},
"tokenSettings": {
"settings": {
"settings.token.reuse-refresh-tokens": true,
"settings.token.id-token-signature-algorithm": "RS256",
"settings.token.access-token-time-to-live": "PT5M",
"settings.token.access-token-format": {
"value": "self-contained"
},
"settings.token.refresh-token-time-to-live": "PT1H",
"settings.token.authorization-code-time-to-live": "PT5M"
},
"idTokenSignatureAlgorithm": "RS256",
"authorizationCodeTimeToLive": "PT5M",
"accessTokenFormat": {
"value": "self-contained"
},
"accessTokenTimeToLive": "PT5M",
"refreshTokenTimeToLive": "PT1H",
"reuseRefreshTokens": true
}
}
OAuth2Authorization
OAuth2Authorization은 OAuth2AuthorizedClient 처럼 해당 사용자에 대한 access token, refresh token과 같이 인가 상태를 유지하기 위한 객체이다.
public class OAuth2Authorization implements Serializable {
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
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;
}
권한 부여 방식, scope에 따라 저장되는 정보가 상이하다.
- Authorization Code 방식의 경우, Authorization Code, Access Token, Refresh Token이 저장된다.
- Client Credentials의 경우 Access Token
- OIDC 방식의 경우 Authorization Code, Access Token, Refresh Token, ID Token이 저장된다.
이러한 OAuth2Authorization은 OAuth2AuthorizationService에 의해 관리되는데, In-Memory, JDBC type이 존재하는데, 무수히 많은 사용자가 발생할 수 있기 때문에, In-Memory 방식은 개발환경에서는 활용하는 것이 좋다.
OAuth2AuthorizationService
//InMemoryOAuth2AuthorizationService
public final class InMemoryOAuth2AuthorizationService implements OAuth2AuthorizationService {
private int maxInitializedAuthorizations = 100;
private Map<String, OAuth2Authorization> initializedAuthorizations =
Collections.synchronizedMap(new MaxSizeHashMap<>(this.maxInitializedAuthorizations));
private final Map<String, OAuth2Authorization> authorizations = new ConcurrentHashMap<>();
...
}
//JdbcOAuth2AuthorizationService
public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationService {
// @formatter:off
private static final String COLUMN_NAMES = "id, "
+ "registered_client_id, "
+ "principal_name, "
+ "authorization_grant_type, "
+ "authorized_scopes, "
+ "attributes, "
+ "state, "
+ "authorization_code_value, "
+ "authorization_code_issued_at, "
+ "authorization_code_expires_at,"
+ "authorization_code_metadata,"
+ "access_token_value,"
+ "access_token_issued_at,"
+ "access_token_expires_at,"
+ "access_token_metadata,"
+ "access_token_type,"
+ "access_token_scopes,"
+ "oidc_id_token_value,"
+ "oidc_id_token_issued_at,"
+ "oidc_id_token_expires_at,"
+ "oidc_id_token_metadata,"
+ "refresh_token_value,"
+ "refresh_token_issued_at,"
+ "refresh_token_expires_at,"
+ "refresh_token_metadata";
// @formatter:on
private static final String TABLE_NAME = "oauth2_authorization";
private static final String PK_FILTER = "id = ?";
private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorization_code_value = ? OR " +
"access_token_value = ? OR refresh_token_value = ?";
private static final String STATE_FILTER = "state = ?";
private static final String AUTHORIZATION_CODE_FILTER = "authorization_code_value = ?";
private static final String ACCESS_TOKEN_FILTER = "access_token_value = ?";
private static final String REFRESH_TOKEN_FILTER = "refresh_token_value = ?";
// @formatter:off
private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES
+ " FROM " + TABLE_NAME
+ " WHERE ";
// @formatter:on
// @formatter:off
private static final String SAVE_AUTHORIZATION_SQL = "INSERT INTO " + TABLE_NAME
+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
// @formatter:on
// @formatter:off
private static final String UPDATE_AUTHORIZATION_SQL = "UPDATE " + TABLE_NAME
+ " SET registered_client_id = ?, principal_name = ?, authorization_grant_type = ?, authorized_scopes = ?, attributes = ?, state = ?,"
+ " authorization_code_value = ?, authorization_code_issued_at = ?, authorization_code_expires_at = ?, authorization_code_metadata = ?,"
+ " access_token_value = ?, access_token_issued_at = ?, access_token_expires_at = ?, access_token_metadata = ?, access_token_type = ?, access_token_scopes = ?,"
+ " oidc_id_token_value = ?, oidc_id_token_issued_at = ?, oidc_id_token_expires_at = ?, oidc_id_token_metadata = ?,"
+ " refresh_token_value = ?, refresh_token_issued_at = ?, refresh_token_expires_at = ?, refresh_token_metadata = ?"
+ " WHERE " + PK_FILTER;
// @formatter:on
private static final String REMOVE_AUTHORIZATION_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + PK_FILTER;
private static Map<String, ColumnMetadata> columnMetadataMap;
private final JdbcOperations jdbcOperations;
private final LobHandler lobHandler;
private RowMapper<OAuth2Authorization> authorizationRowMapper;
private Function<OAuth2Authorization, List<SqlParameterValue>> authorizationParametersMapper;
...
}
OIDC + Authorization Code 방식을 통한 인증 과정의 경우 아래와 같이 저장되는 것을 확인할 수 있다.
References
link: inflearn
docs: spring_security
댓글남기기