[Spring] 서버개발하면서 아직 JWT를 모른다고?

양예성·2024년 7월 15일
16
post-thumbnail

Kotlin + SpringBoot

JWT

JWT(Json Web Token)란 JSON 포맷을 이용하여 사용자에 대한 속성을 저장하는 Claim 기반의 Web Token이다. 즉, 토큰의 한 종류라고 생각하면 된다.
일반적으로 쿠키 저장소를 사용하여 JWT를 저장한다.

특징

로그인 정보를 Server 에 저장하지 않고, Client 에 로그인 정보를 JWT 로 암호화하여 저장 → 모든 요청에 JWT를 헤더에 담아 보내 유효성 검증

기존 세션 방식에선 서버 데이터 베이스에 정보를 저장하여야 했지만 JWT방식에서 서버는 Secret 만 보유하고 있으면 됨.

장단점

장점

동시 접속자가 많을 때 서버 측 부하 낮춤 (기존 세션시엔 디비조회가 필수였지만 토큰 검증만 하면 되니)
Client, Sever 가 다른 도메인을 사용할 때
예) 카카오 OAuth2 로그인 시 JWT Token 사용
MSA 방식이 인기를 얻으며 덩달아 인기도가 높아짐

단점

구현의 복잡도 증가
JWT 에 담는 내용이 커질 수록 네트워크 비용 증가 (클라이언트 → 서버)
이미 생성된 JWT 를 일부만 만료시킬 방법이 없음 -> 블랙리스트 방식으로 해결이 가능하지만 이렇게 되면 유출된 토큰을 디비에 저장하여야 해서 세션에 비해 장점이 사라짐.
Secret key 유출 시 JWT 조작 가능
JWT 토큰이 유출시 다른사람이 사용가능 -> 그래서 Access/Refresh Token 개념이 등장.

Access/Refresh Token?

Access Token

AccessToken : 유저가 API 요청할때 사용됨
만료기간을 짧게 설정해 유출되더라도 공격자가 오래 사용 못하도록 설정

Refresh Token

RefreshToken : 만료된 AccessToken을 재발급하는데만 사용함
만료시간이 길고 어세스 토큰 발급할때만 사용하며 Body를 통하여 전달하여 유출위험이 낮음

JWT 구조

JWT 는 누구나 평문으로 복호화 가능
하지만 Secret Key 가 없으면 JWT 수정 불가능
→ 결국 JWT 는 Read only 데이터

이렇게 Hearder, Payload, Signature 3가지 요소로 구성됨
Payload에 실제 유저의 정보가 들어있고, HEADER와 VERIFY SIGNATURE부분은 암호화 관련된 정보 양식

Hearder, Payload가 같아도 Signature가 다르면 다른 토큰

JWT 인증 프로세스

사용자가 서버로 로그인을 하면 Access/RefreshToken 이 발급된다
그 토큰을 바탕으로 사용자는 어떤 행위(API요청)시 마다 토큰과 같이 요청을 하면
서버가 토큰을 바탕으로 사용자를 인증하고 알맞은 응답을 반환해준다.

코드로 구현해보자!

전체적인 흐름만 설명할 예정이라 자세한 내용은 깃허브 코드 참조 바랍니다.
[사용하실때 스타 한번식만 부탁드려요 ^^^^^]
(코드복사, 포크, 다운 모두 가능!)

gradle


dependencies {
    // JWT
    implementation("io.jsonwebtoken:jjwt-api:0.12.5")
    implementation("io.jsonwebtoken:jjwt-impl:0.12.5")
    implementation("io.jsonwebtoken:jjwt-jackson:0.12.5")
}

UserEntity

@Entity
class UserEntity (

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null, // ID (PK)

    @Column(nullable = false)
    val email: String, // Email

    @Column(nullable = false)
    val name: String,

    @Column(nullable = false)
    val password: String, // Password

    @Column(nullable = false)
    val role: UserRoles = UserRoles.ROLE_USER

)

일단 예시니 간단하게 id, userId, password를 포함하는 UserEntity를 만들었다.

User

data class User(
    val id: Long? = null,
    val email: String = "",
    val name: String = "",
    val password: String = "",
    val role: UserRoles = UserRoles.ROLE_USER
)

