Kakao 소셜 로그인 공식 문서를 정리한 글
Kakao 소셜 로그인을 이해하자
척척학사 서비스 인증/인가 구현을 마무리했다.
Spring Security를 활용한 카카오 소셜 로그인으로 회원가입 로직을 구현했다.
OIDC를 활용해 ID Token을 사용한 사용자를 식별하고, 인증을 한다.
이건 일반적인 OAuth 2.0과 JWT를 이용한 인증/인가 프로세스이다.
검정색 박스가 백엔드에서 구현해야하는 필터이다.
OIDC 방식은 JWT를 사용하는 일반적인 방식과 조금 다르다.
전체적인 OIDC 인증/인가 프로세스
우리 서비스에서는 별도의 커스텀 JWT를 만들지 않고, ID Token을 사용한다.추후 커스텀 JWT를 사용하는 방식으로 변경했다.
백엔드 OIDC 필터링 프로세스
https://kauth.kakao.com/.well-known/jwks.json
에 요청하여 JSON 형식으로 공개키를 받음OidcPublicKeyService - Kakao 공개키를 받아오는 클래스
@Service
public class KakaoOidcPublicKeyService {
private static final String KAKAO_JWKS_URL = "https://kauth.kakao.com/.well-known/jwks.json";
public JsonNode getKakaoOidcPublicKey() {
RestTemplate restTemplate = new RestTemplate();
return restTemplate.getForObject(KAKAO_JWKS_URL, JsonNode.class);
}
}
해당 클래스의 역할은 단순하다.
RestTemplate를 사용해서 Kakao 공개키 API로 요청을 보내서 Json 형식으로 공개키를 받아온다.
OidcService - ID Token 검증 클래스
@Service
@RequiredArgsConstructor
public class KakaoOidcService {
private final KakaoOidcPublicKeyService publicKeyService;
public Claims verifyIdToken(String idToken) throws Exception {
JsonNode jwks = publicKeyService.getKakaoOidcPublicKey(); // 공개키 목록
// ID 토큰의 헤더에서 kid(Key ID) 추출
String[] parts = idToken.split("\\.");
String headerJson = new String(Base64.getDecoder().decode(parts[0]));
String kid = new ObjectMapper().readTree(headerJson).get("kid").asText();
// kid에 해당하는 공개 키 찾기
JsonNode keyNode = findMatchingKey(jwks, kid);
if (keyNode == null) {
throw new IllegalArgumentException("일치하는 공개키가 없습니다.");
}
// 공개 키 생성
PublicKey publicKey = createPublicKey(keyNode);
// 토큰 검증 및 파싱
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(idToken)
.getBody();
}
private JsonNode findMatchingKey(JsonNode jwks, String kid) {
// 일치하는 공개키가 있다면 반환
for (JsonNode key : jwks.get("keys")) {
if (key.get("kid").asText().equals(kid)) {
return key;
}
}
return null;
}
// 공개키 디코딩
private PublicKey createPublicKey(JsonNode keyNode) throws Exception{
byte[] decodedKey = Base64.getDecoder().decode(keyNode.get("n").asText());
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decodedKey);
return java.security.KeyFactory.getInstance("RSA").generatePublic(keySpec);
}
}
실질적으로 공개키를 통해 ID Token을 검증하는 클래스이다.
// 토큰 검증 및 파싱
return Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(idToken)
.getBody();
위 코드에서 찾은 공개키로 setSigningKey(publicKey)
과정에서 토큰을 검증한다.
JwtAuthenticationFilter - Token 추출 및 신규 회원 생성 클래스
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final KakaoOidcService kakaoOidcService;
private final CustomUserDetailsService userDetailsService;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
Claims claims = kakaoOidcService.verifyIdToken(authHeader);
String email = claims.get("email", String.class);// 사용자 email
// User 조회
userRepository.findByEmail(email)
.orElseGet(() -> {
// 사용자 정보가 없으면 새로 저장
User newUser = User.builder()
.email(email)
.profileNickname("Unknown User")
.build();
return userRepository.save(newUser);
});
//UserDetails 생성 후 인증 객체 저장
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invaild Token");
return;
}
}
}
프론트에서 보내준 토큰을 추출, 검증하고 사용자 정보를 반환하는 필터 클래스이다.
하나씩 설명을 하자면,
일반적으로 백엔드로 토큰을 보낼때는 Header key를 Authorization
으로 통일한다.
백엔드에서는 Authorization
으로 온 헤더의 값(토큰 정보)를 추출한다.
String authHeader = request.getHeader("Authorization");
토큰 정보가 없거나, 토큰 보호를 위한 접두사('Bearer')가 없다면 올바르지 않은 요청이라고 판단하고 요청을 끝낸다.
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
토큰 보호를 위하 추가했던 접두사를 제외한 부분이 실제로 필요한 토큰 정보이기 때문에 substring을 사용해 토큰 정보를 추출한다.
String token = authHeader.substring(7);
위에서 소개했던 OidcService의 메서드를 사용하면 Kakao 공개키를 조회하고, Token이 유효한지 검증을 한다. 토큰이 유효하다면 사용자 정보를 담은 Claims가 반환되고 사용자의 id or email 정보를 추출한다.
Claims claims = kakaoOidcService.verifyIdToken(authHeader);
String email = claims.get("email", String.class);
추출한 사용자의 email을 사용해 User 테이블에 email에 일치하는 사용자가 있는지 조회하고, 일치하는 사용자가 없다면 새로운 사용자를 생성한다.
이때 User의 개인키는
@GeneratedValue(strategy = GenerationType.UUID)
를 통해 UUID 타입으로 자동 생성된다.
userRepository.findByEmail(email)
.orElseGet(() -> {
// 사용자 정보가 없으면 새로 저장
User newUser = User.builder()
.email(email)
.profileNickname("Unknown User")
.build();
return userRepository.save(newUser);
});
CustomUserDetailService를 사용해 email에 일치하는 사용자 정보를 가져와 SecurityContext에 인증/인가 과정을 거친 사용자 정보를 넘긴다.
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
SpringConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // JWT 기반 인증
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/kakao").permitAll() // 로그인 엔드포인트는 허용
.requestMatchers("/api/public").permitAll()
.requestMatchers("/api/private").authenticated()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
return new ProviderManager(authProvider);
}
}
OIDC를 위한 config 설정은 간단하다.
JWT 기반 인증 방식이기 때문에, SessionCreationPolicy.STATELESS
설정을 한다.
UsernamePasswordAuthenticationFilter
는 폼 기반 로그인(username + password) 인증을 처리하는 필터이다. 하지만, 척척학사 서비스는 JWT를 사용하는 인증 방식을 적용하고 있기 때문에 JWT 토큰을 검증하는 필터인 jwtAuthenticationFilter
가 먼저 실행되어야 한다.
따라서 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
를 추가해 필터의 순서를 조정한다.