[Spring][Spring Security] REST api + Session 로그인을 구현해보자

Spring Security의 from 로그인 말고, REST api로 세션 로그인을 구현하기

들어가며

일반적으로 Spring Security는 from 로그인과 http basic 로그인을 지원한다. 이는 RESTful한 방식과는 맞지 않기 때문에, 따로 필터를 만들어야 한다.

간단한 플로우차트는 아래와 같다. alt text

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 시스템이 아닌 이상 큰 장점을 가지지는 않는다고 생각한다. 생각보다 검증해야할 것이 많고, 탈취 당했을 때 대응도 어렵다. 모놀리식 서버 구조라면 세션 방식이 더 효율적이라고 생각한다.