UserModel 도 만들어주고

UserRepository

interface UserRepository : JpaRepository<UserEntity, Long> {
    fun findByEmail(email: String): UserEntity?
    fun existsByEmail(email: String): Boolean
}

UserRepository도 만들어준다 (간단구현을 목적으로하여 JpaRepository를 사용하였다)

UserController

@RestController
@RequestMapping("/user")
class UserController(
    private val userService: UserService
) {
    @PostMapping("/register")
    fun registerUser(@RequestBody registerUserRequest: RegisterUserRequest): BaseResponse<Unit> {
        return userService.registerUser(registerUserRequest)
    }

    @PostMapping("/login")
    fun loginUser(@RequestBody loginRequest: LoginRequest): BaseResponse<JwtInfo> {
        return userService.loginUser(loginRequest)
    }

    @PostMapping("/refresh")
    fun refreshUser(@RequestBody refreshRequest: RefreshRequest): BaseResponse<String> {
        return userService.refreshToken(refreshRequest)
    }
}

회원가입 로그인 리프레쉬만 구현할 예정이라 컨트롤러를 이렇게 만들었다.

UserServiceImpl

@Service
class UserServiceImpl(
    private val userRepository: UserRepository,
    private val userMapper: UserMapper,
    private val bytePasswordEncoder: BCryptPasswordEncoder,
    private val jwtUtils: JwtUtils
) : UserService {

    @Transactional
    override fun registerUser(registerUserRequest: RegisterUserRequest): BaseResponse<Unit> {

        if(userRepository.existsByEmail(registerUserRequest.email)) throw CustomException(UserErrorCode.USER_ALREADY_EXIST)

        userRepository.save(
            userMapper.toEntity(
                userMapper.toDomain(registerUserRequest, bytePasswordEncoder.encode(registerUserRequest.password.trim()))
            )
        )

        return BaseResponse(
            message = "회원가입 성공"
        )

    }

    @Transactional(readOnly = true)
    override fun loginUser(loginRequest: LoginRequest): BaseResponse<JwtInfo> {

        val user = userRepository.findByEmail(loginRequest.email)?: throw CustomException(UserErrorCode.USER_NOT_FOUND)

        if (bytePasswordEncoder.matches(user.password, loginRequest.password)) throw CustomException(UserErrorCode.USER_NOT_MATCH)

        return BaseResponse(
            message = "로그인 성공",
            data = jwtUtils.generate(
                user = userMapper.toDomain(user)
            )
        )

    }

    @Transactional(readOnly = true)
    override fun refreshToken(refreshRequest: RefreshRequest): BaseResponse<String> {
        val token = jwtUtils.getToken(refreshRequest.refreshToken)

        if (jwtUtils.checkTokenInfo(token) == JwtErrorType.ExpiredJwtException) {
            throw CustomException(JwtErrorCode.JWT_TOKEN_EXPIRED)
        }

        val user = userRepository.findByEmail(
            jwtUtils.getUsername(token)
        )

        return BaseResponse (
            message = "리프레시 성공 !",
            data = jwtUtils.refreshToken(
                user = userMapper.toDomain(user!!)
            )
        )
    }
}

서비스에서도 딱히 설명할 코드가 없다.

회원가입시 이미 이메일이 존재하는지 확인하고 있으면 오류 없으면 회원가입 완료시킨다.
로그인시엔 이메일을 바탕으로 데이터를 찾고 비밀번호를 비교한 뒤 비밀번호가 맞으면 JWT반환 안맞을시 오류를 반환한다.
리프레쉬 요청시 토큰을 검증하고 옮다면 새 AccessToken을 반환한다.

SecurityConfig

