Spring Boot+네이티브 앱 소셜 로그인 구현 - id 토큰 검증 및 사용자 정보 추출

사람·2024년 12월 20일
0

Backend

목록 보기
1/11

0. 어디까지가 클라이언트의 역할이고, 어디서부터 서버 개발자의 역할인가?

이 질문에 대해서 정말 오랫동안 고민해왔다.
웹 개발의 경우에는 REST API 방식을 사용해서 백엔드에서 모든 OAuth 인증 처리를 해결해야 하기에 별 고민이 없었다. 그런데 네이티브 앱 개발의 경우에는 Android SDK와 iOS SDK라는 게 존재하니까... 서버에서는 어디서부터 어떤 처리를 해야 할지 결정하는 데 매우 오랜 시간이 걸렸다.
검색을 해보아도 생각보다 이와 관련한 글이 많지 않았고.. 뿐만 아니라 이게 딱 답이 정해져 있는 것이 아니라 구현 방식이 여러 가지가 있고 개발자가 직접 결정해야 하는 문제이기도 해서 더더욱 혼란스러웠다.

소셜 로그인 처리 과정?

https://developers.kakao.com/docs/latest/ko/kakaologin/utilize#oidc

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple

https://developers.google.com/identity/sign-in/ios/backend-auth

각 Provider의 공식 문서를 읽어보면서 내가 이해한 바로는,

  1. 사용자가 로그인을 요청하면 클라이언트에서 SDK를 호출한 후, Provider와의 통신을 통해 인증을 진행한다.
  2. 인증이 완료되면 Provider는 해당 사용자에 대한 정보가 담긴 ID 토큰이라는 것을 발급해준다. 이 ID 토큰은 JWT 형식이다.
  3. 이 ID 토큰의 유효성을 검증한 후 디코딩을 하면 Payload에서 사용자에 대한 정보를 얻을 수 있다.
  4. 특히, 이 Payload에서 얻을 수 있는 여러 사용자 정보 중에는 sub(subject)라는 값이 있는데, 각 사용자마다 부여되는 고유한 값이다. 따라서 이 값으로 유저들을 식별할 수 있다.

Provider에 따라 조금씩은 달라질 수 있겠지만, 기본적으로는 위와 같은 플로우로 소셜 로그인이 이루어지는 것 같았다.
1번, 2번까지는 확실히 클라이언트에서 진행하는 것이 훨씬 효율적인데,

  • 서버에서 클라이언트로부터 ID 토큰을 넘겨 받아 3번 과정부터 진행해야 할지
  • ID 토큰의 디코딩 과정까지 클라이언트에 일임하고 sub 값만 넘겨 받아 서버는 4번 과정만 진행해야 할지

고민이었다.
사실 기존에는 별 생각 없이 두 번째 방식으로 구현해 뒀다. 그런데 클라이언트 자체가 신뢰할 수 없는 환경에서 실행되기 때문에 토큰의 서명을 프론트에서 검증하는 것이 보안상 위험하다고 판단을 했고, AOS, iOS 개발자 분들과 협의를 통해 첫 번째 방식으로 변경하기로 했다.


의존성 추가(Gradle)

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'
}

1. ID 토큰 검증

1) 공통 인터페이스 정의

public interface TokenVerifier {
    boolean verify(String idToken);
}

2) 추상 클래스에 공통 로직 구현

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;
        }

    }
}

코드를 단계별로 살펴보면

JWK KeySet 로드

URL jwkUrl = new URL(getJwkUrl());
JWKSet jwkSet = JWKSet.load(jwkUrl);
  • getJwkUrl()는 각 Provider별 JWK URL을 반환한다.
    (예: Google은 https://www.googleapis.com/oauth2/v3/certs)
  • JWKSet.load()를 사용해 JWK(JSON Web Key) 키셋을 로드한다. 이 키셋은 공개 키(public key)를 포함하며, 토큰 서명을 검증하는 데 사용된다.

JWT 파싱

SignedJWT signedJWT = SignedJWT.parse(idToken);
JWK jwk = jwkSet.getKeyByKeyId(signedJWT.getHeader().getKeyID());
  • SignedJWT.parse(idToken)는 입력받은 ID 토큰(JWT 형식)을 파싱한다.
  • JWT 헤더에서 키 ID(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의 서명을 검증한다.
  • 서명 검증이 실패하면 위변조된 토큰으로 간주하고 예외를 발생시킨다.

JWT Claim 검증

  1. 발급자(issuer) 검증
if (!claims.getIssuer().equals(getIssuer())) {
    throw new RuntimeException("Invalid issuer.");
}
  • getIssuer() 메서드는 각 Provider별 발급자(예: Google은 https://accounts.google.com)를 반환한다.
  • ID 토큰의 iss 클레임이 이 값과 일치하지 않으면 유효하지 않은 토큰으로 간주한다.
  1. 대상(audience) 검증
if (!claims.getAudience().contains(getClientId())) {
    throw new RuntimeException("Invalid audience.");
}
  • getClientId() 메서드는 애플리케이션의 Client ID를 반환한다.
  • ID 토큰의 aud 클레임에 서버의 Client ID가 포함되어 있는지 확인한다.
  • 포함되지 않으면 다른 애플리케이션에 발급된 토큰으로 간주하고 요청을 거부한다.
  1. 만료 시간(expiration time) 검증
if (claims.getExpirationTime().before(new Date())) {
    throw new RuntimeException("Token expired.");
}
  • ID 토큰의 exp(만료 시간) 클레임을 확인한다.
  • 만료 시간이 현재 시간보다 이전이면 토큰이 만료된 것으로 간주한다.

3) Provider별 구현

Google

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 값)";
    }
}

Apple

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 값)";
    }
}

Kakao

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 값)";
    }
}

4) 팩토리 패턴으로 Provider 관리

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);
    }
}

2. 사용자 정보 추출

위 과정을 통해 유효한 토큰임이 검증되면, 해당 토큰의 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 값 외에 다른 정보는 받아 오지 않기 때문에 비교적 간단히 구현되는데, 이메일이나 닉네임 등 다른 정보도 받아와야 하면 구현이 더 복잡해질 것이다.

profile
알고리즘 블로그 아닙니다.

0개의 댓글