첫번째 목표는 회원 이메일 인증 부분을 포스팅
하려고 했지만 생각해보니 로그인을 비롯한 인증/인가는 한번도 구현해본적도 없었고 생각보다 정리할 내용이 많아 별도로 분리해서 진행하였다. 디테일보다는 전반적인 흐름과 Spring Security 그리고 Kotlin에 익숙해지는 것을 중점을 두고 진행해보았다.
회원가입은 웹서비스를 운영함에 있어 대단히 중요한 부분이다. 고객과의 서비스 접점을 만들고 고객의 정보를 제공받아 마케팅과 서비스고도화에 활용할 수 있다. 이에 사이트에 방문한 사용자의 이탈을 최소로 만들기 위해 진입장벽을 낮추려 노력해야 하는 부분이기도 하다. 이탈을 낮추는 방법은 여러가지가 있지만 이번에는 간편한 회원가입을 제공하여 심리적인 진입장벽을 낮춰보려 한다.
쇼핑몰 판매자를 회원으로 정의하고 간단하게 이메일 그리고 이름만을 가지는 판매자를 구현하고 해당 정보를 통하여 가입할 수 있게 하였다.
@Entity
class Seller(
@Column(nullable = false, unique = true)
var email: String,
@Column(nullable = false)
var password: String,
@Column(nullable = false)
var name: String
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@RestController
@RequestMapping("apis/sellers")
class SellerCommandController(private val sellerCommandService: SellerCommandService) {
@PostMapping
fun welcome(@RequestBody command: CreateSellerCommand): ResponseEntity<Seller> {
val seller = sellerCommandService
.welcome(command.email, command.password, command.name)
return ResponseEntity.created(URI.create("apis/sellers/${seller.id}"))
.body(seller)
}
}
@Service
class SellerCommandService(
private val sellerRepository: SellerRepository,
private val passwordEncoder: PasswordEncoder
) {
@Transactional
fun welcome(email: String, password: String, name: String): Seller {
val seller = Seller(email, passwordEncoder.encode(password), name)
return sellerRepository.save(seller)
}
}
로그인도 판매자를 다루는 비지니스 영역에서 구현할 수 있지만 로그인 행위 자체는 프레임워크에 종속적일 수 있다는 생각이 들어 분리하여 구현하였다.
로그인에 성공하면 JWT 토큰을 반환한다.
@RestController
class SellerSignInController(private val sellerSignInService: SellerSignInService) {
@PostMapping("apis/sellers/sign-in")
fun signIn(@RequestBody command: SignInSellerCommand): ResponseEntity<Void> {
val token = sellerSignInService.signIn(command.email, command.password)
return ResponseEntity
.ok()
.header("Authorization", "Bearer $token")
.build()
}
}
@Service
class SellerSignInService(
private val sellerRepository: SellerRepository,
private val jwtProvider: JwtTokenProvider,
private val passwordEncoder: PasswordEncoder
) {
fun signIn(email: String, password: String): String {
authenticate(email, password)
return jwtProvider.create(email)
}
private fun authenticate(email: String, password: String) {
sellerRepository.findByEmail(email)
?.takeIf { passwordEncoder.matches(password, it.password) }
?: throw SignInFailedException()
}
}
@Component
class JwtTokenProvider(private val properties: SecurityProperties) {
private val key: Key = Keys.hmacShaKeyFor(properties.secret.toByteArray(java.nio.charset.StandardCharsets.UTF_8))
fun create(email: String): String = Jwts.builder()
.setSubject("$email")
.setExpiration(Date(System.currentTimeMillis() + properties.expireTime))
//30분의 유효기간을 가진다.
.signWith(key, SignatureAlgorithm.HS256)
.compact()
}
Spring Security의 구조를 간단하게 살펴보면 HTTP요청을 AuthenticationFilter가 이를 가로채 UsernamePasswordAuthenticationToken를 생성하고 AuthenticationManager에 Authentication과 UsernamePasswordAuthenticationToken을 전달하여 인증을 수행하게 된다.
이번에는 JWT를 활용하여 로그인을 구현하려하므로 UsernamePasswordAuthenticationFilter 상속 받아 Filter를 구현하거나 UsernamePasswordAuthenticationFilter 앞에서 인증을 수행하도록 처리 할 수 있다.
발급받은 JWT를 통해 API 접근 권한을 제어할 수 있다. JWT 인증을 사용하므로 session은 STATELESS로 설정하였고 JwtFilter을 UsernamePasswordAuthenticationFilter 앞에 두어 토큰의 유효성 검증 및 인증처리를 수행한다. 회원 가입 API와 로그인 API는 인증과 무관하게 접근가능하도록 하였다.
@Configuration
@EnableWebSecurity
class WebSecurityConfig(private val jwtFilter: JwtFilter) {
@Bean
fun configure(http: HttpSecurity): SecurityFilterChain {
http.csrf().disable()
http.authorizeRequests()
.antMatchers("/apis/examples",
"/apis/sellers/sign-in", "/apis/sellers").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
}
접근제어 기능은 Spring Security를 이용하여 구현하였으므로 Spring Security가 관리할 수 있는 pricipal 객체로 변환이 필요하다. Seller가 직접 UserDetail을 구현할 수 있지만 최대한 프레임워크에 대한 의존성을 분리하고자 별도의 객체와 Service를 만들어서 구현하였다.
class SellerDetails(private val seller : Seller) : UserDetails {
var enabled: Boolean = true
override fun getAuthorities(): MutableCollection<out GrantedAuthority>
= AuthorityUtils.createAuthorityList() // 현재는 권한을 사용하지 않기때문에 빈 콜렉션을 생성하였다.
override fun getPassword(): String = seller.password
override fun getUsername(): String = seller.email
override fun isAccountNonExpired(): Boolean = enabled
override fun isAccountNonLocked(): Boolean = enabled
override fun isCredentialsNonExpired(): Boolean = enabled
override fun isEnabled(): Boolean = enabled
}
@Service
class SellerAuthenticationService(private val sellerRepository: SellerRepository) : UserDetailsService {
override fun loadUserByUsername(email: String): UserDetails {
return sellerRepository.findByEmail(email)?.let {
SellerDetails(it)
} ?: throw UsernameNotFoundException("존재하지 않는 이메일입니다. : $email")
}
}
토큰을 검증하고 Email을 파싱하여 AuthenticationToken을 SecurityContext가 관리하도록 설정하였다.
@Component
class JwtFilter(private val service: SellerAuthenticationService,
private val provider : JwtTokenProvider
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
request.getHeader("Authorization")
?.let {
it.substring("Bearer ".length)
}?.let{
provider.parse(it).id
}?.let {
service.loadUserByUsername(it)
}?.let {
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(it, null, it.authorities)
}
filterChain.doFilter(request, response)
}
}
위 예제의 사용한 소스 코드는 GitHub에서 확인할 수 있습니다.