[Spring Authorization Server Sample 따라 구현하기] Authorization Server

2024. 9. 25. 02:07Spring Security/Spring Authorization Server

반응형
작년 이맘때 쯤 지금은 운영하지 않는 velog 블로그에 게시글을 옮겨온 글

개요

샘플의 마지막 구현인 인증 서버를 살펴본다.

방법과 구현

구조

  • 내가 생각했던 레이어와는 다르지만, 우선 기본적인 골격을 갖추고 있다.
    • config 패키지는 인증 서버의 보안 설정을 다룬다.
    • federation 패키지는 인증 이후의 보안 컨텍스트 (SecurityContext) 의 설정을 다룬다.
    • jose 패키지는 인증 서버가 다른 서버와 통신할 때 사용할 키를 생성한다.

의존성 관리 - build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.webjars:webjars-locator-core'
    implementation 'org.webjars:bootstrap:5.2.3'
    implementation 'org.webjars:jquery:3.6.4'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}
  • spring-boot-starter-data-jdbc : DB 와 자바 프로젝트의 통신에 사용하는 스프링 부트 하위 패키지
  • spring-boot-starter-oauth2-authorization-server : 인증 서버 구축에 필요한 스프링 시큐리티 하위 패키지
  • com.h2database:h2 : 간단하고 빠른 구축에 필요한 in-memory 데이터베이스. runtimeOnly 이므로 서버를 종료하면 초기화된다.

어플리케이션 설정 - application.yml

server:
  port: 9000

spring:
  security:
    oauth2:
      client:
        registration:
          google-idp:
            provider: google
            client-id: ${GOOGLE_CLIENT_ID:google-client-id}
            client-secret: ${GOOGLE_CLIENT_SECRET:google-client-secret}
            scope: openid, https://www.googleapis.com/auth/userinfo.profile, https://www.googleapis.com/auth/userinfo.email
            client-name: Sign in with Google
          github-idp:
            provider: github
            client-id: ${GITHUB_CLIENT_ID:github-client-id}
            client-secret: ${GITHUB_CLIENT_SECRET:github-client-secret}
            scope: user:email, read:user
            client-name: Sign in with GitHub
        provider:
          google:
            user-name-attribute: email
          github:
            user-name-attribute: login
  • server.port : 9000 → 인증 서버의 포트를 9000번으로 설정
  • spring.security.oauth2.client.registration:..
    • 이 인증 서버를 클라이언트로 하여, 외부 인증 서버인 google과 github에 oauth2 인증을 구현한다.
    • 인증 서버가 또다른 인증 서버에게 인증을 위임하는 형태이다.

보안 설정 - DefaultSecurityConfig.java

