Skip to content

Commit d945429

Browse files
feat: 소셜 로그인 (카카오) 기능 구현 (#274)
* feat: 카카오 소셜 로그인 구현 * test: 카카오 소셜 로그인 테스트 코드 작성
1 parent 26be7c5 commit d945429

37 files changed

Lines changed: 2835 additions & 227 deletions

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ dependencies {
5050
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
5151
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
5252
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
53+
// jwt 서명검증
54+
implementation 'com.nimbusds:nimbus-jose-jwt:10.6'
5355

5456
// Testcontainer(Redis 테스트용)
5557
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.back.b2st.domain.auth.client;
2+
3+
import com.back.b2st.domain.auth.dto.oauth.KakaoIdTokenPayload;
4+
5+
public interface KakaoApiClient {
6+
// // 인가 코드로 카카오 액세스 토큰 요청
7+
// KakaoTokenRes getToken(String code);
8+
//
9+
// // 액세스 토큰으로 사용자 정보 조회
10+
// KakaoUserInfo getUserInfo(String accessToken);
11+
12+
// OIDC
13+
KakaoIdTokenPayload getTokenAndParseIdToken(String code);
14+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package com.back.b2st.domain.auth.client;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.http.HttpEntity;
5+
import org.springframework.http.HttpHeaders;
6+
import org.springframework.http.MediaType;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.util.LinkedMultiValueMap;
10+
import org.springframework.util.MultiValueMap;
11+
import org.springframework.web.client.RestClientException;
12+
import org.springframework.web.client.RestTemplate;
13+
14+
import com.back.b2st.domain.auth.dto.oauth.KakaoIdTokenPayload;
15+
import com.back.b2st.domain.auth.dto.oauth.KakaoTokenRes;
16+
import com.back.b2st.domain.auth.error.AuthErrorCode;
17+
import com.back.b2st.global.error.exception.BusinessException;
18+
import com.nimbusds.jose.JWSVerifier;
19+
import com.nimbusds.jose.crypto.RSASSAVerifier;
20+
import com.nimbusds.jose.jwk.JWK;
21+
import com.nimbusds.jwt.JWTClaimsSet;
22+
import com.nimbusds.jwt.SignedJWT;
23+
24+
import lombok.RequiredArgsConstructor;
25+
import lombok.extern.slf4j.Slf4j;
26+
import tools.jackson.databind.ObjectMapper;
27+
28+
@Slf4j
29+
@Component
30+
@RequiredArgsConstructor
31+
public class KakaoApiClientImpl implements KakaoApiClient {
32+
33+
// http 통신용 RestTemplate
34+
private final RestTemplate restTemplate;
35+
private final ObjectMapper objectMapper;
36+
private final KakaoJwksClient jwksClient;
37+
38+
@Value("${oauth.kakao.client-id}")
39+
private String clientId;
40+
@Value("${oauth.kakao.client-secret}")
41+
private String clientSecret;
42+
@Value("${oauth.kakao.redirect-uri}")
43+
private String redirectUri;
44+
@Value("${oauth.kakao.token-uri}")
45+
private String tokenUri;
46+
@Value("${oauth.kakao.user-info-uri}")
47+
private String userInfoUri;
48+
@Value("${oauth.kakao.issuer}")
49+
private String issuer;
50+
51+
@Override
52+
public KakaoIdTokenPayload getTokenAndParseIdToken(String code) {
53+
// 토큰 발급
54+
KakaoTokenRes tokenRes = getTokenWithOpenId(code);
55+
56+
if (tokenRes.idToken() == null || tokenRes.idToken().isBlank()) {
57+
log.warn("[Kakao] id_token 없음 - scope에 openid가 포함되었는지 확인 필요");
58+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
59+
}
60+
61+
// id token 파싱
62+
return parseIdToken(tokenRes.idToken());
63+
}
64+
65+
private KakaoTokenRes getTokenWithOpenId(String code) {
66+
// http 헤더 설정
67+
// 카카오 토큰 api는 form-urlencoded 형식만 허용
68+
HttpHeaders headers = new HttpHeaders();
69+
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
70+
71+
// 리퀘 파라미터
72+
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
73+
params.add("grant_type", "authorization_code"); // OAuth 타입 고정
74+
params.add("client_id", clientId); // 앱 식별자
75+
params.add("client_secret", clientSecret); // 앱 시크릿키
76+
params.add("redirect_uri", redirectUri); // 콜백
77+
params.add("code", code); // 인가 코드
78+
79+
// http 리퀘 객체 생성
80+
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
81+
82+
try {
83+
// 카카오 서버로 post 요청
84+
ResponseEntity<KakaoTokenRes> response = restTemplate.postForEntity(tokenUri, request, KakaoTokenRes.class);
85+
86+
// res 검증
87+
if (response.getBody() == null) {
88+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
89+
}
90+
91+
log.info("[Kakao] 토큰 발급 성공");
92+
return response.getBody();
93+
94+
} catch (RestClientException e) {
95+
log.error("[Kakao] 토큰 발급 실패: {}", e.getMessage());
96+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
97+
}
98+
}
99+
100+
// id_token 파싱 및 서명 검증 메서드
101+
// 순서: jwt 형식(header.payload.signature) 검증 -> 서명 검증(카카오 공개키 사용)
102+
// -> iss 검증 -> aud 검증 -> exp 검증
103+
private KakaoIdTokenPayload parseIdToken(String idToken) {
104+
try {
105+
// signature 포함 jwt 다루는 클래스
106+
SignedJWT signedJWT = SignedJWT.parse(idToken);
107+
108+
// kid 추출
109+
String kid = signedJWT.getHeader().getKeyID();
110+
if (kid == null) {
111+
log.warn("[Kakao] ID Token에 kid 없음");
112+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
113+
}
114+
115+
// 원본 요청
116+
JWK jwk = jwksClient.getKey(kid);
117+
if (jwk == null) {
118+
// 위조 토큰
119+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
120+
}
121+
122+
// 서명 검증
123+
// RSA 검증기에 카카오 공개키 물리기
124+
JWSVerifier verifier = new RSASSAVerifier(jwk.toRSAKey());
125+
126+
// 검증 수행
127+
// payload를 암호화했을 때, signature랑 일치하는지. 카카오 공개키로 풀리냐 안풀리냐 체크
128+
boolean verified = signedJWT.verify(verifier);
129+
130+
if (!verified) {
131+
// 불일치. 내용 조작이나 서명 위조
132+
log.warn("[Kakao] ID Token 서명 검증 실패");
133+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
134+
}
135+
136+
log.info("[Kakao] ID Token 서명 검증 성공");
137+
138+
// 내용물 체크
139+
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
140+
141+
// 발급자 검증
142+
if (!issuer.equals(claims.getIssuer())) {
143+
log.warn("[Kakao] ID Token 발급자 불일치: expected={}, actual={}", issuer, claims.getIssuer());
144+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
145+
}
146+
147+
// 대상 검증
148+
if (!claims.getAudience().contains(clientId)) {
149+
log.warn("[Kakao] ID Token 대상 불일치: expected={}, actual={}", clientId, claims.getAudience());
150+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
151+
}
152+
153+
// 만료 검증
154+
if (claims.getExpirationTime() != null && claims.getExpirationTime().before(new java.util.Date())) {
155+
log.warn("[Kakao] ID Token 만료됨");
156+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
157+
}
158+
159+
// dto 생성
160+
return new KakaoIdTokenPayload(
161+
claims.getIssuer(),
162+
claims.getAudience().get(0),
163+
claims.getSubject(), // 카카오 회원번호
164+
claims.getIssueTime() != null ? claims.getIssueTime().getTime() / 1000 : null,
165+
claims.getExpirationTime() != null ? claims.getExpirationTime().getTime() / 1000 : null,
166+
claims.getDateClaim("auth_time") != null ?
167+
claims.getDateClaim("auth_time").getTime() / 1000 : null,
168+
claims.getStringClaim("nonce"),
169+
claims.getStringClaim("nickname"),
170+
claims.getStringClaim("picture"),
171+
claims.getStringClaim("email")
172+
);
173+
174+
} catch (Exception e) {
175+
log.error("[Kakao] ID Token 파싱 실패: {}", e.getMessage());
176+
throw new BusinessException(AuthErrorCode.OAUTH_AUTHENTICATION_FAILED);
177+
}
178+
}
179+
180+
// //
181+
//
182+
// // 토큰 발급 api 호출
183+
// // POST https://kauth.kakao.com/oauth/token
184+
// // Content-Type: application/x-www-form-urlencoded
185+
// @Override
186+
// public KakaoTokenRes getToken(String code) {
187+
// getTokenWithOpenId(code);
188+
// }
189+
//
190+
// // 카카오 userInfo api 호출
191+
// // GET https://kapi.kakao.com/v2/user/me
192+
// // Authorization: Bearer {accessToken}
193+
// @Override
194+
// public KakaoUserInfo getUserInfo(String accessToken) {
195+
// // 헤더 설정
196+
// HttpHeaders headers = new HttpHeaders();
197+
// headers.setBearerAuth(accessToken);
198+
// headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
199+
//
200+
// HttpEntity<Void> request = new HttpEntity<>(headers);
201+
//
202+
// try {
203+
// ResponseEntity<KakaoUserInfo> response = restTemplate.exchange(userInfoUri, HttpMethod.GET, request,
204+
// KakaoUserInfo.class);
205+
//
206+
// if (response.getBody() == null) {
207+
// throw new BusinessException(AuthErrorCode.OAUTH_USER_INFO_FAILED);
208+
// }
209+
// log.info("[Kakao] 사용자 정보 조회 성공: kakaoId={}", response.getBody().id());
210+
// return response.getBody();
211+
// } catch (RestClientException e) {
212+
// log.error("[Kakao] 사용자 정보 조회 실패: {}", e.getMessage());
213+
// throw new BusinessException(AuthErrorCode.OAUTH_USER_INFO_FAILED);
214+
// }
215+
// }
216+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.back.b2st.domain.auth.client;
2+
3+
import java.net.URL;
4+
import java.util.concurrent.TimeUnit;
5+
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.stereotype.Component;
8+
9+
import com.nimbusds.jose.jwk.JWK;
10+
import com.nimbusds.jose.jwk.JWKMatcher;
11+
import com.nimbusds.jose.jwk.JWKSelector;
12+
import com.nimbusds.jose.jwk.source.JWKSource;
13+
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
14+
import com.nimbusds.jose.proc.SecurityContext;
15+
16+
import lombok.extern.slf4j.Slf4j;
17+
18+
// 카카오 JWKS 조회 클라이언트
19+
@Slf4j
20+
@Component
21+
public class KakaoJwksClient {
22+
23+
@Value("${oauth.kakao.jwks-uri:https://kauth.kakao.com/.well-known/jwks.json}")
24+
private String jwksUri;
25+
26+
// 님버스 라이브러리 제공 JWK 소스
27+
// JWK = JSON Web Key (JSON 형태로 표현된 암호화 키)
28+
// 카카오 서버랑 통신하고 캐시 관리
29+
private volatile JWKSource<SecurityContext> jwksSource;
30+
31+
// 지연 초기화 (Lazy Initialization)
32+
private JWKSource<SecurityContext> getJwksSource() {
33+
if (jwksSource == null) {
34+
synchronized (this) {
35+
if (jwksSource == null) {
36+
initJwksSource();
37+
}
38+
}
39+
}
40+
return jwksSource;
41+
}
42+
43+
private void initJwksSource() {
44+
try {
45+
jwksSource = JWKSourceBuilder
46+
.create(new URL(jwksUri))
47+
// TTL 24시간, 리프레시 타임아웃 1시간
48+
// cache(lifespan, refreshTimeout) - lifespan이 더 길어야 함
49+
.cache(TimeUnit.HOURS.toMillis(24), TimeUnit.HOURS.toMillis(1))
50+
// 속도 제한 비활성화
51+
.rateLimited(false)
52+
.build();
53+
54+
log.info("[Kakao JWKS] 초기화 완료: {}", jwksUri);
55+
} catch (Exception e) {
56+
log.error("[Kakao JWKS] 초기화 실패", e);
57+
throw new IllegalStateException("JWKS 클라이언트 초기화 실패", e);
58+
}
59+
}
60+
61+
// kid로 공개키 조회
62+
public JWK getKey(String kid) {
63+
// null 또는 빈 kid는 조회 불가
64+
if (kid == null || kid.isBlank()) {
65+
log.warn("[Kakao JWKS] kid가 null 또는 빈 문자열");
66+
return null;
67+
}
68+
69+
try {
70+
// 검색조건. id가 'kid'인 키
71+
JWKMatcher matcher = new JWKMatcher.Builder()
72+
.keyID(kid)
73+
.build();
74+
75+
// 검색 실행
76+
var keys = getJwksSource().get(new JWKSelector(matcher), null);
77+
78+
if (keys.isEmpty()) {
79+
log.warn("[Kakao JWKS] kid={} 에 해당하는 키 없음", kid);
80+
return null;
81+
}
82+
83+
// 1개지만 리스트 반환이라
84+
return keys.get(0);
85+
} catch (Exception e) {
86+
log.error("[Kakao JWKS] 키 조회 실패: kid={}", kid, e);
87+
return null;
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)