OAuth2 Client
[ 목차 ]
설정
* 환경 spring boot 3.2.1
소셜 로그인을 하려면 먼저 아래와 같은 설정을 해주어야한다.
1. gradle OAuth2 client 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
2. spring application.yaml 에 client 정보 등록
authorization server(kakao developers) 에 client 로써, client application 을 등록하면 client id, secret 을 받을 수 있다.
scope 은 고객에게서 수집할 정보로 선택한 항목들이며.
redirect uri 는 authorization server 로부터 authorization code 를 전달 받을 uri 로, 애플리케이션을 등록할 때 직접 입력하는 항목이다.
※ /login/oauth2/code 는 OAuth2LoginAuthenticationFilter 의 기본 uri 이다.
client 로 등록한 정보는 스프링에서 RegisteredClient 로 InMemory 에 저장된다.
spring:
application:
name: oauth2-client
security:
oauth2:
client:
registration:
kakao:
client-id: ******
client-secret: ******
authorization-grant-type: authorization_code
redirect-uri: http://localhost:8888/login/oauth2/code/
scope:
- profile_nickname
- profile_image
client-authentication-method: client_secret_post
※ 선택할 개인정보의 ID 를 scope 에 명시해줘야하는데 잘못 입력하면 로그인 화면을 띄워줄 때 오류가 난다.
kakao 에서 고객 정보 수집 동의 사항 선택 | scope 에 ID 를 잘못 넣었을 때 로그인 화면 redirect 시 발생하는 오류 |
수집 정보 ID 가 명시되어 있다.![]() |
![]() |
구현
카카오에 로그인해서 얻는 authorization_code, access_code 등의 정보는 기본적으로 아래와 같은 테이블 구조에 저장된다.
CREATE TABLE oauth2_authorized_client (
client_registration_id varchar(100) NOT NULL, //kakao server
principal_name varchar(200) NOT NULL, // user 명
access_token_type varchar(100) NOT NULL,
access_token_value blob NOT NULL,
access_token_issued_at timestamp NOT NULL,
access_token_expires_at timestamp NOT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
refresh_token_value blob DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (client_registration_id, principal_name)
);
spring 에서 default 로는 InMemoryOAuth2AuthorizedClientService 를 사용하고 JdbcOAuth2AuthorizedClientService 로 대체할 수 있는데, 나는 JPA 를 사용하기 위한 구현체를 따로 만들었다.
import com.sample.oauth.client.repository.JpaAuthorizedClientRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataRetrievalFailureException;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.Instant;
import java.util.Collections;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class JpaAuthorizedClientStore implements OAuth2AuthorizedClientService {
private final JpaAuthorizedClientRepository clientRepository;
private final ClientRegistrationRepository registrationRepository;
@Override
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
AuthorizedClientJpe authorizedClientJpe = clientRepository.findByClientPkClientRegistrationIdAndClientPkPrincipalName(clientRegistrationId, principalName);
ClientRegistration clientRegistration = this.registrationRepository
.findByRegistrationId(clientRegistrationId);
if (clientRegistration == null) {
throw new DataRetrievalFailureException(
"The ClientRegistration with id '" + clientRegistrationId + "' exists in the data source, "
+ "however, it was not found in the ClientRegistrationRepository.");
}
OAuth2AccessToken.TokenType tokenType = null;
String accessTokenType = authorizedClientJpe.getAccessTokenType();
if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase(accessTokenType)) {
tokenType = OAuth2AccessToken.TokenType.BEARER;
}
String tokenValue = authorizedClientJpe.getAccessTokenValue();
Instant issuedAt = authorizedClientJpe.getAccessTokenIssuedAt();
Instant expiresAt = authorizedClientJpe.getAccessTokenExpiresAt();
Set<String> scopes = Collections.emptySet();
String accessTokenScopes = authorizedClientJpe.getAccessTokenScopes();
if (accessTokenScopes != null) {
scopes = StringUtils.commaDelimitedListToSet(accessTokenScopes);
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, tokenValue, issuedAt, expiresAt, scopes);
String refreshTokenValue = authorizedClientJpe.getRefreshTokenValue();
Instant refreshTokenIssuedAt = authorizedClientJpe.getRefreshTokenIssuedAt();
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(refreshTokenValue, refreshTokenIssuedAt);
return (T) new OAuth2AuthorizedClient(clientRegistration, principalName, accessToken, refreshToken);
}
@Override
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
AuthorizedClientJpe authorizedClientJpe = new AuthorizedClientJpe();
authorizedClientJpe.setClientPk(new ClientPK(clientRegistration.getRegistrationId(), principal.getName()));
authorizedClientJpe.setAccessTokenType(accessToken.getTokenType().getValue());
authorizedClientJpe.setAccessTokenValue(accessToken.getTokenValue());
authorizedClientJpe.setAccessTokenIssuedAt(accessToken.getIssuedAt());
authorizedClientJpe.setAccessTokenExpiresAt(accessToken.getExpiresAt());
authorizedClientJpe.setAccessTokenScopes(StringUtils.collectionToDelimitedString(accessToken.getScopes(), ","));
authorizedClientJpe.setRefreshTokenValue(refreshToken.getTokenValue());
authorizedClientJpe.setRefreshTokenIssuedAt(refreshToken.getIssuedAt());
authorizedClientJpe.setCreatedAt(Instant.now());
clientRepository.save(authorizedClientJpe);
}
@Override
public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
clientRepository.deleteByClientPkClientRegistrationIdAndClientPkPrincipalName(clientRegistrationId, principalName);
}
}
진행과정
1. authorization server 에 request 를 보낸다.
Get
{{client-server-uri}}/oauth2/authorization/{{registrationId}}
registrationId 는 yml 에 등록한 authorization server 의 이름이다.
http://localhost:8888/oauth2/authorization/kakao
oauth2/authorization 이라는 기본 uri 는 resolver 설정을 통해 원하는 uri 로 변경할 수 있다.
private OAuth2AuthorizationRequestResolver authorizationRequestResolver(
ClientRegistrationRepository clientRegistrationRepository) {
DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository, "/oauth2/authorization");
return authorizationRequestResolver;
}
아래와 같이 호출하면 authorization server 에서 인증되지 않은 유저에 대해 로그인 페이지를 반환한다.
로그인을 하면 client에서 OAuth2AuthorizationRequestRedirectFilter 에서 DefaultOAuth2AuthorizationRequestResolver 를 사용해 authorization_code 발행 요청을 하는 request 객체를 만들어 호출한다.
request 객체에 들어가는 정보는 yaml 에 설정했던 정보이다.
http://localhost:85/api/v1/oauth2/authorize?response_type=code
&client_id={{registrationid_clientid}}
&scope=name
&state=aoknqLflw9irBhXTkowkBKCUZBGBjmPILj8AhGeNlvs%3D
&redirect_uri=http://localhost:8888/login/oauth2/code&prompt=consent
해당 request 를 받고 authorization server 에서 redirect_uri 로 설정된 주소로 authorization_code 를 반환해주면,
OAuth2LoginAuthenticationFilter 가 authorization_code 정보와 RegisteredClient 정보로
access_token 을 요청하는 request 를 생성하고 response 를 받는다.
박스에 있는 getAuthenticationManager 에서 호출된 ProviderManager 는 OAuth2LoginAuthenticationProvider 를 호출하고
OAuth2LoginAuthenticationProvider 는 OAuth2AuthorizationCodeAuthenticationProvider를,
OAuth2AuthorizationCodeAuthenticationProvider는 DefaultAuthorizationCodeTokenResponseClient를 호출해
access token 을 요청하는 request 를 보낸다.
받아온 access token 으로 DefaultOAuth2UserService 에서는 리소스 서버에 userinfo 를 요청해 OAuth2User 를 받는다.
만약 scopes에 openid 가 포함되어있다면, 위 로직을 타지 않고, OidcAuthorizationCodeAuthenticationProvider 와 OidcUserService 를 통해 OAuth2User 를 로드한다.