@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize ->
                        authorize
                                .requestMatchers(
                                        new AntPathRequestMatcher("/assets/**"),
                                        new AntPathRequestMatcher("/webjars/**"),
                                        new AntPathRequestMatcher("/login"))
                                .permitAll()
                                .anyRequest().authenticated()
                )
                .formLogin(formLogin ->
                        formLogin
                                .loginPage("/login")
                )
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .loginPage("/login")
                                .successHandler(authenticationSuccessHandler())
                );

        return http.build();
    }

    private AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new FederatedIdentityAuthenticationSuccessHandler();
    }

    @Bean
    public UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("user1")
                .password("password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}
  • defaultSecurityFilterChain()
    • .authorizeHttpRequests() : html 처리에 필요한 경로와 로그인 이전 경로에 대한 요청은 전부 허용하고 나머지 경로는 전부 인증을 요구한다.
    • .formLogin() : 로그인 화면 호출에 /login 경로를 사용함을 명시
    • .oauth2Login() : oauth2 로그인 인증에 사용할 로그인 페이지를 명시하고, 로그인 성공시 동작을 제어할 successHandler 를 부착한다.
  • users() : 회원 가입 기능이 구현되어 있지 않은 상태이므로 DB 에 유저 정보가 하나도 없다. 테스트 유저를 하나 생성한다.
  • sessionRegistry(), httpSessionEventPublisher() : 스프링 시큐리티가 제공하는 세션 관리 모듈을 bean으로 등록한다.

인증 서버 설정 - AuthrorizationServerConfig.java

@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
    private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(
            HttpSecurity http, RegisteredClientRepository registeredClientRepository,
            AuthorizationServerSettings authorizationServerSettings) throws Exception {

        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
                new DeviceClientAuthenticationProvider(registeredClientRepository);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
                        deviceAuthorizationEndpoint.verificationUri("/activate")
                )
                .deviceVerificationEndpoint(deviceVerificationEndpoint ->
                        deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
                )
                .clientAuthentication(clientAuthentication ->
                        clientAuthentication
                                .authenticationProvider(deviceClientAuthenticationProvider)
                )
                .authorizationEndpoint(authorizationEndpoint ->
                        authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
                .oidc(Customizer.withDefaults());

        http
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                )
                .oauth2ResourceServer(oauth2ResourceServer ->
                        oauth2ResourceServer.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("messaging-client")
                .clientSecret("{noop}secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
                .redirectUri("http://127.0.0.1:8080/authorized")
                .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope("message.read")
                .scope("message.write")
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .build();

        RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("device-messaging-client")
                .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
                .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .scope("message.read")
                .scope("message.write")
                .build();

        JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
        registeredClientRepository.save(registeredClient);
        registeredClientRepository.save(deviceClient);

        return registeredClientRepository;
    }

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
                                                           RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
                                                                         RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
    }

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> idTokenCustomizer() {
        return new FederatedIdentityIdTokenCustomizer();
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = Jwks.generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().build();
    }

    @Bean
    public EmbeddedDatabase embeddedDatabase() {
        return new EmbeddedDatabaseBuilder()
                .generateUniqueName(true)
                .setType(EmbeddedDatabaseType.H2)
                .setScriptEncoding("UTF-8")
                .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
                .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")
                .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
                .build();
    }
}

Annotation

  • @Order : 현재 인증 서버에는 두 개의 SecurityFilterChain 객체가 Bean 으로 등록되어 있는 상태이다. 이 경우 실행시 순서를 지정해 주지 않으면 충돌이 일어나므로 우선순위를 지정해 준다.

Method

  • applyDefaultSecuirty() : 인증 서버에 스프링 인증 서버 프레임워크가 제공하는 기본 설정 셋을 적용한다.
  • authorizationEndPoint() : 인증에 성공한 경우 동작슬 설정한다. 샘플에서는 conentPage() 메서드를 사용하여 필요한 권한 동의를 받는 페이지로 이동하도록 되어 있다.
  • exceptionHasndling() : 로그인 페이지에서 발생한 예외들을 어떻게 처리할 것인지 설정한다.
  • oauth2ResourceServer() : 리소스 서버에 정보를 요청하는 방식을 설정한다. 스프링이 제공하는 기본 설정을 사용하는 것으로 되어 있다.

Bean

  • RegisterdClientRepository : 인증 서버를 이용할 클라이언트를 등록하는 부분이다. 여러가지 정보를 등록해둔다
    • Client의 접근을 위한 Id와 Secret
    • 클라이언트의 인증 방식
    • 인증에 성공할 경우 콜백 경로 (redirectUri)
    • 로그아웃 할 경우 리디렉션 경로
    • 클라이언트가 요청 가능한 범위 (scope)
  • OAuth2AuthorizationService : 등록한 클라이언트의 유저를 인증시키는 구현. 스프링 기본 구현체가 사용되었다.
  • OAuth2AuthorizationConsentService : 인증 유저의 권한 동의 구현. 스프링 기본 구현체가 사용되었다.
  • EmbeddedDatabase : 서버가 실행될 때 사용할 DB 정보를 외부에서 가져오기 위한 구현
    • in memory DB 인 H2 가 사용되었다.
    • 스프링 공식 repo에 있는 스키마 스크립트를 불러와 테이블을 생성한다.

