개발/Java & Kotlin

[Kotlin] 코틀린 + 스프링부트로 단숨에 완성하는 초강력 JWT 인증 시스템!💡 보안과 성능을 모두 잡아라!🔥(2)

devhooney 2025. 1. 20. 10:26
728x90

지난번에 예외발생 DTO를 생성까지 작성했다.

 

[Kotlin] 코틀린 + 스프링부트로 단숨에 완성하는 초강력 JWT 인증 시스템!💡 보안과 성능을 모두 잡아라!🔥

 

 

1. validation 추가

컨트롤러를 다시 보면 @RequestBody 옆에 @validated가 있었다. 이는 필요한 값이 안왔을 경우 예외를 발생시킨다.

    @PostMapping("/signup")
    fun signup(
        request: HttpServletRequest,
        @RequestBody @Validated(SignUpValidation::class) reqAuthDto: ReqAuthDto,
        result: BindingResult
    ): ResponseEntity<ResAuthDto> {
        if (result.hasErrors()) {
            // 필드 오류 메시지만 가져오기
            val errorMessage = result.fieldErrors
                .joinToString(", ") { it.defaultMessage ?: "Unknown error" }

            // 하나의 메시지만 반환
            log.error(errorMessage)
            throw InvalidCredentialsException(errorMessage)
        }
        // ip 가져오기
        val ipAddress = ipAddressUtil.getClientIp(request)
        reqAuthDto.ipAddress = ipAddress

        val user = userService.signup(reqAuthDto)
        val resAuthDto = ResAuthDto(email = user.email, message = "회원가입 성공!", token = "")
        return ResponseEntity.ok(resAuthDto)
    }
}

 

여기서는 reqAuthDto에 걸려있는데,

 

data class ReqAuthDto (
    @field:NotEmpty(message = "이메일을 입력해주세요.")
    val email: String,
    @field:NotEmpty(message = "비밀번호를 입력해주세요.")
    val password: String,
    @field:NotEmpty(message = "이름을 입력해주세요.", groups = [SignUpValidation::class])
    val name: String?,
    var ipAddress: String?
)

 

회원가입 시 이메일, 비밀번호, 이름을 필수로 받는다. 하지만 로그인 할 때도 ReqAuthDto를 사용하는데 이때는 이름은 필요가 없어서 SignUpValidation을 만들었다.

 

interface SignUpValidation : Default

 

 

 

 

728x90

 

 

2. 토큰 필터 만들기

가입 후 로그인까지 했다면, 요청이 있을 때 마다 검사를 해줘야한다. 그래야 회원인지, 로그인을 했는지를 파악이 가능하기 때문에.

@Component
class JwtAuthenticationFilter(
    private val jwtUtil: JwtUtil,
): OncePerRequestFilter() {

    private val whiteList = listOf(
        "/auth/**",
        "/"
    )

    private fun isWhiteListed(request: HttpServletRequest): Boolean {
        val requestURI = request.requestURI
        // 화이트리스트 URL 패턴 확인
        return whiteList.any { pattern ->
            AntPathMatcher().match(pattern, requestURI)
        }
    }

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        if (!isWhiteListed(request)) {
            val token = request.getHeader("Authorization")?.removePrefix("Bearer ")
            if (!token.isNullOrEmpty() && jwtUtil.validateToken(token)) {
                    println("토큰 검증!")
            } else {
                // 액세스 토큰이 만료된 경우, 리프레시 토큰을 사용하여 액세스 토큰을 재발급
                val refreshToken = request.getHeader("Refresh-Token")
                if (!refreshToken.isNullOrEmpty() && jwtUtil.validateRefreshToken(refreshToken)) {
                    println("액세스토큰 재발급!")
                    val newToken = jwtUtil.generateToken(refreshToken)
                    response.setHeader("Authorization", "Bearer $newToken")
                } else {
                    println("토큰 만료!")
                    response.status = HttpServletResponse.SC_UNAUTHORIZED
                    response.writer.write("Refresh token is required")
                    return
                }
            }
        }
        filterChain.doFilter(request, response)
    }


}

 

코드를 보면 whiteList를 만들어 필터를 타지 않도록 했다. jwtUtil에 토큰을 만들어주고, 검사하는 로직을 넣어두었다.

 

@Component
class JwtUtil(
    @Value("\${jwt.secret-key}") private val secretKey: String,
    @Value("\${jwt.expiration}") private val expiration: Long,
    @Value("\${jwt.refresh-secret-key}") private val refreshSecretKey: String,
    @Value("\${jwt.refresh-expiration}") private val refreshExpiration: Long
) {
    val key: SecretKey = Keys.hmacShaKeyFor(secretKey.toByteArray(StandardCharsets.UTF_8))
    val refreshKey: SecretKey = Keys.hmacShaKeyFor(refreshSecretKey.toByteArray(StandardCharsets.UTF_8))

    // 한국 시간대에 맞는 Date 객체를 생성하는 함수

    fun generateToken(email: String): String {
        val claims = mapOf("email" to email)
        return Jwts.builder()
            .claims(claims)
            .issuedAt(Date())
            .expiration(Date(System.currentTimeMillis() + expiration * 1000))
            .signWith(key)
            .compact()
    }

    fun generateRefreshToken(email: String): String {
        val claims = mapOf("email" to email)
        return Jwts.builder()
            .claims(claims)
            .issuedAt(Date())
            .expiration(Date(System.currentTimeMillis() + refreshExpiration * 1000))
            .signWith(refreshKey)
            .compact()
    }

    fun validateToken(token: String): Boolean {
        return try {
            val claims = getClaims(token, key)
            !claims.expiration.before(Date())
        } catch (e: Exception) {
            false
        }
    }

    fun validateRefreshToken(token: String): Boolean {
        return try {
            val claims = getClaims(token, refreshKey)
            !claims.expiration.before(Date())
        } catch (e: Exception) {
            false
        }
    }

    fun getClaims(token: String, key: SecretKey): Claims {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .payload
    }
}

 

key는 application.yml에 넣어두었다.

 

 

 

3. 로그 작성

로그작성은 애플리케이션을 운영하는데 필수요소이다. 로그설정도 해보면,

application.yml에

logging:
  level:
    root: INFO
    com.example.kotlinboilerplate: DEBUG
  pattern:
    console: '%d{yyyy-MM-dd HH:mm:ss} | %5p | %c{1} | %m%n'
    file: '%d{yyyy-MM-dd HH:mm:ss} | %5p | %c{1} | %m%n'
  file:
    name: logs/app.log
  logback:
    rollingpolicy:
      max-file-size: 10MB
      max-history: 30

 

작성하고 로그가 필요한 컨트롤러나 서비스 등에

  private val log = LoggerFactory.getLogger(this.javaClass)
  
 log.error(errorMessage)

 

이런식으로 해두면, 커맨드창에 로그가 남겨지고

    console: '%d{yyyy-MM-dd HH:mm:ss} | %5p | %c{1} | %m%n'
    file: '%d{yyyy-MM-dd HH:mm:ss} | %5p | %c{1} | %m%n'

파일에도 같은 양식으로 로그가 저장된다.

 

 

 

나눠서 작성했더니 누락된 것이 있을 수도 있는데,

따라해보고 문제가 있을 경우 알려주시면 수정하겠습니다 ㅎㅎ

 

 

728x90