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
'개발 > Java & Kotlin' 카테고리의 다른 글
[Kotlin] 코틀린 + 스프링부트로 단숨에 완성하는 초강력 JWT 인증 시스템!💡 보안과 성능을 모두 잡아라!🔥(2) (38) | 2025.01.20 |
---|---|
[Spring] 동기, 비동기 차이 (70) | 2024.12.31 |
[Spring] Annotation 알아보기 (83) | 2024.12.25 |
[Java] 동일성과 동등성 알아보기 (85) | 2024.12.22 |
[Java] equals와 hashCode 알아보기 (82) | 2024.12.19 |