소셜 로그인 기능 설계 ‐ (1) 소셜 서비스 인가 - ttasjwi/board-system GitHub Wiki
- 대부분의 서비스는 소셜로그인 기능을 지원한다.
- 사용자가 아이디/비밀번호를 입력하지 않고, 소셜서비스를 통해 로그인 해서 우리 서비스에 로그인하는 기능이다.
- 이 기능은 OAuth2 인증/인가 프레임워크 흐름을 따라 구현된다.
- 소셜서비스 사용자 정보를 기반으로 로그인을 해야한다. 즉 소셜서비스의 사용자 정보가 필요하다.
- 사용자의 허락을 받아, 사용자 정보를 획득할 허락을 구하고, 사용자의 정보를 소셜서비스로부터 받아와 로그인 하는 것이다.
- 이 허락을 구하는 작업을 위해서는 사용자를 '소셜서비스'의 로그인 페이지로 리다이렉트 시키는 작업이 필요하다.
- 우리 서비스에 소셜로그인 시키기 위해서는 우선 사용자가 우리서비스에 사전요청을 하게 해야한다.
- 그냥 웹페이지에서 바로 사용자를 소셜로그인 페이지로 이동시키는 방법도 있지만, 보안강화를 위한 여러가지 값들을 관리할 수 없고, 사용자의 소셜로그인 흐름을 우리 서비스에스 주도해서 통제할 수 없어진다.
- state : 우리 서비스에서 임의로 생성한 값. 이 값을 사용자에게 전달하고 사용자가 소셜 로그인 본요청 시 이 파라미터를 전달하여 우리 서비스에서 관리하는 인가요청 정보를 조회하고 사용할 수 있다.
- nonce : oidc 방식 인증/인가의 보안강화를 위한 파라미터
- code_challenge, code_challenge_method : PKCE 방식 보안 강화를 위한 추가 파라미터
- 최초 소셜로그인 요청을 하기 전에 우리서비스에게 사전요청을 하여, 인가요청을 정보를 생성해 저장하고, 사용자를 소셜로그인페이지로 리다이렉트 시켜야한다.
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분간 유효하게 하면 된다.
@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 적용)
- 반환