Spring발행일 2024. 9. 10.원본 https://blog.naver.com/jword_/223579167683 ↗

Spring Securty에 JWT 완벽 적용하기!

Spring Securty에 JWT 완벽 적용하기! — #자바스프링 #Javaspring #JWT #JWT적용 #개발자의도구들 🚨📝 📖 📒✏️💡🔍 AI스쿨...

#Spring#Naver Blog

#자바스프링 #Javaspring #JWT #JWT적용 #개발자의도구들

🚨📝 📖 📒✏️💡🔍

​

AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다

\* 현재 Spring 프로젝트를 진행 중입니다. 전체적인 목차를 보시려면 여기를 눌러주세요.

이미지

Intro

JWT 적용에 생각보다 애를 많이 먹었습니다. 내부 클래스 & 인터페이스와 연동하여 커스텀한 class를 만들다 보니 헷갈리는게 너무나도 많았습니다.

​

하지만 이번글을 통해서 누구나 명료하고 투명하게 로직과정을 이해할 수 있도록 최선을 다해 글을 작성해보겠습니다.

​

목차

  1. JWT Structure
  2. Why we use JWT?
  3. JWT Flow
  4. JWT in Spring Security

JWT

JWT는 Json Web Token으로 보안 정보를 전달하기 위해 암호화된 토큰입니다. JWT의 경우 Session기반 Authenticaion과는 다르게 클라이언트 측에서 Token을 서버로 전달하는 방법으로 사용합니다. 아래에서 더 자세히 설명해보겠습니다.

JWT Structure

JWT는 3가지 구조를 가지고 있습니다. Header, Payload, Signature

​

Header

Header는 두 가지 parts로 구분 됩니다. 하나는 JWT토큰 생성을 위해 사용된 알고리즘이고, 하나는 토큰의 type입니다.

