소셜 로그인 기능 설계 ‐ (2) 소셜 로그인 - ttasjwi/board-system GitHub Wiki

개요

  • 앞에서 사용자의 소셜서비스 인가를 처리했다고 가정한다.
  • 사용자는 소셜서비스 인가페이지에서 승인을 하고, state, code 를 리다이렉트 파라미터를 통해 얻어온다.
    • 소셜 서비스에서 우리서비스와 계약한 페이지로 리다이렉트시키는데 이 때 state, code가 파라미터에 포함된다.
  • 우리 서비스에게 사용자가 state, code 를 보내면 사용자를 소셜로그인 처리할 수 있다.

Processor 흐름

    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 프레임워크 흐름에 따라 소셜서비스의 사용자 정보를 가져오기
  • 사용자 획득 : 소셜서비스 사용자 정보를 통해 우리 서비스의 사용자 식별
  • 인증 회원 구성
  • 토큰 생성 (액세스토큰, 리프레시토큰)

OAuth2UserPrincipalLoader : 소셜서비스 사용자 정보 획득

@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 는 랜덤으로 생성하고 추후 사용자가 자유롭게 수정하게 하면 된다.
  • 이 작업을 거침으로서 우리서비스의 회원을 획득할 수 있게 되고, 로그인 처리 흐름을 따르면 된다.
⚠️ **GitHub.com Fallback** ⚠️