JWT토큰을 이용한 로그인 기능

2024. 6. 5. 12:01TIL

✔오늘 배운 중요한 🔑 point

  • Spring Security 의존성을 추가하게 되면 15개의 기본 필터들이 자동으로 적용이 되니 SecurityConfig에서 원치 않은 필터를 끌수 있다.
  • https://jwt.io/ 사이트를 통해서 내가 생성한 토큰에 정보가 잘 담겼는지 확인할 수 있다.
  • 모든 인증을 Spring Security를 통하여 처리하고 싶다면 Filter를 쓰면 되고, 그렇지 않다면 Controller를 사용하여 구현하면 된다.

🎯 오늘 배운 내용

 

인증 기본 설정

 

인증 관련 설정하기

implementation("org.springframework.boot:spring-boot-starter-security")

build.gradle.kts에 spring-security 의존성 추가

 

implementation("io.jsonwebtoken:jjwt-api:0.12.3")

JWT관련 스펙들을 대부분 지원하는 jjwt 의존성 추가

 

runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")

jjwt-impl: JWT를 생성,파싱,검증하는데 필요한 핵심 구현 코드를 제공

jjwt-jackson: JWT를 Jackson JSON 라이브러리와 함께 사용하기위한 추가 기능을 제공

 

설정을 마치고 실행을 시킨뒤 swagger로 확인을 하면

요런 로그인 창이 뜨게 된다.

Spring Securtiy 의존성을 추가했기때문에  Spring Security에서 기본적으로 제공하는 Filter들이 적용이 된 상황이다

[org.springframework.security.web.session.DisableEncodeUrlFilter@6bf27411, 
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@4a66949a, 
org.springframework.security.web.context.SecurityContextHolderFilter@2eef43f5, 
org.springframework.security.web.header.HeaderWriterFilter@6a2d0a19, 
org.springframework.security.web.csrf.CsrfFilter@324afa73, 
org.springframework.security.web.authentication.logout.LogoutFilter@6792aa3e, 
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@20673498, 
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@46c28400, 
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@57e83608, 
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@4b4b02d, 
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@6c65a7fc, 
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3b09582d, 
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@57d8d8e2, 
org.springframework.security.web.access.ExceptionTranslationFilter@4a4979bf, 
org.springframework.security.web.access.intercept.AuthorizationFilter@7a14d8a4]

 

SecurityContextHolderFilter: SecurityContext 객체를 생성하고 저장,조회  ★ ★ ★ ★ ★ ★

DefaultLoginPageGeneratingFilter: 로그인,로그아웃 페이지를 띄우는 필터!!!! <- 이 필터 때문에 로그인페이지가 나온 상황!!

AuthorizationFilter : 권한을 확인하는 필터

 

즉 이렇게 많은 필터들이 기본적으로 설정이 되다보니 swagger을 켰을때 로그인 페이지가 나오게 된것이다!

package org.example.spartatodolist.infra.security

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain

@Configuration
@EnableWebSecurity 
class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        return http
            .httpBasic{ it.disable() } 
            .formLogin { it.disable() }
            .csrf{ it.disable() }
            .build()
    }
}

 SecurityConfig에서 필요없는 필터부분을 제외시켜준다.

 

이제는 로그인창이 뜨지 않게 되었다.

 

JWT 토큰 생성,검증

 

검증

fun validateToken(jwt: String): Result<Jws<Claims>>{ // Result는 try catch 대신에 사용하는것, 코틀린에서 우아하게 exception을 처리할때 Result 객체를 반환함. 실제로 validateToken()을 사용하는 쪽에서 exception을 핸들링할수 있게끔 한다

    return kotlin.runCatching {
        val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))

        Jwts.parser().verifyWith(key).build().parseSignedClaims(jwt)
    }
}
Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))

JWT 서명을 검증하기 위해서 비밀 키를 생성하는 과정

secret 문자열을 UTF-8 바이트 배열로 변환한 후  Keys.hmacShakeyFor()를 사용해서 HMAC-SHA 알고리즘에 맞는 키 객체를 만든다!

 

Jwts.parser().verifyWith(key).build().parseSignedClaims(jwt)