{ "alg" : Hs256", "type" : "JWT"}

이런식으로 Json처럼 데이터가 표현됩니다.

​

PayLoad

Ciaim이라 불리는 데이터 정보를 가지고 있습니다 Claim에는 entity정보나 추가적인 데이터가 포함됩니다.

{ "sub": "1234567890", "name": "John Doe", "admin": true }

⚠️payload에는 민감한 정보를 절대 넣으면 안됩니다.

​

Signature

signature는 말그대로 토큰을 검증(무결성, 신뢰성)하는데 사용됩니다. Signature의 경우 JWT의 Header와 Payload를 기반으로 생성됩니다. 이 서명을 통해 JWT가 발급 이후 수정되지 않았는지 확인이 가능합니다.

​

이미지

[JWT.IO
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.
jwt.io](https://jwt.io/#debugger-io)

JWT를 테스트 해보면 위 사이트로 이동하시면 됩니다. 각 단계는 xxx.yyy.zzz 형태로 구분되며 차례대로 header, payload, signature 키 입니다.

이미지

이미지

만약 header나 payload 키를 수정한다면 signature가 맞지 않다는 것을 확인하실 수 있습니다.

​

​

​

Why we use JWT?

JWT를 사용하는 가장 큰 이유는 편리함입니다. 공부할 때 빠르고 쉽게 사용하기 좋아서, 인증/인가 시스템을 간단하게 만들기에 좋습니다.

​

하지만, 실제 필드에서는 크게 사용되지 않는다고 합니다. 이유는 아래에 있습니다.

​

단점

  1. JWT를 정보를 탈취하여 사용자 정보를 취득할 수 있다.
  1. JWT 만료 시간이 되기전에 사용자가 PW를 변경해도 바로 적용되지 않는다.

​

위 문제를 해결하기 위해 만료시간을 짧게 설정하고, 매 요청마다 재 발급하는 방식이 존재하는데, 이는 복잡하므로 기회가 된다면 다음에 공부해서 정리해 보겠습니다.

​

JWT Flow

  1. 클라이언트가 로그인을 시도한다.(로그인 시작 시점)
  2. 서버는 클라이언트 정보를 확인후 인증이 완료되면 클라이언트에게 JWT를 발급한다.(HTTP 헤더에 넣어서 보낸다) (로그인 검증 시점)
  3. 클라이언트는 JWT를 쿠키형태로 저장해둔다.(로그인 완료시점)
  4. 로그인이 완료된 클라이언트는 서버에게 특정 리소스를 요청하기 위해 JWT를 HTTP 요청에 넣어 보낸다.
  5. 서버는 JWT를 해독하여 인증된 사용자인지 확인한다.
  6. 이 과정에서 DB에서 데이터를 읽어오는 가정이 생략된다. (ContextHolder에서 바로 검증) - JWT에는 유저 정보가 들어잇다.
  7. 인증이 무결하면, 서버는 요청 리소스를 클라이언트에게 보낸다.

JWT in Spring Security

Sign-In flow

이미지

이미지

Spring Security / Jwt + Spring Security

위 그림 한정에 JWT를 통해 수행되는 로그인 과정을 명확하게 이해할 수 있습니다. JWT도 기본적으로는 Spring Security에서 배웟던 UsernamePasswordFilter를 통해 인증을 진행할 수 있습니다. 하지만 이전과의 차이점은 인증이후 JWT 발급에있습니다.

​

프로그래밍 관점에서 flow를 다시 정리해보죠,

  1. Username Password Authentication Filter는 사용자의 요청을 가로채어 사용자 인증을 시작한다.
  2. 인증 과정은 다음과 같다. Filter -> Manger -> Provider -> DetailsService -> Provider -> Manger -> Filter -> SecurityHolder
  1. 최종적으로 SecurityHolder에 Authentication이 등록 된 이후, JWT토큰을 client에게 발급한다.
  1. REST API 환경에서는 Controller에서 JWT 전송을 처리한다. (SuccessHandler 대신)

5

\* Token 발급은 JwtAuthenticationProvider가 담당한다.

​

Log-in 코드

JwtAuthenticaionProvider

import io.jsonwebtoken.Jwtsimport io.jsonwebtoken.SignatureAlgorithmimport io.jsonwebtoken.security.Keysimport org.springframework.beans.factory.annotation.Valueimport org.springframework.security.authentication.AuthenticationProviderimport org.springframework.security.authentication.UsernamePasswordAuthenticationTokenimport org.springframework.security.core.Authenticationimport org.springframework.security.core.userdetails.UserDetailsimport org.springframework.security.core.userdetails.UserDetailsServiceimport org.springframework.security.core.AuthenticationExceptionimport org.springframework.security.core.authority.SimpleGrantedAuthorityimport org.springframework.security.core.userdetails.UsernameNotFoundExceptionimport org.springframework.security.crypto.password.PasswordEncoderimport org.springframework.stereotype.Componentimport java.util.\​@Componentclass JwtAuthenticationProvider( private val userDetailsService: UserDetailsService, private val passwordEncoder: PasswordEncoder, @Value("\${jwt.secret}") private val jwtSecret: String, @Value("\${jwt.expiration}") private val jwtExpirationMs: Long) : AuthenticationProvider {​ // JWT 서명에 사용할 SecretKey 생성 private val secretKey = Keys.hmacShaKeyFor(jwtSecret.toByteArray())​ override fun authenticate(authentication: Authentication): Authentication { val username = authentication.name val rawPassword = authentication.credentials as String​ // 1. UserDetailsService를 통해 사용자 정보 로드 val userDetails: UserDetails = try { userDetailsService.loadUserByUsername(username) } catch (e: UsernameNotFoundException) { throw AuthenticationException("Invalid Username or Password") }​ // 2. 비밀번호 검증 if (!passwordEncoder.matches(rawPassword, userDetails.password)) { throw AuthenticationException("Invalid Username or Password") }​ // 3. 인증 성공 시 JWT 토큰 생성 및 반환 val token = generateToken(userDetails)​ // 4. 새로운 인증 객체 생성 및 반환 return UsernamePasswordAuthenticationToken(userDetails, token, userDetails.authorities) }​ override fun supports(authentication: Class<\>): Boolean { return UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication) }​ // JWT 토큰 생성 메서드 fun generateToken(userDetails: UserDetails): String { val now = Date() val expiryDate = Date(now.time + jwtExpirationMs)​ return Jwts.builder() .setSubject(userDetails.username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(secretKey, SignatureAlgorithm.HS512) .compact() }​ // JWT 토큰에서 사용자 이름 가져오기 fun getUsernameFromToken(token: String): String { return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).body.subject }​ // JWT 토큰의 유효성 검증 메서드 fun validateToken(token: String): Boolean { return try { Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token) true } catch (ex: Exception) { false } }}

+ 토큰 생성 및 usernmae을 불러오는 기능은 JwtUtils 클래스로 따로 만드는 것도 좋아보입니다. 이렇게 설정시 UsernamePassword는 Provider 사용시 JwtAuthenticationProvider를 사용하게 됩니다.

​

​

​

AuthController

@RestController@RequestMapping("/auth")class AuthController( private val authenticationManager: AuthenticationManager, private val jwtAuthenticationProvider: JwtAuthenticationProvider) {​ @PostMapping("/login") fun login(@RequestBody loginRequest: LoginRequest): ResponseEntity<Any> { val authentication = authenticationManager.authenticate( UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password) )​ SecurityContextHolder.getContext().authentication = authentication​ val token = jwtAuthenticationProvider.generateToken(authentication) return ResponseEntity.ok(mapOf("token" to token)) }}

실질적으로 Login을 수행하는 곳은 AuthController이다. SecurityContextHolder.getContext() 부분에서 사실상 모든 검증이 끝난 상태입니다. 이후 jwtAuthenticaionProvider에게 authentcation 정보를 넘긴 후 토큰을 발급받아 client에게 전달합니다.

Service Flow

로그인이 완료되었으면 이제 사용자는 JWT 토큰 번호를 서버에게 전송하며 리소스를 요청하게 됩니다..

​

필터 정책 설정

package com.example.x.config​import com.example.x.security.JwtAuthenticationFilterimport com.example.x.security.JwtAuthenticationProviderimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.security.authentication.AuthenticationManagerimport org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfigurationimport org.springframework.security.config.annotation.web.builders.HttpSecurityimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurityimport org.springframework.security.crypto.bcrypt.BCryptPasswordEncoderimport org.springframework.security.crypto.password.PasswordEncoderimport org.springframework.security.web.SecurityFilterChainimport org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter​@Configuration@EnableWebSecurityclass SecurityConfig( private val jwtAuthenticationProvider: JwtAuthenticationProvider, private val jwtAuthenticationFilter: JwtAuthenticationFilter){​ @Bean fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { http.csrf { csrf -> csrf.disable() } .authorizeHttpRequests { auth -> auth .requestMatchers("/login").permitAll() .requestMatchers("/sign-up").permitAll() .requestMatchers("/boards").permitAll() .anyRequest().authenticated() } .authenticationProvider(jwtAuthenticationProvider) .addFilterBefore(JwtAuthenticationFilter(jwtAuthenticationProvider), UsernamePasswordAuthenticationFilter::class.java,) return http.build() }​ @Bean fun passwordEncoder(): PasswordEncoder { return BCryptPasswordEncoder()// }​ @Bean fun authenticationManger(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager { return authenticationConfiguration.authenticationManager }​ @Bean fun jwtAuthenticationFilter(): JwtAuthenticationFilter { return JwtAuthenticationFilter(jwtAuthenticationProvider) }​}

우선적으로 알아야 할 부분은 바로 filter의 동작원리입니다. 필터를 위와 같이 설정하고 나면, JwtAuthenticaionFilter가 지정 url("/login", "/sign-up" 등)을 제외하고 들어온 필터에서 항상 UsernamePasswordAutheticaionFilter를 앞서게 됩니다. 즉 가장 처음으로 Request요청이 시작되는 시점입니다.

​

\* 만약 Filter에서 JWT인증을 성공하면, UsernmaePasswordAuthenticaionFilter는 실행되지 않습니다.

이미지

헷갈릴 수 잇는데 마지막으로 flow를 정리해 봅시다.

​

  1. 클라이언트는 로그인 후 JWT를 헤더에 넣어 서버로 보낸다.
  2. 서버의 JwtAuthentcaionFilter는 getTokenFromRequest() 메서드를 통해 JWT 토큰을 추출한다.
  3. 추출된 JWT토큰은 JwtAuthenticaionProvider에 전송된다.

  1. JwtAuthenticaionProvider는 Jwt로부터 username을 추출한다.
  1. JwtAuthentcaionProvider는 UsernameService를 통해 UserDetails를 추출하여 다시 JwtAuthentcaionFilter로 전송한다.

  1. JwtAuthentcationFilter는 제공받은 UserDetails를 바탕으로 UsernamePasswordAuthentcationToken을 생성한다.
  1. 생성된 UsernamePasswordAuthentcationToken은 SecurityContextHolder에 등록된다. 이때, token의 details한 정보를 request로 부터 가져온다. (IP 주소와 같은 것 들)
  1. JwtAuthentcationFilter는 통과되었다. 이제 인증이 완료되었으니 클라이언트가 원하는 서비스를 제공한다.

Sign-up code

JwtAuthenticationFilter

@Componentclass JwtAuthenticationFilter( private val jwtAuthenticationProvider: JwtAuthenticationProvider) : OncePerRequestFilter() {​ override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain ) { val token = getTokenFromRequest(request) // 헤더에서 JWT 토큰 추출 try { if (token != null && jwtAuthenticationProvider.validateToken(token)) { val userDetails = jwtAuthenticationProvider.getUserFromToken(token) val authentication = UsernamePasswordAuthenticationToken( userDetails, null, userDetails.authorities ) authentication.details = WebAuthenticationDetailsSource().buildDetails(request) SecurityContextHolder.getContext().authentication = authentication } else { throw RuntimeException("Invalid JWT token") // 토큰이 없거나 유효하지 않은 경우 예외 발생 } } catch (e: Exception) { // JWT 인증 실패 시 응답 설정 unsuccessfulAuthentication(request, response, e) return }​ filterChain.doFilter(request, response) // 다음 필터로 요청을 전달 }​ private fun getTokenFromRequest(request: HttpServletRequest): String? { val bearerToken = request.getHeader("Authorization") return if (bearerToken != null && bearerToken.startsWith("Bearer ")) { bearerToken.substring(7) } else null }​ // 인증 실패 처리 메서드 private fun unsuccessfulAuthentication( request: HttpServletRequest, response: HttpServletResponse, failed: Exception ) { response.status = HttpServletResponse.SC\_UNAUTHORIZED // 401 상태 코드 설정 response.contentType = "application/json" response.writer.write("{\"error\": \"Unauthorized: ${failed.message}\"}") }}​

​