킴의 레포지토리
인증(1) - 스프링 시큐리티의 OAuth2.0 로그인 과정과 네이버, 카카오 로그인 구현 본문
이 글은 프로젝트에서 스프링 시큐리티를 사용해서 OAuth 2.0 로그인을 구현한 과정을 담았다. 먼저 OAuth 2.0 프로토콜에 대해서 이해하기 위해서 관련 개념을 정리하고, 검사 도구를 사용해 네이버 OAuth2.0 로그인이 진행되는 과정에서 일어나는 네트워크 통신을 살펴보았다. 그 후, 스프링 시큐리티에서 어떻게 OAuth 2.0 로그인을 지원하는지 관련 클래스 코드들을 분석했다. 마지막으로 프로젝트에 적용하였다.
1. OAuth 2.0 프로토콜
1-1. OAuth 란?
OAuth란 third-party 앱이 접근 제한된 서비스 자원(protected resource)를 사용하기 위해 리소스 소유자(사용자)의 허락을 받아 자원을 사용할 수 있도록 하는 프로토콜이다. 즉, 클라이언트 어플리케이션이 사용자로부터 권한을 부여받아 사용자를 대신해서 리소스 서버에 요청을 보낼 수 있게 한다.
대표적으로 로그인 및 회원가입에 사용가능하다. OAuth를 사용하면 사용자는 어플리케이션을 위한 별도의 계정을 만들지 않고도 OAuth 2.0 프로바이더(카카오, 네이버)에 이미 가지고 있는 계정으로 다른 어플리케이션에 회원 가입 및 로그인이 가능하다.
- 제한된 서비스 자원: 사용자가 허용해야만 사용할 수 있는 접근된 자원을 의미한다. 사용자의 데이터나 액션을 의미한다.
- 사용자 데이터 예시: 카카오 사용자 프로필 조회, 구글 캘린더 조회 등
- 사용자 액션 예시: Gmail에서 이메일 전송, 카카오페이에서 송금하기 등
- 리소스 소유자: 제한된 서비스 자원의 소유자로, 사용자를 의미한다.
- 클라이언트 어플리케이션: 리소스 소유자로부터 접근 권한을 부여받아 제한된 서비스 자원에
- OAuth 2.0 프로바이더: 인증 서버와 리소스 서버를 제공해야합니다.
- 인증 서버는 사용자가 클라이언트에 권한을 부여하면(로그인을 통해 권한 코드 전달) 인증 서버는 이를 확인하고 액세스 토큰을 발급한다.
- 리소스 서버: 제한된 서비스 자원을 호스팅하는 서버이다. 클라이언트 어플리케이션은 액세스 토큰을 사용하여 리소스 서버에 사용자의 제한된 서비스 자원에 대한 요청을 보낸다.
1-2. OAuth 2.0를 사용한 네이버 사용자 프로필 가져오기
실제 네이버에서 리소스 서버로부터 사용자 정보를 가져오는 과정을 살펴보며 OAuth 2.0 프로토콜에 대해서 이해할 수 있다. 클라이언트 어플리케이션은 dev.api.ai-review.site로 현재 개발중인 프로젝트이고, OAuth2.0을 사용해 네이버로부터 인증된 사용자의 유저 프로필을 가져오고자 한다.
1. Authorization Request: 클라이언트 어플리케이션이 사용자를 인증 서버로 리다이렉트시켜 권한 부여를 요청한다. 사용자가 oauth 인증 요청 url인 /oauth2/authorization/naver에 요청하면 클라이언트 어플리케이션은 302 응답을 통해 사용자를 oauth 인증 서버로 리다이렉트한다. 리다이렉트되는 url은 다음과 응답 헤더 Location에서 볼 수 있듯이 https://nid/naver.com/oauth2.0/authorize이다. 이때 redirect uri에 쿼리 파라미터에 client id와 redirect uri를 추가한다. client id와 redirect uri로 클라이언트 어플리케이션이 신뢰할 수 있는지 검증한다(네이버에 등록된 client id와 redirect uri인지 검증). 또, 인증이 완료되면 redirect uri가 호출될 수 있도록 한다.
실제로 사용자가 보는 페이지는 다음과 같이 네이버 로그인 페이지이다. httsp://nid/naver.com/oauth2.0/authorize 요청이 로그인 html을 반환한다.
2.User Authentication & Authorization Code Grant: 사용자가 로그인하면(인증되면) 여러 네이버 oauth 관련 url로 요청과 응답을 주고받은 후 최종적으로 redirect uri에 AuthorizationCode를 담아서 요청한다. 이때 redirect uri는 1.의 요청 쿼리 파라미터에 추가한 redirect_uri이다. 다음 그림에서 사용자가 로그인 버튼을 누르면 최종적으로 클라이언트 어플리케이션의 /login/oauth2/code/naver가 호출되고 쿼리 파라미터로 code가 전달되는 것을 확인할 수 있다.각 요청이 redirect되는 것은 아니고 200 OK 응답을 받지만 페이지 로드 후 동적으로 이후 url들을 호출하는 것으로 보인다(실제로 어떻게 호출되는지 CORS 때문인지 확인이 어렵다). 즉, redirect uri가 실제로는 redirect라기 보다는 인증 성공시 호출되는 콜백 uri라고 볼 수 있다.
3.Token Exchange: 클라이언트 어플리케이션은 권한 코드를 사용하여 액세스 토큰을 요청한다. 이때, client id와 client secret을 담아서 신뢰할 수 있는 어플리케이션임을 인증한다.
4.리소스 접근: 클라이언트 어플리케이션이 액세스 토큰을 사용하여 네이버가 유저 프로필을 제공하는 API를 호출한다.
1-3. OpenID 및 OpenID Connect - OAuth2.0 로그인보다 OpenID 로그인이 더 나은 이유
OAuth2.0와 유사한 개념으로 OpenID와 OpenID Connect가 있다.OpenID는 사용자가 별도의 계정을 만들지 않고도 OpenID를 통해 여러 앱에 인증할 수 있도록 하는 프로토콜이다. OpenID 프로바이더는 사용자가 인증되면 third-party 앱에 ID 토큰을 사용하여 인증되었다는 정보를 전달한다. 이때 인증되었다는 정보만 전달하기 때문에 OAuth2.0에서 클라이언트 어플리케이션이 액세스 토큰으로 사용자의 리소스에 접근이 가능한 것과 달리 OpenID는 접근이 불가능하다.
OpenID Connect는 OpenID를 통한 인증에 더해서 OAuth2.0을 통한 유저 프로필 접근 권한을 부여한다. OpenID Connect 프로토콜에서는 사용자가 인증되었을때 클라이언트 어플리케이션에 ID 토큰과 액세스 토큰를 함께 제공한다. 이때, OAuth2.0 액세스 토큰은 사용자의 다양한 리소스에 접근이 가능한반면 OpenID Connect의 액세스 토큰은 사용자 프로필 정보에만 접근이 가능한 토큰이다.
정리하자면 다음과 같다.
구분 | 사용자 인증시 클라이언트에게 제공되는 정보 | 참고 |
OpenID | ID 토큰 | ID 토큰은 인증된 결과를 담은 JWT임 |
OpenID Connect | ID 토큰 및 사용자 사용자 프로파일 접근 가능한 액세스 토큰. | ID 토큰으로 인증하고, 액세스 토큰으로 사용자 프로파일 접근 권한 제공. 사용자 프로파일을 제외한 다른 리소스에는 접근 할 수 없음. |
OAuth2.0 | 사용자 다양한 리소스 접근 가능한 액세스 토큰 | 사용자의 프로필뿐 아니라, 사진, 친구 목록등 다양한 리소스에 접근 가능. |
로그인 기능만 사용하고 사용자 프로필이나 다른 리소스에 대한 접근이 필요없다면 OpenID나 OpenID Connect 프로토콜을 사용한 로그인이 더 나을 수 있다. 왜냐하면 OpenID는 사용자가 인증 서버에서 로그인하고 나면 바로 ID 토큰을 받아서 사용자가 인증되었음을 확인할 수 있기 때문이다. 반면에, OAuth2.0을 사용할 경우 사용자가 인증 서버에서 로그인하고 나서 인가 코드로 다시 액세스 코드를 받고 또 액세스 코드로 사용자 정보를 요청해야해서 몇번의 네트워크 통신이 더 필요하다. 인가 코드나, 액세스 토큰은 자원에 접근이 가능하도록 하는 코드로 사용자에 대한 정보를 담고 있지 않아 어떤 사용자와 관련된 정보인지 알 수 없고 최종적으로 리소스 서버로부터 정보를 얻어와야지만 사용자 인증을 완료할 수 있다.
1-4. Authorization Code Flow - OAuth2.0에서 권한 코드로 액세스 토큰을 얻도록 하는 이유
OAuth2.0 프로바이더는 사용자가 인증되면 클라이언트 어플리케이션에 바로 액세스 토큰을 제공하지 않고 권한 코드(Authorization Code)를 사용해서 액세스 코드를 얻도록 한다. 이런 방식은 Authorization Code Flow라고 한다. 한번 더 통신이 일어나야하는데도 불구하고 이러한 방식을 사용하는 이유는 다음과 같은 이유 때문이다.
- 클라이언트 시크릿 보호: 클라이언트 어플리케이션이 액세스 토큰을 얻기 위해서는 서버 측에서 클라이언트 시크릿을 사용하여 토큰을 요청한다. 만약, Authorization Code Flow를 사용하지 않는다면 클라이언트 시크릿이 인증 서버로 요청이 리다이렉트될때 같이 전달될 수 있도록 응답에 담아야할 것이다. 클라이언트 시크릿은 민감한 정보이므로, 브라우저나 모바일 앱과 같은 클라이언트 측에 노출되지 않아야 한다. 따라서,서버는 코드를 전달받아서 직접 코드와 클라이언트 시크릿으로 토큰을 요청한다.
- 액세스 토큰 노출 방지: 액세스 토큰을 사용해 사용자의 리소스에 접근할 수 있는데, 액세스 토큰이 클라이언트에 직접 노출되면 토큰이 탈취될 위험이 커진다. Authorization Code Flow에서는 액세스 토큰이 클라이언트에 노출되지 않고 서버에서 관리하게 된다.
2. 스프링 시큐리티 OAuth 2.0 코드 분석하기
스프링 시큐리티는 org.springframework.security.oauth2 패키지를 통해 OAuth 2.0 로그인 기능을 지원한다. (다른 리소스 접근 기능은 제공하지 않으므로 직접 구현해야한다)이 패키지를 사용해서 OAuth 2.0으로 회원가입을 하고 로그인할 수 있도록 구현해보았다. 먼저,스프링 시큐리티에서 OAuth 2.0 인증 및 리소스 서버 접근이 어떻게 지원되는지 코드를 통해 살펴본다.
스프링 시큐리티가 지원하는 OAuth 2.0은 1. 스프링 어플리케이션이 시작 시에 관련 빈을 application context에 등록하는 과정과 2.사용자 요청에 따라 인증 및 리소스 접근이 이루어지는 과정으로 나눌 수 있다.
2-1. OAuth 2.0 관련 빈을 어플리케이션 컨텍스트에 등록
아래와 같이 SecurityFilterChain에서 oauth2Login을 활성화하면 OAuth2LoginConfigurer에 의해서 OAuth2AuthorizationRequestRedirectFilter와, OAuth2LoginAuthenticationFilter가 필터로 등록된다.
또한, OAuth2UserService, OAuth2LoginAuthenticationProvider등이 빈으로 등록된다. 각 클래스의 역할은 아래 <2-2.사용자 요청에 따라 인증 및 리소스 접근>에서 확인할 수 있다.
스프링부트의 자동 설정 (org.springframework.boot.autocongure.security.oauth2.client.servlet. OAuth2ClientAutoConfiguration) 을 통해서 InMemroyClientRegistrationRepository, InMemoryOAuth2AuthorizedClientRepsotiroy가 등록된다.
OAuth2ClientRegistrationrepository가 빈으로 등록되는 과정을 살펴보면 다음과 같다. 1.에 의해서 application.yaml에 spring.security.oauth2.client.registation/provider로 설정한 값들은 OAuth2ClientProperties로 변환된다. OAuth2ClientProperties는 ClientRegistration으로 변환되고, ClientRegistraion을 담은 InMemoryClientRegistrationRepository가 빈으로 등록된다. 즉, InMemoryClientRegistraionRespository는 OAuth 2.0 프로바이더 및 클라이언트 정보를 메모리에 저장해두는 클래스이다.
2-2.사용자 요청에 따라 인증 및 리소스 접근
사용자의 OAuth2.0 로그인 요청은 OAuth2AuthorizationRequestRedirectFilter, OAuth2LoginAuthenticationFilter에 의해서 처리된다. 각 필터를 자세히 살펴보면 다음과 같다.
OAuth2AuthorizationRequestRedirectFilter는 사용자의 로그인 요청을 인증 서버로 리다이렉트하는 역할을 한다.
- 사용자의 요청이 OAuth 2.0 로그인 요청인지 확인한다. AuthorizationRequestResolver는 사용자 요청 url이 /oauth2/authorization/{ClientRegistrationRepository에 등록된 providerId} 에 해당하는지 확인하고 맞으면 OAuth2AuthorizationRequest를 아니면 null을 반환한다.
- 사용자의 OAuth2.0 로그인 요청을 인증 서버로 리다이렉트한다.
- 사용자의 인증 요청은 저장해둔다. 이는 추후 인증 완료 후 redirect uri로 요청이 전달될때 사용자에 의한 유효한 인증 요청이라는 것을 검증하는데 사용된다. (OAuth2LoginAuthenticaitonFilter 2.참고)만약, redirect uri로 들어온 요청이 authorizationRequestRepository에 저장되어 있지 않다면 악의적인 공격일 수 있기 때문이다. 이때, AuthorizationRequestRepository로 HttpSessionOAuth2AuthorizationRequestRepository가 사용되며 사용자의 인증 요청은 세션에 저장된다.
- 인증 서버로 요청을 리다이렉트한다.
- 만약 OAuth 2.0 로그인 요청이 아니라면 다음 필터를 실행한다.
OAuth2LoginAuthenticationFilter는 인가 코드를 사용해 인증 서버에서 액세스 토큰을 얻고, 액세스 토큰을 사용해 리소스 서버에서 사용자 정보를 가져오는 역할을 한다.
- 요청 파라미터를 통해 인증 서버의 응답이 맞는지 확인한다. CODE, STATE 혹은 ERROR, STATE가 요청 파라미터에 포함되어야 인증 서버로부터의 응답이다.
- AuthorizationRequestRepository(세션 저장소 사용)로부터 저장된 인증 요청을 복원한다. 만약, 해당 요청과 매치되는 요청 정보가 없다면 위조된 요청으로 간주하고 예외를 발생시킨다. 사용자의 인증 요청이 없이(OAuth2AuthorizationRequestRedirectFilter를 거치지 않고) 발생한 요청이기 때문이다.
- OAuth2LoginAuthenticationToken으로 인증 매니저에게 인증을 요청한다.3-2. AuthorizationCodeAuthenticatinonProvider가 인가 코드로 액세스 토큰을 얻는다. 이때 클라이언트 id, 클라이언트 secret정보를 Authorization 헤더에 추가해 신뢰할 수 있는 클라이언트 어플리케이션임을 인증한다.
- 3-3. 액세스토큰으로 리소스 서버에서 유저 정보를 가져온다.
- 3-1. OAuth2LoginAuthenticationToken은 OAuth2LoginAuthenticationProvider에 의해 인증된다.
- AuthorizedClient(액세스 토큰 및 리프레시 토큰 포함)를 저장한다. 이때 저장소로 세션 혹은 in-memory, jdbc 등을 지정할 수 있다. 기본적으로는 in-memory 저장소가 사용된다. 저장된 정보는 추후 다른 리소스 접근시 혹은 액세스 토큰 갱신시 사용될 수 있다.
3. 스프링 시큐리티 OAuth 2.0을 활용해 회원가입 및 로그인 구현
OAuth2.0 인증이 성공하면 successHandler가, 인증이 실패하면 failureHandler가 실행된다. 기본 설정은 요청을 리다이렉트하는 SimpleUrlAuthenticationSuccessHandler, SimpleUrlAuthenticationFailureHandler이다. successHandler와 failureHandler를 지정해서 회원가입 및 로그인을 구현할 수 있다. 인증이 끝나면 결과에 상관없이 다음 필터는 호출하지 않고 return되어 응답이 반환된다.
3-1. SuccessHandler
구현한 OAuth2AuthenticationSuccessHandler는 OAuth 2.0 인증이 성공했다면 사용자를 어플리케이션에서도 로그인 시킨다.
- OAuth 2.0 리소스 서버로부터 받은 사용자 프로필이 들어있는 OAuth2User를 OAuth2UserInfo라는 클래스로 변환한다. OAuth 2.0 프로바이더마다 응답 형식이 다르기 때문에 Map으로 저장된 정보들을 일관된 인터페이스로 변환하기 위해서이다. 이때 OAuth2UserAttribute2OAuthuserInfoConverter라는 클래스를 사용한다.
- OAuth 2.0 유저 정보가 어플리케이션 DB에 저장되어있는지 확인한다. 이때, OAuth 2.0프로바이더와 OAuth 2.0 인증 서버가 제공한 유저 식별자를 기준으로 정보를 찾는다. 만약에 정보가 존재한다면 어플리케이션에 회원가입된 유저로 로그인 처리하고, 정보가 존재하지 않는다면 새로운 유저로 해당 정보를 DB에 저장한 후 로그인 처리한다.
3-2. 템플릿 메소드 패턴을 사용한 프로바이더 응답 정보 인터페이스 통일
OAuthUserAttribute2OAuth2UserInfoConverter는 프로바이더마다 서로 다른 응답 형식을 통일하는 역할을 한다. getUserInfoFromOAuth2UserAttribute()라는 정적함수를 제공한다. 이 함수는 프로바이더 id를 사용해 해당하는 enum객체를 찾고 그 객체의 from()함수를 호출한다. 각 enum객체들은 from()이라는 추상 함수를 구현해야한다.
이는 템플릿 메소드 패턴을 이용하여 객체지향 설계 원칙 OCP를 준수한 것이다.providerId로부터 해당하는 객체를 얻고 from()을 호출하는 흐름을 같지만 어떤 enum 객체가 대응되는지에 따라서 호출되는 from()함수가 달라진다. 프로바이더가 추가되어도 기존 코드는 수정되지 않고, enum 객체만 추가하면 된다.
3-3.User 테이블 구조 - 일반 유저와 OAuth 2.0 유저를 분리하지 않은 이유
어플리케이션은 id/pw를 사용한 회원가입과 OAuth 2.0 로그인을 통한 회원가입을 둘다 지원한다. 이를 위해서 각각 User, OAuthUser 테이블을 생성하거나, 하나의 테이블로 관리할 수 있다
User, OAuthUser 테이블을 분리하는 경우 낭비되는 필드(일반 유저의 경우 oauth_provider, oauth_user_id가 null이 되어야한다)가 없기 때문에 디스크 공간 사용에 효과적일수는 있다. 하지만, 로그인 방식과 상관없이 같은 권한을 가진 유저로 관리하고 싶을때 조회시 매번 두 테이블을 조회해야해서 복잡해진다. 또한, 유저 테이블에 필드가 추가되거나 삭제될때 OAuthUser 테이블에도 반영해주어야해서 유지보수가 어려워진다.
개발중인 어플리케이션의 경우 로그인 방식에 상관없이 같은 권한을 가진 유저이기 때문에 비지니스 로직을 단순하게 하기 위해 두 테이블을 분리하기 보다 한 테이블에서 관리하도록 하였다.
3-4. 세션 저장소로 redis를 지정한 이유
개발 중인 어플리케이션은 JWT를 사용해 인증 상태를 유지하기 때문에 기본적으로 세션을 사용하지 않는다. 하지만 스프링 시큐리티의 OAuth 2.0에 의해서 세션이 생성된다. 스프링 시큐리티는 사용자의 인증 요청을 저장하는 AuthorizationRequestRepository와 인증된 사용자 정보를 저장하는 AuthorizedClientRepository로 세션을 사용하기 때문이다. 스프링에서 세션은 기본적으로 메모리 내에 저장된다.
OAuth 2.0 인증 과정에서 필요한 AuthorizationRequest를 메모리 내 세션에 저장할 경우 서버를 스케일 아웃했을때 문제가 생길 수 있다. 서버가 여러대이고 요청이 로드밸런서의 라운드로빈에 의해서 분산된다면 처음 사용자아 인증 요청을 한 서버와, 인증 서버에서 인증이 완료되고 응답이 전달되는 서버가 달라질 수 있다. 세션 저장소에서 AuthorizationRequest를 복원할 수 없고 해당 인증은 실패하게 된다.
이를 해결하기 위해서는 1. 스티키 세션 2. 중앙 집중식 세션 저장소 두가지 방식을 사용할 수 있으며 각 방식은 다음과 같은 장단점을 가진다.
구분 | 설명 | 장점 | 단점 |
스티키 세션 | 로드 밸런서가 특정 클라이언트의 요청을 항상 동일한 서버로 라우팅하는 방식 | 1. 설정이 간단하다. 2. 메모리에서 바로 접근이 가능해서 빠르다 |
1. 특정 서버가 과부화 될 수 있다. 2. 특정 서버가 다운되면, 해당 서버에 고정된 세션 정보가 손실된다 |
중앙 집중식 세션 저장소 |
모든 서버가 공유하는 세션 저장소를 사용하는 방식 | 1. 세션 상태의 일관성을 유지할 수 있다. 2. 하나의 서버가 다운되어도 세션은 유실되지 않는다. |
1. 구현이 복잡하다. 2. 세션 데이터에 접근하기 위해 네트워크 요청이 필요해 느리다 3. SPOF가 될 수 있다. |
현재 여러 대의 서버를 사용하지 않지만, 스케일 아웃이 필요할 경우를 대비해서 미리 redis를 세션 저장소로 사용하도록 하였다. 이를 통해, 인증 서버로부터 요청이 리다이렉트될때 어떤 서버로 요청이 전달되어도 redis로부터 인증 요청을 복원하고 인증을 계속 진행할 수 있다.
스프링에서 세션 저장소로 redis를 사용하기 위해서 아래와 같이 build.gradle에 spring-data-redis 및 spring-session-data-redis에 대한 의존성을 추가하고, application.yaml에서 세션 저장소 타입을 redis로 설정하였다.
정리
✅ 클라이언트 어플리케이션이 사용자로부터 권한을 부여받아 사용자를 대신해서 리소스 서버에 요청을 보낼 수 있게 하는 프로토콜이다. 1. 사용자 인증 후 인가 코드 발급 2. 인가 코드로 액세스 토큰 발급 3. 액세스 토큰으로 리소스 접근 순서로 진행된다.
✅ 스프링 시큐리티의 OAuth2.0 로그인 요청은 OAuth2AuthorizationRequestRedirectFilter, OAuth2LoginAuthenticationFilter에 의해서 처리된다.
✅ SuccessHandler, FailureHandler를 구현해서 인증 성공, 실패에 따른 처리를 통제할 수 있다. 예를 들어 사용자가 처음으로 인증 성공시 사용자 정보를 DB에 저장한다.
'study > spring' 카테고리의 다른 글
스프링 부트의 WAS 설정 알아보기 (1) | 2024.07.08 |
---|---|
어디서 JWT를 체크해야할까? Filter와 Interceptor의 장단점 분석 (3) | 2024.04.10 |