Spring Securty에 JWT 완벽 적용하기!
Spring Securty에 JWT 완벽 적용하기! — #자바스프링 #Javaspring #JWT #JWT적용 #개발자의도구들 🚨📝 📖 📒✏️💡🔍 AI스쿨...
#자바스프링 #Javaspring #JWT #JWT적용 #개발자의도구들
🚨📝 📖 📒✏️💡🔍
AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다
\* 현재 Spring 프로젝트를 진행 중입니다. 전체적인 목차를 보시려면 여기를 눌러주세요.
Intro
JWT 적용에 생각보다 애를 많이 먹었습니다. 내부 클래스 & 인터페이스와 연동하여 커스텀한 class를 만들다 보니 헷갈리는게 너무나도 많았습니다.
하지만 이번글을 통해서 누구나 명료하고 투명하게 로직과정을 이해할 수 있도록 최선을 다해 글을 작성해보겠습니다.
목차
- JWT Structure
- Why we use JWT?
- JWT Flow
- 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를 사용하는 가장 큰 이유는 편리함입니다. 공부할 때 빠르고 쉽게 사용하기 좋아서, 인증/인가 시스템을 간단하게 만들기에 좋습니다.
하지만, 실제 필드에서는 크게 사용되지 않는다고 합니다. 이유는 아래에 있습니다.
단점
- JWT를 정보를 탈취하여 사용자 정보를 취득할 수 있다.
- JWT 만료 시간이 되기전에 사용자가 PW를 변경해도 바로 적용되지 않는다.
위 문제를 해결하기 위해 만료시간을 짧게 설정하고, 매 요청마다 재 발급하는 방식이 존재하는데, 이는 복잡하므로 기회가 된다면 다음에 공부해서 정리해 보겠습니다.
JWT Flow
- 클라이언트가 로그인을 시도한다.(로그인 시작 시점)
- 서버는 클라이언트 정보를 확인후 인증이 완료되면 클라이언트에게 JWT를 발급한다.(HTTP 헤더에 넣어서 보낸다) (로그인 검증 시점)
- 클라이언트는 JWT를 쿠키형태로 저장해둔다.(로그인 완료시점)
- 로그인이 완료된 클라이언트는 서버에게 특정 리소스를 요청하기 위해 JWT를 HTTP 요청에 넣어 보낸다.
- 서버는 JWT를 해독하여 인증된 사용자인지 확인한다.
- 이 과정에서 DB에서 데이터를 읽어오는 가정이 생략된다. (ContextHolder에서 바로 검증) - JWT에는 유저 정보가 들어잇다.
- 인증이 무결하면, 서버는 요청 리소스를 클라이언트에게 보낸다.
JWT in Spring Security
Sign-In flow
Spring Security / Jwt + Spring Security
위 그림 한정에 JWT를 통해 수행되는 로그인 과정을 명확하게 이해할 수 있습니다. JWT도 기본적으로는 Spring Security에서 배웟던 UsernamePasswordFilter를 통해 인증을 진행할 수 있습니다. 하지만 이전과의 차이점은 인증이후 JWT 발급에있습니다.
프로그래밍 관점에서 flow를 다시 정리해보죠,
- Username Password Authentication Filter는 사용자의 요청을 가로채어 사용자 인증을 시작한다.
- 인증 과정은 다음과 같다. Filter -> Manger -> Provider -> DetailsService -> Provider -> Manger -> Filter -> SecurityHolder
- 최종적으로 SecurityHolder에 Authentication이 등록 된 이후, JWT토큰을 client에게 발급한다.
- 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.configimport 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를 정리해 봅시다.
- 클라이언트는 로그인 후 JWT를 헤더에 넣어 서버로 보낸다.
- 서버의 JwtAuthentcaionFilter는 getTokenFromRequest() 메서드를 통해 JWT 토큰을 추출한다.
- 추출된 JWT토큰은 JwtAuthenticaionProvider에 전송된다.
- JwtAuthenticaionProvider는 Jwt로부터 username을 추출한다.
- JwtAuthentcaionProvider는 UsernameService를 통해 UserDetails를 추출하여 다시 JwtAuthentcaionFilter로 전송한다.
- JwtAuthentcationFilter는 제공받은 UserDetails를 바탕으로 UsernamePasswordAuthentcationToken을 생성한다.
- 생성된 UsernamePasswordAuthentcationToken은 SecurityContextHolder에 등록된다. 이때, token의 details한 정보를 request로 부터 가져온다. (IP 주소와 같은 것 들)
- 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}\"}") }} |
|---|