Jwts.parser()로 토큰을 열기위한 도구 세팅! (파서)verifyWith(key)로  서명을 검증할 키를 설정하고 (키)build()로 최종적으로 토큰을 열기위한 도구 세팅 완료 ( JwtParser 객체 생성)parseSignedClaims(jwt)로 jwt문자열을 열어서 서명이 잘 되었는지 확인!

 

 

생성

private fun generateToken(subject: String,email:String,role:String, expirationPeriod: Duration): String{

    val claims: Claims = Jwts.claims()
        .add(mapOf("role" to role, "email" to email))
        .build()

    val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))
    val now= Instant.now()

    return Jwts.builder()
        .subject(subject)
        .issuer(issuer)
        .issuedAt(Date.from(now))
        .expiration(Date.from(now.plus(expirationPeriod)))
        .claims(claims)
        .signWith(key)
        .compact()
}
private fun generateToken(subject: String,email:String,role:String, expirationPeriod: Duration): String

subject: 토큰을 받을 주체!, 보통 클라이언트의 사용자 (subject는 반드시 문자열만 와야함)email: 다양한 식별자로써의 사용을 위한 이메일

role: 일반사용자인지,관리자인지 등등의 권한

expirationPeriod: 토큰의 유효기간을 나타내는 Duration 객체

return type String: 토큰은 문자열로 되어있으니 String

 

val claims: Claims = Jwts.claims()
    .add(mapOf("role" to role, "email" to email))
    .build()

Jwts.claims(): 다양한 정보를 담을수 있는 빈 클레임 객체 생성

.add( mapOf() ) : role(역할) 과 email(이메일)에 대한 정보를 클레임에 추가

.build() : 클레임 객체 생성완료

 

val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))
val now= Instant.now()

싸인할 키 생성하고 현재 시간을 나타내는 시간 설정

 

return Jwts.builder()
    .subject(subject)
    .issuer(issuer)
    .issuedAt(Date.from(now))
    .expiration(Date.from(now.plus(expirationPeriod)))
    .claims(claims)
    .signWith(key)
    .compact()

subject(토큰을 발급받는 주체가 누구인지),

issuer(토큰을 발행하는 주체가 누구인지) ,

issuedAt(토큰이 언제 발급되었는지),

expiration(토큰의 만료시간은 언제인지),

claims(어떤 정보가 있는지),

signWith(어떤 key로 토큰에 서명을 할건지),

compact() : 설정된 모든 정보를 기반으로 JWT토큰 생성하고 문자열로 반환!

 

fun generateAccessToken(subject: String,email:String, role:String): String{

    return generateToken(subject, email, role,Duration.ofHours(accessTokenExpirationHour))
}

결국 generateAccessToken을 통해 발급의 유효기간을 넣어줌으로써 완성

 

전체코드

package org.example.spartatodolist.infra.security.jwt

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.time.Instant
import java.util.*

@Component
class JwtPlugin (
    @Value("\${auth.jwt.issuer}") private val issuer:String,
    @Value("\${auth.jwt.secret}") private val secret:String,
    @Value("\${auth.jwt.accessTokenExpirationHour}") private val accessTokenExpirationHour:Long,
){

    fun validateToken(jwt: String): Result<Jws<Claims>>{ // Result는 try catch 대신에 사용하는것, 코틀린에서 우아하게 exception을 처리할때 Result 객체를 반환함. 실제로 validateToken()을 사용하는 쪽에서 exception을 핸들링할수 있게끔 한다

        return kotlin.runCatching {
            val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))

            Jwts.parser().verifyWith(key).build().parseSignedClaims(jwt)
        }
    }

    fun generateAccessToken(subject: String,email:String, role:String): String{

        return generateToken(subject, email, role,Duration.ofHours(accessTokenExpirationHour))
    }

    private fun generateToken(subject: String,email:String,role:String, expirationPeriod: Duration): String{

        val claims: Claims = Jwts.claims()
            .add(mapOf("role" to role, "email" to email))
            .build()

        val key = Keys.hmacShaKeyFor(secret.toByteArray(StandardCharsets.UTF_8))
        val now= Instant.now()

        return Jwts.builder()
            .subject(subject)
            .issuer(issuer)
            .issuedAt(Date.from(now))
            .expiration(Date.from(now.plus(expirationPeriod)))
            .claims(claims)
            .signWith(key)
            .compact()
    }
}

 

 

