개발/Java & Kotlin

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

devhooney 2025. 1. 16. 10:37
728x90

코틀린 스프링부트로 JWT구현해봤다.

 

제목은 어그로 ㅎㅎ GPT가 만들어준..

 

 

 

1. 코틀린으로 프로젝트 생성한다.

라이브러리는

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-logging")


    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // JWT
    implementation("io.jsonwebtoken:jjwt-api:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")

    // DB
    runtimeOnly("org.mariadb.jdbc:mariadb-java-client")

    // Test
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

 

 

이렇게 사용했다.

 

2. User 생성, 관련 서비스도

@Entity
class User (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(unique = true)
    val email: String,
    val password: String,
    val name: String,
    @Column(name = "ip_address")
    val ipAddress: String
): BaseEntity()

 

 

BaseEntity는

@MappedSuperclass
class BaseEntity (
    @Column(name = "create_date", nullable = false, updatable = false)
    val createDate: Date = Date(),

    @Column(name = "update_date")
    var updateDate: Date = Date(),
) {
    @PrePersist
    fun prePersist() {
        updateDate = createDate // createDate와 updateDate를 동일하게 설정
    }

    @PreUpdate
    fun preUpdate() {
        updateDate = Date() // update 시 updateDate만 최신화
    }
}

 

이렇게 구현

 

 

리포지토리는

@Repository
interface UserRepository : JpaRepository<User, Long> {
    fun findByEmail(email: String): User?
}

 

이렇게 구현

코틀린은 기본적으로 nullsafe이기 때문에 ?로 Optional 처리를 해주면 코드작성 시 편하다.

 

 

서비스는 로그인, 회원가입까지 구현

@Service
class UserService(
    private val passwordEncoder: PasswordEncoder,
    private val userRepository: UserRepository
) {
    fun signin(reqAuthDto: ReqAuthDto): User {
        val user: User = userRepository.findByEmail(reqAuthDto.email) ?: throw InvalidCredentialsException("아이디나 비밀번호를 확인해주세요.")

        // 비밀번호 비교
        if (!passwordEncoder.matches(reqAuthDto.password, user.password)) {
            throw InvalidCredentialsException("아이디나 비밀번호를 확인해주세요.")
        }

        return user
    }

    fun signup(reqAuthDto: ReqAuthDto): User {
        val email = reqAuthDto.email
        val encodedPassword = passwordEncoder.encode(reqAuthDto.password)
        val name: String = reqAuthDto.name!!
        val ipAddress = reqAuthDto.ipAddress!!
        val user = User(email = email, password = encodedPassword, name = name, ipAddress = ipAddress)
        return userRepository.save(user)
    }

}

 

 

컨트롤러는

@RestController
@RequestMapping("/auth")
class AuthController(
    val userService: UserService,
    val tokenService: TokenService,
    val jwtUtil: JwtUtil,
    val ipAddressUtil: IpAddressUtil
) {
    private val log = LoggerFactory.getLogger(this.javaClass)


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

            // 하나의 메시지만 반환
            log.error(errorMessage)
            throw InvalidCredentialsException(errorMessage)
        }

        val usery = userService.signin(reqAuthDto)
        val token = jwtUtil.generateToken(user.email)
        val refreshToken = jwtUtil.generateRefreshToken(user.email)
        tokenService.tokenSave(refreshToken, user.id!!, ipAddressUtil.getClientIp(request))


        val resAuthDto = ResAuthDto(email = user.email, message = "로그인 성공!", token = token)
        return ResponseEntity.ok(resAuthDto)
    }

    @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)
    }
}

 

이렇게 구현했는데

validation, customException, log가 포함되어 있다.

토큰은 access, refresh 두가지가 있다.

refresh는 DB에 저장한다.

 

 

3. Token 모델 생성, 서비스도

@Entity
data class Token (
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
    @Column(nullable = false, unique = true)
    val refreshToken: String,
    @Column(nullable = false, unique = true)
    val userId: Long,
    @Column(name = "ip_address")
    val ipAddress: String
): BaseEntity() {
    fun updateId(oldToken: TokenEntity) {
        this.id = oldToken.id
    }
}

 

 

리파지토리

@Repository
interface TokenRepository: JpaRepository<TokenEntity, Long> {
    fun findByUserId(userId: Long): TokenEntity?
}

 

 

