Nie przedłużając, proszę o krytykę mojej implementacji mechanizmu autentykacji użytkownika przy użyciu access oraz refresh tokenów w aplikacji Springowej napisanej w Kotlinie. Zależy mi zarówno na weryfikacji ewentualnych błędów logicznych w działaniu mechnizmu logowania, jak i zwyczajnym sprawdzeniu kodu pod względem best practices.
- Logowanie użytkownika. Po udanej walidacji użytkownika, zwracany jest HTTP 200 z pustym body, a same tokeny wracają jako HttpOnly cookies. Dodatkowo, jeżeli w requeście przekazany został parameter
stayLoggedIn: true
, to refresh token danego użytkownika zostaje zapisany w bazie danych.
@RestController
@RequestMapping("/api/auth")
internal class AuthApi(private val signInService: SignInService) {
@PostMapping("/sign-in")
fun signIn(@RequestBody request: SignInRequest, response: HttpServletResponse): ResponseEntity<Void> {
val signInResponse = signInService.signIn(request)
response.addCookie(signInResponse.accessToken.cookie)
response.addCookie(signInResponse.refreshToken.cookie)
return ResponseEntity(HttpStatus.OK)
}
}
@Service
class SignInService(
private val userRepository: UserRepository,
private val tokenRepository: RefreshTokenRepository,
private val tokenUtils: JwtTokenUtils,
private val passwordEncoder: PasswordEncoder
) {
fun signIn(request: SignInRequest): AccessTokenDto {
return userRepository.findByEmail(request.email)
?.let { user -> authenticate(request, user) }
?: throw EmailNotFound(request.email)
}
private fun authenticate(request: SignInRequest, user: User): AccessTokenDto {
when (validPassword(request.password, user.password)) {
true -> {
val refreshToken = tokenUtils.generateRefreshToken(user.id)
val response = AccessTokenDto(
TokenCookie.accessTokenCookie(tokenUtils.generateAccessToken(user.id)),
TokenCookie.refreshTokenCookie(refreshToken)
)
if (request.stayLoggedIn) saveUserRefreshToken(user, refreshToken)
return response
}
false -> throw IncorrectPassword()
}
}
private fun saveUserRefreshToken(user: User, refreshToken: String) {
tokenRepository.findByUserId(user.id)?.let {
tokenRepository.save(it.copy(token = refreshToken))
} ?: run {
tokenRepository.save(RefreshToken(userId = user.id, token = refreshToken))
}
}
private fun validPassword(providedPassword: String, actualPassword: String) =
passwordEncoder.matches(providedPassword, actualPassword)
}
Użyte wyżej TokenCookie
wygląda w ten sposób
enum class TokenType(val value: String) {
ACCESS_TOKEN("access_token"), REFRESH_TOKEN("refresh_token")
}
class TokenCookie(name: String, token: String) {
val cookie: Cookie = Cookie(name, token)
init {
this.cookie.isHttpOnly = true
this.cookie.path = "/"
}
companion object {
fun accessTokenCookie(token: String) = TokenCookie(ACCESS_TOKEN.value, token)
fun refreshTokenCookie(token: String) = TokenCookie(REFRESH_TOKEN.value, token)
}
}
- Weryfikacja access tokenu przy kolejnych requestach. Pierwszym krokiem jest sprawdzenie poprawności access tokenu, jeżeli ten jest poprawny, to w
SecurityContext
zostaje ustawiony odpowiedni obiekt autentykacji a także sprawdzane jest czy w requeście był dołączony poprawny refresh token, jeżeli tak to access token zostaje wygenerowany ponownie z odświeżonym expiration date. Ma to na celu uniknięcie sytuacji gdy ktoś zaloguje się bez opcji "Pozostań zalogowany", aktywnie korzysta z serwisu przez ponad 15 minut i nagle jego token wygasa, przez co wymagane jest ponowne wpisanie nazwy użytkownika i hasła.
fun validateToken(authToken: String?, tokenType: TokenType = ACCESS_TOKEN): TokenValidationResult {
return try {
val claims = Jwts.parser().setSigningKey(tokenSecret).parseClaimsJws(authToken)
if (claims.body["type"] == tokenType.value) SUCCESS else INVALID
} catch (ex: UnsupportedJwtException) {
INVALID
} catch (ex: MalformedJwtException) {
INVALID
} catch (ex: IllegalArgumentException) {
INVALID
} catch (ex: SignatureException) {
INVALID
} catch (ex: ExpiredJwtException) {
EXPIRED
}
}
@Component
internal class JwtTokenFilter(
private val tokenUtils: JwtTokenUtils,
private val expiredTokenHandler: ExpiredTokenHandler
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
if (!request.requestURI.startsWith("/api/auth")) {
validateTokens(request, response)
}
filterChain.doFilter(request, response)
}
private fun validateTokens(
request: HttpServletRequest,
response: HttpServletResponse
) {
getTokenFromCookies(request, ACCESS_TOKEN)?.let {
val tokenValidationResult = tokenUtils.validateToken(it.value)
val userId = tokenUtils.getId(it.value)
if (tokenValidationResult == SUCCESS) {
updateSecurityContext(userId)
extendAccessToken(request, response, userId)
} else if (tokenValidationResult == EXPIRED) {
val refreshToken = getTokenFromCookies(request, REFRESH_TOKEN)?.value
if (expiredTokenHandler.checkRefreshEligibility(response, it.value, refreshToken)) {
updateSecurityContext(userId)
}
}
}
}
private fun updateSecurityContext(userId: Int) {
SecurityContextHolder.getContext().authentication =
UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList())
}
private fun getTokenFromCookies(request: HttpServletRequest, tokenType: TokenType) =
request.cookies?.find { cookie -> cookie.name == tokenType.value }
private fun extendAccessToken(request: HttpServletRequest, response: HttpServletResponse, userId: Int) {
getTokenFromCookies(request, REFRESH_TOKEN)?.let {
if (tokenUtils.validateToken(it.value, REFRESH_TOKEN) == SUCCESS) {
response.addCookie(TokenCookie.accessTokenCookie(tokenUtils.generateAccessToken(userId)).cookie)
}
}
}
}
- Obsługa refresh tokenu. Jak widać w powyższym kodzie, są 3 rezultaty walidacji tokenu.
SUCCESS
- wszysto ok,INVALID
- bliżej nieokreślony błąd orazEXPIRED
, mówiący o tym ze token stracił ważność. W takiej sytuacji sprawdzany jest refresh token. Pierwszym krokiem jest sprawdzenie czy w bazie danych istnieje refresh token dla danego użytkownika (czyli czy zaznaczył on opcję "Pozostań zalogowany" przy logowaniu. Następnie sam token jest walidowany z tokenem który przyszedł w requeście i jeżeli wszystko jest ok, to generowana jest nowa para tokenów.
@Service
class ExpiredTokenHandler(private val tokenRepository: RefreshTokenRepository, private val tokenUtils: JwtTokenUtils) {
fun checkRefreshEligibility(response: HttpServletResponse, expiredToken: String, refreshToken: String?): Boolean {
val userId = tokenUtils.getId(expiredToken)
val savedRefreshToken = tokenRepository.findByUserId(userId)
return if (validateRefreshToken(refreshToken, savedRefreshToken)) {
val (newAccessToken, newRefreshToken) = generateNewTokens(userId, savedRefreshToken!!)
updateCookies(newAccessToken, newRefreshToken, response)
true
} else {
false
}
}
private fun validateRefreshToken(
refreshToken: String?,
savedRefreshToken: RefreshToken?
): Boolean {
return refreshToken != null &&
savedRefreshToken != null &&
refreshToken == savedRefreshToken.token &&
tokenUtils.validateToken(refreshToken, REFRESH_TOKEN) == SUCCESS
}
private fun updateCookies(accessToken: String, refreshToken: String, response: HttpServletResponse): Boolean {
response.addCookie(accessTokenCookie(accessToken).cookie)
response.addCookie(refreshTokenCookie(refreshToken).cookie)
return true
}
private fun generateNewTokens(userId: Int, savedRefreshToken: RefreshToken): Pair<String, String> {
val newAccessToken = tokenUtils.generateAccessToken(userId)
val newRefreshToken = tokenUtils.generateRefreshToken(userId)
tokenRepository.save(savedRefreshToken.copy(token = newRefreshToken))
return Pair(newAccessToken, newRefreshToken)
}
}