진행하고 있는 프로젝트인 페스타고
에서는 사용자 인증 기능을 OAuth2를 기반으로 한 인증을 구현하고 사용했다.
OAuth2의 장점이라면 서비스 내에 사용자의 비밀번호와 같은 민감한 정보를 보관하지 않아도 되므로 보안이 향상된다는 것이다.
또한 OAuth2에는 여러 권한 부여 유형이 있는데, 그중에서 보안성이 가장 높은 Authorization Code Grant
유형을 사용했다.
하지만 모바일 앱 환경에서 제공하는 SDK에서 authorization code를 발급 해주지 않는 이슈가 있어, 보안에 취약한 Implicit Grant
유형을 사용할 수밖에 없었고, 이는 서비스에서 추후 해결해야 할 문제점 중 하나였다.
그 뒤 iOS를 지원하면서 Apple OAuth2 또한 구현해야 했는데, 관련 문서를 보니 OpenID Connect
라는 용어가 눈에 띄었고, 자세히 알아보니 OpenID Connect에서 제공하는 ID 토큰
을 사용하면 Authorization Code Grant
유형을 사용하는 것처럼 보안도 강화하고, API 요청을 한 번만 보내거나, 아예 보내지 않고 인증 기능과 사용자의 신원을 확인할 수 있어서 최종적으로 OpenID Connect를 기반으로 인증을 구현하였다.
우선 OAuth2에 대해 간단히 설명하자면, 사용자가 인증을 수행할 때, 계정의 정보를 클라이언트(백엔드)에 노출시키지 않고, 인증을 수행하는 것이라 볼 수 있겠다.
백엔드는 사용자의 민감한 정보를 보관하지 않으니, 서버가 털리는 최악의 상황이 발생해도 사용자의 계정 정보가 유출되지 않으니 안심할 수 있고, 사용자도 안심할 수 있다.
OAuth2에는 네 가지 구성 요소가 있는데 차례대로 다음과 같다.
나의 프로젝트에서도 그렇고 대부분은 Authorization Code Grant
유형을 사용해 OAuth2 인증과 권한 부여를 구현한다.
하지만 서론에서 말했듯, 서비스하는 대상인 앱의 제약으로 인해 Implicit Grant
유형을 사용했다.
Implicit Grant
유형을 사용하며 뜻밖의 이득을 보기는 했는데, Authorization Code Grant
유형을 사용하면, 백엔드가 인증 서버와 리소스 서버에 각 요청을 보내야 하므로 때문에 부하가 발생한다.
하지만 Implicit Grant
유형은 리소스 서버에만 요청을 보내면 되므로, Authorization Code Grant
유형 대비 절반의 트래픽 비용을 줄일 수 있었다.
그렇다고 사용자의 보안을 대가로 트래픽 비용을 줄이는 것은 서비스 제공자가 올바른 구현을 했다고 생각되지는 않는다.
서버에서 통신은 HTTPS를 이용하고, 사용자 환경이 모바일 앱을 사용하기에 엑세스 토큰이 탈취될 일은 극히 드물긴 하겠지만, 우리 서비스가 사용자의 보안에 구멍을 낼 수 있다는 것은 문제로 삼아야 할 일이었다.
따라서 Authorization Code Grant
유형을 사용하는 것처럼 좀 더 보안에 안전한 인증 방식이 필요했다.
그러다 iOS 지원을 위해 Apple OAuth2 기능을 구현하려고 문서를 찾다 보니 OpenID Connect
라는 용어가 계속 눈에 띄었고, 이것에 대해 자세히 알아보기 시작했다.
OpenID Connect
는 OAuth2를 확장하여 사용자 인증 기능을 추가한다.
사용자 인증 기능을 추가한다는 말은, OAuth2는 사용자 인증 기능이 빠져있다는 말이 되는데, 사실 OAuth2는 인증을 위해 설계된 게 아닌, 권한 부여를 위해 설계된 프로토콜이다.
따라서 OAuth2를 사용하여 인증을 수행한다는 것은, 권한 부여를 통해 얻은 엑세스 토큰을 사용해 리소스 서버로 요청을 보내고, 리소스 서버로부터 성공적으로 응답을 받았다면 이를 인증으로 사용하는 것이다.
OpenID Connect
는 OAuth2 위에서 동작하며, OAuth2로 권한 부여를 마치면, 엑세스 토큰과 ID 토큰
이 발급된다.
여기서 엑세스 토큰이 권한 부여를 위해 사용되고, ID 토큰이 인증을 위해 사용된다.
ID 토큰은 JWT 형식으로, 세 가지 영역으로 구성된다.
ID 토큰의 Payload는 표준이므로 다음과 같은 값들을 대체로 사용할 수 있다.
따라서 OAuth2를 통해 발급받은 ID 토큰을 서버에 전송하면, 서버는 해당 ID 토큰의 유효성을 검증하고, 페이로드에 있는 값을 사용해 인증 수단으로 사용할 수 있다.
카카오 로그인 기능에서도 OpenID Connnet 기능을 제공한다.
따라서 카카오 기준으로 설명을 진행한다.
가장 먼저 해야 할 것은 사용자가 보낸 ID 토큰이 사용자가 카카오 OAuth2를 통해 얻은 유효한 토큰이 맞는지를 검사하는 것이다.
만약 이를 검사하지 않으면, 인증을 수행한다고 할 수 없다.
ID 토큰은 공개키 암호화 방식으로 서명되어 있으며, 카카오 서버에서 서명한 토큰인지 확인하기 위해 제공하는 공개키 목록을 통해 유효한 토큰이 맞는지 검사할 수 있다.
해당 공개키 목록은 JWK
목록으로 제공되는데, JWK를 통해 ID 토큰의 유효성을 검증할 수 있다.
예를 들어, ID 토큰의 헤더의 kid
가 1234
일때, JWK 목록에서 kid
가 1234
인 JWK를 사용하면, ID 토큰의 유효성을 판단할 수 있다.
(물론 해당 kid의 JWK가 잘못된 경우 검증에 실패한다)
JWK에는 키의 타입과 용도 및 알고리즘이 명시되어 있으므로, 이것을 바탕으로 유효성을 판단하면 된다.
하지만 이를 직접 구현하려면 매우 많은 수고와 노력, 시간이 들어가므로 잘 만든 라이브러리를 사용하면 된다.
프로젝트에서는 jjwt 라이브러리를 사용했다.
기존 라이브러리 버전은 0.11.5
를 사용했는데, 0.12.0
버전부터 JWK에 대한 지원이 제공되니 참고하길 바란다.
마이그레이션 하는 경우 일부 메서드가 deprecated 되었고, 메서드가 아예 변경된 경우가 있으니, 주의!
가장 먼저 OpenID 인증을 수행하는 인터페이스를 정의한다.
public interface OpenIdClient {
UserInfo getUserInfo(String idToken);
SocialType getSocialType(); // Enum(APPLE, KAKAO)
}
인터페이스로 정의한 이유는 다양한 구현체(카카오, 애플 등)를 지원하기 위함이다.
그리고 해당 인터페이스를 구현한 KakaoOpenIdClient
클래스를 정의한다.
@Slf4j
@Component
public class KakaoOpenIdClient implements OpenIdClient {
@Override
public UserInfo getUserInfo(String idToken) {
// 구현
return null;
}
@Override
public SocialType getSocialType() {
return SocialType.KAKAO;
}
}
이제 getUserInfo()
메서드에 토큰의 유효성을 검증하고 인증하는 로직을 구현하면 된다.
카카오에서 ID 토큰의 유효성을 검증하는 순서는 다음과 같다.
iss
값이 https://kauth.kakao.com
와 일치하는지 확인aud
값이 서비스 앱 키와 일치하는지 확인exp
값이 현재 UNIX 타임스탬프(Timestamp)보다 큰 값인지 확인(ID 토큰이 만료되지 않았는지 확인)nonce
값이 카카오 로그인 요청 시 전달한 값과 일치하는지 확인해당 순서로 로직을 구현하면 다음과 같다.
@Slf4j
@Component
public class KakaoOpenIdClient implements OpenIdClient {
private static final String ISSUER = "https://kauth.kakao.com";
private final String restApiKey;
public MyKakaoOpenIdClient(
@Value("${festago.oauth2.kakao.rest-api-key}") String restApiKey
) {
this.restApiKey = restApiKey;
}
@Override
public UserInfo getUserInfo(String idToken) {
JwtParser jwtParser = Jwts.parser()
// .verifyWith(?) kid에 대한 공개키를 어떻게 넣지..?
.build();
Claims payload = jwtParser.parseSignedClaims(idToken).getPayload();
if (!Objects.equals(payload.getIssuer(), ISSUER)) {
throw new IllegalArgumentException();
}
if (!payload.getAudience().contains(restApiKey)) {
throw new IllegalArgumentException();
}
return UserInfo.builder()
.socialType(SocialType.KAKAO)
.socialId(payload.getSubject())
.nickname(payload.get("nickname", String.class))
.profileImage(payload.get("picture", String.class))
.build();
}
@Override
public SocialType getSocialType() {
return SocialType.KAKAO;
}
}
하지만 이렇게 구현한 코드에서는 여러 문제가 있다.
jjwt
라이브러리에서 JWT Parser 인스턴스를 생성할 때는 빌더를 사용하는데, verifyWith()
메서드의 인자로 서명을 확인하기 위해 Key
를 필요로 한다.
하지만 ID 토큰은 kid
에 따라 각기 다른 Key가 필요할 수 있기 때문에, 각 kid
마다 별도의 Parser를 생성해야 한다.
그런데, kid
를 알아내려면 JWT를 파싱해야 하고, 다시 JWT를 파싱하려면 kid
에 맞는 Key가 필요한 어처구니없는 상황이 생긴다.
이는 verifyWith()
메서드의 Javadoc을 확인하면 쉽게 정답을 알 수 있는데, 0.12.5
기준 다음과 같이 설명되어 있다.
Sets the signature verification SecretKey used to verify all encountered JWS signatures. If the encountered JWT string is not a JWS (e.g. unsigned or a JWE), this key is not used.
This is a convenience method to use in a specific scenario: when the parser will only ever encounter JWSs with signatures that can always be verified by a single SecretKey. This also implies that this key MUST be a valid key for the signature algorithm (alg header) used for the JWS.
If there is any chance that the parser will also encounter JWEs, or JWSs that need different signature verification keys based on the JWS being parsed, it is strongly recommended to configure your own keyLocator instead of calling this method.
Calling this method overrides any previously set signature verification key.
간략히 해석하면, 해당 메서드는 Parser가 JWT를 파싱할 때, 항상 단일 Key로 서명된 경우에만 verifyWith()
를 사용하는 것이 권장된다.
따라서 파서가 JWT가 서명된 방법에 따라 동적으로 Key를 사용할 경우 keyLocator
를 사용하는 것이 매우 권장된다고 한다.
KeyLocator
는 다음과 같이 빌더 메서드로 사용할 수 있는데, 인자로 Locator<Key>
타입을 받는다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(...)
.build();
인자인 Locator
는 인터페이스로, 다음과 같이 하나의 추상 메서드를 제공한다.
public interface Locator<T> {
T locate(Header header);
}
locate()
메서드의 Header
타입은 JWT의 헤더에 속한다.
따라서 다음과 같이 람다를 사용해서 구현할 수 있다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
return ...?
})
.build();
하지만 반환 값으로 Key
타입의 객체가 필요로 하므로 어딘가에서 kid
문자열을 Key
객체로 매핑해주는 역할이 필요하다.
그렇다면 Key
를 우선 어디서 받아와야 하는데, 위에 적었던 공개키 목록을 확인하면 JSON 포맷의 공개키를 획득할 수 있다.
{
"keys": [
{
"kid": "123",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "...",
"e": "..."
},
{
"kid": "456",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "...",
"e": "..."
}
]
}
해당 JSON 구문은 JWK Set
이라고 하는 RFC 7517 표준이다.
따라서 사용중인 라이브러리에도 해당 표준을 지원하는 기능을 제공한다.
해당 기능을 사용해서 공개키 목록 요청을 통해 JWKS를 얻고, JWKS를 자바 객체로 파싱하여 Key를 추출하는 로직을 작성하면 다음과 같다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
return (Key) jwkSet.get(kid);
})
.build();
하지만 이러한 구현에도 문제점이 있다.
위 구현에서 사용자의 로그인 요청마다 공개키 조회 요청을 보낸다.
하지만 공개키 조회 요청에서 주의사항으로 다음과 같이 작성되어 있다.
카카오 인증 서버가 ID 토큰 서명 시 사용한 공개키 목록을 조회합니다.
공개키는 일정 주기 또는 특별한 이슈 발생 시 변경될 수 있습니다.
주기적으로 최신 공개키 목록을 조회한 후, 일정 기간 캐싱(Caching)하여 사용할 것을 권장합니다.
지나치게 빈번한 공개키 목록 조회 요청 시, 요청이 차단될 수 있습니다.
만약 이 구현을 그대로 사용한다면, 어느 순간 서버가 차단당해 사용자가 로그인할 수 없는 치명적인 상황이 발생할 것이다.
이를 위해 JWKS를 캐싱하는 방법이 필요하다.
간단하게 생각해 보면, 서버가 처음 실행될 때 공개키 목록을 저장해서 그걸 쭉 사용하면 될 것 같다.
private static final Map<String, Key> jwkCache = new HashMap<>();
@EventListener(ApplicationReadyEvent.class)
public void init() {
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
jwkSet.forEach(jwk -> jwkCache.put(jwk.getId(), jwk.toKey()));
}
...
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
return jwkCache.get(kid);
})
.build();
하지만 이 방법도 잘못된 것이, 공개키 목록은 일정 주기 또는 특별한 이슈 발생 시 변경될 수 있다는 것이다.
만약 서버가 공개키 목록을 조회하고, 그 뒤 카카오에서 공개키 목록이 새로 바뀐다면 서버를 재시작 해야만 사용자가 로그인할 수 있을 것이다.
따라서 jwkCache
에 get()
을 호출해 꺼낸 Key가 null
이면 그때 공개키 목록을 조회해서 새롭게 Key를 갱신하는 방법을 사용하면 된다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
Key key = jwkCache.get(kid);
if (key != null) {
return key;
}
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
jwkSet.forEach(jwk -> jwkCache.put(jwk.getId(), jwk.toKey()));
return jwkCache.get(kid);
})
.build();
여기까지만 하더라도 이제 더할 나위 없이 완벽한 것 같지만, 아직도 문제가 남아있다.
위 코드에서 발생하는 문제는 동시에 여러 요청이 들어오면 공개키 목록을 해당 요청만큼 조회한다.
서버는 멀티 스레드로 동작하기 때문이다.
서버 개발자라면 반드시 스레드를 다룰 줄 알아야 하고, 스레드를 다루면서 발생하는 동시성 문제를 신경 써야 한다.
위의 케이스에서 발생하는 동시성 이슈는 DB와 관련된 것이 아니라, 어플리케이션 레벨에서 발생하는 동시성 이슈이기 때문에 DB의 락 같은 지원을 받을 수 없다.
따라서 어플리케이션 레벨에서 동시성 이슈를 해결해야 한다.
여기서 임계 영역은 restTemplate
으로 공개키 목록을 조회하는 곳부터, jwkCache
에 키를 갱신하는 곳까지이다.
따라서 해당 영역에 상호 배제를 구현하면 된다.
상호 배제를 구현하기 위해 가장 간단한 방법은 synchronized
키워드를 사용하는 것인데, synchronized
블럭을 사용하면 원하는 곳에 상호 배제를 구현할 수 있다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
Key key = jwkCache.get(kid);
if (key != null) {
return key;
}
synchronized(this) {
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
jwkSet.forEach(jwk -> jwkCache.put(jwk.getId(), jwk.toKey()));
}
return jwkCache.get(kid);
})
.build();
이렇게 구현하면, 단 하나의 요청(스레드)만 임계 영역에 접근할 수 있으므로, 동시성 문제를 해결할 수 있다.
하지만 처음 임계 영역에 진입한 요청이 키를 새롭게 갱신했다면, 이후 대기하던 스레드는 임계 영역에 들어와서 새롭게 요청할 필요가 없다.
이미 키가 새롭게 갱신되었기 때문이다.
따라서 다음과 같이 Double Checked Locking 기법을 사용하여 최적화할 수 있다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
Key key = jwkCache.get(kid);
if (key != null) {
return key;
}
synchronized(this) {
key = jwkCache.get(kid); // 한 번 더 검사한다.
if (key != null) {
return key;
}
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
jwkSet.forEach(jwk -> jwkCache.put(jwk.getId(), jwk.toKey()));
}
return jwkCache.get(kid);
})
.build();
동시성 문제를 해결할 때 락으로 인한 경합으로 인한 성능 문제도 중요한데, 위에서 구현한 코드에서 경합으로 인한 많은 대기가 발생할 수 있다.
restTemplate에서 타임아웃을 설정했다고 가정한다.
만약 공개키 목록을 제공하는 곳에서 응답이 매우 느리게 오는 경우 타임아웃이 발생해 키 목록을 조회하는 데 실패했다고 가정해 보자.
synchronized
블럭에서 대기 중인 스레드 중 하나가 락 획득에 성공하여 다시 요청을 보내지만, 위와 같은 문제로 타임아웃이 또 발생하고,
다시 synchronized
블럭에서 대기 중인 스레드가 같은 문제를 겪을 것이다.
결국 많은 요청이 동시에 들어왔을 때, 마지막에 락을 획득한 사용자는 (사용자 수 * 대기 시간) 만큼 응답을 기다려야 하는 문제가 발생한다.
따라서 락 획득 대기를 할 때 타임아웃을 적용할 필요가 있는데, 기본으로 제공되는 synchronized
는 이러한 기능을 제공해 주지 않는다.
따라서 ReentrantLock
을 사용하는 방법으로 동시성을 제어하면 대기에도 타임아웃을 구현할 수 있다.
JwtParser jwtParser = Jwts.parser()
.keyLocator(header -> {
String kid = (String) header.get("kid");
Key key = jwkCache.get(kid);
if (key != null) {
return key;
}
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
key = jwkCache.get(kid);
if (key != null) {
return key;
}
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
jwkSet.forEach(jwk -> jwkCache.put(jwk.getId(), jwk.toKey()));
return jwkCache.get(kid);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
throw new IllegalStateException();
})
.build();
여러 스레드는 락 획득을 위해 lock.tryLock()
메서드에서 대기를 하게 된다.
만약, 시간 초과로 인해 락 획득에 실패하게 되면, 락 경쟁에서 빠지게 된다.
이렇게 최종으로 동시성 문제를 해결하며 ID 토큰을 파싱하는 로직을 작성할 수 있었다.
람다에 코드가 너무 길어지니 별도의 클래스로 분리하면 다음과 같다.
@Component
public class MyKeyLocator implements Locator<Key> {
private final Map<String, Key> jwkCache = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
private RestTemplate restTemplate;
// 생성자
@Override
public Key locate(Header header) {
String kid = (String) header.get("kid");
Key key = jwkCache.get(kid);
if (key != null) {
return key;
}
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
key = jwkCache.get(kid);
if (key != null) {
return key;
}
String jwksJson = restTemplate.getForObject("https://blahblah.com", String.class);
Parser<JwkSet> jwkSetParser = Jwks.setParser()
.build();
JwkSet jwkSet = jwkSetParser.parse(jwksJson);
jwkSet.forEach(jwk -> jwkCache.put(jwk.getId(), jwk.toKey()));
return jwkCache.get(kid);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
throw new IllegalStateException();
}
}
위에서 작성한 코드는 완벽하지 않은 것이, 새로운 OpenID Connect 구현체가 생기면, Locator<Key>
인터페이스 구현체를 새롭게 만들어야 한다.
따라서 나는 다음과 같이 CachedOpenIdKeyProvider
라는 클래스를 별도로 만들어, JwkSet
을 반환하는 Suppler<JwkSet>
람다를 사용하는 클래스를 만들어서 구현했다.
@Component
@RequiredArgsConstructor
public class KakaoOpenIdPublicKeyLocator implements Locator<Key> {
private final KakaoOpenIdJwksClient kakaoOpenIdJwksClient;
private final CachedOpenIdKeyProvider cachedOpenIdKeyProvider;
@Override
public Key locate(Header header) {
String kid = (String) header.get("kid");
if (kid == null) {
throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN);
}
return cachedOpenIdKeyProvider.provide(kid, kakaoOpenIdJwksClient::requestGetJwks);
}
}
@Component
public class CachedOpenIdKeyProvider {
private final Map<String, Key> cache = new HashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public Key provide(String kid, Supplier<JwkSet> fallback) {
Key key = cache.get(kid);
if (key != null) {
return key;
}
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
key = cache.get(kid);
if (key != null) {
return key;
}
JwkSet jwkSet = fallback.get();
jwkSet.forEach(jwk -> cache.put(jwk.getId(), jwk.toKey()));
return cache.get(kid);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
throw new IllegalStateException();
}
}
또한 JwtParser
를 생성할 때 빌더에는 requireIssuer()
같은 메서드를 제공하기에 굳이 다음과 같이 직접 검증할 필요가 없다.
JwtParser jwtParser = Jwts.parser()
.requireIssuer(ISSUER)
.keyLocator(keyLocator)
.build();
// if (!Objects.equals(payload.getIssuer(), ISSUER)) {
// throw new IllegalArgumentException();
// }
그 외 nonce
검증은 리프래쉬 토큰을 검증하는 것처럼, 이전에 사용된 nonce
값을 기록해 둔 뒤, 해당 nonce
값의 토큰이 오면 거절하는 방법으로 구현하면 될 것 같다.
JwtParser
로 파싱할 때 발생하는 예외 또한 처리 해야 한다.
@Component
public class KakaoOpenIdClient implements OpenIdClient {
private static final String ISSUER = "https://kauth.kakao.com";
private final OpenIdNonceValidator openIdNonceValidator;
private final OpenIdIdTokenParser idTokenParser;
private final Set<String> appKeys;
public KakaoOpenIdClient(
@Value("${festago.oauth2.kakao.rest-api-key}") String restApiKey,
@Value("${festago.oauth2.kakao.native-app-key}") String nativeAppKey,
KakaoOpenIdPublicKeyLocator kakaoOpenIdPublicKeyLocator,
OpenIdNonceValidator openIdNonceValidator,
Clock clock
) {
this.appKeys = Set.of(restApiKey, nativeAppKey);
this.openIdNonceValidator = openIdNonceValidator;
this.idTokenParser = new OpenIdIdTokenParser(Jwts.parser()
.keyLocator(kakaoOpenIdPublicKeyLocator)
.requireIssuer(ISSUER)
.clock(() -> Date.from(clock.instant()))
.build());
}
@Override
public UserInfo getUserInfo(String idToken) {
Claims payload = idTokenParser.parse(idToken);
openIdNonceValidator.validate(payload.get("nonce", String.class), payload.getExpiration());
validateAudience(payload.getAudience());
return UserInfo.builder()
.socialType(SocialType.KAKAO)
.socialId(payload.getSubject())
.nickname(payload.get("nickname", String.class))
.profileImage(payload.get("picture", String.class))
.build();
}
private void validateAudience(Set<String> audiences) {
for (String audience : audiences) {
if (appKeys.contains(audience)) {
return;
}
}
throw new UnauthorizedException(ErrorCode.OPEN_ID_INVALID_TOKEN);
}
@Override
public SocialType getSocialType() {
return SocialType.KAKAO;
}
}
OpenID Connect
를 사용하여 인증마다 API를 요청할 필요 없이 인증 기능을 수행할 수 있었다.
이전까지 OAuth2
가 인증이 아닌, 권한 부여를 위해 설계가 되었다는 것이 약간 아리송했는데, 이제서야 퍼즐이 맞춰지는 듯하다.
이러한 기능을 구현하며, 객체 지향적인 요소와 캐싱, 동시성 이슈 처리, 테스트 등 여러 지식을 다시 획득할 수 있었다.
클린 코드와 객체 지향, 테스트, 동시성 등 여러 요소를 처음 학습하고자 한다면, OpenID Connect
기반 인증 기능을 구현하는 게 많은 도움이 될 것 같다.