Spring Security 적용기

Jaychy·2021년 3월 25일
4

프로젝트 꼬리별

목록 보기
3/4
post-thumbnail

본 글은 글쓴이의 개인적인 생각이 담겨있을 수 있습니다.

꼬리별 프로젝트 - Server Clematis
https://github.com/KKoRiByeol/Clematis

이번 꼬리별 프로젝트를 할 때는 팀프로젝트에서는 무서워서 도입하기 힘들었던
기술들을 도입하여 여러 기술들을 체험해보기로 결심했다.

그 중 하나가 Spring Security이다.
Spring SecuritySpring Boot를 하면서 꼭 도입해야겠다고 생각만 하다가,
결국 내 인증 로직이 무너지면 실제 서비스에서 대처하기 힘들 것 같아서
이해를 하고 도입하려고 했었다.

그렇게 꼬리별에 도입하게 되었다.
그런데 별 다른 테스트 없이 바로 프로젝트에 적용하니 알 수 없는 에러들이 잔뜩 나왔다.
그래서 새로운 레포지토리를 파서 Spring Security에 대한 연구를 하기로 했다.

Spring Security 테스트

[스프링 시큐리티 테스트] https://github.com/Lee-Jin-Hyeok/spring-security-study

환경은 다음과 같다.

  • Kotlin 1.4.21
  • Spring Boot 2.4.1
  • Spring Data JPA
  • Spring Security

테스트를 하면서 여러 가지 시행착오를 겪었지만
단순하게 Spring Security를 사용하려면 다음과 같은 순서를 따르면 된다.

  1. UserDetails를 구현하는 계정 Account 클래스를 만든다.
  2. UserDetailsService를 구현하는 AuthenticationProvider (가칭) 클래스를 만든다.
  3. Authorization 헤더에서 토큰 추출을 할 수 있는 TokenProvider (가칭) 클래스를 만든다.
  4. OncePerRequestFilter를 구현하여 토큰의 유효성을 확인하는 Filter를 만든다.
  5. WebSecurityConfigurerAdapter를 구현하는 SecurityConfiguration (가칭) 클래스를 만든다.

1. UserDetails 를 구현하라.

기존에 Spring Security를 사용하기 전에 사용하던 Account 클래스에 UserDetails만 구현하면 된다.

@Entity
@Table(name = "account")
class Account(

    @Id @Column(name = "id")
    val id: String,

    @Column(name = "password")
    private var password: String,

    name: String,
) : UserDetails {

    @Column(name = "name")
    var name = name
        private set

    fun modifyPassword(newPassword: String) {
        this.password = newPassword
    }

    fun modifyName(newName: String) {
        this.name = newName
    }

    override fun getAuthorities() = mutableListOf<SimpleGrantedAuthority>()

    override fun getPassword() = password

    override fun getUsername() = id

    override fun isAccountNonExpired() = true

    override fun isAccountNonLocked() = true

    override fun isCredentialsNonExpired() = true

    override fun isEnabled() = true
}

password 프로퍼티가 private인 이유는 UserDetailsgetPassword()가 있어서 그렇다.
password 프로퍼티의 getPassword()와 컴파일러가 혼동하기 때문에 private으로 설정해야 한다.

getUsername()은 아이디를, getPassword()는 비밀번호를,
getAuthorities()는 계정의 역할을 리턴하도록 구현한다.

is...() 로 시작하는 함수 네 개는 true로 설정한다.
함수명을 보면 기능을 대충 유추할 수는 있지만 정확한 기능은 잘 모르겠다.

또한 계정의 ID를 가져오는 메소드명이 getId()가 아니라 getUsername()인 이유는
옛날엔 아이디가 그대로 이름으로 결정되는 경우가 많았기 때문이라고 한다.

2. UserDetailsService 를 구현하라.

UserDetailsServiceUserDetails를 구현한 계정 클래스를 Username(ID)를 이용해서
가져올 수 있도록 loadUserByUsername() 메소드를 구현해야 한다.

