✅ 일반적인 로직

✅ 구조 전반 요약
• CustomUserDetailsService: 로그인 ID 기준 사용자 조회
• JwtTokenProvider: Access & Refresh Token 생성, 검증, 인증 정보 추출
• JwtAuthenticationFilter: 요청마다 JWT 검증 및 자동 재발급 처리
• SecurityConfig: 필터와 인증 설정
• JwtArgumentResolver: 컨트롤러에서 JWT 클레임을 추출하기 위한 커스텀 파라미터 리졸버
• MemberController: 로그인/로그아웃 처리 및 쿠키 설정
CustomUserDetailsService.kt
import com.startax.application.repositories.user.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service
class CustomUserDetailsService(
private val memberRepository: MemberRepository,
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
// 로그인 ID로 회원 정보를 조회
val member = memberRepository.findByLoginId(username)
?: throw UsernameNotFoundException("존재하지 않는 사용자입니다.")
// Spring Security에서 사용하는 UserDetails 객체 반환
return User(
member.loginId,
member.password,
member.memberRole?.map { SimpleGrantedAuthority("ROLE_${it.role.name}") }?.toList(),
)
}
}
JwtAuthenticationFilter.kt
import io.jsonwebtoken.ExpiredJwtException
import jakarta.servlet.FilterChain
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.GenericFilterBean
import org.springframework.web.filter.OncePerRequestFilter
class JwtAuthenticationFilter(
private val jwtTokenProvider: JwtTokenProvider,
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val token = resolveToken(request)
try {
if (token != null && jwtTokenProvider.validateToken(token)) {
val authentication = jwtTokenProvider.getAuthentication(token)
SecurityContextHolder.getContext().authentication = authentication
}
} catch (e: ExpiredJwtException) {
tokenExceptionResponse(
response,
"만료된 AccessToken입니다.",
)
return
} catch (e: Exception) {
tokenExceptionResponse(
response,
"유효하지 않은 AccessToken입니다.",
)
return
}
chain.doFilter(request, response)
}
private fun resolveToken(request: HttpServletRequest): String? {
val bearer = request.getHeader("Authorization")
return if (!bearer.isNullOrBlank() && bearer.startsWith("Bearer ")) {
bearer.substring(7)
} else {
null
}
}
private fun tokenExceptionResponse(httpResponse: HttpServletResponse, message: String) {
httpResponse.status = HttpServletResponse.SC_UNAUTHORIZED
httpResponse.contentType = "application/json"
httpResponse.characterEncoding = "UTF-8"
httpResponse.writer.write(
"""
{
"resultCode": ${HttpServletResponse.SC_UNAUTHORIZED},
"message": "$message",
"data": null
}
""".trimIndent(),
)
}
}
JwtTokenProvider.kt
// Access, Refresh Token 유효시간 설정
const val ACCESS_EXPIRATION_MILLISECONDS: Long = 1000 * 10 * 60 * 30 // 30분
const val REFRESH_EXPIRATION_MILLISECONDS: Long = 1000 * 60 * 60 * 24 * 7 // 7일
data class TokenInfo(
val grantType: String,
val accessToken: String,
val refreshToken: String,
val accessTokenExpiresIn: LocalDateTime? = null,
)
@Component
class JwtTokenProvider(
private val tokenRepository: TokenRepository,
private val memberRepository: MemberRepository,
) {
@Value("\${jwt.secret}")
lateinit var secretKey: String
private val key by lazy { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) }
/** Access / Refresh Token 생성 */
fun createToken(loginId: String, authentication: Authentication): TokenInfo {
val now = Date()
val authorities = authentication.authorities.joinToString(",") { it.authority }
val member = memberRepository.findByLoginId(loginId) ?: throw NotFoundMemberException()
// Access Token 생성
val accessToken = Jwts.builder()
.setSubject(authentication.name)
.claim("auth", authorities)
.claim("memberId", member.id)
.claim("name", member.name)
.claim("email", member.email)
.setIssuedAt(now)
.setExpiration(Date(now.time + ACCESS_EXPIRATION_MILLISECONDS))
.signWith(key, SignatureAlgorithm.HS256)
.compact()
// Refresh Token 생성 및 저장
val refreshToken = createRefreshToken(authentication, authorities)
val tokenEntity = tokenRepository.findByMemberId(member.id!!) ?: TokenEntity()
val savedToken = tokenEntity.apply {
this.memberId = member.id
this.refreshToken = refreshToken
this.refreshExpiration = Date(now.time + REFRESH_EXPIRATION_MILLISECONDS).toInstant()
.atZone(ZoneId.systemDefault()).toLocalDateTime()
}.let(tokenRepository::saveAndFlush)
// 저장된 토큰과 비교 (비정상적 상황 체크)
if (savedToken.refreshToken != refreshToken) {
throw RefreshTokenMismatchException()
}
return TokenInfo("Bearer", accessToken, refreshToken, savedToken.refreshExpiration)
}
// Refresh Token 생성
fun createRefreshToken(authentication: Authentication, authorities: String): String {
val now = Date()
val expiration = Date(now.time + REFRESH_EXPIRATION_MILLISECONDS)
return Jwts.builder()
.setSubject(authentication.name)
.claim("auth", authorities)
.claim("type", "refresh")
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(key, SignatureAlgorithm.HS256)
.compact()
}
// Token에서 인증 정보 추출
fun getAuthentication(token: String): Authentication {
val claims: Claims = getClaims(token)
val auth = claims["auth"] ?: throw ExpiredTokenException("Refresh Token")
val authorities = (auth as String).split(",").map { SimpleGrantedAuthority(it) }
val principal: UserDetails = User(claims.subject, "", authorities)
return UsernamePasswordAuthenticationToken(principal, token, authorities)
}
// Token 유효성 검사
fun validateToken(token: String): Boolean {
return try {
getClaims(token)
true
} catch (e: ExpiredJwtException) {
throw e
} catch (e: MalformedJwtException) {
throw InvalidTokenException()
} catch (e: Exception) {
throw RuntimeException("토큰 검증 중 알 수 없는 오류가 발생했습니다.")
}
}
// Claims 추출
private fun getClaims(token: String): Claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.body
}
SecurityConfig.kt
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
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.crypto.factory.PasswordEncoderFactories
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@EnableWebSecurity
class SecurityConfig(
private val jwtTokenProvider: JwtTokenProvider,
private val customUserDetailsService: CustomUserDetailsService,
) {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.cors { it.configurationSource(corsConfigurationSource()) } // ✅ CORS 설정 연결
.httpBasic { it.disable() }
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests {
it
.requestMatchers("/v1/member/signup", "/v1/member/login").anonymous() // 비로그인 사용자만 접근 가능
.requestMatchers("/v1/member/logout").authenticated() // 로그인 사용자만 접근 가능
// ADMIN 전용 엔드포인트
.requestMatchers(HttpMethod.PUT, "/v1/inquiry/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/v1/inquiry/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/v1/video").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/v1/video/*").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/v1/video/*").hasRole("ADMIN")
.anyRequest().permitAll()
}
.addFilterBefore(
JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter::class.java,
)
return http.build()
}
@Bean
fun authenticationManager(http: HttpSecurity): AuthenticationManager = http.getSharedObject(AuthenticationManagerBuilder::class.java)
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder())
.and()
.build()
@Bean
fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
}
JwtArgumentResolver.kt
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class Jwt(val claim: String)
// Controller에서 @Jwt("memberId") 등으로 JWT 클레임을 직접 주입받기 위한 Resolver
class JwtArgumentResolver(
private val secretKey: String,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(Jwt::class.java)
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?,
): Any? {
val annotation = parameter.getParameterAnnotation(Jwt::class.java)!!
val claimName = annotation.claim
val httpServletRequest = webRequest.getNativeRequest(HttpServletRequest::class.java)
// 쿠키에서 Access Token 추출
val token = httpServletRequest?.cookies
?.firstOrNull { it.name == "access_token" }
?.value ?: throw IllegalStateException("Access token not found")
// 클레임 파싱 후 원하는 값 반환
val claims = Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)))
.build()
.parseClaimsJws(token)
.body
return claims[claimName]
}
}
MemberController.kt
// 로그인 시 토큰 발급 및 쿠키 설정
@Transactional
fun login(loginDto: LoginRequest, response: HttpServletResponse): TokenInfo {
// 사용자가 입력한 로그인 정보를 바탕으로 인증 토큰 생성
// UsernamePasswordAuthenticationToken은 Spring Security에서 사용자 인증을 위한 기본 토큰 객체
val authToken = UsernamePasswordAuthenticationToken(loginDto.loginId, loginDto.password)
// 일반적으로 DaoAuthenticationProvider가 사용되며, 이 과정에서 우리가 구현한 CustomUserDetailsService의 loadUserByUsername(loginDto.loginId)가 호출됨
// 이후 AuthenticationProvider는 입력받은 비밀번호와 DB에 저장된 비밀번호(보통 암호화됨)를 PasswordEncoder를 사용하여 비교함
val authentication = authenticationManager.authenticate(authToken)
val tokenInfo = jwtTokenProvider.createToken(loginDto.loginId, authentication)
// Access Token 쿠키 설정
val accessCookie = Cookie("access_token", tokenInfo.accessToken).apply {
path = "/"
isHttpOnly = true
maxAge = 60 * 30
}
// Refresh Token 쿠키 설정
val refreshCookie = Cookie("refresh_token", tokenInfo.refreshToken).apply {
path = "/"
isHttpOnly = true
maxAge = 60 * 60 * 24 * 7
}
response.addCookie(accessCookie)
response.addCookie(refreshCookie)
return tokenInfo
}
// 로그아웃 시 쿠키 삭제
@Transactional
fun logout(response: HttpServletResponse): String {
val accessCookie = Cookie("access_token", null).apply {
path = "/"
isHttpOnly = true
maxAge = 0
}
val refreshCookie = Cookie("refresh_token", null).apply {
path = "/"
isHttpOnly = true
maxAge = 0
}
response.addCookie(accessCookie)
response.addCookie(refreshCookie)
return "로그아웃 성공"
}