-
OAuth2 Client카테고리 없음 2024. 3. 7. 19:36
[ 목차 ]
설정
* 환경 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 를 로드한다.
반응형