오늘은 Spring security + JWT를 이용하여 로그인을 구현해보도록 하겠습니다. 언어는 kotlin을 사용했습니다.
1. User Entity 생성
로그인을 하기 위해서는 회원가입된 사용자 정보가 필요하기 때문에, UserDetails를 상속받은 User Entity를 생성합니다.
@Entity
class User(name: String, email: String, m_password: String): BaseTime(), UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
@Column(nullable = false)
var name: String = name
@Column(nullable = false, unique = true)
var email: String = email
@Column(nullable = false)
var m_password: String = m_password
override fun getAuthorities(): MutableCollection<out GrantedAuthority>? {
return null
}
override fun getPassword(): String {
return m_password
}
override fun getUsername(): String {
return email
}
override fun isAccountNonExpired(): Boolean {
return true
}
override fun isAccountNonLocked(): Boolean {
return true
}
override fun isCredentialsNonExpired(): Boolean {
return true
}
override fun isEnabled(): Boolean {
return true
}
}
override된 메소드 중 getPassword와 getUsername은 각각 비밀번호와 아이디를 return 하도록 수정해줍니다.
(getUsername은 사용자 이름을 반환하는 메소드가 아닌 사용자 아이디를 반환하는 메소드입니다.)
UserDetails와 BaseTime을 상속받고 있는데, 여기서 BaseTime은 생성 시간과 수정 시간을 저장하는 객체입니다.
자세한 내용은 아래를 참고해주세요.
2021.12.20 - [Spring Boot] - [Spring boot / Kotlin] 생성/수정 시간 자동화
JwtTokenProvider 생성
JWT 토큰을 발급하고, 인증 정보를 조회하고, 회원 정보를 추출하는 JwtTokenProvider를 생성합니다.
@Component
class JwtTokenProvider(private val userDetailsService: UserDetailsService) {
// JWT를 생성하고 검증하는 컴포넌트
private var secretKey = "thisistestusersecretkeyprojectnameismologaaaaaaaaaaaaaaaa"
// 토큰 유효시간 30분
private val tokenValidTime = 30 * 60 * 1000L
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected fun init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.toByteArray())
}
// JWT 토큰 생성
fun createToken(userPk: String): String {
val claims: Claims = Jwts.claims().setSubject(userPk) // JWT payload 에 저장되는 정보단위
claims["userPk"] = userPk // 정보는 key / value 쌍으로 저장된다.
val now = Date()
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(Date(now.time + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact()
}
// JWT 토큰에서 인증 정보 조회
fun getAuthentication(token: String): Authentication {
val userDetails = userDetailsService.loadUserByUsername(getUserPk(token))
return UsernamePasswordAuthenticationToken(userDetails, "", userDetails.authorities)
}
// 토큰에서 회원 정보 추출
fun getUserPk(token: String): String {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).body.subject
}
// Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
fun resolveToken(request: HttpServletRequest): String? {
return request.getHeader("Authorization")
}
// 토큰의 유효성 + 만료일자 확인
fun validateToken(jwtToken: String): Boolean {
return try {
val claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken)
!claims.body.expiration.before(Date())
} catch (e: Exception) {
false
}
}
}
JwtAuthenticationFilter 생성
앞에서 구현한 JwtTokenProvider를 이용해 헤더에서 JWT를 받아와 유효한 토큰인지 확인하고, 유효할 경우 유저 정보를 SecurityContextHolder에 저장하는 JwtAuthenticationFilter를 생성합니다.
class JwtAuthenticationFilter(private val jwtTokenProvider: JwtTokenProvider): GenericFilterBean() {
@Throws(IOException::class, ServletException::class)
override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
// 헤더에서 JWT 를 받아옵니다.
val token: String? = jwtTokenProvider.resolveToken((request as HttpServletRequest))
// 유효한 토큰인지 확인합니다.
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
val authentication = jwtTokenProvider.getAuthentication(token)
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().authentication = authentication
}
chain.doFilter(request, response)
}
}
SecurityConfig 생성
SecurityConfig를 생성해 passwordEncoder를 만들고, 앞에서 만들어준 JwtAuthenticationFilter를 등록합니다.
로그인과 회원가입 요청 Url을 제외한 나머지는 인증을 받아야 요청 가능하도록 권한을 설정합니다.
@EnableWebSecurity
class SecurityConfig(private val jwtTokenProvider: JwtTokenProvider): WebSecurityConfigurerAdapter() {
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
@Bean
override fun authenticationManagerBean(): AuthenticationManager {
return super.authenticationManagerBean()
}
override fun configure(http: HttpSecurity) {
http.
httpBasic().disable() // rest api만 고려, 기본 설정 해제
.csrf().disable() // csrf 보안 토큰 disable 처리
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 사용 안함
.and()
.authorizeRequests() // 요청에 대한 사용권한 체크
.antMatchers("/api/**").authenticated()
.antMatchers("/register/**", "/login/**", "/logout/**").permitAll() // 로그인, 회원가입은 누구나 접근 가능
.and()
.addFilterBefore(JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java)
}
}
UserController 생성
이제 Controller를 생성하여 회원 로그인을 구현해보겠습니다.
회원 가입시에는 사용자가 입력한 이메일 중복 검사를 실시하고, 중복되지 않은 경우에는 사용자가 입력한 정보 중 패스워드를 함호화하여 데이터베이스에 저장합니다. 회원가입된 사용자가 로그인을 요청하면 존재하는 사용자인지 확인하고, 비밀번호를 암호화하여 데이터베이스에 저장되어있는 비밀번호와 동일한지 검사한 뒤 알맞은 사용자라면 토큰을 발급합니다.
@RestController
class UserController(private val userService: UserService, private val passwordEncoder: PasswordEncoder) {
@PostMapping("/register")
fun register(@RequestBody userRegisterReq: UserRegisterReq): ResponseEntity<UserRegisterRes> {
if(userService.existsUser(userRegisterReq.email)) {
throw BaseException(BaseResponseCode.DUPLICATE_EMAIL)
}
userRegisterReq.password = passwordEncoder.encode(userRegisterReq.password)
return ResponseEntity.ok(userService.createUser(userRegisterReq))
}
@PostMapping("/login")
fun login(@RequestBody userLoginReq: UserLoginReq): ResponseEntity<UserLoginRes> {
if(!userService.existsUser(userLoginReq.email)) {
throw BaseException(BaseResponseCode.USER_NOT_FOUND)
}
val user: User = userService.findUser(userLoginReq.email)
if(!passwordEncoder.matches(userLoginReq.password, user.password)) {
throw BaseException(BaseResponseCode.INVALID_PASSWORD)
}
return ResponseEntity.ok(userService.login(userLoginReq))
}
}
UserDetailService 생성
UserDetailsService를 상속받은 Service를 생성합니다. 사용자가 로그인하기위해 입력한 아이디를 통해 존재하는 사용자인지 검사하는 메소드가 있습니다.
@Service
class UserDetailService(private val userRepository: UserRepository): UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
return userRepository.findByEmail(username).orElseThrow{ BaseException(BaseResponseCode.USER_NOT_FOUND) }
}
}
UserService 생성
UserDetailService와 UserService를 따로 구현한 이유는 순환 참조를 막기 위해서입니다.
@Service
class UserService(private val userRepository: UserRepository, private val jwtTokenProvider: JwtTokenProvider) {
fun findUser(email: String): User {
return userRepository.findByEmail(email).orElseThrow{BaseException(BaseResponseCode.USER_NOT_FOUND)}
}
fun existsUser(email: String): Boolean {
return userRepository.existsByEmail(email).orElseThrow{BaseException(BaseResponseCode.DUPLICATE_EMAIL)}
}
fun createUser(userRegisterReq: UserRegisterReq): UserRegisterRes {
val user = User(userRegisterReq.name, userRegisterReq.email, userRegisterReq.password)
userRepository.save(user)
return UserRegisterRes(user.id, user.email)
}
fun login(userLoginReq: UserLoginReq): UserLoginRes {
val token: String = jwtTokenProvider.createToken(userLoginReq.email)
return UserLoginRes(HttpStatus.OK, token)
}
}
테스트
코드 작성이 다 끝났으면, 로그인이 잘 구현되었는지 테스트합니다.
localhost:8080/register에 POST 요청으로 회원가입하고 싶은 이메일 주소와 비밀번호를 전송합니다.
localhost:8080/login에 POST 요청으로 회원가입한 이메일 주소와 비밀번호를 전송합니다.
토큰을 응답받았다면 테스트는 끝났습니다..!
위에서 작성한, SecurityConfig에서 회원가입과 로그인 URL을 제외한 나머지 요청은 권한을 확인하고 유효하다면 요청을 처리하도록 해주었습니다. 그래서 다른 요청을 보낼 때는 반드시 헤더에 Authrization - token값을 입력하여 전송해야 원하는 응답을 얻을 수 있습니다.
여기까지 Spring security + JWT를 이용하여 로그인을 구현해봤습니다.
Spring security와 JWT에 대해서 알고 싶으신 분들은 아래 글을 참고해주세요!
2021.12.28 - [Spring Boot] - [Spring boot] Spring security란?
2021.12.30 - [Spring Boot] - [Spring boot] JWT(Json Web Token)란?
'Spring Boot > Kotlin' 카테고리의 다른 글
[Spring boot / Kotlin] exception Handling (0) | 2021.12.20 |
---|---|
[Spring boot / Kotlin] 생성/수정 시간 자동화 (0) | 2021.12.20 |