Spring Security 세션 로그인 직접 구현하기(Kotlin)

2025. 2. 2. 21:32·JVM/Kotlin
목차
  1. 환경
  2. 구현
  3. 스프링 시큐리티의 기본 form 로그인, http 로그인 비활성화 하기
  4. UserRepository 구현하기
  5. LoginService 구현하기
  6. Authentication 구현하기(중요!!!!!)
  7. RequestWrapper 구현하기(중요!!!)
  8. LoginFilter 구현하기
  9. Spring Security에 Filter 적용하기
  10. 로그인해보기
  11. Controller에 적용하기

오늘은 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()
        }
    }
}
  1. 원본 request의 byte를 복사하기. 복사할 때는 원본 ServletInputStream에서 데이터를 가져가지 않아야 함
  2. 복사한 값으로 새로운 ServletInputStream을 제작 및 반환
  3. 이렇게 되면 원본 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
  1. 환경
  2. 구현
  3. 스프링 시큐리티의 기본 form 로그인, http 로그인 비활성화 하기
  4. UserRepository 구현하기
  5. LoginService 구현하기
  6. Authentication 구현하기(중요!!!!!)
  7. RequestWrapper 구현하기(중요!!!)
  8. LoginFilter 구현하기
  9. Spring Security에 Filter 적용하기
  10. 로그인해보기
  11. Controller에 적용하기
'JVM/Kotlin' 카테고리의 다른 글
  • Spring Security CORS 설정하기 (Kotlin)
우띵이
우띵이
코딩해요~
  • 우띵이
    ChODING
    우띵이
  • 전체
    오늘
    어제
    • 분류 전체보기 (11)
      • JVM (6)
        • Java (4)
        • Kotlin (2)
      • Python (4)
      • JavaScript (0)
      • Computer Science (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Metaclass
    synchronize
    Monitor
    jdk21
    java
    concurrency
    Python
    Spring
    Thread
    kotlin
    binary
    CS
    complement
    CORS
    Virtual Thread
    computer science
    hash
    WSGI
    spring security
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
우띵이
Spring Security 세션 로그인 직접 구현하기(Kotlin)

개인정보

  • 티스토리 홈
  • 포럼
  • 로그인
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.