오늘은 Spring Security에서 세션 로그인을 직접 구현해 봅시다.
Spring Security는 기본적으로 form 로그인을 제공해주고 있습니다. 하지만 RESTful API을 목적으로 개발하는 경우 form 로그인은 좋은 선택이 아닙니다. 그래서 직접 세션 로그인을 구현하거나, 다른 방식으로 로그인 방식을 구현해야 하는데요.
저는 Spring Security의 Filter 기능을 이용해서 직접 세션 로그인을 구현해 볼 겁니다.
환경
- JDK17
- Kotlin
- Spring Boot 3.2.4
- Spring Security
구현
스프링 시큐리티의 기본 form 로그인, http 로그인 비활성화 하기
직접 세션 로그인을 구현할 것이므로 이 기능을 비활성화해야 합니다.
@ComponentScan
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
class SpringConfiguration{
@Bean
@Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.formLogin {
it.disable()
}
.httpBasic {
it.disable()
}
.csrf {
it.disable()
}
return http.build()
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
키워드 | 설명 |
EnableWebSecurity | 스프링 시큐리티를 활성화합니다. |
EnableMethodSecurity | 각 컨트롤러, 컨트롤러의 메소드마다 인증/인가를 설정하도록 합니다. |
securedEnabled | @Secure 어노테이션을 활성화합니다. |
prePostEnabled | @PreAuthorize, @PostAuthorize 어노테이션을 활성화합니다. |
PasswordEncoder | 비밀번호 암호화에 이용할 객체입니다. 직접 인터페이스를 구현해서 사용해도 됩니다. 저는 Spring Bean이 제공해주는 DelegatingPasswordEncoder를 사용하겠습니다. |
UserRepository 구현하기
기본 제공되는 JpaRepository
인터페이스의 메서드를 사용해도 충분하므로 다른 메서드는 구현하지 않도록 하겠습니다.
interface UserRepository: JpaRepository<User, String> {}
LoginService 구현하기
향후 구현할 LoginFilter에서 사용할 서비스 클래스를 만들어 줍시다.
class LoginService(
private val passwordEncoder: PasswordEncoder,
private val userRepository: UserRepository
) {
fun fetchUser(id: String, password: String): User? {
val user = userRepository.findByIdOrNull(id) ?: return null
val isMatch = passwordEncoder.matches(password, user.password)
if (!isMatch) return null
return user
}
}
유저를 데이터베이스에서 조회해서 패스워드가 맞는지 확인하는 작업을 수행합니다. 유저가 조회되지 않거나 패스워드가 맞지 않으면 null을 반환합니다.
Authentication 구현하기(중요!!!!!)
Authentication
객체는 세션을 유지하는데 꼭 필요한 객체입니다. 스프링은 세션 쿠키별로 Authentication
을 내부 저장소에 저장하고 있으며, 세션 관련 연산이 있을 때마다 Authentication
객체가 사용됩니다.
class UserAuthentication(val user: User): Authentication {
override fun getName(): String {
return user.id
}
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
return ArrayList()
}
override fun getCredentials(): String {
return user.password
}
// 사용자 상세 정보가 들어감
override fun getDetails(): Any? {
return null
}
// 일반적으로 ID가 들어감
override fun getPrincipal(): String {
return user.id
}
override fun isAuthenticated(): Boolean {
return true
}
override fun setAuthenticated(isAuthenticated: Boolean) {
}
}
메소드 | 설명 |
getName | 해당 인증 정보의 이름을 반환합니다. |
getAuthorities | 해당 인증 정보가 어떠한 권한을 가지고 있는지 반환합니다. 이는 권한 인가 행위에서 사용됩니다. |
getCredentials | 해당 인증 정보의 자격 증명을 의미합니다. 일반적으로 패스워드가 자격 증명 정보입니다. |
getDetails | 해당 인증 정보의 상세 정보를 반환합니다. |
getPrincipal | 해당 인증 정보의 사용자 정보를 반환합니다. |
RequestWrapper 구현하기(중요!!!)
HttpServletRequest
객체는 http request 정보를 가지고 있습니다. 이 객체의 getInputStream
은 request body 정보를 담고 있는 ServletInputStream
을 반환합니다. 이 ServletInputStream
에서 데이터를 가져간 이후에는 다시는 데이터를 가져갈 수 없습니다. 가져가게 된다면 에러가 발생합니다.
스프링 시큐리티의 Filter 기능은 스프링 컨텍스트에서 작동하지 않고 Tomcat의 서블릿 단에서 작동하는 필터입니다. 여기서 HttpServletRequest
의 getInputStream
를 호출해서 ServletInputStream
의 데이터를 쓴다면 문제가 발생합니다. 스프링이 컨트롤러로 데이터를 넘기기 전에 ServletInputStream
의 데이터를 사용하거든요. 그럼 앞서 말씀드린 것처럼 에러가 발생하게 됩니다.
이를 어떻게 해결해야 할까요? 이럴 때를 대비해 서블릿은 HttpServletRequestWrapper
라는 클래스를 제공합니다. 이 클래스는 HttpServletRequest
의 getInputStream
이 발생할 때 ServletInputStream
을 복사할 기회를 줍니다.
class RequestWrapper(request: HttpServletRequest) : HttpServletRequestWrapper(request) {
private val contents = ByteArrayOutputStream()
override fun getInputStream(): ServletInputStream {
// 원본 request의 byte를 복사하기, 원본 ServletInputStream은 사용되지 않았음
IOUtils.copy(super.getInputStream(), contents)
// 복사한 값을 ServletInputStream 인터페이스에 맞추어서 익명 클래스 제작
return object : ServletInputStream() {
private val buffer = ByteArrayInputStream(contents.toByteArray())
override fun read(): Int = buffer.read()
override fun isFinished(): Boolean = buffer.available() == 0
override fun isReady(): Boolean = true
override fun setReadListener(p0: ReadListener?) = throw NotImplementedError()
}
}
}
- 원본 request의 byte를 복사하기. 복사할 때는 원본
ServletInputStream
에서 데이터를 가져가지 않아야 함 - 복사한 값으로 새로운
ServletInputStream
을 제작 및 반환 - 이렇게 되면 원본
ServletInputStream
을 사용하지 않게 됨
여기까지 하면 LoginFilter
를 구현하기 위한 모든 준비가 끝나게 됩니다.
LoginFilter 구현하기
@Component
class LoginFilter(
private val loginService: LoginService
) : OncePerRequestFilter() {
// 로그인 URL path
private var loginPath: String = ""
// 세션을 얼마나 유지하도록 할 것인지
private var maxInactiveInterval: Int = 60 * 60 * 24 * 3
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
// 메소드가 POST가 아니거나, 로그인 path와 일치하지 않으면 다음 필터 진행
if (request.method != HttpMethod.POST.name() || request.requestURI != loginPath) {
filterChain.doFilter(request, response)
// 메소드만 다르면, METHOD_NOT_ALLOWED 반환
} else if (request.method != HttpMethod.POST.name()) {
failLogin(response, HttpStatus.METHOD_NOT_ALLOWED)
// 세션 로그인 진행
} else {
val newRequest = RequestWrapper(request)
try {
// request body의 객체화 및 validation
val om = jacksonObjectMapper()
val logInRequest = om.readValue(newRequest.inputStream, LogInRequest::class.java)
// 유저가 정보 조회, 없으면 FORBIDDEN 반환
val user = loginService.fetchUser(logInRequest.id, logInRequest.password) ?: return failLogin(response, HttpStatus.FORBIDDEN)
val auth = UserAuthentication(user)
// 세션에 Authentication 저장
// 현재 리퀘스트 바운드에 세션 적용
SecurityContextHolder.getContext().authentication = auth
// 리퀘스트 바운드 영역 데이터를 글로벌한 영역으로 저장함. 향후 다른 리퀘스트에서도 세션이 유지되도록
newRequest.session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext()
)
// 해당 세션 비활성화 유지 시간 설정
newRequest.session.maxInactiveInterval = maxInactiveInterval
} catch (err: Exception) {
err.printStackTrace()
return failLogin(response, HttpStatus.FORBIDDEN)
}
}
}
private fun failLogin(response: HttpServletResponse, status: HttpStatus) {
response.sendError(status.value())
}
fun setLoginPath(loginPath: String): LoginFilter {
this.loginPath = loginPath
return this
}
fun setMaxInactiveInterval(value: Int): LoginFilter{
this.maxInactiveInterval = value
return this
}
}
상세 설명은 주석을 참고해주시면 됩니다.
Spring Security에 Filter 적용하기
@ComponentScan
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
class SpringConfiguration(
// 추가된 부분
private val userRepository: UserRepository
) {
@Bean
@Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.formLogin {
it.disable()
}
.httpBasic {
it.disable()
}
.csrf {
it.disable()
}
// 추가된 부분
// UsernamePasswordAuthenticationFilter 이전에 해당 필터를 적용하도록 한다.
.addFilterBefore(
loginFilter()
.setLoginPath("/user/login")
.setMaxInactiveInterval(60 * 60 * 24 * 3),
UsernamePasswordAuthenticationFilter::class.java
)
return http.build()
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// 추가된 부분
fun loginService(): LoginService {
return LoginService(passwordEncoder(), userRepository)
}
// 추가된 부분
@Bean
fun loginFilter(): LoginFilter {
return LoginFilter(loginService())
}
}
로그인해보기
POST /user/login
으로 HTTP 요청을 하면 로그인이 되고, 세션에 로그인 정보가 추가된다.
Controller에 적용하기
@RestController
@PreAuthorize("isAuthenticated()")
@RequestMapping("board")
@Validated
@ResponseBody
class BoardController(
private val boardService: BoardService
) {
@PreAuthorize("permitAll")
@GetMapping("posts")
fun getPostSummaries(@Valid @ModelAttribute query: PostSummariesRequest): PostSummaries {
...
}
@PreAuthorize("permitAll")
@GetMapping("post/{postId}")
fun getPostDetail(@PathVariable postId: Long): PostDetail {
...
}
@PostMapping("post")
@ResponseStatus(HttpStatus.CREATED)
fun postPost(@AuthenticationPrincipal userId: String, @Valid @RequestBody body: PostAdditionRequest): SavedPost {
...
}
...
}
앞서 @EnableMethodSecurity
를 설정했기 때문에 컨트롤러, 메서드 단위마다 인증 여부로 제한을 걸 수 있습니다.
예를 들어, 컨트롤러에 @PreAuthorize("isAuthenticated()")
가 적용되었기 때문에 해당 컨트롤러의 메서드들은 모두 인증되어야 접근할 수 있는것이죠.
메서드에 @PreAuthorize("permitAll")
은 컨트롤러의 @PreAuthorize("isAuthenticated()")
은 무시되고 @PreAuthorize("permitAll")
가 우선적으로 적용됩니다.
'JVM > Kotlin' 카테고리의 다른 글
Spring Security CORS 설정하기 (Kotlin) (0) | 2025.02.03 |
---|