본 글은 글쓴이의 개인적인 생각이 담겨있을 수 있습니다.
꼬리별 프로젝트 - Server Clematis
https://github.com/KKoRiByeol/Clematis
이번 꼬리별 프로젝트를 할 때는 팀프로젝트에서는 무서워서 도입하기 힘들었던
기술들을 도입하여 여러 기술들을 체험해보기로 결심했다.
그 중 하나가 Spring Security
이다.
Spring Security
는 Spring Boot
를 하면서 꼭 도입해야겠다고 생각만 하다가,
결국 내 인증 로직이 무너지면 실제 서비스에서 대처하기 힘들 것 같아서
이해를 하고 도입하려고 했었다.
그렇게 꼬리별에 도입하게 되었다.
그런데 별 다른 테스트 없이 바로 프로젝트에 적용하니 알 수 없는 에러들이 잔뜩 나왔다.
그래서 새로운 레포지토리를 파서 Spring Security
에 대한 연구를 하기로 했다.
[스프링 시큐리티 테스트] https://github.com/Lee-Jin-Hyeok/spring-security-study
환경은 다음과 같다.
테스트를 하면서 여러 가지 시행착오를 겪었지만
단순하게 Spring Security
를 사용하려면 다음과 같은 순서를 따르면 된다.
UserDetails
를 구현하는 계정 Account
클래스를 만든다.UserDetailsService
를 구현하는 AuthenticationProvider (가칭)
클래스를 만든다.Authorization
헤더에서 토큰 추출을 할 수 있는 TokenProvider (가칭)
클래스를 만든다.OncePerRequestFilter
를 구현하여 토큰의 유효성을 확인하는 Filter
를 만든다.WebSecurityConfigurerAdapter
를 구현하는 SecurityConfiguration (가칭)
클래스를 만든다.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
인 이유는 UserDetails
의 getPassword()
가 있어서 그렇다.
password
프로퍼티의 getPassword()
와 컴파일러가 혼동하기 때문에 private
으로 설정해야 한다.
getUsername()
은 아이디를, getPassword()
는 비밀번호를,
getAuthorities()
는 계정의 역할을 리턴하도록 구현한다.
is...()
로 시작하는 함수 네 개는 true로 설정한다.
함수명을 보면 기능을 대충 유추할 수는 있지만 정확한 기능은 잘 모르겠다.
또한 계정의
ID
를 가져오는 메소드명이getId()
가 아니라getUsername()
인 이유는
옛날엔 아이디가 그대로 이름으로 결정되는 경우가 많았기 때문이라고 한다.
UserDetailsService
를 구현하라.UserDetailsService
는 UserDetails
를 구현한 계정 클래스를 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
의 아이디를 가져오는 메소드이다.
토큰 제공자 (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
을 사용했다.
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
객체를 만들고,
SecurityContextHolder
에 Authentication
객체를 저장하면 된다.
그 후 Filter
체인을 계속 지속시키기 위해서
filterChain.doFilter(request, response)
를 실행한다.
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()
메소드 세 개를 재정의 해야 한다.
configure(auth: AuthenticationManagerBuilder)
여기서는 이제껏 만든 UserDetailsService
를 구현한 서비스와
Spring Security
에서 지원하는 Password Encoder
를 저장하는 설정 메소드이다.
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
보다
먼저 실행되도록 한다.
위
Configuration
에서는Swagger
와 관련된 모든 리소스에 대해 허용하였다.
이렇게 Spring Security
를 프로젝트에 적용해보았다.
여러 가지 시행착오가 있어서 많은 시간을 쏟아부었지만 그만큼 값진 결과라고 생각한다.
이번에 시작하는 팀프로젝트인 Secret JuJu (뉴스 기반 주식 추천 서비스)
에서는
Google
, Facebook
, Naver
의 OAuth2
를 사용하기로 했는데,
이를 구현함에 있어 Spring Security
가 큰 도움이 될 것으로 예상된다.
스프링 Security 공부중인데 도움이 많이되네요 ㅎㅎ
좋은글 감사합니다~