@Configuration
@EnableWebSecurity
class SecurityConfig (
    private val jwtUtils: JwtUtils,
    private val objectMapper: ObjectMapper
) {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .cors {
                corsConfigurationSource()
            }

            .csrf {
                it.disable()
            }

            .formLogin {
                it.disable()
            }

            .sessionManagement { session ->
                session.sessionCreationPolicy(
                    SessionCreationPolicy.STATELESS
                )
            }

            .authorizeHttpRequests {
                it
                    .requestMatchers("/user/**",).permitAll()
                    .anyRequest().authenticated()
            }

            .addFilterBefore(JwtAuthenticationFilter(jwtUtils, objectMapper), UsernamePasswordAuthenticationFilter::class.java)

            .exceptionHandling {
                it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.NOT_FOUND))
            }

            .build()
    }

    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource {
        val corsConfiguration = CorsConfiguration()
        corsConfiguration.addAllowedOriginPattern("*")
        corsConfiguration.addAllowedHeader("*")
        corsConfiguration.addAllowedMethod("*")
        corsConfiguration.allowCredentials = true

        val urlBasedCorsConfigurationSource = UrlBasedCorsConfigurationSource()
        urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration)

        return urlBasedCorsConfigurationSource
    }

}

cors를 방지하기 위하여 corsConfigurationSource를 통하여 cors를 해결하였고, csrf, Security formLogin (기본로그인), sessionManagement를 모두 disable 하였다.

authorizeHttpRequests에서 리퀘스트시 어느 부분에서 권한체크를 할지 어느부분에서 권한체크를 풀지 결정할 수 있다.

그리고 addFilterBefore를 통하여 요청시 앞에 JwtAuthenticationFilter로 권한체크를 진행한다.

JwtAuthenticationFilter

class JwtAuthenticationFilter(
    private val jwtUtils: JwtUtils,
    private val objectMapper: ObjectMapper
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val token: String? = request.getHeader("Authorization")
        val path: String = request.servletPath

        if (path.startsWith("/user/")
        ) {
            filterChain.doFilter(request, response)
            return
        }

        if (token.isNullOrEmpty() || !token.startsWith("Bearer ")) {
            setErrorResponse(response, JwtErrorCode.JWT_EMPTY_EXCEPTION)
        } else {
            when (jwtUtils.checkTokenInfo(jwtUtils.getToken(token))) {
                JwtErrorType.OK -> {
                    SecurityContextHolder.getContext().authentication = jwtUtils.getAuthentication(token)
                    doFilter(request, response, filterChain)
                }

                JwtErrorType.ExpiredJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_EXPIRED)
                JwtErrorType.SignatureException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR)
                JwtErrorType.MalformedJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_ERROR)
                JwtErrorType.UnsupportedJwtException -> setErrorResponse(
                    response,
                    JwtErrorCode.JWT_TOKEN_UNSUPPORTED_ERROR
                )

                JwtErrorType.IllegalArgumentException -> setErrorResponse(
                    response,
                    JwtErrorCode.JWT_TOKEN_ILL_EXCEPTION
                )

                JwtErrorType.UNKNOWN_EXCEPTION -> setErrorResponse(response, JwtErrorCode.JWT_UNKNOWN_EXCEPTION)
            }
        }
    }

    private fun setErrorResponse(
        response: HttpServletResponse,
        errorCode: JwtErrorCode
    ) {
        response.status = errorCode.status.value()
        response.contentType = "application/json;charset=UTF-8"

        response.writer.write(
            objectMapper.writeValueAsString(
                BaseResponse<String>(
                    status = errorCode.status.value(),
                    state = errorCode.state,
                    message = errorCode.message
                )
            )
        )
    }
}

request에서 해더를 가져와 분석한다. 만약 요청주소가 앞에서 권한인증이 필요없는 부분은

if (path.startsWith("/user/")
        ) {
            filterChain.doFilter(request, response)
            return
        }

이렇게 해서 아래 코드로 넘어가지 않도록 만들어주자.

if (token.isNullOrEmpty() || !token.startsWith("Bearer ")) {
            setErrorResponse(response, JwtErrorCode.JWT_EMPTY_EXCEPTION)
        } else {
            when (jwtUtils.checkTokenInfo(jwtUtils.getToken(token))) {
                JwtErrorType.OK -> {
                    SecurityContextHolder.getContext().authentication = jwtUtils.getAuthentication(token)
                    doFilter(request, response, filterChain)
                }

                JwtErrorType.ExpiredJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_EXPIRED)
                JwtErrorType.SignatureException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_SIGNATURE_ERROR)
                JwtErrorType.MalformedJwtException -> setErrorResponse(response, JwtErrorCode.JWT_TOKEN_ERROR)
                JwtErrorType.UnsupportedJwtException -> setErrorResponse(
                    response,
                    JwtErrorCode.JWT_TOKEN_UNSUPPORTED_ERROR
                )

                JwtErrorType.IllegalArgumentException -> setErrorResponse(
                    response,
                    JwtErrorCode.JWT_TOKEN_ILL_EXCEPTION
                )

                JwtErrorType.UNKNOWN_EXCEPTION -> setErrorResponse(response, JwtErrorCode.JWT_UNKNOWN_EXCEPTION)
            }
        }
    }
}

