들어가며
일반적으로 Spring Security는 from 로그인과 http basic 로그인을 지원한다. 이는 RESTful한 방식과는 맞지 않기 때문에, 따로 필터를 만들어야 한다.
간단한 플로우차트는 아래와 같다.
Authentication
세션에 등록할 객체이다. 이 친구를 세션에 등록해서 Sprint Security의 인증, 인가를 사용할 수 있다. Authentication 자체는 인터페이스이며, 구현체를 직접 만들어야 한다.
package com.fhdufhdu.kiosk.auth
import com.fhdufhdu.kiosk.entity.Store
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
class StoreUserDetails(val store: Store) : Authentication {
private var isAuthenticated: Boolean = false
// 이름 반환
override fun getName(): String {
return store.name
}
// 해당 유저의 권한 리스트
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return ArrayList()
}
// 비밀번호 반환
override fun getCredentials(): Any {
return store.password
}
// 상세 내용 반환
override fun getDetails(): Any {
return store.name
}
// 아이디 반환
override fun getPrincipal(): Any {
return store.id
}
// 인증 여부 반환
override fun isAuthenticated(): Boolean {
return isAuthenticated
}
// 인증 여부 설정
override fun setAuthenticated(isAuthenticated: Boolean) {
this.isAuthenticated = isAuthenticated
}
}
이 부분에서 제일 중요한 부분은, getAuthorities()
와 isAuthenticated()
이다. 이 두 가지가 인가, 인증을 검증할 때 사용된다.
LoginFilter 코드
package com.fhdufhdu.kiosk.auth
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fhdufhdu.kiosk.common.KioskPasswordEncoder
import com.fhdufhdu.kiosk.domain.store.StoreRequest
import com.fhdufhdu.kiosk.repository.StoreRepository
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
import org.springframework.web.filter.OncePerRequestFilter
class LoginFilter(
// Bean 주입
private val passwordEncoder: KioskPasswordEncoder,
private val storeRepository: StoreRepository
) : OncePerRequestFilter() { // 스프링 시큐리티 필터를 제작하려면, OncePerRequestFilter를 상속받아야 한다.
// 필수 구현
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// URI 체크
if (request.requestURI != "/store/sign-in") {
// 로그인 요청이 아니면 다음 필터 호출
filterChain.doFilter(request, response)
} else {
// request.inputStream은 한번 읽으면 다시 읽을 수 없다.
// 그러므로 RequestWrapper를 만들어서 여러번 읽을 수 있도록 한다.
val newRequest = LoginRequestWrapper(request)
try {
// request.inputStream을 DTO 클래스로 변환
val om = jacksonObjectMapper()
val signInDto = om.readValue(newRequest.inputStream, StoreRequest.SingIn::class.java)
// 데이터가 존재하는 지 확인, 없으면 401 에러 반환
val store = storeRepository.findByIdOrNull(signInDto.id) ?: return failLogin(response)
// 비밀번호가 일치하는 지 확인, 일치하지 않으면 401 에러 반환, 일치하면 Authentication 제작
val auth = StoreUserDetails(store)
auth.isAuthenticated = passwordEncoder.matches(signInDto.password + store.salt, store.password)
if (!auth.isAuthenticated) return failLogin(response)
// 현재 리퀘스트 바운드에서 auth를 적용한다.
SecurityContextHolder.getContext().authentication = auth
// 리퀘스트 바운드 영역 데이터를 글로벌한 영역으로 저장함. 향후 다른 리퀘스트에서도 세션이 유지되도록
newRequest.session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext()
)
// 해당 세션 비활성화 유지 시간 설정
newRequest.session.maxInactiveInterval = 10
} catch (err: Exception) {
err.printStackTrace()
return failLogin(response)
}
}
}
private fun failLogin(response: HttpServletResponse) {
response.sendError(401)
}
}
스프링 설정 변경
package com.fhdufhdu.kiosk
import com.fhdufhdu.kiosk.auth.LoginFilter
import com.fhdufhdu.kiosk.common.KioskPasswordEncoder
import com.fhdufhdu.kiosk.repository.StoreRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration
@ComponentScan
@EnableWebSecurity
class SpringConfiguration(
val authenticationConfiguration: AuthenticationConfiguration,
val storeRepository: StoreRepository
) {
@Bean
@Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
// 기본 로그인 방식 비활성화
.formLogin {
it.disable()
}
.httpBasic {
it.disable()
}
.csrf {
it.disable()
}
// 필터 추가
.addFilterBefore(
LoginFilter(kioskPasswordEncoder(), storeRepository),
UsernamePasswordAuthenticationFilter::class.java
)
// 특정 URL 오픈, 그 외 URL 모두 인증해야만 접근할 수 있도록
.authorizeHttpRequests {
it.requestMatchers("/store/sign-in", "/store/sign-up", "/error").permitAll()
.anyRequest().authenticated()
}
return http.build()
}
@Bean
fun authenticationManager(): AuthenticationManager {
return authenticationConfiguration.authenticationManager
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return kioskPasswordEncoder()
}
@Bean
fun kioskPasswordEncoder(): KioskPasswordEncoder {
return KioskPasswordEncoder()
}
}
결과
실제로 테스트 해본 결과 로그인 한 이후 인증 전용 URL에도 접근이 잘 되는 것을 확인했다. 사실 JWT를 사용해볼까도 싶었지만, 이번 개발의 목적이 MSA가 아닌 대규모 트래픽 대응 서버를 만들어보는 것이기 때문에 세션을 사용하기로 결정했다.
JWT는 MSA 시스템이 아닌 이상 큰 장점을 가지지는 않는다고 생각한다. 생각보다 검증해야할 것이 많고, 탈취 당했을 때 대응도 어렵다. 모놀리식 서버 구조라면 세션 방식이 더 효율적이라고 생각한다.