[Spring Boot] Spring Security + Kotlin + JWT를 이용한 로그인
들어가기 전
이번 포스팅에서는 스프링 시큐리티 + JWT + 코틀린을 사용하여 일반 로그인 구현하는 방법에 대해서 알아보겠습니다.
만약 스프링 시큐리티 + Oauth2.0 + JWT + 자바를 이용한 소셜 로그인 구현하는 방법에 대해서 궁금하신 분은 아래 포스팅을 참고하시는 것을 추천드립니다.
https://hoestory.tistory.com/32
설정
build.gradle.kts
//security
implementation("org.springframework.boot:spring-boot-starter-security")
//jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2")
application.yml
jwt:
secret-key : ${SECRET_KEY}
access-token-expire-time : 300
refresh-token-expire-time : 7200
secret-key 같은 경우는 base64로 encode 한 값입니다. base64 Encode 하실 분은 아래 사이트에서 진행하시면 됩니다.
access-token의 만료시간은 5분으로 설정하였습니다.
https://www.base64encode.org/ko/
지금까지 일반 로그인을 구현하기 위한 설정에 대해서 알아보았습니다. 이제 로그인을 구현하는 코드에 대해서 알아보겠습니다.
RefreshToken, RefreshTokenRepository
@Entity
class RefreshToken(
@Enumerated(EnumType.STRING)
val role: Role,
@Column(nullable = false)
val token: String,
@Column(nullable = false)
val expireAt: LocalDateTime,
@OneToOne(fetch = FetchType.LAZY)
val account: Account,
@Column(nullable = false)
val accessToken: String,
@Column(nullable = false)
val accessTokenExpireAt: LocalDateTime
) : BaseEntity() {
}
// repository
interface RefreshTokenRepository : JpaRepository<RefreshToken, Long> {
fun existsByAccountId(accountId: Long): Boolean
fun findByAccountId(accountId: Long): RefreshToken?
fun deleteByAccountId(accountId: Long)
fun findByAccessToken(accessToken: String): RefreshToken?
}
SecurityConfig
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
class SecurityConfig(
private val tokenProvider: TokenProvider,
private val jwtAccessDeniedHandler: JwtAccessDeniedHandler,
private val refreshTokenRepository: RefreshTokenRepository,
) : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http.httpBasic().disable()
.cors().configurationSource(corsConfigurationSource())
.and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(
LoginFilter(tokenProvider, refreshTokenRepository),
UsernamePasswordAuthenticationFilter::class.java
)
.exceptionHandling()
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.authorizeRequests()
.antMatchers("/**")
.permitAll()
.anyRequest().authenticated()
}
}
- cors().configurationSource(corsConfigurationSource()) : Cors에 대한 설정
- . addFilterBefore(LoginFilter(tokenProvider, refreshTokenRepository), UsernamePasswordAuthenticationFilter::class.java) : 로그인을 할 때 UsernamePasswordAuthenticationFilter 전에 실행되어야 하는 필터를 등록하는 설정
- accessDeniedHandler(jwtAccessDeniedHandler) : 인가 안된 사용자가 접근했을 때에 대한 처리 설정
- .authorizeRequests().antMatchers("/**") : 접근 경로 설정
- @EnableGlobalMethodSecurity(securedEnabled = true) : @Secured를 사용하여 인가 절차를 수행할 수 있게 해주는 설정
LoginFilter
- 디스패처 서블릿에 도달하기 전에 토큰의 유효성 검증 역할을 하는 필터
class LoginFilter(
private val tokenProvider: TokenProvider,
private val refreshTokenRepository: RefreshTokenRepository,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
tokenProvider.resolveToken(request = request as? HttpServletRequest?)?.also {
val loginToken = it.split(" ")[1]
val findToken = refreshTokenRepository.findByAccessToken(loginToken)
if (findToken == null || LocalDateTime.now()
.isAfter(findToken.accessTokenExpireAt) || !it.toLowerCase()
.startsWith("bearer ") || !tokenProvider.validateToken(loginToken)
) {
response.sendError(401)
return
}
val authentication = tokenProvider.getAuthentication(loginToken)
SecurityContextHolder.getContext().authentication = authentication
}
filterChain.doFilter(request, response)
}
}
JwtAccessDeniedHandler
- 인가 과정에서 권한이 없는 사용자가 접근했을때에 대한 핸들러
@Component
class JwtAccessDeniedHandler : AccessDeniedHandler{
override fun handle(request: HttpServletRequest?, response: HttpServletResponse, accessDeniedException: AccessDeniedException?) {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
LoginToken
- 토큰, 토큰 만료시간을 담고 있는 객체
data class LoginToken(
val id: Long,
val grantType: String = "Bearer",
val accessToken: String,
val refreshToken: String,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val accessTokenExpiredAt : LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val refreshTokenExpiredAt : LocalDateTime,
)
TokenProvider
- 토큰(access, refresh) 생성 및 만료시간 체크
- 토큰에 담겨져 있는 사용자 정보 추출
- 토큰 유효성 검증
@Component
class TokenProvider(
private val accountRepository: AccountRepository,
@Value("\${jwt.secret-key}")
private val secretKey: String,
@Value("\${jwt.access-token-expire-time}")
private val ACCESS_TOKEN_EXPIRE_TIME: String,
@Value("\${jwt.refresh-token-expire-time}")
private val REFRESH_TOKEN_EXPIRE_TIME: String,
private val refreshTokenRepository: RefreshTokenRepository,
) {
val key: Key by lazy {
Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))
}
/**
* accessToken, refreshToken 생성
*/
fun getToken(id: Long, role: String, now: Date): LoginToken {
val now: LocalDateTime = LocalDateTime.now()
val accessTokenTime: LocalDateTime =
now.plus(ACCESS_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
val refreshTokenTime: LocalDateTime =
now.plus(REFRESH_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
val accessToken = createToken(
id = id,
tokenExpireTime = accessTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
role = role
)
val loginUserRole = Role.valueOf(role)
var loginToken: LoginToken? = refreshTokenRepository.findByAccountId(id)?.let {
if (now.isAfter(it.expireAt)) {
throw CustomException(ExceptionCode.REFRESH_TOKEN_EXPIRE)
}
val account = accountRepository.findByIdOrNull(it.id)
?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
LoginToken(
accessToken = accessToken,
refreshToken = it.token,
accessTokenExpiredAt = accessTokenTime,
refreshTokenExpiredAt = it.expireAt,
id = account.id
)
}
if (loginToken == null) {
val refreshToken = createToken(
id = id,
tokenExpireTime = refreshTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
role = role
)
val account = accountRepository.findByIdOrNull(id)
?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
val savedRefreshToken = refreshTokenRepository.save(
RefreshToken(
account = account,
token = refreshToken,
expireAt = refreshTokenTime,
role = loginUserRole,
accessToken = accessToken,
accessTokenExpireAt = accessTokenTime
)
)
loginToken = LoginToken(
accessToken = accessToken,
refreshToken = refreshToken,
accessTokenExpiredAt = accessTokenTime,
refreshTokenExpiredAt = refreshTokenTime,
id = savedRefreshToken.id
)
}
return loginToken
}
fun getAuthentication(token: String): Authentication {
val account = accountRepository.findByIdOrNull(getAccount(token)) ?: throw CustomException(
ExceptionCode.NOT_EXSISTS_INFO
)
val loginUserDetail = LoginUserDetail(account.email, account.role.name);
return UsernamePasswordAuthenticationToken(loginUserDetail, "", loginUserDetail.authorities)
}
fun getAccount(token: String): Long {
return Jwts.parserBuilder().setSigningKey(secretKey).build()
.parseClaimsJws(token).body.subject.toLong()
}
fun validateToken(token: String): Boolean {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body
return true;
} catch (e: SecurityException) {
e.printStackTrace()
} catch (e: ExpiredJwtException) {
return false
} catch (e: UnsupportedJwtException) {
e.printStackTrace()
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
return false
}
fun resolveToken(request: HttpServletRequest?): String? {
return request?.getHeader("Authorization")
}
//토큰 생성
private fun createToken(id: Long, tokenExpireTime: Long, role: String): String {
return Jwts.builder()
.setSubject(id.toString())
.claim("auth", role)
.setExpiration(Date(tokenExpireTime))
.signWith(key, SignatureAlgorithm.HS512)
.compact()
}
}
토큰 생성
private fun createToken(id: Long, tokenExpireTime: Long, role: String): String {
return Jwts.builder()
.setSubject(id.toString())
.claim("auth", role)
.setExpiration(Date(tokenExpireTime))
.signWith(key, SignatureAlgorithm.HS512)
.compact()
}
토큰 유효성 검증
fun validateToken(token: String): Boolean {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body
return true;
} catch (e: SecurityException) {
e.printStackTrace()
} catch (e: ExpiredJwtException) {
return false
} catch (e: UnsupportedJwtException) {
e.printStackTrace()
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
return false
}
헤더에 담긴 토큰 추출
fun resolveToken(request: HttpServletRequest?): String? {
return request?.getHeader("Authorization")
}
토큰에 담긴 사용자 정보 추출
fun getAccount(token: String): Long {
return Jwts.parserBuilder().setSigningKey(secretKey).build()
.parseClaimsJws(token).body.subject.toLong()
}
Access, Refresh 토큰 저장 및 응답
fun getToken(id: Long, role: String, now: Date): LoginToken {
val now: LocalDateTime = LocalDateTime.now()
val accessTokenTime: LocalDateTime =
now.plus(ACCESS_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
val refreshTokenTime: LocalDateTime =
now.plus(REFRESH_TOKEN_EXPIRE_TIME.toLong(), ChronoUnit.SECONDS)
val accessToken = createToken(
id = id,
tokenExpireTime = accessTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
role = role
)
val loginUserRole = Role.valueOf(role)
var loginToken: LoginToken? = refreshTokenRepository.findByAccountId(id)?.let {
if (now.isAfter(it.expireAt)) {
throw CustomException(ExceptionCode.REFRESH_TOKEN_EXPIRE)
}
val account = accountRepository.findByIdOrNull(it.id)
?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
LoginToken(
accessToken = accessToken,
refreshToken = it.token,
accessTokenExpiredAt = accessTokenTime,
refreshTokenExpiredAt = it.expireAt,
id = account.id
)
}
if (loginToken == null) {
val refreshToken = createToken(
id = id,
tokenExpireTime = refreshTokenTime.toInstant(ZoneOffset.UTC).toEpochMilli(),
role = role
)
val account = accountRepository.findByIdOrNull(id)
?: throw CustomException(ExceptionCode.NOT_EXSISTS_INFO)
val savedRefreshToken = refreshTokenRepository.save(
RefreshToken(
account = account,
token = refreshToken,
expireAt = refreshTokenTime,
role = loginUserRole,
accessToken = accessToken,
accessTokenExpireAt = accessTokenTime
)
)
loginToken = LoginToken(
accessToken = accessToken,
refreshToken = refreshToken,
accessTokenExpiredAt = accessTokenTime,
refreshTokenExpiredAt = refreshTokenTime,
id = savedRefreshToken.id
)
}
return loginToken
}
@LoginUser
- 로그인한 사용자 정보를 가지고 있는 커스텀 어노테이션
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
annotation class LoginUser
LoginInterception
- @LoginUser 어노테이션에 사용자 정보를 담아주는 인터셉터
@Component
class LoginInterception(
private val accountRepository: AccountRepository,
) : HandlerMethodArgumentResolver {
override fun supportsParameter(parameter: MethodParameter): Boolean {
return parameter.hasParameterAnnotation(LoginUser::class.java)
}
override fun resolveArgument(
parameter: MethodParameter,
mavContainer: ModelAndViewContainer?,
webRequest: NativeWebRequest,
binderFactory: WebDataBinderFactory?
): Any? {
val authentication = SecurityContextHolder.getContext().authentication
val role = authentication.authorities.first().toString().split("_")
var authenRole =
if (role.size == 2) {
role[1]
} else {
"${role[1]}_${role[2]}"
}
val account = accountRepository.findByEmailAndRole(
authentication.name, Role.valueOf(authenRole)
)
?: throw CustomException(ExceptionCode.TOKEN_EXPIRE)
return LoginUserInfo(account.id, account.role)
}
}
LoginUserDetail
- 사용자에 대한 권한, 정보를 담는 객체
class LoginUserDetail(
val email: String,
val role: String?,
) : UserDetails{
override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
if(role == null) {
return mutableListOf()
}
return mutableListOf(SimpleGrantedAuthority("ROLE_${role}"))
}
override fun getPassword(): String = ""
override fun getUsername(): String = email
override fun isAccountNonExpired(): Boolean = true
override fun isAccountNonLocked(): Boolean = true
override fun isCredentialsNonExpired(): Boolean = true
override fun isEnabled(): Boolean = true
}
이제 위 코드 기반으로 로그인 및 사용자 정보 조회 API를 만들어서 결과를 확인해 보겠습니다.
※참고 : 간단하게 보여주기 위해 Service 계층 없이 예제를 진행하겠습니다.
data class LoginToken(
val id: Long,
val grantType: String = "Bearer",
val accessToken: String,
val refreshToken: String,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val accessTokenExpiredAt : LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val refreshTokenExpiredAt : LocalDateTime,
)
data class AccountInfo(
val id: Long,
val name: String,
@JsonFormat(pattern = "yyyy-MM-dd")
val birth: LocalDate
)
@ApiModel(value = "로그인 요청")
data class LoginInfo(
@ApiModelProperty(value = "이메일")
val email: String,
@ApiModelProperty(value = "비밀번호")
val password: String,
@ApiModelProperty(value = "로그인 타입")
val loginType: LoginType,
)
data class LoginUserInfo(val id: Long, val role: Role)
@RestController
@RequestMapping("/api")
class AccountController(
private val accountRepository: AccountRepository,
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
private val refreshTokenRepository: RefreshTokenRepository,
private val tokenProvider: TokenProvider,
) {
@ApiOperation(value = "로그인")
@PostMapping("/login")
fun login(@RequestBody loginInfo: LoginInfo): LoginToken {
val account = accountRepository.findByEmail(loginInfo.email)
?: throw CustomException(ExceptionCode.NOT_MATCH_ID_OR_PASSWORD)
require(bCryptPasswordEncoder.matches(loginInfo.password, account.password)) {
throw CustomException(ExceptionCode.NOT_MATCH_ID_OR_PASSWORD)
}
if (refreshTokenRepository.existsByAccountId(account.id)) {
refreshTokenRepository.deleteByAccountId(account.id)
}
return tokenProvider.getToken(account.id, account.role.name, Date())
}
@ApiOperation(value = "사용자 정보")
@GetMapping
@Secured("ROLE_USER")
fun getInfo(@LoginUser loginUserInfo: LoginUserInfo) : AccountInfo {
val account = accountRepository.findByIdOrNull(loginUserInfo.id) ?: throw CustomException(
ExceptionCode.NOT_EXSISTS_INFO
)
return AccountInfo(id = account.id, name = account.name, birth = account.birth)
}
}
- @Secured : 해당 어노테이션을 통해 API에 접근할 수 있는 권한을 설정할 수 있습니다.
사용자 권한이 ADMIN일 경우
사용자 권한이 USER일 경우
'Spring Boot' 카테고리의 다른 글
[Spring] 나태지옥에 빠지지 않기 위한 스케줄러 (1) | 2024.04.07 |
---|---|
[Spring Boot] Spring Boot + Kotlin + AWS S3를 이용한 이미지 다루는 방법 (0) | 2024.03.18 |
[Spring Boot] @Transactional readOnly 속성에 따른 비즈니스 로직 분리 (1) | 2023.12.21 |
[Spring Boot] 스프링 Event에 대하여 (0) | 2023.12.01 |
[Spring Boot] 크롤링, 메일 전송 및 PDF 암호화 (0) | 2023.08.10 |
댓글
이 글 공유하기
다른 글
-
[Spring] 나태지옥에 빠지지 않기 위한 스케줄러
[Spring] 나태지옥에 빠지지 않기 위한 스케줄러
2024.04.07 -
[Spring Boot] Spring Boot + Kotlin + AWS S3를 이용한 이미지 다루는 방법
[Spring Boot] Spring Boot + Kotlin + AWS S3를 이용한 이미지 다루는 방법
2024.03.18 -
[Spring Boot] @Transactional readOnly 속성에 따른 비즈니스 로직 분리
[Spring Boot] @Transactional readOnly 속성에 따른 비즈니스 로직 분리
2023.12.21 -
[Spring Boot] 스프링 Event에 대하여
[Spring Boot] 스프링 Event에 대하여
2023.12.01