Spring security (Cookie)

세모네모동굴배이·2025년 4월 11일

✅ 일반적인 로직

✅ 구조 전반 요약
• 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 "로그아웃 성공"
}

0개의 댓글