이 질문에 대해서 정말 오랫동안 고민해왔다.
웹 개발의 경우에는 REST API 방식을 사용해서 백엔드에서 모든 OAuth 인증 처리를 해결해야 하기에 별 고민이 없었다. 그런데 네이티브 앱 개발의 경우에는 Android SDK와 iOS SDK라는 게 존재하니까... 서버에서는 어디서부터 어떤 처리를 해야 할지 결정하는 데 매우 오랜 시간이 걸렸다.
검색을 해보아도 생각보다 이와 관련한 글이 많지 않았고.. 뿐만 아니라 이게 딱 답이 정해져 있는 것이 아니라 구현 방식이 여러 가지가 있고 개발자가 직접 결정해야 하는 문제이기도 해서 더더욱 혼란스러웠다.
https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#oidc
https://developers.google.com/identity/sign-in/ios/backend-auth
각 Provider의 공식 문서를 읽어보면서 내가 이해한 바로는,
- 사용자가 로그인을 요청하면 클라이언트에서 SDK를 호출한 후, Provider와의 통신을 통해 인증을 진행한다.
- 인증이 완료되면 Provider는 해당 사용자에 대한 정보가 담긴 ID 토큰이라는 것을 발급해준다. 이 ID 토큰은 JWT 형식이다.
- 이 ID 토큰의 유효성을 검증한 후 디코딩을 하면 Payload에서 사용자에 대한 정보를 얻을 수 있다.
- 특히, 이 Payload에서 얻을 수 있는 여러 사용자 정보 중에는
sub
(subject)라는 값이 있는데, 각 사용자마다 부여되는 고유한 값이다. 따라서 이 값으로 유저들을 식별할 수 있다.
Provider에 따라 조금씩은 달라질 수 있겠지만, 기본적으로는 위와 같은 플로우로 소셜 로그인이 이루어지는 것 같았다.
1번, 2번까지는 확실히 클라이언트에서 진행하는 것이 훨씬 효율적인데,
고민이었다.
사실 기존에는 별 생각 없이 두 번째 방식으로 구현해 뒀다. 그런데 클라이언트 자체가 신뢰할 수 없는 환경에서 실행되기 때문에 토큰의 서명을 프론트에서 검증하는 것이 보안상 위험하다고 판단을 했고, AOS, iOS 개발자 분들과 협의를 통해 첫 번째 방식으로 변경하기로 했다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
public interface TokenVerifier {
boolean verify(String idToken);
}
위 TokenVerifier
를 상속받는 추상클래스인 AbstractTokenVerfier
를 생성하고, 모든 Provider에 대해 공통으로 수행되는 로직을 구현했다.
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.net.URL;
import java.util.Date;
public abstract class AbstractTokenVerifier implements TokenVerifier {
protected abstract String getJwkUrl();
protected abstract String getIssuer();
protected abstract String getClientId();
@Override
public boolean verify(String idToken) {
try {
// JWK 키셋 로드
URL jwkUrl = new URL(getJwkUrl());
JWKSet jwkSet = JWKSet.load(jwkUrl);
// JWT 파싱
SignedJWT signedJWT = SignedJWT.parse(idToken);
JWK jwk = jwkSet.getKeyByKeyId(signedJWT.getHeader().getKeyID());
if (jwk == null) {
throw new RuntimeException("No matching JWK found.");
}
// 서명 검증
RSASSAVerifier verifier = new RSASSAVerifier(jwk.toRSAKey());
if (!signedJWT.verify(verifier)) {
throw new RuntimeException("Signature verification failed.");
}
// Claims 검증
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
if (!claims.getIssuer().equals(getIssuer())) {
throw new RuntimeException("Invalid issuer.");
}
if (!claims.getAudience().contains(getClientId())) {
throw new RuntimeException("Invalid audience.");
}
if (claims.getExpirationTime().before(new Date())) {
throw new RuntimeException("Token expired.");
}
return true;
} catch (Exception e) {
return false;
}
}
}
코드를 단계별로 살펴보면
URL jwkUrl = new URL(getJwkUrl());
JWKSet jwkSet = JWKSet.load(jwkUrl);
getJwkUrl()
는 각 Provider별 JWK URL을 반환한다.JWKSet.load()
를 사용해 JWK(JSON Web Key) 키셋을 로드한다. 이 키셋은 공개 키(public key)를 포함하며, 토큰 서명을 검증하는 데 사용된다.SignedJWT signedJWT = SignedJWT.parse(idToken);
JWK jwk = jwkSet.getKeyByKeyId(signedJWT.getHeader().getKeyID());
SignedJWT.parse(idToken)
는 입력받은 ID 토큰(JWT 형식)을 파싱한다.kid
)를 가져온다. 이 키 ID를 이용해 JWK keyset에서 적합한 키를 찾는다. 찾은 JWK는 서명 검증에 사용된다.RSASSAVerifier verifier = new RSASSAVerifier(jwk.toRSAKey());
if (!signedJWT.verify(verifier)) {
throw new RuntimeException("Signature verification failed.");
}
jwk.toRSAKey()
를 사용해 JWK에서 공개 키를 생성한다.RSASSAVerifier
객체를 사용하여 JWT의 서명을 검증한다.if (!claims.getIssuer().equals(getIssuer())) {
throw new RuntimeException("Invalid issuer.");
}
getIssuer()
메서드는 각 Provider별 발급자(예: Google은 https://accounts.google.com)를 반환한다.iss
클레임이 이 값과 일치하지 않으면 유효하지 않은 토큰으로 간주한다.if (!claims.getAudience().contains(getClientId())) {
throw new RuntimeException("Invalid audience.");
}
getClientId()
메서드는 애플리케이션의 Client ID를 반환한다.aud
클레임에 서버의 Client ID가 포함되어 있는지 확인한다.if (claims.getExpirationTime().before(new Date())) {
throw new RuntimeException("Token expired.");
}
exp
(만료 시간) 클레임을 확인한다.public class GoogleTokenVerifier extends AbstractTokenVerifier {
@Override
protected String getJwkUrl() {
return "https://www.googleapis.com/oauth2/v3/certs";
}
@Override
protected String getIssuer() {
return "https://accounts.google.com";
}
@Override
protected String getClientId() {
return "(자신의 Client Id 값)";
}
}
public class AppleTokenVerifier extends AbstractTokenVerifier {
@Override
protected String getJwkUrl() {
return "https://appleid.apple.com/auth/keys";
}
@Override
protected String getIssuer() {
return "https://appleid.apple.com";
}
@Override
protected String getClientId() {
return "(자신의 Client Id 값)";
}
}
public class KakaoTokenVerifier extends AbstractTokenVerifier {
@Override
protected String getJwkUrl() {
return "https://kauth.kakao.com/.well-known/jwks.json";
}
@Override
protected String getIssuer() {
return "https://kauth.kakao.com";
}
@Override
protected String getClientId() {
return "(자신의 Client Id 값)";
}
}
Provider에 따라 알맞은 TokenVerifier 객체가 생성되도록 인스턴스를 동적으로 관리할 것이다.
public class TokenVerifierFactory {
public static TokenVerifier getVerifier(String provider) {
return switch (provider.toLowerCase()) {
case "google" -> new GoogleTokenVerifier();
case "apple" -> new AppleTokenVerifier();
case "kakao" -> new KakaoTokenVerifier();
default -> throw new IllegalArgumentException("Unknown provider: " + provider);
};
}
}
다음과 같이 사용 가능하다.
public class AuthService {
public boolean verifyToken(String provider, String idToken) {
TokenVerifier verifier = TokenVerifierFactory.getVerifier(provider);
return verifier.verify(idToken);
}
}
위 과정을 통해 유효한 토큰임이 검증되면, 해당 토큰의 Payload에서 사용자의 정보를 추출해 사용할 수 있다.
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.stereotype.Service;
import java.text.ParseException;
@Service
public class AuthService {
private boolean verifyToken(String provider, String idToken) {
TokenVerifier verifier = TokenVerifierFactory.getVerifier(provider);
return verifier.verify(idToken);
}
public Long getUserId(String provider, String idToken) {
try {
if (verifyToken(provider, idToken)) {
SignedJWT signedJWT = SignedJWT.parse(idToken);
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
return Long.parseLong(claims.getSubject());
}
} catch (ParseException e) {
throw new RuntimeException("Failed to extract user info", e);
}
return 0L;
}
}
스코어에서는 sub
값 외에 다른 정보는 받아 오지 않기 때문에 비교적 간단히 구현되는데, 이메일이나 닉네임 등 다른 정보도 받아와야 하면 구현이 더 복잡해질 것이다.