토큰이 nullOrEmpty 이거나 Bearer로 시작하지 않으면 오류 반환
jwtUtils을 통하여 또 오류가 발생하면 오류를 반환한다
오류는

private fun setErrorResponse(
        response: HttpServletResponse,
        errorCode: JwtErrorCode
    ) {
        response.status = errorCode.status.value()
        response.contentType = "application/json;charset=UTF-8"

        response.writer.write(
            objectMapper.writeValueAsString(
                BaseResponse<String>(
                    status = errorCode.status.value(),
                    state = errorCode.state,
                    message = errorCode.message
                )
            )
        )
    }

을 통해서 설정해 클라이언트한테 json으로 반환한다.

jwt는 CustomException을 통하여 클라이언트한테 값이 전달이 안된다.
다음과 같이 직접 반환값을 만들어 오류를 반환해줘야 클라이언트한테 값이 전달된다.

JwtUtils

@Component
class JwtUtils(
    private val jwtProperties: JwtProperties,
    private val userDetailsService: UserDetailsService
) {

    private val secretKey: SecretKey = SecretKeySpec(
        this.jwtProperties.secretKey.toByteArray(StandardCharsets.UTF_8),
        Jwts.SIG.HS256.key().build().algorithm
    )

    fun getUsername(token: String): String {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).payload.get(
            "email",
            String::class.java
        )
    }

    fun checkTokenInfo(token: String): JwtErrorType {
        try {
            Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token)
            return JwtErrorType.OK
        } catch (e: ExpiredJwtException) {
            return JwtErrorType.ExpiredJwtException
        } catch (e: SignatureException) {
            return JwtErrorType.SignatureException
        } catch (e: MalformedJwtException) {
            return JwtErrorType.MalformedJwtException
        } catch (e: UnsupportedJwtException) {
            return JwtErrorType.UnsupportedJwtException
        } catch (e: IllegalArgumentException) {
            return JwtErrorType.IllegalArgumentException
        } catch (e: Exception) {
            return JwtErrorType.UNKNOWN_EXCEPTION
        }
    }

    fun getToken(token: String): String {
        return token.removePrefix("Bearer ")
    }

    fun generate(member: Member): JwtInfo {
        val accessToken = createToken(
            member = member,
            tokenExpired = jwtProperties.accessExpired
        )
        val refreshToken = createToken(
            member = member,
            tokenExpired = jwtProperties.refreshExpired
        )


        return JwtInfo("Bearer $accessToken", "Bearer $refreshToken")
    }

    fun getAuthentication(token: String): Authentication {
        val userDetails: UserDetails = userDetailsService.loadUserByUsername(getUsername(getToken(token)))
        return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
    }

    fun refreshToken(member: Member): String {
        return "Bearer " + createToken(
            member = member,
            tokenExpired = jwtProperties.accessExpired
        )
    }

    private fun createToken(member: Member, tokenExpired: Long): String {
        val now: Long = Date().time
        return Jwts.builder()
            .claim("id", member.id?.value)
            .claim("email", member.email.value)
            .claim("role", member.role.value)
            .issuedAt(Date(now))
            .expiration(Date(now + tokenExpired))
            .signWith(secretKey)
            .compact()
    }

}

코드를 아래서에서 하나씩 설명하자면

getUsername

토큰을 바탕으로 유저 이메일을 가져온다.

fun getUsername(token: String): String {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).payload.get(
            "email",
            String::class.java
        )
    }

checkTokenInfo

토큰을 바탕으로 토큰 상태를 판단하고 에러코드를 반환한다.