@Component
class AuthenticationProvider(
    private val accountRepository: AccountRepository,
) : UserDetailsService {

    override fun loadUserByUsername(username: String) =
        accountRepository.findByIdOrNull(username) ?: throw AccountNotFoundException(username)

    fun getAccountIdByAuthentication() =
        (SecurityContextHolder.getContext().authentication.principal as Account).id
}

@Component를 이용해서 스프링 컨테이너에서 관리하도록 하고,
AccountRepository를 이용해서 Account를 가져오고 없다면 AccountNotFoundException을 일으킨다.

getAccountIdByAuthentication() 메소드는 나중에 Filter 에서
SecurityContextHolder 에 담은 Authorization 객체에서 Account의 아이디를 가져오는 메소드이다.

3. 토큰 제공자 (Token Provider) 를 만들어라.

토큰 제공자 는 다음과 같은 기능을 가지고 있어야 한다.

  • 토큰 생성하기
  • 토큰 검증하기
  • Authorization 헤더에서 토큰 가져오기
  • Authentication 객체 리턴하기

이를 구현한 TokenProvider 클래스는 다음과 같다.

@Component
class TokenProvider(
    @Value("\${TOKEN_SECRET_KEY:spring-security-love}")
    private val secretKey: String,
    private val userDetailsService: UserDetailsService,
) {
    private val encodedSecretKey = Base64.getEncoder().encodeToString(secretKey.toByteArray())

    fun createToken(accountId: String, tokenType: Token): String =
        Jwts.builder()
            .setSubject(accountId)
            .setExpiration(Date(System.currentTimeMillis() + tokenType.millisecondOfExpirationTime))
            .signWith(SignatureAlgorithm.HS384, encodedSecretKey)
            .compact()

    fun getData(token: String): String =
        Jwts.parser()
            .setSigningKey(encodedSecretKey)
            .parseClaimsJws(token)
            .body
            .subject

    fun getAuthentication(token: String): Authentication {
        val userDetails = userDetailsService.loadUserByUsername(getData(token))
        return UsernamePasswordAuthenticationToken(
            userDetails,
            null,
            userDetails.authorities,
        )
    }

    fun extractToken(request: HttpServletRequest): String? =
        request.getHeader("Authorization")

    fun validateToken(token: String) =
        try {
            val expirationTime = Jwts.parser()
                .setSigningKey(encodedSecretKey)
                .parseClaimsJws(token)
                .body
                .expiration
            expirationTime.after(Date())
        } catch (e: Exception) { false }
}

다른 기능들은 Spring Security를 사용하기 전과 똑같은데
Authentication이라는 새로운 객체가 보일 것이다.
이는 SecurityContextHolder 에 값을 넣는 형식이 되기 때문에 꼭 필요하다.
여기서는 Authentication 의 구현체인 UsernamePasswordAuthenticationToken을 사용했다.

4. Filter 를 정의하라.

Spring Security 에 미리 정의해놓은 Filter 들이 존재하는데
DefaultLoginPageGeneratingFilter가 로그인 한 후
로그인 페이지로 리다이렉트하는 기능을 진행하는 Filter 이다.

하지만 우리는 REST API 서버이기 때문에 DefaultLoginPageGeneratingFilter 보다
우선순위가 높은 Filter 를 정의해야 한다.

그래서 GenericBeanFilter, OncePerRequestFilter 와 같은 Filter 중에
하나를 구현해야 한다.

그런데 GenericBeanFilter를 사용하면 필터가 두 번씩 적용되는 문제로 인해
OncePerRequestFilter를 사용하게 되었다.

다음은 이를 구현한 Filter 이다.

@Component
class AuthenticationFilter(
    private val tokenProvider: TokenProvider,
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val token = tokenProvider.extractToken(request)

        if (token != null && tokenProvider.validateToken(token)) {
            val authentication = tokenProvider.getAuthentication(token) as UsernamePasswordAuthenticationToken
            authentication.details = WebAuthenticationDetailsSource().buildDetails(request)
            SecurityContextHolder.getContext().authentication = authentication
        }

        filterChain.doFilter(request, response)
    }
}

