GitHub와 카카오 로그인을 붙이면서 정한 규칙들
이메일 로그인만 있던 WhoIsLoafing에 GitHub와 카카오 로그인을 붙였어요. 어떤 이메일을 믿을지, 이미 가입한 이메일과 겹치면 어떻게 할지, 마지막 로그인 방법을 끊으려 할 때 어떻게 막을지 정리했어요.
WhoIsLoafing은 처음에 이메일과 비밀번호로만 가입할 수 있었어요. 개발자가 많이 쓰는 서비스라 GitHub 로그인이 있으면 편하고, 한국 사용자에게는 카카오 로그인이 익숙해요. 그래서 둘을 붙였어요.
소셜 로그인 자체는 정해진 순서가 있어요. 사용자를 GitHub나 카카오로 보내고, 돌아올 때 받은 코드로 토큰을 받고, 그 토큰으로 사용자 정보를 읽어요. 어려운 것은 그다음이에요. 받은 정보로 누구를 로그인시킬지, 새로 가입시킬지, 거절할지를 정하는 규칙이에요.
소셜 계정은 id로 찾아요
GitHub와 카카오는 사용자마다 바뀌지 않는 숫자 id를 줘요. 회원 테이블에 githubId, kakaoId 칸을 두고, 소셜 로그인이 오면 이 id로 먼저 찾아요.
private fun findSocialUser(identity: SocialIdentity): User? {
return when (identity.provider) {
SocialProvider.GITHUB -> userRepository.findByGithubId(identity.id)
SocialProvider.KAKAO -> userRepository.findByKakaoId(identity.id)
}
}
이메일로 찾지 않는 이유는 이메일이 바뀔 수 있기 때문이에요. GitHub에서 대표 이메일을 바꾸면, 이메일로 찾을 때 같은 사람을 새 회원으로 착각해요. id는 바뀌지 않아요.
GitHub 아이디(로그인 이름)도 같이 저장하지만 찾을 때는 쓰지 않아요. GitHub 아이디는 사용자가 바꿀 수 있어요. 로그인할 때마다 새 값으로 덮어써서 화면에 보여줄 때만 써요.
확인된 이메일만 믿어요
처음 오는 소셜 계정은 회원으로 만들어야 하고, 회원에게는 이메일이 필요해요. 그런데 소셜 서비스가 주는 이메일을 무조건 믿으면 안 돼요. 확인되지 않은 이메일이라면, 남의 이메일을 적어 둔 계정으로 그 사람인 척 가입할 수 있어요.
GitHub는 이메일 목록을 따로 주는데, 그중 대표 이메일이면서 인증된 것만 써요.
val email = gitHubOAuthClient.getEmails(accessToken).firstOrNull { it.primary && it.verified }?.email
카카오는 이메일이 유효한지와 인증됐는지를 따로 알려줘요. 둘 다 참일 때만 써요.
email = account?.email?.takeIf { account.emailValid && account.emailVerified },
이메일이 없을 때
믿을 만한 이메일이 없는 경우도 있어요. 두 서비스를 다르게 처리했어요.
카카오는 이메일 제공에 동의하지 않을 수 있어요. 그래도 카카오 id는 확실하니, kakao-숫자@users.whoisloafing 형태로 서비스 안에서만 쓰는 이메일을 만들어 가입시켜요. 실제로 존재하지 않는 도메인이라 다른 사람의 진짜 이메일과 겹칠 일이 없어요. 이 주소로 메일을 보낼 일은 없고, 회원을 구분하는 값으로만 써요.
GitHub는 인증된 대표 이메일이 없으면 가입을 거절하고 "소셜 계정에 인증된 이메일이 없습니다."라고 알려요. GitHub에서는 이메일을 인증하는 것으로 바로 해결할 수 있는 문제예요.
val email = identity.email
?: if (identity.provider == SocialProvider.KAKAO) {
"$KAKAO_EMAIL_PREFIX${identity.id}$KAKAO_EMAIL_DOMAIN"
} else {
throw SocialEmailMissingException()
}
이미 가입한 이메일과 겹치면 거절해요
가장 고민한 경우예요. 이메일로 가입한 회원이 있는데, 같은 이메일의 GitHub 계정으로 로그인하면 어떻게 할까요?
자동으로 이어 붙이면 편해 보여요. 하지만 그러면 "이 이메일을 가진 GitHub 계정"만 있으면 이메일 회원의 계정에 들어갈 수 있게 돼요. 인증된 이메일만 받는다 해도, 이메일 소유가 바뀌는 경우가 있어요. 예를 들어 퇴사하면서 회사 이메일이 다른 사람에게 넘어가는 경우예요.
그래서 자동으로 합치지 않고 "이미 이메일로 가입한 계정입니다."라며 거절해요. 그 계정으로 로그인한 뒤 직접 연결하면 돼요.
if (userRepository.findIdByEmail(email) != null) {
log.warn("[signInWithSocial] 이메일로 가입한 계정이 있는 소셜 로그인: {}", email)
throw EmailRegisteredException()
}
연결은 이미 로그인한 상태에서만 할 수 있어요. 계정의 주인이라는 것을 비밀번호로 먼저 증명한 다음에 소셜 계정을 붙이는 거예요.
다른 계정에 붙은 소셜 계정은 못 가져와요
연결할 때도 확인할 것이 있어요. 연결하려는 GitHub 계정이 이미 다른 회원에게 붙어 있으면 거절해요. 그렇지 않으면 한 GitHub 계정으로 두 회원에 로그인할 수 있게 되고, 어느 쪽으로 들어갈지 정할 수 없어요.
val owner = findSocialUser(identity)
if (owner != null && owner.id != user.id) {
throw SocialAlreadyLinkedException()
}
마지막 로그인 방법은 끊을 수 없어요
연결을 해제하는 기능도 만들었어요. 여기서 막아야 할 상황이 하나 있어요. 카카오로만 가입한 회원이 카카오 연결을 끊으면, 다시 로그인할 방법이 사라져요.
소셜로 가입한 회원은 비밀번호가 없어요. 내부적으로는 무작위 비밀번호를 넣어 두지만 아무도 모르는 값이에요. 그래서 회원마다 "직접 정한 비밀번호가 있는지"를 따로 기록해요.
val others = when (provider) {
SocialProvider.GITHUB -> user.kakaoId != null
SocialProvider.KAKAO -> user.githubId != null
}
if (!user.passwordSet && !others) {
throw SocialUnlinkLastException()
}
비밀번호도 없고 다른 소셜 계정도 없으면 해제를 거절해요. 로그인할 방법이 최소 하나는 남아 있어야 해요.
앱에서 시작한 연결은 일회용 값으로
macOS 앱에서는 소셜 계정 연결이 한 번 더 복잡해요. 소셜 로그인 화면은 기본 브라우저에서 열리는데, 브라우저는 앱에 로그인한 사람이 누구인지 몰라요.
그래서 앱이 먼저 서버에 "이 회원이 GitHub를 연결하려 한다"고 알리고, 일회용 값을 받아요. 이 값은 32바이트 무작위 값이고, 10분 동안만 Redis에 남아요.
fun issue(username: String, provider: String): String {
val ticket = ByteArray(TICKET_BYTES).also { secureRandom.nextBytes(it) }.let { encoder.encodeToString(it) }
redisService.setValues("$KEY_PREFIX$ticket", "$provider$DELIMITER$username", TICKET_DURATION)
return ticket
}
브라우저는 이 값을 들고 GitHub에 다녀온 뒤, 값과 받은 코드를 함께 서버에 보내요. 서버는 값을 꺼내는 동시에 지워서, 같은 값을 두 번 쓸 수 없게 해요.
fun consume(ticket: String): SocialLinkTicket? {
return parse(redisService.getAndDeleteValues("$KEY_PREFIX$ticket") as? String)
}
값에는 어느 소셜 서비스를 연결하려 했는지도 들어 있어서, GitHub 연결용 값으로 카카오를 연결할 수는 없어요.
정리
- 소셜 계정은 바뀌지 않는 id로 찾고, 이메일이나 아이디로 찾지 않아요.
- 소셜 서비스가 인증했다고 알려 준 이메일만 믿어요.
- 이미 가입한 이메일과 겹치면 자동으로 합치지 않고, 로그인한 뒤 직접 연결하게 해요.
- 마지막 로그인 방법은 끊지 못하게 막아요.
- 브라우저와 앱을 오가는 연결은 짧게 사는 일회용 값으로 사람을 이어요.