소셜 로그인 기능 설계 ‐ (1) 소셜 서비스 인가 - ttasjwi/board-system GitHub Wiki

개요

  • 대부분의 서비스는 소셜로그인 기능을 지원한다.
    • 사용자가 아이디/비밀번호를 입력하지 않고, 소셜서비스를 통해 로그인 해서 우리 서비스에 로그인하는 기능이다.
  • 이 기능은 OAuth2 인증/인가 프레임워크 흐름을 따라 구현된다.

소셜서비스의 인가 필요성

  • 소셜서비스 사용자 정보를 기반으로 로그인을 해야한다. 즉 소셜서비스의 사용자 정보가 필요하다.
  • 사용자의 허락을 받아, 사용자 정보를 획득할 허락을 구하고, 사용자의 정보를 소셜서비스로부터 받아와 로그인 하는 것이다.
  • 이 허락을 구하는 작업을 위해서는 사용자를 '소셜서비스'의 로그인 페이지로 리다이렉트 시키는 작업이 필요하다.

소셜서비스로 리다이렉트 시키기

image

  • 우리 서비스에 소셜로그인 시키기 위해서는 우선 사용자가 우리서비스에 사전요청을 하게 해야한다.
  • 그냥 웹페이지에서 바로 사용자를 소셜로그인 페이지로 이동시키는 방법도 있지만, 보안강화를 위한 여러가지 값들을 관리할 수 없고, 사용자의 소셜로그인 흐름을 우리 서비스에스 주도해서 통제할 수 없어진다.
    • state : 우리 서비스에서 임의로 생성한 값. 이 값을 사용자에게 전달하고 사용자가 소셜 로그인 본요청 시 이 파라미터를 전달하여 우리 서비스에서 관리하는 인가요청 정보를 조회하고 사용할 수 있다.
    • nonce : oidc 방식 인증/인가의 보안강화를 위한 파라미터
    • code_challenge, code_challenge_method : PKCE 방식 보안 강화를 위한 추가 파라미터
  • 최초 소셜로그인 요청을 하기 전에 우리서비스에게 사전요청을 하여, 인가요청을 정보를 생성해 저장하고, 사용자를 소셜로그인페이지로 리다이렉트 시켜야한다.

인가요청(OAuth2AuthorizationRequest)

