Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(./gradlew compileKotlin:*)",
"Bash(git mv:*)"
],
"deny": [],
"ask": []
}
}
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0")
implementation("org.springframework.boot:spring-boot-starter-quartz")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("io.jsonwebtoken:jjwt-api:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
runtimeOnly("com.mysql:mysql-connector-j")

testImplementation("org.mockito:mockito-core:4.11.0")
Expand Down
3 changes: 3 additions & 0 deletions src/main/kotlin/nexters/weski/WeskiApplication.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package nexters.weski

import nexters.weski.auth.JwtProperties
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling

@SpringBootApplication(scanBasePackages = ["nexters.weski"])
@EnableScheduling
@EnableConfigurationProperties(JwtProperties::class)
class WeskiApplication

fun main(args: Array<String>) {
Expand Down
41 changes: 41 additions & 0 deletions src/main/kotlin/nexters/weski/auth/AuthController.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package nexters.weski.auth

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import nexters.weski.user.ApiResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@Tag(name = "인증 API", description = "JWT 토큰 관리 API")
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService
) {

@Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급받습니다")
@PostMapping("/refresh")
fun refreshToken(
@Valid @RequestBody request: RefreshTokenRequest
): ResponseEntity<ApiResponse<TokenResponse>> {
val response = authService.refreshToken(request.refreshToken)
return ResponseEntity.ok(
ApiResponse.success("토큰이 성공적으로 갱신되었습니다", response)
)
}

@Operation(summary = "로그아웃", description = "Refresh Token을 삭제하여 로그아웃합니다")
@PostMapping("/logout")
fun logout(
@Valid @RequestBody request: RefreshTokenRequest
): ResponseEntity<ApiResponse<Unit>> {
authService.logout(request.refreshToken)
return ResponseEntity.ok(
ApiResponse.success<Unit>("로그아웃이 성공적으로 처리되었습니다")
)
}
}
20 changes: 20 additions & 0 deletions src/main/kotlin/nexters/weski/auth/AuthDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package nexters.weski.auth

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank

@Schema(description = "토큰 갱신 요청 DTO")
data class RefreshTokenRequest(
@field:NotBlank(message = "Refresh Token은 필수입니다")
@Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
val refreshToken: String
)

@Schema(description = "토큰 응답 DTO")
data class TokenResponse(
@Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
val accessToken: String,

@Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
val refreshToken: String
)
64 changes: 64 additions & 0 deletions src/main/kotlin/nexters/weski/auth/AuthService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package nexters.weski.auth

import nexters.weski.user.RefreshToken
import nexters.weski.user.RefreshTokenRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime

@Service
@Transactional
class AuthService(
private val jwtTokenProvider: JwtTokenProvider,
private val refreshTokenRepository: RefreshTokenRepository
) {

/**
* Refresh Token으로 새로운 Access Token 발급
*/
fun refreshToken(refreshToken: String): TokenResponse {
// Refresh Token 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw IllegalArgumentException("유효하지 않은 Refresh Token입니다")
}

// DB에서 Refresh Token 조회
val storedToken = refreshTokenRepository.findByToken(refreshToken)
?: throw IllegalArgumentException("등록되지 않은 Refresh Token입니다")

// 만료 확인
if (storedToken.expiresAt.isBefore(LocalDateTime.now())) {
refreshTokenRepository.delete(storedToken)
throw IllegalArgumentException("만료된 Refresh Token입니다")
}

// 새로운 토큰 발급
val userId = storedToken.userId
val newAccessToken = jwtTokenProvider.generateAccessToken(userId)
val newRefreshToken = jwtTokenProvider.generateRefreshToken(userId)

// 기존 Refresh Token 삭제 후 새로 저장
refreshTokenRepository.delete(storedToken)
val newTokenEntity = RefreshToken(
userId = userId,
token = newRefreshToken,
expiresAt = LocalDateTime.now().plusDays(7)
)
refreshTokenRepository.save(newTokenEntity)

return TokenResponse(
accessToken = newAccessToken,
refreshToken = newRefreshToken
)
}

/**
* 로그아웃 - Refresh Token 삭제
*/
fun logout(refreshToken: String) {
val storedToken = refreshTokenRepository.findByToken(refreshToken)
storedToken?.let {
refreshTokenRepository.delete(it)
}
}
}
52 changes: 52 additions & 0 deletions src/main/kotlin/nexters/weski/auth/JwtAuthenticationFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package nexters.weski.auth

import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter

@Component
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider
) : OncePerRequestFilter() {

override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
try {
val jwt = getJwtFromRequest(request)

if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
val userId = jwtTokenProvider.getUserIdFromToken(jwt)

val authentication = UsernamePasswordAuthenticationToken(
userId,
null,
emptyList()
)
authentication.details = WebAuthenticationDetailsSource().buildDetails(request)

SecurityContextHolder.getContext().authentication = authentication
}
} catch (ex: Exception) {
logger.error("Could not set user authentication in security context", ex)
}

filterChain.doFilter(request, response)
}

private fun getJwtFromRequest(request: HttpServletRequest): String? {
val bearerToken = request.getHeader("Authorization")
return if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
bearerToken.substring(7)
} else {
null
}
}
}
10 changes: 10 additions & 0 deletions src/main/kotlin/nexters/weski/auth/JwtProperties.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package nexters.weski.auth

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(
val secret: String,
val accessTokenExpiration: Long,
val refreshTokenExpiration: Long
)
76 changes: 76 additions & 0 deletions src/main/kotlin/nexters/weski/auth/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package nexters.weski.auth

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.stereotype.Component
import java.util.Date
import javax.crypto.SecretKey

@Component
class JwtTokenProvider(
private val jwtProperties: JwtProperties
) {
private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtProperties.secret.toByteArray())

/**
* Access Token 생성
*/
fun generateAccessToken(userId: Long): String {
val now = Date()
val expiryDate = Date(now.time + jwtProperties.accessTokenExpiration)

return Jwts.builder()
.subject(userId.toString())
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact()
}

/**
* Refresh Token 생성
*/
fun generateRefreshToken(userId: Long): String {
val now = Date()
val expiryDate = Date(now.time + jwtProperties.refreshTokenExpiration)

return Jwts.builder()
.subject(userId.toString())
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact()
}

/**
* Token에서 사용자 ID 추출
*/
fun getUserIdFromToken(token: String): Long {
val claims = parseToken(token)
return claims.subject.toLong()
}

/**
* Token 유효성 검증
*/
fun validateToken(token: String): Boolean {
return try {
parseToken(token)
true
} catch (e: Exception) {
false
}
}

/**
* Token 파싱
*/
private fun parseToken(token: String): Claims {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.payload
}
}
39 changes: 39 additions & 0 deletions src/main/kotlin/nexters/weski/auth/SecurityConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package nexters.weski.auth

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.config.http.SessionCreationPolicy
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtAuthenticationFilter: JwtAuthenticationFilter
) {

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { authorize ->
authorize
.requestMatchers(
"/api/users/auth/**", // 로그인/회원가입
"/api/auth/**", // 토큰 갱신/로그아웃
"/v3/api-docs/**", // Swagger
"/swagger-ui/**", // Swagger UI
"/swagger-ui.html",
"/actuator/**" // Actuator
).permitAll()
// 나머지는 인증 필요
.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)

return http.build()
}
}
Loading