[척척학사] OIDC 인증, 인가 처리 With Kakao 소셜 로그인

박상민·2025년 3월 19일
1

척척학사

목록 보기
2/15

Kakao 소셜 로그인 공식 문서를 정리한 글
Kakao 소셜 로그인을 이해하자

서론

척척학사 서비스 인증/인가 구현을 마무리했다.
Spring Security를 활용한 카카오 소셜 로그인으로 회원가입 로직을 구현했다.
OIDC를 활용해 ID Token을 사용한 사용자를 식별하고, 인증을 한다.


이건 일반적인 OAuth 2.0과 JWT를 이용한 인증/인가 프로세스이다.
검정색 박스가 백엔드에서 구현해야하는 필터이다.

OIDC 방식은 JWT를 사용하는 일반적인 방식과 조금 다르다.

OIDC 인증/인가 프로세스

전체적인 OIDC 인증/인가 프로세스

  1. 프론트에서 ID Token 발급
  2. ID Token을 포함하여 서버로 요청
  3. 서버에서 ID Token을 필터링, 검증
  4. 회원가입 또는 로그인 성공 시 access token과 refresh token 발급

    우리 서비스에서는 별도의 커스텀 JWT를 만들지 않고, ID Token을 사용한다. 추후 커스텀 JWT를 사용하는 방식으로 변경했다.

백엔드 OIDC 필터링 프로세스

  1. ID Token의 서명을 검증
  • 카카오 공개키 URL https://kauth.kakao.com/.well-known/jwks.json에 요청하여 JSON 형식으로 공개키를 받음
  1. ID Token의 헤더에서 kid를 추출
  2. 1번에서 받아온 공개키를 사용해 토큰 서명 검증
  • RSA 공개키(n, e)을 사용하여 토큰의 서명이 유효한지 검증
  1. 서명이 검증되면, ID 토큰의 페이로드에서 사용자 ID(sub) 추출
  2. sub 값으로 DB에 사용자 정보가 있는지 확인하여 로그인, 회원가입 처리

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)를 추가해 필터의 순서를 조정한다.

0개의 댓글