소셜 로그인 기능 설계 ‐ (2) 소셜 로그인 - ttasjwi/board-system GitHub Wiki
- 앞에서 사용자의 소셜서비스 인가를 처리했다고 가정한다.
- 사용자는 소셜서비스 인가페이지에서 승인을 하고, state, code 를 리다이렉트 파라미터를 통해 얻어온다.
- 소셜 서비스에서 우리서비스와 계약한 페이지로 리다이렉트시키는데 이 때 state, code가 파라미터에 포함된다.
- 우리 서비스에게 사용자가 state, code 를 보내면 사용자를 소셜로그인 처리할 수 있다.
fun socialLogin(command: SocialLoginCommand): Triple<User?, AccessToken, RefreshToken> {
// state 를 통해 인가 요청 조회, 삭제
val authorizationRequest = removeAuthorizationRequestOrThrow(command.state)
// ClientRegistration 가져오기
val clientRegistration = oAuth2ClientRegistrationPersistencePort.findById(authorizationRequest.oAuth2ClientRegistrationId)!!
// 소셜서비스 인가
val oAuth2UserPrincipal = oAuth2UserPrincipalLoader.getOAuth2UserPrincipal(command.code, clientRegistration, authorizationRequest)
// 소셜서비스 사용자 정보를 통해 우리 서비스의 사용자 식별
val (userCreated, user) = getUserOrCreate(oAuth2UserPrincipal, command.currentTime)
// 인증회원 구성
val authUser = AuthUser.create(user.userId, user.role)
// 토큰 발급
val accessToken = accessTokenGeneratePort.generate(authUser, command.currentTime, command.currentTime.plusMinutes(30))
val refreshToken = refreshTokenHandler.createAndPersist(authUser.userId, command.currentTime)
return Triple(
if (userCreated) user else null,
accessToken,
refreshToken
)
}
- OAuth2 인가요청 조회 : state 를 통해 우리서비스에서 기억하고 있는 사용자 OAuth2 인가요청을 조회후 삭제한다.
- 존재하지 않으면 사용자의 소셜서비스 요청은 우리 서비스에서 통제하고 있는 소셜로그인이 아니므로 예외를 발생시킨다.
- ClientRegistartion 조회 : 소셜서비스 인가요청에 저장된 소셜서비스 식별자 정보를 기반으로, OAuth2 클라이언트 등록정보를 가져오기
- 소셜서비스 인가 : OAuth2 프레임워크 흐름에 따라 소셜서비스의 사용자 정보를 가져오기
- 사용자 획득 : 소셜서비스 사용자 정보를 통해 우리 서비스의 사용자 식별
- 인증 회원 구성
- 토큰 생성 (액세스토큰, 리프레시토큰)
@DomainService
class OAuth2UserPrincipalLoader(
private val oAuth2AccessTokenClientPort: OAuth2AccessTokenClientPort,
private val oAuth2UserPrincipalClientPort: OAuth2UserPrincipalClientPort,
private val oidcOAuth2UserPrincipalPort: OidcOAuth2UserPrincipalPort,
) {
fun getOAuth2UserPrincipal(
code: String,
oAuth2ClientRegistration: OAuth2ClientRegistration,
oAuth2AuthorizationRequest: OAuth2AuthorizationRequest
): OAuth2UserPrincipal {
// 토큰 엔드포인트와 통신하여, 액세스토큰을 얻어옴. (oidc 의 경우 IdToken 도 얻어와짐)
val accessTokenResponse = oAuth2AccessTokenClientPort.authenticate(
code = code,
oAuth2ClientRegistration = oAuth2ClientRegistration,
oAuth2AuthorizationRequest = oAuth2AuthorizationRequest
)
// scope에 "openid"가 없는 경우 사용자 정보 엔드포인트와 통신하여 사용자 정보 획득
if (!oAuth2ClientRegistration.scopes.contains("openid")) {
return oAuth2UserPrincipalClientPort.fetch(accessTokenResponse.accessToken, oAuth2ClientRegistration)
}
// 여기서부터 oidc 방식
// 응답에 idToken 이 없으면 예외 발생 (서버 에러)
if (accessTokenResponse.idToken == null) {
throw IllegalStateException("Oidc - IdToken not found from OAuth2AccessTokenResponse")
}
// IdToken 을 통해 사용자 정보 획득
return oidcOAuth2UserPrincipalPort.getUserPrincipal(accessTokenResponse.idToken!!, oAuth2ClientRegistration, oAuth2AuthorizationRequest)
}
}
위에서 소셜서비스와 실제 인가 처리를 하는 부분은 OAuth2UserPrincipalLoader 이다.
- 토큰 엔드포인트와 통신하여, 액세스토큰을 얻어옴. (oidc 의 경우 IdToken 도 얻어와짐)
- scope에 "openid"가 없는 경우 사용자 정보 엔드포인트와 통신하여 사용자 정보 획득한다. (OAuth2 방식)
- scope에 "openid"가 있는 경우 IdToken 을 통해 사용자 정보를 획득한다. (Oidc 방식)
@Component
class OAuth2AccessTokenClientAdapter : OAuth2AccessTokenClientPort {
private val restClient: RestClient = RestClient.create()
override fun authenticate(
code: String,
oAuth2ClientRegistration: OAuth2ClientRegistration,
oAuth2AuthorizationRequest: OAuth2AuthorizationRequest
): OAuth2AccessTokenResponse {
val tokenUri = oAuth2ClientRegistration.providerDetails.tokenUri
val headers = defaultHeaders()
val form = defaultForm(code, oAuth2AuthorizationRequest)
handleHeadersAndForm(oAuth2ClientRegistration, headers, form)
return restClient.post()
.uri(tokenUri)
.headers{ it.addAll(headers) }
.body(form)
.retrieve()
.body<TokenResponse>()
?.mapToOAuth2AccessTokenResponse() ?: throw IllegalStateException("소셜서비스 인증 실패")
}
- OAuth2AccessTokenClientAdapter 에서는 실제 소셜서비스의 토큰 엔드포인트와 통신해서, 액세스토큰을 획득해온다.
- 만약 scope에 "openid"가 있으면 openid-connect 흐름을 따름으로서 IdToken 도 얻어온다.
@Component
class OAuth2UserPrincipalClientAdapter : OAuth2UserPrincipalClientPort {
private val restClient: RestClient = RestClient.create()
override fun fetch(accessToken: String, oAuth2ClientRegistration: OAuth2ClientRegistration): OAuth2UserPrincipal {
val userInfoUri = oAuth2ClientRegistration.providerDetails.userInfoEndpoint.uri
val headers = requestHeaders(accessToken)
val response = restClient.get()
.uri(userInfoUri)
.headers { it.addAll(headers) }
.retrieve()
.body(Map::class.java) as Map<*, *>
return mapToOAuth2UserPrincipal(response, oAuth2ClientRegistration)
}
- OAuth2UserPrincipalClientAdapter 에서는 사용자 정보 엔드포인트와 통신해서 사용자 정보를 획득해온다. (OAuth2 방식)
@Component
class OidcOAuth2UserPrincipalAdapter : OidcOAuth2UserPrincipalPort {
override fun getUserPrincipal(
idToken: String,
oAuth2ClientRegistration: OAuth2ClientRegistration,
oAuth2AuthorizationRequest: OAuth2AuthorizationRequest
): OAuth2UserPrincipal {
val jwt = SignedJWT.parse(idToken)
validateJwt(jwt, oAuth2ClientRegistration, oAuth2AuthorizationRequest)
return mapToOAuth2UserPrincipal(jwt.jwtClaimsSet, oAuth2ClientRegistration)
}
- OidcOAuth2UserPrincipalAdapter 에서는 openid connect 흐름을 따를 경우 기능이 동작한다.
- IdToken 을 Jwt 파싱하여, 사용자 정보를 획득할 수 있다.
private fun getUserOrCreate(oAuth2UserPrincipal: OAuth2UserPrincipal, currentTime: AppDateTime): Pair<Boolean, User> {
// 소셜 연동에 해당하는 회원을 식별하는데 성공하면, 회원을 그대로 반환
val socialService = SocialService.restore(oAuth2UserPrincipal.socialServiceName)
val socialServiceUserId = oAuth2UserPrincipal.socialServiceUserId
val email = oAuth2UserPrincipal.email
val socialConnection = socialConnectionPersistencePort.read(socialService, oAuth2UserPrincipal.socialServiceUserId)
if (socialConnection != null) {
return Pair(false, userPersistencePort.findByIdOrNull(socialConnection.userId)!!)
}
// 소셜 연동은 없지만 이메일에 해당하는 회원이 있으면, 소셜 연동 시키고 회원을 그대로 반환
val user = userPersistencePort.findByEmailOrNull(email)
if (user != null) {
createSocialConnectionAndSave(user.userId, socialService, socialServiceUserId, currentTime)
return Pair(false, user)
}
// 회원도 없고, 소셜 연동도 찾지 못 했으면 회원 생성 및 소셜 연동 생성 후 회원 반환
return createNewUserAndPersist(socialService, socialServiceUserId, email, currentTime)
}
- OAuth2UserPrincipal 을 앞에서 얻었는데, 이 정보를 기반으로 우리서비스에 저장된 소셜 연동을 조회한다.
- 소셜 연동 정보가 있으면, 여기서 userId를 획득한다. userId 에 해당하는 우리서비스 회원을 얻어낼 수 있다.
- 소셜 연동에 해당하는 회원을 식별하는데 성공하면, 회원을 그대로 반환
- 소셜 연동은 없지만 OAuth2UserPrincipal 에 포함된, 이메일에 해당하는 회원이 있으면, 소셜 연동 시키고 회원을 그대로 반환
- 회원도 없고, 소셜 연동도 찾지 못 했으면 회원 생성 및 소셜 연동 생성 후 회원 반환
- username, nickname, password 는 랜덤으로 생성하고 추후 사용자가 자유롭게 수정하게 하면 된다.
- 이 작업을 거침으로서 우리서비스의 회원을 획득할 수 있게 되고, 로그인 처리 흐름을 따르면 된다.