서비스

@Service
class TokenService(
    private val tokenRepository: TokenRepository
) {
    fun tokenSave(refreshToken: String, userId: Long, ipAddress: String) {
        val oldToken = tokenRepository.findByUserId(userId)
        val token = Token(refreshToken = refreshToken, userId = userId, ipAddress = ipAddress)
        if (oldToken != null) {
            token.updateId(oldToken = oldToken)
        }
        tokenRepository.save(token)
    }
}

 

 

 

4. 시큐리티 설정

@Configuration
@EnableWebSecurity
class SecurityConfiguration {

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf { it.disable() }
            .authorizeHttpRequests { authorize ->
                authorize
                    .requestMatchers("/public/**", "/auth/**", "/", "/api/**").permitAll()
                    .anyRequest().authenticated()
            }
            .sessionManagement { session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            }
            .logout { logout ->
                logout
                    .logoutRequestMatcher(AntPathRequestMatcher("/auth/logout"))
                    .logoutSuccessUrl("/")
                    .invalidateHttpSession(true)
                    .deleteCookies("JSESSIONID")
            }
            .exceptionHandling { exception ->
                exception
                    .accessDeniedPage("/error/403")
            }

        return http.build()
    }
}

 

 

시큐리티는 최신버전을 사용했다.

너무 바뀌었고, 코틀린으로 적용하다보니 헤맸다.

이 프로젝트에서는 jwt로 검증을 하기 때문에 url에 제한을 두지 않았다.

permitAll()로 처리했다.

세션방식이 아니기 때문에 세션정책도 STATELESS로 설정

로그아웃은 구현 안했지만, 기본값으로넣었두었다. 구현 시 수정 예정

 

 

 

728x90

 

 

 

5. CustomException 구현

@RestControllerAdvice
class GlobalExceptionHandler {
    // 커스텀 예외 처리 (예: Not Found)
    @ExceptionHandler(NotFoundException::class)
    fun handleCustomNotFoundException(ex: NotFoundException): ResponseEntity<ResException> {
        val resException = ResException(
            status = HttpStatus.NOT_FOUND.value(),
            message = ex.message ?: "찾을 수 없습니다."
        )
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(resException)
    }

    // 다른 커스텀 예외 처리 (예: Invalid Credentials)
    @ExceptionHandler(InvalidCredentialsException::class)
    fun handleInvalidCredentialsException(ex: InvalidCredentialsException): ResponseEntity<ResException> {
        val resException = ResException(
            status = HttpStatus.UNAUTHORIZED.value(),
            message = ex.message ?: "권한이 없습니다."
        )
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(resException)
    }

    // 기타 예외 처리
    @ExceptionHandler(Exception::class)
    fun handleGeneralException(ex: Exception): ResponseEntity<ResException> {
        val resException = ResException(
            status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
            message = ex.message ?: "서버에 문제가 발생했습니다."
        )
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(resException)
    }
}

 

 

일단 3가지로 구현했고, 기본 예외는 3번째 핸들러를 활용하면 좋을듯 하다.

RestAPI를 생각했기 때문에 RestControllerAdvice를 사용했다.

ControllerAdvice와 차이점은

특성 @ControllerAdvice @RestControllerAdvice
기본 응답 형태 뷰 이름 또는 HTTP 응답 본문 항상 HTTP 응답 본문
적용 대상 Spring MVC 컨트롤러 (@Controller) REST 컨트롤러 (@RestController)
@ResponseBody 필요 여부 명시적으로 추가해야 JSON 반환 가능 자동으로 JSON 응답 처리
주요 사용 사례 웹 애플리케이션의 HTML 또는 JSON 처리 REST API 개발 및 JSON/XML 응답 처리

 

 

각각 Exception구현

class InvalidCredentialsException (message: String) : RuntimeException(message)

class NotFoundException (message: String) : RuntimeException(message)

 

 

화면에 보내줄 공통 ResponseDto 추가

data class ResException(val timestamp: LocalDateTime = LocalDateTime.now(),
                        val status: Int,
                        val message: String)

 

 

 

 

 

 

 

생각보다 길어져서

나머지를 다음에 이어서 작성하려한다.

 

 

728x90