doFilterInternal()Authorization 에서 토큰을 가져오고,
토큰을 검증하고, Authentication 객체를 만들고,
SecurityContextHolderAuthentication 객체를 저장하면 된다.
그 후 Filter 체인을 계속 지속시키기 위해서
filterChain.doFilter(request, response) 를 실행한다.

5. WebSecurityConfigurerAdapter 을 구현하는 Configuration 을 정의하라.

@Configuration
@EnableWebSecurity
class SecurityConfiguration(
    private val authenticationProvider: AuthenticationProvider,
    private val invalidTokenExceptionEntryPoint: InvalidTokenExceptionEntryPoint,
    private val tokenProvider: TokenProvider,
    private val passwordEncoder: PasswordEncoder,
) : WebSecurityConfigurerAdapter() {

    override fun configure(auth: AuthenticationManagerBuilder) {
        auth.userDetailsService(authenticationProvider)
            .passwordEncoder(passwordEncoder)
    }

    override fun configure(http: HttpSecurity) {
        http
            .cors()
                .and()
            .csrf().disable()
            .exceptionHandling().authenticationEntryPoint(invalidTokenExceptionEntryPoint)
                .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
            .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/account").permitAll()
                .antMatchers(HttpMethod.POST, "/account/login").permitAll()
                .anyRequest().authenticated()

        http
            .addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter::class.java)
    }

    override fun configure(web: WebSecurity) {
        web.ignoring()
            .antMatchers("/**/swagger-ui.html/**", "/webjars/**", "/swagger/**", "/v2/api-docs", "/swagger-resources/**")
    }
}

위와 같은 WebSecurityConfigurerAdapter 을 구현하는 Configuration 을 만들어야 한다.
다음과 같은 configure() 메소드 세 개를 재정의 해야 한다.

  1. configure(auth: AuthenticationManagerBuilder)
    여기서는 이제껏 만든 UserDetailsService 를 구현한 서비스와
    Spring Security 에서 지원하는 Password Encoder 를 저장하는 설정 메소드이다.

  2. configure(http: HttpSecurity)
    여기서는 Authorization 을 받을 API와 안 받을 API를 나누는 핵심적인 역할을 한다.
    이뿐만 아니라 CORS, CSRF, Session 등을 설정하기도 한다.

JWT 기반 토큰 인증에서는 Session 기반 기능이 필요 없으므로,
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
을 사용하여 Session 을 사용하지 않겠다고 정의한다.

CSRF 기능은 REST API 에서 필요 없으므로 disable() 시킨다.

Configuration 에서는 POST /account (회원가입)
POST /account/login (로그인) 기능에서는 Autorization 토큰을 받지 않도록 하였다.

addFilterBefore() 을 이용하여 로그인 창으로 리다이렉션 되는 Filter 보다
먼저 실행되도록 한다.

  1. configure(web: WebSecurity)
    여기서는 예외적으로 인증을 하지 않아도 되는 리소스를 정의한다.

Configuration 에서는 Swagger 와 관련된 모든 리소스에 대해 허용하였다.

마무리

이렇게 Spring Security 를 프로젝트에 적용해보았다.
여러 가지 시행착오가 있어서 많은 시간을 쏟아부었지만 그만큼 값진 결과라고 생각한다.

이번에 시작하는 팀프로젝트인 Secret JuJu (뉴스 기반 주식 추천 서비스) 에서는
Google, Facebook, NaverOAuth2 를 사용하기로 했는데,
이를 구현함에 있어 Spring Security 가 큰 도움이 될 것으로 예상된다.

profile
아름다운 코드를 꿈꾸는 백엔드 주니어 개발자입니다.

2개의 댓글

comment-user-thumbnail
2021년 8월 6일

스프링 Security 공부중인데 도움이 많이되네요 ㅎㅎ
좋은글 감사합니다~

1개의 답글