class OAuth2AuthorizationRequest
internal constructor(
    val authorizationUri: String,
    val oAuth2ClientRegistrationId: String,
    val responseType: OAuth2AuthorizationResponseType,
    val clientId: String,
    val redirectUri: String,
    val scopes: Set<String>,
    val state: String,
    val pkceParams: PKCEParams,
    val nonceParams: NonceParams?,
) {

    val authorizationRequestUri: String by lazy {
        val queryParams = mutableListOf(
            "client_id" to clientId,
            "redirect_uri" to redirectUri,
            "response_type" to responseType.value,
            "scope" to scopes.joinToString(" "),
            "state" to state,
            "code_challenge" to pkceParams.codeChallenge,
            "code_challenge_method" to pkceParams.codeChallengeMethod,
        )

        if (this.nonceParams != null) {
            queryParams.add("nonce" to nonceParams.nonce)
        }
        buildUriWithQuery(authorizationUri, queryParams)
    }

    companion object {

        private val stateGenerator: Base64SecureKeyGenerator = Base64SecureKeyGenerator.init(Base64.getUrlEncoder())
        private val secureKeyGenerator: Base64SecureKeyGenerator =
            Base64SecureKeyGenerator.init(Base64.getUrlEncoder().withoutPadding(), 96)

        private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256"

        fun create(clientRegistration: OAuth2ClientRegistration): OAuth2AuthorizationRequest {
            return OAuth2AuthorizationRequest(
                authorizationUri = clientRegistration.providerDetails.authorizationUri,
                oAuth2ClientRegistrationId = clientRegistration.registrationId,
                responseType = OAuth2AuthorizationResponseType.CODE,
                clientId = clientRegistration.clientId,
                redirectUri = clientRegistration.redirectUri,
                scopes = clientRegistration.scopes,
                state = stateGenerator.generateKey(),
                pkceParams = PKCEParams.create(),
                nonceParams = generateNonceParamsIfNecessary(clientRegistration),
            )
        }

        fun restore(
            authorizationUri: String,
            oAuth2ClientRegistrationId: String,
            responseType: String,
            clientId: String,
            redirectUri: String,
            scopes: Set<String>,
            state: String,
            codeChallenge: String,
            codeChallengeMethod: String,
            codeVerifier: String,
            nonce: String?,
            nonceHash: String?,
        ): OAuth2AuthorizationRequest {
            return OAuth2AuthorizationRequest(
                authorizationUri = authorizationUri,
                oAuth2ClientRegistrationId = oAuth2ClientRegistrationId,
                responseType = OAuth2AuthorizationResponseType.restore(responseType),
                clientId = clientId,
                redirectUri = redirectUri,
                scopes = scopes,
                state = state,
                pkceParams = PKCEParams.restore(codeChallenge, codeChallengeMethod, codeVerifier),
                nonceParams = nonce?.let { NonceParams.restore(it, nonceHash!!) }
            )
        }

        /**
         * Scope 목록에 "openid"가 있으면 Nonce 적용
         */
        private fun generateNonceParamsIfNecessary(clientRegistration: OAuth2ClientRegistration): NonceParams? {
            // Scope 목록에 "openid"가 있으면 Nonce 적용, 없으면 null 반환
            if (!clientRegistration.scopes.contains("openid")) {
                return null
            }
            return NonceParams.create()
        }

        private fun createHash(value: String): String {
            val md = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM)
            val digest = md.digest(value.toByteArray(StandardCharsets.US_ASCII))
            return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
        }
    }

    fun matchesNonce(idTokenNonce: String): Boolean {
        val idTokenNonceHash = createHash(idTokenNonce)
        return idTokenNonceHash == nonceParams!!.nonceHash
    }

    private fun buildUriWithQuery(base: String, queryParams: List<Pair<String, String>>): String {
        val queryString = queryParams.joinToString("&") { (k, v) ->
            "${encodeQueryParam(k)}=${encodeQueryParam(v)}"
        }
        return "$base?$queryString"
    }

    private fun encodeQueryParam(value: String): String {
        return URLEncoder.encode(value, StandardCharsets.UTF_8.toString())
            .replace("+", "%20")
    }

    data class NonceParams(
        val nonce: String,
        val nonceHash: String,
    ) {

        companion object {

            fun create(): NonceParams {
                // 원본 값
                val nonce = secureKeyGenerator.generateKey()

                // 해시된 값
                val nonceHash = createHash(nonce)

                return NonceParams(
                    nonce = nonce,
                    nonceHash = nonceHash
                )
            }

            fun restore(nonce: String, nonceHash: String): NonceParams {
                return NonceParams(nonce, nonceHash)
            }
        }
    }

    data class PKCEParams(
        val codeChallenge: String,
        val codeChallengeMethod: String,
        val codeVerifier: String
    ) {

        companion object {

            internal const val DEFAULT_CODE_CHALLENGE_METHOD = "S256"

            fun create(): PKCEParams {
                // 원본 값
                val codeVerifier = secureKeyGenerator.generateKey()

                // 알고리즘
                val codeChallengeMethod = DEFAULT_CODE_CHALLENGE_METHOD


                // 해시된 값
                val codeChallenge = createHash(codeVerifier)

                return PKCEParams(
                    codeVerifier = codeVerifier,
                    codeChallengeMethod = codeChallengeMethod,
                    codeChallenge = codeChallenge
                )
            }

            fun restore(
                codeChallenge: String,
                codeChallengeMethod: String,
                codeVerifier: String,
            ): PKCEParams {
                return PKCEParams(
                    codeChallenge = codeChallenge,
                    codeChallengeMethod = codeChallengeMethod,
                    codeVerifier = codeVerifier
                )
            }
        }
    }
}
  • 사용자가 소셜서비스 인가요청을 할때, 해당사용자의 인가요청을 OAuth2Authorization 인스턴스를 생성한다.
  • 이를 저장하여 관리하면 되는데, state 값을 통해 이 인스턴스 정보를 복원할 수 있게 하면 된다.
    override fun save(authorizationRequest: OAuth2AuthorizationRequest, expiresAt: AppDateTime) {
        val key = generateKey(authorizationRequest.state)
        val data = RedisOAuth2AuthorizationRequest.from(authorizationRequest)

        redisTemplate.opsForValue().set(key, DataSerializer.serialize(data))
        redisTemplate.expireAt(key, expiresAt.toInstant())

        log.info{ "Successfully saved authorization request: key = $key" }
    }
  • 소셜서비스 인가 요청은 Redis를 통해 저장한다. 5분간 유효하게 하면 된다.