fun checkTokenInfo(token: String): JwtErrorType {
        try {
            Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token)
            return JwtErrorType.OK
        } catch (e: ExpiredJwtException) {
            return JwtErrorType.ExpiredJwtException
        } catch (e: SignatureException) {
            return JwtErrorType.SignatureException
        } catch (e: MalformedJwtException) {
            return JwtErrorType.MalformedJwtException
        } catch (e: UnsupportedJwtException) {
            return JwtErrorType.UnsupportedJwtException
        } catch (e: IllegalArgumentException) {
            return JwtErrorType.IllegalArgumentException
        } catch (e: Exception) {
            return JwtErrorType.UNKNOWN_EXCEPTION
        }
    }

getToken

토큰 앞단에 Bearer 를 제거한 문자열을 반환한다.

fun getToken(token: String): String {
        return token.removePrefix("Bearer ")
    }

generate

맴버 정보를 바탕으로 AccessToken과 RefreshToken을 발급한다.

fun generate(member: Member): JwtInfo {
        val accessToken = createToken(
            member = member,
            tokenExpired = jwtProperties.accessExpired
        )
        val refreshToken = createToken(
            member = member,
            tokenExpired = jwtProperties.refreshExpired
        )


        return JwtInfo("Bearer $accessToken", "Bearer $refreshToken")
    }

getAuthentication

토큰을 가지고 사용자의 이름을 찾고, 사용자 세부 정보를 로드하여 인증 토큰을 생성하고 반환하는 역할한다.

    fun getAuthentication(token: String): Authentication {
        val userDetails: UserDetails = userDetailsService.loadUserByUsername(getUsername(getToken(token)))
        return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
    }

refreshToken

토큰을 바탕으로 리프레쉬 토큰을 생성한다.

fun refreshToken(member: Member): String {
        return "Bearer " + createToken(
            member = member,
            tokenExpired = jwtProperties.accessExpired
        )
    }

createToken

토큰을 생성하는 메서드다

 private fun createToken(member: Member, tokenExpired: Long): String {
        val now: Long = Date().time
        return Jwts.builder()
            .claim("id", member.id?.value)
            .claim("email", member.email.value)
            .claim("role", member.role.value)
            .issuedAt(Date(now))
            .expiration(Date(now + tokenExpired))
            .signWith(secretKey)
            .compact()
    }

JwtUserDetails

UserDetails를 상속받아 아래서 재정의를 한다.

class JwtUserDetails(
    val user: User
) : UserDetails {

    val id: Long? = user.id

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        val authorities: MutableCollection<GrantedAuthority> = ArrayList()

        authorities.add(SimpleGrantedAuthority(user.role.name))

        return authorities
    }

    override fun getPassword(): String {
        return user.password
    }

    override fun getUsername(): String {
        return user.name
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }

    override fun isEnabled(): Boolean {
        return true
    }

GetAuthenticatedId

이건 내가 만든 커스텀 어노테이션인데 컨트롤러에서 사용시 요청한 유저 id를 반환하도록 만들었다

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? -1L : #this.id")
// if this == anonymousUser return -1 else return userId
annotation class GetAuthenticatedId

사용예제

PK = 1인 유저가 헤더에 JWT를 넣고 요청하면

@RestController
class TestController {
    @PostMapping("/")
    fun test(
        @GetAuthenticatedId userId: Long
    ){
        println("======================")
        println("userId = $userId")
        println("======================")
    }
}

======================

userId = 1

======================

가 출력된다.

실행 테스트해보기

회원가입

로그인

리프레쉬

토큰이 만료됬을경우

이상한 토큰인 경우

빈 토큰으로 요청시

이제 당신도 JWT하는 서버개발자라고요!!

자 이제 어디가서 나 회원가입/로그인 할 수 있다 하고 다니자고요!!!!!!!
장난이고 더 공부하세요

Template을 58000% 활용하는 방법!
https://velog.io/@yeseong0412/바퀴를-다시-만들-필요가-있나요

2개의 댓글

comment-user-thumbnail
2024년 7월 18일

실습까지 정리해주셔서 이해하기 쉽네요. 글 잘 보고 갑니다!!

1개의 답글