Backend API 관점에서 OAuth 인증 구현하기

김태훈·2023년 9월 27일
0

개요

현재 만들고 있는 서비스는 구글 계정을 사용해 로그인을 할 수 있어야 합니다.
프론트엔드와 백엔드가 분리되어 있는 상황에서 어떻게 OAuth를 구현했는지에 대해 작성했습니다.
해당 글에서 어떤 부분에서 고민을 했는지, 어떤 해결방법을 적용했는지 적어보았습니다.

대상

OAuth2가 어떻게 동작하는지 이해가 필요합니다. 그 중에서도 Auth Code를 사용하는 방식에 대해 알아야 합니다.

서비스 목표

  1. Google 계정을 사용해 우리 서비스의 회원가입 & 로그인이 가능하게 만들기
  2. 추후 로그인 방식을 확장가능하게 하기

Spring Security를 사용하는 방식

처음에는 Spring Security를 사용해서 OAuth 인증을 처리하려 했습니다. 결론부터 말씀드리면 해당 방식은 사용하지 않았습니다.
Frontend와 Backend API 서버가 분리되어 있는 시점에서 자동으로 OAuth 흐름을 만들어주는 Spring Security는 적절하지 않다 판단했습니다.
따라서 Spring Security를 사용하지는 않았지만, 모르시는 분들을 위해 ClientRegistration에 대해서만 간단하게 설명드리겠습니다.
이 부분은 넘기셔도 무방합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.oauth2Login(configurer -> configurer
        	.clientRegistrationRepository(clientRepository()))
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable);
     	.authorizeHttpRequests(registry -> registry
          	.anyRequest().authenticated());
        return http.build();
    }
    
    private ClientRegistrationRepository clientRepository() {
        return new InMemoryClientRegistrationRepository(googleClientRegistration());
    }
    
    private ClientRegistration googleClientRegistration() {
        return ClientRegistration.withRegistrationId("google")
            .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth") 
            .clientId("구글 클라우드 플랫폼에서 제공받은 Id")
            .clientSecret("구글 클라우드 플랫폼에서 제공받은 Secrets")
            .redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}")
            .scope("email")
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .build();
    }
}
    

ClientRegistration

위 사진은 OAuth 흐름에 대한 사진입니다. 위 사진에서 1~4번 과정을 위해 Google의 authorizationUri에 요청을 보냅니다. clientId, redirectUri, responseType(예제에서는 AUTHORIZATION_CODE 방식), scope, state를 파라미터로 보내는데, state를 제외한 모든 값들은 ClientRegistration에 등록되는 걸 확인할 수 있습니다.
이처럼 변수만 입력해주면 스프링 시큐리티가 자동으로 OAuth 흐름을 만들어줍니다.

state는 랜덤한 문자열입니다.
이 값은 런타임시 생성되고 변경되는 값이기 때문에, 프로그램 실행 시점에 등록하지 않습니다.

문제점

앞서 말씀드렸던 것처럼 우리는 프론트엔드와 백엔드가 분리되어 있습니다.
스프링 시큐리티를 사용하면 API 서버에 접근 권한을 얻을 뿐입니다.
하지만 우리는 서비스에 대해 접근 권한을 제공해야 합니다.

프론트엔드에서 OAuth 인증을 처리한다면..?

프론트엔드에서 OAuth 흐름에 따라 권한을 얻으면 되지 않냐는 의문이 생길 수 있습니다.
하지만 그렇게 하면 보안상 큰 문제가 발생합니다.
OAuth로 인증을 하기 위해서는 clientSecret이라는 값이 필요한데, 해당 값은 절대 노출되어서는 안됩니다.
하지만 프론트엔드에서는 해당 변수를 숨길 수 없습니다. 프론트엔드에 들어가는 모든 코드는 사용자가 확인할 수 있기 때문입니다.

보안상 유출되어서는 안되는 값들은 절대 프론트엔드에 존재하면 안됩니다.

Auth Code는 클라이언트에서, Access Token는 서버에서 받아오도록 만들기

이 문제를 해결하기 위해 보안이 필요한 부분만 백엔드에서 처리하기로 합니다.
인증코드를 사용한 OAuth 방식은 크게 두 단계로 나누어집니다.

1. 사용자는 구글, 깃허브 등 권한 부여 서버에게 인증 코드를 받아온다.
2. 인증 코드를 사용해 권한 부여 서버에게 Access Token을 받아온다.

1번 과정은 보안상 아무런 문제가 존재하지 않기 때문에 프론트엔드에게 해당 책임을 위임합니다.
2번 과정에서는 Access Token을 받아오기 위해 인증 코드와 함께 ClientSecret(노출되어서는 안되는 값)을 보내줍니다. 아래 이미지에서 빨간 동그라미 부분이 여기에 해당합니다.

아쉬운 점

위 방식을 사용하면 프론트엔드와 백엔드 서버가 분리된 상태에서 안전하게 OAuth 인증을 할 수 있습니다. 하지만 한 가지 아쉬운 부분이 보이는데요.
바로 관리 지점이 두 군데로 쪼개진다는 것입니다.

authorizationUri, clientId, redirectUri, scope에 관련한 부분은 프론트엔드에서 가지고 있고 clientSecret은 백엔드 서버에서 가지고 있습니다. 만약에 Google의 정책이 바뀌거나, 혹은 여러 가지 이유로 인증 방식이 변경된다면 프론트에서 해당되는 부분을 찾아 고치고, 백엔드에서도 별도로 해당되는 부분을 고쳐줘야겠죠?

2차 결론

해당 글을 보고 이 문제를 해결하는 실마리를 얻었습니다. 백엔드에서는 다음과 같은 API 명세를 갖습니다.

1. request URL 가져오기

# 요청
GET /oauth/google/authorized-uri

# 응답
{
"authorizedUri": "http://localhost:8443/login/oauth2/code/google?state=auKjwwAgBdfA0Q4XrKjFkqEjDY1NbPPdlXs4GVLG_iA&code=4%2F0AfJohXm31FsnnZRjzzjlBxjEG5wQar6qY5vOj13x7rDQzc5OF60UHt9wky_Sudubml0Qag&scope=email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+openid&authuser=0&prompt=consent"
}

2. access Token 가져오기

# 요청
POST /oauth/google/login

{
  "auth code" : "~~"
}

# 응답
{
"accessToken": “~~”,
"refreshToken": “~~”,
”grantType”:”~~”,
...
}

클라이언트는 먼저 GET /oauth/google/authorized-uri 에 요청을 보내 어떤 URI로 응답을 보내야 할지 결정합니다.
위에서 받은 URI에 요청을 보내 Auth Code를 받아오고, POST /oauth/google/authorized-uri 로 요청을 보냅니다.
Auth Code를 받은 서버는 구글에게 요청을 보내 구글의 Access Token을 받아옵니다.
성공적으로 Access Token을 받았다면, 우리 서비스의 Access Token과 Refresh Token을 만들어서 클라이언트에게 전달합니다.
이 과정을 통해 성공적으로 인증을 처리할 수 있게 되었습니다.

마치며

위 방식으로 다음 문제들을 해결했습니다.

1. 보안 이슈 해결
2. 관리 지점을 하나로 통합

그런데 아직 확장가능한 설계를 하지 않았는데요.
바로 다음 장에서 어떻게 확장을 고려한 설계를 했는지에 보여드리며 OAuth 인증에 대한 글을 마무리하겠습니다.

profile
작은 지식 모아모아

0개의 댓글