번외

companion object {
    const val SECRET= "SDFASDFSDSADsdf321gsdfg"
    const val ISSUER= "team.sparta.com"
    const val ACCESS_TOKEN_EXPIRATION_HOUR:Long =168
}

이 부분은 토큰의 만료시간을 바꾼다거나, 발행자가 바뀐다거나 할때마다 코드를 수정해야하는데,

이를 원치 않을경우에는 application.yml파일에 작성해도 된다.

auth:
  jwt:
    issuer: team.sparta.com
    secret: SDFASDFSDSADsdf321gsdfg
    accessTokenExpirationHour: 168

yml 파일에 작성해준 다음

 

@Component
class JwtPlugin (
    @Value("\${auth.jwt.issuer}") private val issuer:String,
    @Value("\${auth.jwt.secret}") private val secret:String,
    @Value("\${auth.jwt.accessTokenExpirationHour}") private val accessTokenExpirationHour:Long,
)

생성자에 @Value 어노테이션을 작성하면 된다

 

 

 

로그인 

 

비밀번호 암호화

Spring Security의 Filter 구현과 Controller,service에서의 구현 중 Controller,service구현 사용

@Configuration
class PasswordEncoderConfig { // 비밀번호 인코딩!

    @Bean
    fun passwordEncoder(): PasswordEncoder {
        return BCryptPasswordEncoder()
    }
}

passwordEncoder()를 Bean으로 등록한 뒤

class UserServiceImpl(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder
)

생성자에 추가하고

 

@Transactional
    override fun signUp(request: SignUpRequest): UserResponse {
        if(userRepository.existsByLoginId(request.loginId)){
            throw IllegalArgumentException("ID is already exists")
        }

        return userRepository.save(
            User(
                loginId=request.loginId,
                loginPassword=passwordEncoder.encode(request.loginPassword),
                profile= Profile(
                    loginNickname=request.loginNickname
                ),
                role=when(request.role){
                    UserRole.ADMIN.name -> UserRole.ADMIN
                    UserRole.NORMAL.name -> UserRole.NORMAL
                    else -> throw IllegalArgumentException("Invalid role")
                }
            )
        ).toResponse()
    }
loginPassword=passwordEncoder.encode(request.loginPassword)

적용하면 끝

 

로그인

Controller

class UserController(
    private val userService: UserService
) {

    @PostMapping("/login")
    fun login(@RequestBody loginRequest: LoginRequest): ResponseEntity<LoginResponse> {
        return ResponseEntity.status(HttpStatus.OK).body(userService.login(loginRequest))
    }
}

 

Service

override fun login(request: LoginRequest): LoginResponse {
    val user= userRepository.findByLoginId(request.loginId) ?: throw ModelNotFoundException("User",null)

    if(user.role.name != request.role || !passwordEncoder.matches(request.loginPassword,user.loginPassword)){
        throw InvalidCredentialException()
    }

    return LoginResponse(
        accessToken =jwtPlugin.generateAccessToken(
            subject=user.id.toString(),
            email = user.loginId,
            role= user.role.name
        )
    )
}

 

실행결과

 

로그인 성공시 토큰이 잘 생성된것을 볼수있고

 

해당 토큰이 정보를 잘 담고있는 것도 확인할 수 있다

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

 

🤔 어떻게 활용할까?

회원가입,로그인 기능을 활용해서 각 사용자에 각기 다른 권한을 부여하여 다른사람이 작성한 댓글이나,카드를 삭제하지 못하도록 제어할수 있게 되었다.

📓 오늘의 한줄

"All animals are equal, but some animals are more equal than others."

- George Orwell( Animal Farm: A Fairy Story )-

 

 

'TIL' 카테고리의 다른 글

인증, 인가 부분이 추가된 개인 프로젝트(1일차)  (0) 2024.06.07
인가  (0) 2024.06.06
중복된 부가기능을 커스텀 어노테이션으로 (AOP)  (0) 2024.06.04
팀 프로젝트 회고  (0) 2024.06.03
적합한 유효성 검사?  (0) 2024.06.02