직렬화 - jose

public final class Jwks {

    private Jwks() {
    }

    public static RSAKey generateRsa() {
        KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }
}
final class KeyGeneratorUtils {

    private KeyGeneratorUtils() {
    }

    static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }

}
  • JOSE 는 Java Object Serialization 의 약자로, 자바 시스템 내에서 사용되는 객체를 외부에서 사용할 때 데이터를 변환하여 호환시키는 기술이다.
  • 데이터를 직렬화 (Serialize) 또는 역직렬화 (Serialize) 하는 것은 암호화 복호화와 같이 대칭 키를 필요로 한다.
  • jwk (json web key) 와 RSA 를 사용한 기본 구현 형태를 제공하고 있다.

인증 객체와 필터 설정 - federation

  • federation 은 ‘연합’ 의 의미로, 인증 전후의 정보들을 통합 관리하겠다는 의미이다.
  • 인증 과정에서 거치는 각 필터의 구성요소들을 구현 또는 대체하고 있다
public class FederatedIdentityAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

    }
}
  • 스프링 시큐리티의 기본 구현체인 AuthenticationSuccessHandler 의 구현체이다.
  • onAuthenticationSuccess() 메서드를 오버라이딩하여야 하며, 인증에 성공한 객체를 어떻게 처리할 것인지 다룬다.
  • 기본 구현은 특별한 동작을 구현하고 있지는 않다.
public final class FederatedIdentityIdTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    private static final Set<String> ID_TOKEN_CLAIMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
            IdTokenClaimNames.ISS,
            IdTokenClaimNames.SUB,
            IdTokenClaimNames.AUD,
            IdTokenClaimNames.EXP,
            IdTokenClaimNames.IAT,
            IdTokenClaimNames.AUTH_TIME,
            IdTokenClaimNames.NONCE,
            IdTokenClaimNames.ACR,
            IdTokenClaimNames.AMR,
            IdTokenClaimNames.AZP,
            IdTokenClaimNames.AT_HASH,
            IdTokenClaimNames.C_HASH
    )));

    @Override
    public void customize(JwtEncodingContext context) {
        if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
            Map<String, Object> thirdPartyClaims = extractClaims(context.getPrincipal());
            context.getClaims().claims(existingClaims -> {
                existingClaims.keySet().forEach(thirdPartyClaims::remove);
                ID_TOKEN_CLAIMS.forEach(thirdPartyClaims::remove);
                existingClaims.putAll(thirdPartyClaims);
            });
        }
    }

    private Map<String, Object> extractClaims(Authentication principal) {
        Map<String, Object> claims;
        if (principal.getPrincipal() instanceof OidcUser) {
            OidcUser oidcUser = (OidcUser) principal.getPrincipal();
            OidcIdToken idToken = oidcUser.getIdToken();
            claims = idToken.getClaims();
        } else if (principal.getPrincipal() instanceof OAuth2User) {
            OAuth2User oauth2User = (OAuth2User) principal.getPrincipal();
            claims = oauth2User.getAttributes();
        } else {
            claims = Collections.emptyMap();
        }

        return new HashMap<>(claims);
    }

}
  • 인증 결과 생성되는 Auth Token, Id Token 에 포함될 정보를 커스터마이징한다.
  • ID_TOKEN_CLAIMS : ID 토큰에 저장될 정보를 정의한다.
    • iss : issuer. 발급자
    • sub : subject. 토큰 제목
    • aud : audience. 토큰의 대상
    • exp : expiration. 토큰 만료 시간
    • iat : issued at. 토큰 발급 시간
    • nonce : 암호화 전용 임시 토큰

실행

Run

Brouser (http://localhost:9000)

결론

인증 서버 구현이 끝났다. 이제 OAuth2 구조대로 인증이 이루어지는지 확인할 것이다.

Repository

https://github.com/gigyesik/spring-authorization-server-sample/tree/clone

반응형