카카오 로그인 구현하기(2) - JWT + Spring Security

1. JWT

 

 

JWT 개념 | Notion

1. JWT

chanmin-study-log.notion.site

 

 

1.1 application.yml, build.gradle 설정하기

 

jwt:
  secret: ${JWT_SECRET_KEY}
  access-token-validity: ${JWT_ACCESS_TOKEN_TIME}
  refresh-token-validity: ${JWT_REFRESH_TOKEN_TIME}

 

secret key와, 액세스 토큰 만료시간, 리프레시 토큰 만료 시간을 yml에 지정

 

secret key 값은 중요한 정보이니만큼 github로 관리하지 않고, 별도로 다른 파일에 설정해 주는 것이 좋음

 

# jwt build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
# Spring Security build.gradle
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-security'

 

1.2 JwtTokenProvider - 사용자 정보로 JWT 토큰 생성하기

 

@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-validity}")
    private long accessTokenValidityMilliSeconds;

    @Value("${jwt.refresh-token-validity}")
    private long refreshTokenValidityMilliSeconds;

    private Key key;

    @PostConstruct
    public void initKey() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }
}

 

JWT 생성에 사용될 Key는 initKey() 메소드로 secretKey를 decode 하여 Key에 주입

 


 

# 토큰 생성 메서드
public String generateAccessToken(Long memberId) {
        return generateToken(memberId, accessTokenValidityMilliSeconds);
}

public String generateRefreshToken(Long memberId) {
    return generateToken(memberId, refreshTokenValidityMilliSeconds);
}

public String generateToken(Long memberId, long validity) {

    long now = (new Date()).getTime();
    Date validityDate = new Date(now + validity);
    Claims claims = Jwts.claims();
    claims.put("id", memberId);

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(new Date())
            .setExpiration(validityDate)
            .signWith(key)
            .compact();
}

 

JWT (JSON Web Token)에서 Claims는 토큰에 포함된 정보(클레임)들을 나타냄

 

JWT는 기본적으로 세 부분으로 구성

 

  • 헤더(Header), 페이로드(Payload), 그리고 서명(Signature). 이 중 페이로드(Payload) 부분에 클레임(Claims)이 포함됨

 

💡 Claims는 JWT 내에 포함된 정보의 집합으로, 보통 사용자나 시스템 간에 정보를 안전하게 전달하기 위해 사용됩니다. 클레임은 토큰에 포함될 수 있는 정보를 key-value 형태로 표현한 것. -chatGPT-

# 토큰 유효성 검사 메소드
public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        return true;
    } catch (UnsupportedJwtException
            | MalformedJwtException
            | IllegalArgumentException
            | SignatureException e) {
        throw new GlobalException(GlobalErrorCode.AUTH_INVALID_TOKEN);
     } catch (ExpiredJwtException e) {
        throw new GlobalException(GlobalErrorCode.AUTH_EXPIRED_TOKEN);
     }
}
# 토큰 에러 코드
AUTH_EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401", "만료된 토큰입니다."),
AUTH_INVALID_TOKEN(HttpStatus.NOT_FOUND, "AUTH402", "유효하지 않은 코드입니다..");
# 요청으로부터 헤더를 추출하여 인증 토큰을 추출하는 메소드
public String resolveBearerToken(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");
    if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}
# token을 통해 memberId 추출 메소드
public Long getmemberId(String token) {
    return Long.parseLong(Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("id").toString());
}

 

1.3 JwtAuthenticationFilter 를 등록하여 자체 인증 로직 구현하기

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final MemberDetailsService memberDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String token = jwtTokenProvider.resolveBearerToken(request);

        if (token != null) {
            if (jwtTokenProvider.validateToken(token)) {
                Long memberId = jwtTokenProvider.getmemberId(token);
                UserDetails userDetails =
                        memberDetailsService.loadUserByUsername(memberId.toString());
                if (userDetails != null) {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails, "", userDetails.getAuthorities());
                    SecurityContextHolder.getContext()
                            .setAuthentication(usernamePasswordAuthenticationToken);
                } else {
                    throw new GlobalException(GlobalErrorCode.MEMBER_NOT_FOUND);
                }
            } else {
                throw new GlobalException(GlobalErrorCode.AUTH_INVALID_TOKEN);
            }
        }

        filterChain.doFilter(request, response); // 다음 필터로 넘어가기
    }
}

 