API 흐름

@RestController
class SocialServiceAuthorizationController(
    private val socialServiceAuthorizationUseCase: SocialServiceAuthorizationUseCase
) {

    @PermitAll
    @GetMapping("/api/v1/auth/social-service-authorization/{socialServiceId}")
    fun authorize(@PathVariable("socialServiceId") socialServiceId: String): ResponseEntity<Void> {
        val url = socialServiceAuthorizationUseCase.generateAuthorizationRequestUri(socialServiceId)
        return ResponseEntity
            .status(HttpStatus.FOUND)
            .header(HttpHeaders.LOCATION, url)
            .build()
    }
}
  • 사용자로부터 소셜서비스 인가 요청을 받고, UseCase 를 통해 인가요청을 처리한다.
  • 이를 통해 소셜서비스 인가페이지 리다이렉트 Url 을 구성한 뒤, 리다이렉트 시킨다.
@UseCase
class SocialServiceAuthorizationUseCaseImpl(
    private val commandMapper: SocialServiceAuthorizationCommandMapper,
    private val processor: SocialServiceAuthorizationProcessor,
) : SocialServiceAuthorizationUseCase {

    override fun generateAuthorizationRequestUri(socialServiceId: String): String {
        val command = commandMapper.mapToCommand(socialServiceId)
        val oAuth2AuthorizationRequest = processor.generateOAuth2AuthorizationRequest(command)
        return oAuth2AuthorizationRequest.authorizationRequestUri
    }
}
  • 유즈케이스는 내부적으로 애플리케이션 명령을 생성하고, Processor를 통해 소셜서비스 인가 생성처리를 한뒤 인가 요청 Uri 를 구성한다.
@ApplicationProcessor
class SocialServiceAuthorizationProcessor(
    private val oAuth2ClientRegistrationPersistencePort: OAuth2ClientRegistrationPersistencePort,
    private val oAuth2AuthorizationRequestPersistencePort: OAuth2AuthorizationRequestPersistencePort,
) {

    fun generateOAuth2AuthorizationRequest(command: SocialServiceAuthorizationCommand): OAuth2AuthorizationRequest {
        // Client Registration 찾기, 조회 실패 시 예외 발생
        val clientRegistration = getClientRegistrationOrThrow(command.oAuth2ClientRegistrationId)

        // OAuth2 인가요청 생성
        val oauth2AuthorizationRequest = OAuth2AuthorizationRequest.create(clientRegistration)

        // OAuth2 인가요청 저장 (5분간 유효)
        oAuth2AuthorizationRequestPersistencePort.save(oauth2AuthorizationRequest, command.currentTime.plusMinutes(5))

        return oauth2AuthorizationRequest
    }

    private fun getClientRegistrationOrThrow(socialServiceId: String): OAuth2ClientRegistration {
        return oAuth2ClientRegistrationPersistencePort.findById(socialServiceId)
            ?: throw UnsupportedSocialServiceIdException(socialServiceId)
    }
}

Processor의 처리흐름은 다음과 같다.

  • ClientRegistration : OAuth2 클라이언트 등록정보 조회
  • OAuth2AuthorizationRequest 생성 및 5분간 저장(ttl 적용)
  • 반환
⚠️ **GitHub.com Fallback** ⚠️