[Spring] OAuth2 인증 서버 구축하기

Kai·2024년 2월 29일
1

스프링과 OAuth2

목록 보기
6/11

📌 글에서 사용한 코드 : 깃헙

☕ 개요


이번 글에서는 spring-oauth2-authorization-server를 통해서 인증서버를 직접 구축하는 방법에 대해서 알아보도록 하겠다.
디테일하게 정리하려면, 내용이 방대하므로 이번 글에서는 가장 기본적인 것들에 대해서 알아보고, 이후에 디테일하게 커스터마이징해보도록 하겠다.

또, OAuth2인증 방식 자체에 대한 개념적인 내용들은 깊게 다루지 않을 것이니, 참고바란다. 🙏

바~로 드가자 🔥


Spring oauth2 authorization server


OAuth 인증 방식은 기능의 표준이 공식적으로 정리가 되어 있다. 그래서, 카카오든 구글이든 내가 만든 인증 서버든 OAuth 인증 방식을 사용한다면, 모두 동일한 표준 스펙으로 구현이 되어 있다.

다만, 스펙이 매우 방대하고, 디테일해서 인증 서버를 처음부터 한땀 한땀 구현한다면... 굉장히 힘들 것이 불보듯 뻔하다. 😅

그래서 Spring-security프로젝트에서는 OAuth인증 기능을 손쉽게(?) 구현할 수 있도록 다양한 라이브러리나 프레임워크를 제공한다. 그 중에서, OAuth 표준에 맞는 "인증 서버"를 구현할 수 있도록 도와주는 프레임워크가 Spring OAuth2 Authorization Server이다.

어떻게 손쉽게 구현할 수 있는지 알아보자.


🐘 build.gradle


dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
	implementation 'org.springframework.boot:spring-boot-starter-web'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

spring-boot-starter-oauth2-authorization-serverspring-boot-starter-web를 설치해주고, 편의를 위해서 Lombok도 설치해주었다.


📄 설정


💡 인증서버 스타터 라이브러리를 사용하면, 웬만한 설정들은 application.yml을 통해서 설정이 가능하지만, 이번 글에서는 설정 클래스를 통해서 설정을 해보도록 하겠다.

설정 클래스

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    /**
     * Protocol endpoints 를 위한 설정
     */
    @Bean
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(withDefaults());	// OpenID Connect 1.0 사용
        http.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor( // 인가 실패에 대한 처리를 정의
                        new LoginUrlAuthenticationEntryPoint("/login"),
                        new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                ));
        http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults())); // '토큰 검증'에 대한 설정

        return http.build();
    }

    /**
     * 인증(Authentication)을 위한 설정
     */
    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);
        http.authorizeHttpRequests(authorize -> {
            authorize
                    .requestMatchers("/").permitAll()
                    .anyRequest().authenticated();
        });
        http.formLogin(withDefaults());
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails userDetails = User.withDefaultPasswordEncoder()
                .username("user")
                .password("1234")
                .roles("USER")
                .build();

        return new InMemoryUserDetailsManager(userDetails);
    }

    /**
     * 클라이언트의 정보를 등록하고 관리하는 역할을 한다.
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientName("Your client name")
                .clientId("your-client")
                .clientSecret("{noop}your-secret") // 실제 운영환경에서는 임의의 문자열을 사용하고, 코드에 올리면 안됨
                .clientAuthenticationMethods(methods -> {
                    methods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
                })
                .authorizationGrantTypes(types -> {
                    types.add(AuthorizationGrantType.AUTHORIZATION_CODE);
                    types.add(AuthorizationGrantType.REFRESH_TOKEN);
                })
                .redirectUris(uri -> {
                    uri.add("http://localhost:3000");
                })
                .postLogoutRedirectUri("http://localhost:3000")
                .scopes(scope -> {
                    scope.add(OidcScopes.OPENID);
                    scope.add(OidcScopes.PROFILE);
                })
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
                .build();

        return new InMemoryRegisteredClientRepository(oidcClient);
    }

    /**
     * jwt 생성에 필요한 RSA키 generate, 실제 운영에 사용하려면 KeyStore에 저장해야한다.
     */
    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        RSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

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

    /**
     * 토큰 검증을 위한 디코더
     */
    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    /**
     * Authorization server를 구성하기 위한 여러 EndPoint를 설정하는 객체
     */
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
                .build();
    }

}

코드 길이 때문에 import문은 생략하였다.
추가적인 개발없이 위와 같이 설정 클래스만 만들어주면, 인증/인가 기능 개발이 완료된다.

물론, 실무에서는 DB의 유저 테이블과 연동하거나 JWT안에 포함시킬 정보를 추가해주는 것과 같은 다양한 커스터마이징이 필요하고 이런 내용은 다음 글에서 다뤄보도록 하겠다.


동작 확인 : authorization_code 방식


1) 코드 발급

http://localhost:9000/oauth2/authorize?response_type=code&client_id=your-client&scope=openid&redirect_uri=http://localhost:3000

브라우저에서 위 경로로 접속하면, 로그인화면이 뜬다. 로그인을 하게 되면, redirect_uri에 명시된 곳으로 리다이렉트되고, code값을 발급받을 수 있다.

여기서 redirect_uri에 입력되어 있는 localhost:3000은 가상의 클라이언트이고, 인증 서버의 설정에 미리 등록해줘야한다.

2) 토큰 요청

POST http://localhost:9000/oauth2/token

POST방식으로 위 엔드포인트에 요청을 보내면 된다.

Basic auth 방식으로 클라이언트Id와 Secret을 헤더에 담고,

위와 같이 파라미터를 입력해주면 된다.
코드 발급할 때 입력한 scope, redirect_uri를 동일하게 입력해주고, code값은 위에서 발급받은 코드를 입력해준다.

이렇게 요청을 보냈을 때, 아래와 같이 토큰 발급에 성공하면 정상적으로 기능이 동작한 것이다.

3) 토큰 갱신

access_token은 상대적으로 짧은 만료기간을 갖고 있어서, refresh_token으로 주기적으로 갱신을 해줘야한다.

POST http://localhost:9000/oauth2/token

토큰 발급에서 사용한 엔드포인트와 동일한 엔드포인트를 사용하고,

Body의 grant_type에는 refresh_token을 넘겨주고, refresh_token에는 위에서 발급받은 refresh_token을 넣어주면 된다.

그리고, 토큰 발급때와 마찬가지로 클라이언트Id와 Secret을 Basic auth 방식으로 헤더에 실어주면 된다.

이렇게 세팅하고 요청을 했을 때, 아래와 같이 새로운 access_token을 발급받았다면, 토큰 갱신이 잘 이루어진 것이다.

4) 유저 정보 조회

GET http://localhost:9000/userinfo

헤더에 Bearer 토큰으로 access_token을 실어서 위 엔드포인트에 GET요청을 보냈을 때, 아래와 같이 유저 관련 정보가 뜬다면, 유저 정보를 조회하는 기능 또한 정상적으로 동작한 것이다.


☕ 마무리


이번 글에서는 가장 기본적인 기능과 설정들에 대해서 알아보았다.
다음 글에서는 다양한 커스터마이징을 적용해보는 시간을 가져보도록 하겠다. 🙏


🙏 참고


0개의 댓글