해당 지식은 혼자 디버깅과 gpt, 블로그를 교차 검증 하면서 얻은 정보입니다. 틀린 내용이 있을 수 있습니다.
소셜 로그인을 사용하면서 oauth2를 사용하게 되었는데 코드를 한번 다시 살펴보고 새로운 소셜 로그인을 추가하기 쉽도록 코드 리팩토링을 진행해보려고 한다.
그전에 다시 한번 oauth2의 흐름을 정리하고 넘어가자.
위 사진처럼 oauth2에는 대표적으로 4가지의 역할이 존재한다.
- Resource Owner : 사용자
- Resource Server : 소셜 서버(네이버, 구글, 카카오)
- Authorization Server : 토큰 발급 서버(네이버, 구글, 카카오)
- Client : 우리 애플리케이션
여기서 가장 혼동하기 쉬운 부분이 Client다. 여기서 말하는 Client는 사용자를 의미하는 것이 아니다. 구글의 입장에서 보면 우리 서버가 토큰 발급을 요청하는 그림이 된다. 즉, 구글한테는 우리가 Client인 것이다.
여기서 Client는 백엔드(서버)를 의미한다.
소셜 로그인은 사용자의 가입 절차를 간소화하고 이미 다른 서비스 이용자(구글, 네이버, 카카오 등)가 제3자 애플리케이션을 사용할 수 있도록 접근성을 높여준다. 따라서 처음 액세스 토큰을 받아서 사용자 정보를 취득하고 나서 제3자 애플리케이션의 전용 인증/인가 토큰을 추가로 발급하는 것이 합리적이다.(주로 JWT를 사용한다)
또한 이렇게 만든 JWT에 유저의 정보를 담아두고 사용하게 되는데, 토큰 기반에서는 세션을 사용하지 않기에 웹스토리지나 쿠키에 보관하게 된다. 그래서 어디에 이 토큰을 저장할 것이냐도 중대한 문제가 된다.
백엔드 API를 호출할 때 헤더의 Authorization에 넣어주거나 쿠키를 통해 자동으로 전송하는 방법이 대표적이다.
쿠키를 사용하면 모든 요청에 대해 자동으로 추가되어 전송되지만 Bearer 토큰은 헤더에 직접 넣어줘야 하는 불편함이 존재한다. OAuth2에서도 액세스 토큰이나 사용자 정보를 받을 때 Bearer 토큰 방식을 사용한다.
Oauth2를 사용하기 위해서 Spring-oauth2-client 라이브러리를 사용한다. 해당 라이브러리를 추가하면 아래처럼 시큐리티 필터 체인에 필터들이 추가된다.
필터 체인에 있는 필터는 순서대로 동작하게 된다. 즉 0번부터 16번의 필터를 전부 통과해야만 서블릿으로 넘어갈 수 있게 된다.
시큐리티에서 확인해야 하는 코드는 아래 부분이다.
.oauth2Login(oauth -> oauth
.successHandler(successHandler)
.failureHandler(failureHandler)
.userInfoEndpoint(userInfo -> userInfo.userService(principalOAuth2UserService))
);
oauth2Login()을 사용하면 외부 oauth2 제공자를 통해 인증을 수행하게 된다. oauth2Login()을 사용하게 되면 OAuth2LoginConfigurer 객체가 생성되는데 내부 주석에는 아래처럼 설명이 적혀있다.
해당 Filter는 6, 7번에 해당하는 필터가 추가된 것을 확인할 수 있다.
아래 경로로 요청을 보내고 디버깅을 찍어보자.
/oauth2/authorization/~~~~
필터 체인을 돌면서 17개의 필터를 순서대로 동작하는데 이때 처음으로 oauth와 관련된 OAuth2AuthorizationRequestRedirectFilter가 동작하게 된다.
OAuth2AuthorizationRequestRedirectFilter
필터 내부 코드를 살펴보면 아래 URI를 적어둔 것이 보인다.
즉 '/oauth2/authorization' 으로 요청을 보내게 되면 해당 필터가 동작하게 되는 것이다. 그럼 이 필터는 무슨 역할을 하는 걸까?
해당 필터는 직역하면 인가 요청 리다이렉트를 해주는 필터다. 즉 인가 -> 인증 과정을 거쳐야 하기 때문에 인가를 요청하기 위해 소셜 서버의 로그인창을 리다이렉트 해준다고 볼 수 있다.
doFilterInternal을 살펴보면 아래와 같다.
여기서 볼 부분은 this.authorizationRequestResolver로 resolve를 하는 데 사용이 가능하면 OAuth2AuthorizationRequest를 반환하고, 사용하지 못하면 null을 반환해 준다.
그리고 해당 authorizationRequestResolver를 구현한 구현체는 DefaultOAuth2AuthorizationRequestResolver이다.
해당 구현체에서 '/oauth2/authorization/{registrationId}' 로 설정하는 것을 확인할 수 있다.(생성자를 이용)
위에 보이는 registrationId는 yml에 설정해 놓은 Id를 ClientRegistrationRepository에 저장해 놓고 사용하게 된다.
Oauth에서 기본적으로 제공하는 서버는 CommonOAuth2Provider enum 안에 들어있다.
public enum CommonOAuth2Provider {
GOOGLE {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}
},
GITHUB {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},
FACEBOOK {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_POST, DEFAULT_REDIRECT_URL);
builder.scope("public_profile", "email");
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}
},
OKTA {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Okta");
return builder;
}
};
private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
protected final ClientRegistration.Builder getBuilder(String registrationId, ClientAuthenticationMethod method,
String redirectUri) {
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUri(redirectUri);
return builder;
}
/**
* Create a new
* {@link org.springframework.security.oauth2.client.registration.ClientRegistration.Builder
* ClientRegistration.Builder} pre-configured with provider defaults.
* @param registrationId the registration-id used with the new builder
* @return a builder instance
*/
public abstract ClientRegistration.Builder getBuilder(String registrationId);
}
위에 존재하지 않다면 yml에서 설정을 따로 진행해야 하는 것이다.(네이버, 카카오 등)
예시로 구글 같은 경우는 아래처럼 인가 Uri, 토큰 Uri, 유저정보 Uri가 기본으로 설정되어 있지만 카카오는 그런 게 없어서 yml에서 직접 설정을 해줬던 것이다.
그러면 디버깅을 통해 전체적으로 어떻게 필터를 타는지 파악해 보자.
우선 사용자가 소셜 로그인 요청을 보낸 상태이고 OAuth2AuthorizationRequestRedirectFilter의 doFilterInternal에 bp를 찍어두었다.
그러면 authorizationRequestResolver.resolve가 호출되게 된다. yml의 설정에 따라 OAuth2AuthorizationRequest 객체가 하나 만들어진 것을 확인할 수 있다.
여기서 authorizationRequestUri를 보면 yml 설정을 하나의 URI로 만들어서 요청을 보내도록 만들어 놓은 것을 볼 수 있다.
그다음에 authorizationRequest가 null이 아니기 때문에 sendRedirect를 동작시키게 된다.
authorizationRequest의 GrantTpye이 AUTHORIZATION_CODE와 일치하기 때문에 authorizationRequestRepository.saveAuthorizationReuqest를 동작시키게 된다.
그냥 리다이렉트 하기 전에 인가 요청 객체의 정보를 저장해 둔다고 보면 된다.
그리고 이제 웹 브라우저를 리다이렉트 하게 된다.
그러면 유저는 로그인 창으로 이동하고 로그인을 진행한다.
여기서 사용자의 로그인(인가)이 완료되면 OAuth2 제공자는 서버의 리다이렉션 URI로 인가 코드를 포함한 요청을 보내게 된다.
인가를 성공하면 인증을 확인하게 되는데 이때 OAuth2LoginAuthenticationFilter가 잡게 된다.
OAuth2LoginAuthenticationFilter는 다음처럼 기본 URI를 필터링하고 있다.
우리는 리다이렉트 URI를 'http://localhost:8080/login/oauth2/code/kakao' 이렇게 설정했기 때문에 해당 필터에서 잡을 수 있게 된다.
OAuth2LoginAuthenticationFilter의 경우 AbstractAuthenticationProcessingFilter를 상속받고 있는데 내부 doFilter를 보면 requiresAuthentication 메서드를 통해 URI가 매칭되는지 확인하게 된다.
기본 경로인 'login/oauth2/code/*' 에 속하면 true, 아니면 false를 줘서 필터를 더 진행시키거나 아니면 넘기게 된다.
여기서 신기했던 점이 OAuth2LoginAuthenticationFilter의 경우 doFilter를 오버라이드 하지 않았다. 대신 attemptAuthentication 메서드를 오버라이드 했는데, AbstractAuthenticationProcessingFilter의 doFilter에서 attemptAuthentication 메서드를 호출하는 부분이 존재한다. 그래서 doFilter를 오버라이드 하지 않은 것 같다.
OAuth2LoginAuthenticationFilter의 전체 로직을 다 설명하면 길어질 것 같아서 핵심만 살펴보도록 하자.
얘도 동일하게 인증 요청 객체를 가지고 온다. null이면 예외를 던진다.
그다음 Id를 가져온다. 여기서 Id도 null이면 예외를 던진다.
그다음 요청 URL에서 쿼리파라미터를 제외한 URI를 뽑아낸다. 그러면 아래와 같은 리다이렉트 URI가 뽑혀 나온다.
그다음에 리다이렉트 URI와 code, state를 가지고 있는 Map인 params를 매개변수로 넣어서 OAuth2AuthorizationResponse 객체를 하나 뽑아낸다.
그리고 나머지 결과들을 다 넣어서 인가 토큰 객체를 뽑아내게 된다.
해당 토큰 안에는 principal, 액세스토큰, 리프레시토큰 등 다양한 oauth 정보들이 포함되어 있다.
그러고 oauth2Authentication 객체를 리턴하게 된다. 이 안에는 토큰의 정보가 들어있기 때문에 서버에서 원하는 액세스 토큰을 확보할 수 있게 되는 것이다.
전체적으로 정리하면
1. 사용자가 소셜 서버에 로그인 요청.
2. 서버는 yml의 정보를 통해 로그인 uri 전달
3. 사용자가 로그인 시도(인가)
4. 소셜 서버는 로그인이 완료되면 인가 코드를 리다이렉트 Uri로 전달.
5. 서버는 인가 코드를 받아서 액세스토큰으로 교환 시도.(필터에서 진행)
6. 액세스 토큰 확보
해당 절차를 진행하게 되고 서버는 액세스 토큰을 확보했기에 소셜 서버에서 유저의 정보에 접근이 가능해지게 된다.
이제 뽑아낸 유저 정보를 가지고 우리 서버에서 JWT를 통해 관리를 진행하면 된다.
'CS지식' 카테고리의 다른 글
자바 8과 11에서 String 연산을 처리하는 방법 (0) | 2024.08.10 |
---|---|
CSRF 공격을 막기 위한 CSRF 토큰 (0) | 2024.07.31 |
Transaction의 개념 (Feat, ACID) (0) | 2024.05.26 |
SOP 정책 (Feat, CORS 및 Preflight) (0) | 2024.05.25 |