OAuth2란?

  • OAuth2는 사용자가 하나의 서비스(ex: 웹사이트, Application)에 로그인할 때 다른 서비스의 자격증명을 사용하여 인증할 수 있게 해주는 인증 프로토콜입니다.

ex)
새로운 웹사이트에 가입하려고 할 때, google, naver 로그인을 보셨을 겁니다.
이 경우, 해당 웹사이트는 사용자의 google 또는 naver 계정을 통해 사용자를 인증합니다.
사용자는 별도의 새로운 계정을 만들 필요 없이 기존의 소셜 미디어 계정을 사용하여 쉽게 로그인 할 수 있습니다.
이 과정에서 OAuth2 프로토콜이 사용되며, 사용자의 로그인 정보는 안전하게 보호됩니다.


OAuth2 구조

OAuth2 의 플로우는 위 아래 그림과 같습니다.

기본적으로는

  1. Client -> OAuth Authentication Server 인증코드 받기
  2. OAuth Authentication Server 에서 받은 인증코드를 기반으로 Client에서 OAuth Authentication Server로 token 발급
  3. OAuth Authentication Server 에서 받은 token 기반으로 OAuth Resource Server에서 Resource 할당


OAuth2 말하는 역할 및 주요 용어는 어떤게 있나요?

1) 역할

이름 설명
Resource Owner 리소스(자원) 소유자입니다.
본인의 정보에 접근할 수 있는 자격을 승인하는 주체입니다.
예를 들어보겠습니다.
서비스를 이용하기 위해 해당 서비스에서 제공하는 Google 간편 로그인으로 로그인을 한 경우,
해당 Google 아이디를 가지고 있는 본인이 Resource Owner가 되는 겁니다.
Client Resource Owner의 리소스(자원)를 사용하고자 접근 요청을 하는 서비스(어플리케이션) 입니다.
Resource Server Resource Owner의 정보가 저장되어 있는 서버입니다.
위 예시에서 Google 로그인을 하는 본인이 Resource Owner라고 했었습니다.
이 때, Google이 Resource Server가 되는 겁니다.
Autehorization Server Resource Server에서 리소스를 가져오기 위한 token(권한)을 할당해주는 권한 서버입니다.
인증 / 인가를 수행하는 서버로써 Client의 접근 자격을 확인하고 Access Token을 발급하여 권한을 부여하는 역할을 수행합니다.

2) 주요 용어

이름 설명
Authentication(인증) 인증, 접근 자격이 있는지 검증하는 단계입니다.
Authorization(인가) 자원에 접근할 권한을 부여하고 리소스 접근 권한이 담긴 Access Token을 제공합니다.
Access Token Resource Server 에서 Resource Owner 의 리소스(자원)를 획득 할 때 사용되는 만료기간이 있는 Token 입니다.
Refresh Token Access Token 만료시 이를 재발급 받기위한 용도로 사용하는 Token 입니다.



토큰(Token) 발급 방식

  • 아래에서 사용하는 URL은 기본 oauth2.0 코드 발급 URL 입니다.
  1. code (code를 발급 받고 code로 access token 발급)

1) Client -> Authentication Server 로 code를 발행 합니다.

2) 1번에서 발급받은 code를 기반으로 Client -> Authentication Server로 access token을 발급 받습니다.

  • 발급 받은 code를 가지고 token을 발행 합니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=code, authorization_code=발급받은 코드, redirect_uri=코드 전달받은 URL
  1. password(id, pw로 access token 발급)
  • id, pw로 token을 바로 발급 받을 수 있습니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=password, username=아이디, password=비밀번호, scope=read
  1. client_credentials(바로 access token 발행)
  • header의 authorization에 clientId, secret key만 등록된 정보를 기반으로 access token을 발급해 주며, 별다른 인증이 필요 없는 신뢰도가 높은 Client에게만 해당 방식을 허용하도록 합니다.
  • 해당 방식은 별다른 인증을 요하지 않기에 refresh_token은 따로 발행해주지 않습니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=client_credentials, scope=read
  1. refresh_token
  • refresh token으로 access token 발행합니다.
  • 1, 2번 방식으로 access_token 발행과 동시에 refresh_token 도 같이 발행해줍니다.
  • URL: http://localhost:8080/oauth2/token
  • Parameter: grant_type=refresh_token, refresh_token=이전에 발급받은 refresh token, scope=read



OAuth2 예시 코드

  • Springboot 2.7.10, java 11, gradle 8.4 환경에서 구현하였습니다.
  • 저는 OAuth2의 Resource Server와 Authentication Server를 합쳐서 구현하였습니다.
  • 단순 FrontEnd와 BackEnd 간의 통신에서 사용하기 위하여 구현하였기에 client_credentials 기반으로 구현하였습니다.



build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.14'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.oauth2.resource.server'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '11'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.security:spring-security-oauth2-resource-server:5.7.11'
    implementation 'org.springframework.security:spring-security-oauth2-jose:5.7.11'

    implementation 'org.springframework.security:spring-security-oauth2-authorization-server:0.3.1'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.7.14'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

}

security 설정

package com.cafe.be.security.config;