Spring Security에서 JWT 기반 인증을 처리하기 위해 작성된 커스텀 필터

 

JwtAuthenticationFilter 클래스는 OncePerRequestFilter를 상속받아 매 요청마다 한 번씩 실행

 

요청에 포함된 JWT 토큰을 검증하고, 검증에 성공하면 해당 사용자를 인증된 사용자로 설정

 

  • doFilterInternal 메서드 HttpServletRequest, HttpServletResponse, FilterChain을 인자로 받아 필터링 작업을 수행
  • 사용자 정보 로드
    • jwtTokenProvider.getUserId(token)을 통해 토큰에서 사용자 ID를 추출
    • userDetailsService.loadUserByUserName(memberId)을 사용해 해당 사용자 ID에 대한 UserDetails 객체를 로드
  • 사용자 인증 설정 UserDetails 객체가 유효하다면, UsernamePasswordAuthenticationToken 객체를 생성하고, Spring Security의 SecurityContextHolder에 설정하여 현재 요청을 인증된 것으로 표시

 

💡 UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationToken은 Spring Security에서 사용자의 인증 정보를 나타내는 클래스입니다. 이 클래스는 Authentication 인터페이스를 구현하며, 주로 사용자가 제출한 인증 정보(예: 사용자 이름과 비밀번호)를 나타내거나, 인증이 완료된 후 인증된 사용자를 나타내기 위해 사용

💡 SecurityContextHolder

SecurityContextHolder는 Spring Security에서 현재 애플리케이션의 보안 컨텍스트(Security Context)를 관리하는 중요한 클래스로, 주로 인증 정보와 관련된 데이터를 저장하고 접근하는 데 사용됩니다.

💡 Security Context

보안 컨텍스트는 애플리케이션에서 현재 사용자의 인증 및 권한 부여 상태를 저장하는 객체입니다. 이 객체는 일반적으로 SecurityContext 인터페이스를 구현한 SecurityContextImpl 클래스의 인스턴스로 표현됩니다.

 

추후 SecurityConfig 생성 후 위 필터 등록 필요

http.addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
  • JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
  • 즉, 요청이 들어오면:
    1. 먼저 jwtFilter가 실행되어 JWT 토큰을 검사하고 인증 정보를 설정
    2. 그다음에 UsernamePasswordAuthenticationFilter가 실행되어 추가적인 인증 절차를 처리

 

1.4 예외 처리 필터를 JwtAuthenticationFilter 이전에 등록하여 예외 처리하기

 

💡 특정 필터에서 예외가 발생하면, 앞서 거쳐간 필터에서 예외를 처리한다고 함!!

 

JwtAuthenticationFilter에서 발생한 예외를 처리하기 위해 필터를 하나 더 등록

 

@Component
public class ExceptionHandleFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (GlobalException e) {
            response.setContentType("application/json; charset=UTF-8");
            response.setStatus(HttpStatus.UNAUTHORIZED.value());

            ObjectMapper mapper = new ObjectMapper();
            mapper.writeValue(response.getOutputStream(), (new ErrorResponse(e.getGlobalErrorCode())));
        }
    }
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandleFilter, JwtAuthenticationFilter.class)

 

1.5 JwtHandler를 등록하여 JwtFilter를 통과하지 못한 경우에 대하여 처리하기

 

필터를 통과하지 못한 경우는 두 가지가 존재하는데 인증에 통과하지 못한 경우 인가(권한)에 통과하지 못한 두 가지 경우가 존재

 

  1. 인증에 실패한 경우

 

@Slf4j
@Component
public class JwtAuthenticationFailEntryPoint implements AuthenticationEntryPoint {

    private final HandlerExceptionResolver resolver;

    public JwtAuthenticationFailEntryPoint(
            @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
        this.resolver = resolver;
    }

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException)
            throws IOException {

        resolver.resolveException(
                request, response, null, new GlobalException(GlobalErrorCode._UNAUTHORIZED));
    }
}

 

인증에 대한 실패 처리 handler는 AuthenticationEntryPoint 인터페이스의 commence 메소드를 구현

 

2. 인가에 실패한 경우

 

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final HandlerExceptionResolver resolver;

    public JwtAccessDeniedHandler(
            @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
        this.resolver = resolver;
    }

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException)
            throws IOException {

        resolver.resolveException(
                request, response, null, new GlobalException(GlobalErrorCode._FORBIDDEN));
    }
}

 

