본문 바로가기

2. JWT + oAuth2 카카오 로그인 삽질일기

by 흐리멍텅 2024. 6. 28.

2. 인증은 언제 어떻게 이루어지는가?

코드까지 전부 기록해 놓은 내용은 노션에서 확인할 수 있다.

해당 코드를 사용한 프로젝트는 깃허브에서 확인할 수 있다.

 

[인증]에 대해서 자세히

SecurityConfig.SecurityFilterChain

    @Bean  // 스프링 컨테이너에 의해 관리되는 빈 객체를 생성
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
//                .cors(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)  // CSRF(Cross-Site Request Forgery) 보호 기능을 비활성화
                .formLogin(AbstractHttpConfigurer::disable)  // 스프링 시큐리티의 기본 로그인 페이지를 비활성화
                .httpBasic(AbstractHttpConfigurer::disable)  // HTTP 기본 인증 비활성화

                // HTTP 요청에 대한 보안 규칙을 설정
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(JwtConstants.WHITELIST).permitAll()  // 지정된 경로들은 인증 없이 접근 허용
                        .requestMatchers("/admin/**").hasRole("ADMIN")  // "/admin/**" 경로는 'ADMIN' 역할을 가진 사용자만 접근 가능
                        .anyRequest().authenticated())  // 그 외 모든 요청은 인증을 요구

                // JWT 필터 추가
                .addFilterBefore(jwtVerifyFilter, UsernamePasswordAuthenticationFilter.class)

                // 세션 관리 설정
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))  // 세션을 생성하지 않고, 상태를 유지하지 않는 정책 설정

                .build();  // HttpSecurity 객체를 사용하여 SecurityFilterChain을 생성
    }
  • 시큐리티 주요 설정이라고 볼 수 있는 SecurityFilterChain이다.
  • 각 내용에 대한 설명은 주석으로 달아놨으며 Jwt관련 로직만 들여다보자.

JwtVerifyFilter.doFilterInternal

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String authHeader = request.getHeader(JwtConstants.JWT_HEADER);
            checkAuthorizationHeader(authHeader); // header 형식 검사
            String token = JwtUtil.getTokenFromHeader(authHeader);

            if (redisUtil.isTokenBlacklisted(token)) { // 토큰 블랙리스트 검사
                throw new CustomJwtException("로그인 세션이 만료되었습니다.");
            }

            Authentication authentication = JwtUtil.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            handleAuthenticationError(response, e);
        }
    }
  • JwtVerifyFilter 클래스의 doFilterInternal
    : 시큐리티의 필터를 구현할 때 사용되는 메소드로, 실제 필터링 로직을 구현한다.
    Authentication authentication = JwtUtil.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication);
    토큰 검증이 성공적으로 이루어지면 위 코드가 Authenticaion 객체를 생성한 한 후 SecurityContextHolder에 저장하게 된다.
  • 이번엔 위의 JwtUtil.getAuthenticaion을 자세히 들여다보자

JwtUtil.getAuthentication

public class JwtUtil {

    public static String secretKey = JwtConstants.key;

    // 헤더에 "Bearer XXX" 형식으로 담겨온 토큰을 추출한다
    public static String getTokenFromHeader(String header) {
        return header.split(" ")[1];
    }

    public static String generateToken(Map<String, Object> valueMap, int validTime) {
        SecretKey key = null;
        try {
            key = Keys.hmacShaKeyFor(JwtUtil.secretKey.getBytes(StandardCharsets.UTF_8));
        } catch(Exception e){
            throw new RuntimeException(e.getMessage());
        }
        return Jwts.builder()
                .setHeader(Map.of("typ","JWT"))
                .setClaims(valueMap)
                .setIssuedAt(Date.from(ZonedDateTime.now().toInstant()))
                .setExpiration(Date.from(ZonedDateTime.now().plusMinutes(validTime).toInstant()))
                .signWith(key)
                .compact();
    }

    public static Authentication getAuthentication(String token) {
        Map<String, Object> claims = validateToken(token);

        String email = (String) claims.get("email");
        String name = (String) claims.get("name");
        String role = (String) claims.get("role");
        UserRole userRole = UserRole.valueOf(role);
        UserDocument userDocument = UserDocument.builder().email(email).name(name).userRole(userRole).build();

        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority(userDocument.getUserRole().getValue()));
        PrincipalDetail principalDetail = new PrincipalDetail(userDocument, authorities);

        return new UsernamePasswordAuthenticationToken(principalDetail, "", authorities);
    }

    public static Map<String, Object> validateToken(String token) {
        Map<String, Object> claim = null;
        try {
            SecretKey key = Keys.hmacShaKeyFor(JwtUtil.secretKey.getBytes(StandardCharsets.UTF_8));
            claim = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token) // 파싱 및 검증, 실패 시 에러
                    .getBody();
        } catch(ExpiredJwtException expiredJwtException){
            throw new CustomExpiredJwtException("토큰이 만료되었습니다", expiredJwtException);
        } catch(Exception e){
            throw new CustomJwtException("Error");
        }
        return claim;
    }

    // 토큰이 만료되었는지 판단하는 메서드
    public static boolean isExpired(String token) {
        try {
            validateToken(token);
        } catch (Exception e) {
            return (e instanceof CustomExpiredJwtException);
        }
        return false;
    }

    // 토큰의 남은 만료시간 계산
    public static long tokenRemainTime(long expTime) {
        long remainMs = expTime - System.currentTimeMillis();
        return remainMs / (1000 * 60);
    }

}
  • getTokenFromHeader : Header에서 토큰 추출
  • generateToken : 사용자 정보를 포함하는 JWT 토큰을 생성
    → kakaoAuthService에서 담당하기 때문에 없어져야 하는 메소드 (즉, 사용되지 않음. 리팩터링 대상)
  • getAuthentication : JWT 토큰에서 인증 정보를 추출하고, 이를 기반으로 Authentication 객체를 생성
  • validateToken : JWT 토큰을 검증하고, Claim을 추출
    • Claim
      • 토큰 내 포함된 정보를 의미. 
      • kakaoAuthService에서 생성한 클레임은 JWT에 포함되어 전송
      • JWT가 클라이언트에서 서버로 전달될 때, JwtUtil 클래스에서 검증 및 추출되는 클레임은 kakaoAuthService에서 처음 생성한 클레임과 동일