import com.cafe.be.security.handler.CustomAccessDeniedHandler;
import com.cafe.be.security.handler.CustomAuthenticationEntryPoint;
import com.cafe.be.oauth.handler.JwtAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@RequiredArgsConstructor
public class ResourceServerConfig{

    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        http
            .headers().frameOptions().disable()
            .and()
                .csrf().ignoringAntMatchers("/h2-console/**").ignoringAntMatchers("/oauth2/token")
            .and()
                .authorizeRequests()
                    .mvcMatchers("/h2-console/**").permitAll()
                    .mvcMatchers("/oauth2/token").permitAll()
                .anyRequest().authenticated()
            .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
            .and()
                .exceptionHandling()
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler)
            .and()
                .oauth2ResourceServer()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .jwt();

        return http.build();
    }

}

OAuth2 설정

package com.cafe.be.oauth.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
import org.springframework.security.oauth2.server.authorization.config.TokenSettings;
import org.springframework.security.web.SecurityFilterChain;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.UUID;

@Configuration
@Import(OAuth2AuthorizationServerConfiguration.class) // OAuth2 인증 서버에 필요한 기본 설정을 가져옵니다.
public class AuthorizationServerConfig {

    @Value("${oauth.issuer.url}")
    private String issuerUrl;

    @Bean
    public TokenSettings tokenSettings() {
        return TokenSettings.builder()
                .accessTokenTimeToLive(Duration.ofMinutes(10)) // 액세스 토큰 유효 기간을 10분으로 설정
                .build();
    }

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception{
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        return http.build();
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);

        return ((jwkSelector, context) -> jwkSelector.select(jwkSet));
    }

    private RSAKey generateRsa() throws NoSuchAlgorithmException {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        return keyPairGenerator.generateKeyPair();
    }

    @Bean
    public ProviderSettings providerSettings(){
        return ProviderSettings.builder()
                .issuer(issuerUrl)
                .build();
    }

}

OAuth2 관련 Entity

package com.cafe.be.oauth.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "oauth_client")
@Getter
@AllArgsConstructor
@Builder
public class OAuthClient {

    @Id
    private String clientId;

    private String clientSecret;

    private String scopes;

    private String grantType;

    protected OAuthClient() {}

}

OAuth2 관련 Enums

package com.cafe.be.oauth.enums;

public enum OAuthScopes {

    READ("cafe.read");

    private String scopeName;

    OAuthScopes(String scopeName) {
        this.scopeName = scopeName;
    }
}

OAuth2 관련 Repository

package com.cafe.be.oauth.repo;

import com.cafe.be.oauth.entity.OAuthClient;
import lombok.RequiredArgsConstructor;
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.config.TokenSettings;
import org.springframework.stereotype.Repository;

import java.time.Duration;
import java.util.UUID;

@Repository
@RequiredArgsConstructor
public class CustomRegisteredClientRepository implements RegisteredClientRepository {

    private final OAuthClientRepository oauthClientRepository;

    @Override
    public void save(RegisteredClient registeredClient) {
        // 클라이언트 정보를 데이터베이스에 저장하는 로직을 구현합니다.
        OAuthClient oauthClient = OAuthClient.builder()
                .clientId(registeredClient.getClientId())
                .clientSecret(registeredClient.getClientSecret())
                .scopes(String.valueOf(registeredClient.getScopes()))
                .grantType(registeredClient.getAuthorizationGrantTypes().toString())
                .build();

        oauthClientRepository.save(oauthClient);
    }

    @Override
    public RegisteredClient findById(String id) {
        return oauthClientRepository.findById(id)
                .map(this::toRegisteredClient)
                .orElse(null);
    }

    @Override
    public RegisteredClient findByClientId(String clientId) {
        return oauthClientRepository.findByClientId(clientId)
                .map(this::toRegisteredClient)
                .orElse(null);
    }

    private RegisteredClient toRegisteredClient(OAuthClient oauthClient) {
        // OAuthClient 엔티티를 RegisteredClient 객체로 변환하는 로직
//        String grantType = oauthClient.getGrantType();
//
//        Set<AuthorizationGrantType> grantTypeSet = new HashSet<AuthorizationGrantType>();
//        if(grantType != null && grantType.equals("")){
//            String[] grantTypeSplit = grantType.split(",");
//
//            for (String type : grantTypeSplit) {
//                grantTypeSet.add(new AuthorizationGrantType(type));
//            }
//        }

        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId(oauthClient.getClientId())
                .clientSecret(oauthClient.getClientSecret())
                .scope(oauthClient.getScopes())
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(new AuthorizationGrantType(oauthClient.getGrantType()))
                .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofHours(1)).build())
//                .authorizationGrantTypes((Consumer<Set<AuthorizationGrantType>>) grantTypeSet)
                .build();

        return registeredClient;
    }
}

package com.cafe.be.oauth.repo;


import com.cafe.be.oauth.entity.OAuthClient;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface OAuthClientRepository extends JpaRepository<OAuthClient, String> {

    Optional<OAuthClient> findByClientId(String clientId);

}
반응형

'개발 > Spring' 카테고리의 다른 글

AOP란? AOP 내용, AOP 예제  (0) 2024.01.11
응집도와 결합도란?  (1) 2023.12.26
[Querydsl] DTO 조회 방법  (0) 2023.12.17
DTO <-> Entity 변환 이유!  (0) 2023.12.14

+ Recent posts