인가에 대한 실패 처리 handler는 AccessDeniedHandler 인터페이스의 handler 메소드를 구현

 

@ControllerAdvice는 Handler(Controller) 단에서 발생하는 Exception을 처리하는 것이 때문에 컨트롤러 밖으로 던저진 예외 처리가 불가능 → HandlerExceptionResolver 사용

 

1. 예외 발생 컨텍스트

  • 스프링 애플리케이션에서 예외가 발생하는 시점과 위치에 따라 예외 처리의 흐름이 달라집니다.
  • 일반적으로, 컨트롤러나 서비스 계층에서 예외가 발생하면, 스프링은 이 예외를 @ControllerAdvice로 전달하여 처리합니다.
  • 그러나 AccessDeniedHandler와 같은 시큐리티 관련 핸들러에서 발생하는 예외는 이미 시큐리티 필터 체인 내에서 발생한 것입니다. 이 예외는 컨트롤러에 도달하지 않기 때문에, 스프링 MVC의 예외 처리 흐름과는 별개로 처리됩니다.

2. HandlerExceptionResolver의 역할

  • HandlerExceptionResolver는 예외를 처리하기 위해, 예외가 발생한 곳에서 해당 예외를 적절한 방식으로 해석하여 스프링 MVC가 관리하는 예외 처리 메커니즘으로 넘깁니다.
  • 예외가 HandlerExceptionResolver를 통해 전달되지 않으면, 해당 예외는 시큐리티 콘텍스트 내에서만 처리되고 스프링 MVC의 예외 처리 범위 밖에 있게 됩니다. 이 경우, @ControllerAdvice에 정의된 예외 처리 메서드들이 이 예외를 감지하거나 처리할 수 없게 됩니다.

출처 : -chatGPT-


 

추후 SecurityConfig 생성 후 위 핸들러 등록 필요

.exceptionHandling(exceptionHandling -> {
    exceptionHandling.authenticationEntryPoint(jwtAuthenticationFailEntryPoint);
    exceptionHandling.accessDeniedHandler(jwtAccessDeniedHandler);
});

 

2. UserDetails

 

2.1 UserDetails란?

 

Spring Security에서 사용자의 정보를 담는 인터페이스

 

Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스로 기본 오버라이드 메서드들은 아래와 같음

 

메서드 리턴 타입 설명 기본값
getAuthorities() Collection<? extends GrantedAuthority> 계정의 권한 목록을 리턴  
getPassword() String 계정의 비밀번호를 리턴  
getUsername() String 계정의 고유한 값을 리턴 ( ex : DB PK값, 중복이 없는 이메일 값 )  
isAccountNonExpired() boolean 계정의 만료 여부 리턴 true ( 만료 안됨 )
isAccountNonLocked() boolean 계정의 잠김 여부 리턴 true ( 잠기지 않음 )
       
isCredentialsNonExpired() boolean 비밀번호 만료 여부 리턴 true ( 만료 안됨 )
isEnabled() boolean 계정의 활성화 여부 리턴 true ( 활성화 됨 )

 

대부분의 경우 Spring Security의 기본 UserDetails로는 실무에서 필요한 정보를 모두 담을 수 없기에 CustomUserDetails를 구현하여 사용

 

@RequiredArgsConstructor
public class MemberDetails implements UserDetails {

    private final Member member;

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return member.getId().toString();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<String> roles = new ArrayList<>();
        roles.add("ROLE_MEMBER");

        return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

2.2 UserDetailsService 란?

 

Spring Security에서 유저의 정보를 가져오는 인터페이스

 

Spring Security에서 유저의 정보를 불러오기 위해서 구현해야 하는 인터페이스로 기본 오버라이드 메서드는 아래와 같음

 

메서드 리턴 타입 설명
loadUserByUsername UserDetails 유저의 정보를 불러와서 UserDetails로 리턴
@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
        Member member =
                memberRepository
                        .findById(Long.parseLong(memberId))
                        .orElseThrow(
                                () -> new GlobalException(GlobalErrorCode.MEMBER_NOT_FOUND));
        return new MemberDetails(member);
    }
}

 

3. Spring Security 설정하기

 

 

Spring Security 개념 | Notion

1. Spring Security 란?

chanmin-study-log.notion.site

 

 

3.1 SecurityConfig 설정하기

 

SecurityConfig : Spring Security 환경 설정을 구성하기 위한 클래스

 

Spring Security 5.7 이후부터 @Bean으로 SecurityFilterChain을 구현해서 시큐리티를 적용시키는 방법을 권장하기 때문에 필터 체인 구성을 extends로 하는 이전방식을 사용하지 않고 빈 등록 방식으로 코드를 작성

 


@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring()
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}

 

정적 자원에 대해 보안을 적용하지 않도록 설정

 

정적 자원은 보통 HTML, CSS, JavaScript, 이미지 파일 등을 의미하며, 이들에 대해 보안을 적용하지 않음으로써 성능을 향상하도록 함

 


@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.debug("WebSecurityConfig Start !!! ");
    
    return http.csrf(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
            .authorizeHttpRequests(
                    authorize ->
                            authorize
                                    .requestMatchers(
                                           "/api/auth/kakao/login",
                                           "/api/auth/kakao/refresh",
                                           "/swagger-ui/**",
                                           "/swagger-resources/**",
                                           "/v3/api-docs/**")
                                    .permitAll()
                                    .anyRequest()
                                    .authenticated())
            .exceptionHandling(
                    configurer ->
                            configurer
                                    .accessDeniedHandler(jwtAccessDeniedHandler)
                                    .authenticationEntryPoint(jwtAuthenticationFailEntryPoint))
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(exceptionHandleFilter, JwtAuthenticationFilter.class)
            .sessionManagement(
                    session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .formLogin(AbstractHttpConfigurer::disable)
            .build();
}

 

1. CSRF 설정 코드 작성

.csrf(AbstractHttpConfigurer::disable)

 

  • CSRF(Cross-Site Request Forgery)는 웹 애플리케이션의 취약점 중 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 하도록 만드는 공격
  • 이 설정은 CSRF 보호 기능을 비활성화. 이렇게 설정하면, CSRF 토큰 없이도 요청을 처리할 수 있게 됨

 

2.  httpBasic 설정 코드 작성

 

  • httpBasic():  Http basic Auth 기반으로 로그인 인증창이 생김.  기본 인증 로그인을 이용하지 않을 경우 disable 

 

3. cors 설정

 

 

CORS 개념 | Notion

1. CORS

chanmin-study-log.notion.site

 

CORS(Cross-Origin Resource Sharing)는 다른 도메인의 리소스에서 웹 페이지가 접근할 수 있도록 브라우저에게 권한을 부여하는 메커니즘

 

.cors(cors -> cors.configurationSource(corsConfigurationSource()))

 

 특정 CORS 구성 소스(corsConfigurationSource())를 사용하여 CORS 설정을 적용할 필요가 있음

 

 @Bean
 public CorsConfigurationSource corsConfigurationSource() {
     CorsConfiguration configuration = new CorsConfiguration();
     configuration.setAllowedOrigins(Arrays.asList("*"));
     configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
     configuration.setAllowedHeaders(Arrays.asList("X-Requested-With", "Content-Type", "Authorization", "X-XSRF-token"));
     configuration.setAllowCredentials(false);
     configuration.setMaxAge(3600L);

     UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
     source.registerCorsConfiguration("/**", configuration);
     return source;
 }

 

 

4. X-Frame-Option 설정

 

 

x-frame-option 헤더 설정(iframe) | Notion

1. iframe

chanmin-study-log.notion.site

 

5. authorizeHttpRequests 설정코드 작성

 

  • HTTP 요청에 대한 인증 및 권한을 정의
  • permitAll()로 설정한 경로로 들어오는 요청은 모든 사용자에게 허용하도록 설정하고, 그 외의 모든 요청은 인증된 사용자만 접근할 수 있도록 설정

 

6. exceptionHandling 설정 코드 작성

 

  • 인증 및 인가가 되지 않았을 경우에 대한 처리 핸들러 등록

 

7. addFIlterBefore 설정코드 작성

 

  • UsernamePasswordAuthenticationFilter 전에 jwtAuthenticationFilter가 작동하도록 필터 등록

 

8. sessionManagement 설정코드 작성

 

  • 세션 관리 전략을 정의
  • SessionCreationPolicy.STATELESS는 스프링 시큐리티가 세션을 생성하거나 사용하지 않도록 설정. 이는 주로 JWT와 같은 토큰 기반 인증에서 사용됨

 

9. formLogin 설정코드 작성

  • form 기반으로 진행하는 로그인에 관한 설정을 정의. disable로 처리하여 로그인 페이지를 설정하지 않음