From 435298c0134bedebf1c2707d72405a18f78209d2 Mon Sep 17 00:00:00 2001 From: Alan Brault Date: Sat, 21 Mar 2026 08:46:54 -0400 Subject: [PATCH] chore(auth): refresh token and initial logout (broken) support Signed-off-by: Alan Brault --- .../demos/kotlinapi/config/SecurityConfig.kt | 1 + .../kotlinapi/controller/AuthController.kt | 71 ++++++++- .../kotlinapi/domain/model/RefreshToken.kt | 4 +- .../kotlinapi/domain/service/AuthService.kt | 2 + .../domain/service/AuthServiceImpl.kt | 49 +++++- .../domain/service/RefreshTokenService.kt | 11 +- .../domain/service/RefreshTokenServiceImpl.kt | 150 ++++++++++++++++++ .../security/JwtTokenProvider.kt | 6 +- .../user/RefreshTokenRepository.kt | 2 +- 9 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt index 6ae38d5..2c19174 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/config/SecurityConfig.kt @@ -51,6 +51,7 @@ class SecurityConfig { "/api/v1/auth/refresh", "/api/v1/auth/register", ).permitAll() + .pathMatchers("/api/v1/auth/logout").authenticated() .anyExchange() .authenticated() } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt index 9d14730..5bd0009 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/controller/AuthController.kt @@ -5,14 +5,19 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.security.SecurityRequirement import io.swagger.v3.oas.annotations.tags.Tag import io.visus.demos.kotlinapi.api.ApiVersion import io.visus.demos.kotlinapi.api.dto.AuthResponse +import io.visus.demos.kotlinapi.api.dto.ErrorResponse import io.visus.demos.kotlinapi.api.dto.LoginRequest +import io.visus.demos.kotlinapi.api.dto.RefreshTokenRequest +import io.visus.demos.kotlinapi.domain.model.User import io.visus.demos.kotlinapi.domain.service.AuthService import jakarta.validation.Valid import org.springframework.http.MediaType import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController @@ -39,16 +44,12 @@ class AuthController( ApiResponse( responseCode = "400", description = "Invalid request payload", - content = [Content(schema = Schema(implementation = AuthResponse::class))], + content = [Content(schema = Schema(implementation = ErrorResponse::class))], ), ApiResponse( responseCode = "401", description = "Unauthorized, invalid email or password", - content = [Content(schema = Schema(implementation = AuthResponse::class))], - ), - ApiResponse( - responseCode = "403", - description = "Forbidden", + content = [Content(schema = Schema(implementation = ErrorResponse::class))], ), ], ) @@ -58,4 +59,62 @@ class AuthController( authService.login(request.email, request.password).let { ResponseEntity.ok(it) } + + @PostMapping("/auth/logout", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Logout", + description = "Revokes all refresh tokens for the authenticated user", + security = [SecurityRequirement(name = "bearerAuth")], + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "204", + description = "Successfully logged out", + ), + ApiResponse( + responseCode = "401", + description = "Not authenticated", + content = [Content(schema = Schema(implementation = ErrorResponse::class))], + ), + ], + ) + suspend fun logout( + @AuthenticationPrincipal user: User, + ): ResponseEntity { + authService.logout(user.id!!) + return ResponseEntity.noContent().build() + } + + @PostMapping("/auth/refresh", produces = [MediaType.APPLICATION_JSON_VALUE]) + @Operation( + summary = "Refresh access token", + description = "Get a new access token using a valid refresh token", + security = [], + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Successfully refreshed token", + content = [Content(schema = Schema(implementation = AuthResponse::class))], + ), + ApiResponse( + responseCode = "400", + description = "Invalid request payload", + content = [Content(schema = Schema(implementation = ErrorResponse::class))], + ), + ApiResponse( + responseCode = "401", + description = "Invalid or expired refresh token", + content = [Content(schema = Schema(implementation = ErrorResponse::class))], + ), + ], + ) + suspend fun refreshToken( + @Valid @RequestBody request: RefreshTokenRequest, + ): ResponseEntity { + val response = authService.refreshToken(request.refreshToken) + return ResponseEntity.ok(response) + } } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt index fc58051..50aa86a 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/model/RefreshToken.kt @@ -1,5 +1,6 @@ package io.visus.demos.kotlinapi.domain.model +import org.bson.types.ObjectId import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.Id import org.springframework.data.annotation.LastModifiedDate @@ -15,7 +16,7 @@ data class RefreshToken( @Indexed(unique = true) val tokenHash: String, @Indexed - val userId: String, + val userId: ObjectId, @Indexed(expireAfter = "30d") val expiresAt: Date, var isRevoked: Boolean = false, @@ -25,4 +26,5 @@ data class RefreshToken( val createdAt: Instant? = null, @LastModifiedDate val updatedAt: Instant? = null, + var revokedAt: Instant? = null, ) diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt index d15a0e5..c462e42 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthService.kt @@ -8,6 +8,8 @@ interface AuthService { password: String, ): AuthResponse + suspend fun logout(userId: String) + suspend fun refreshToken(refreshToken: String): AuthResponse suspend fun register( diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt index ed490e6..b80ca5e 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/AuthServiceImpl.kt @@ -2,6 +2,9 @@ package io.visus.demos.kotlinapi.domain.service import io.visus.demos.kotlinapi.api.dto.AuthResponse import io.visus.demos.kotlinapi.config.JwtProperties +import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException +import io.visus.demos.kotlinapi.domain.exception.InvalidTokenException +import io.visus.demos.kotlinapi.domain.exception.TokenRevokedException import io.visus.demos.kotlinapi.domain.model.User import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider import io.visus.demos.kotlinapi.infrastructure.user.UserRepository @@ -9,6 +12,7 @@ import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service +import java.util.UUID @Service class AuthServiceImpl( @@ -16,6 +20,7 @@ class AuthServiceImpl( private val passwordEncoder: PasswordEncoder, private val jwtTokenProvider: JwtTokenProvider, private val jwtProperties: JwtProperties, + private val refreshTokenService: RefreshTokenService, ) : AuthService { override suspend fun login( email: String, @@ -29,11 +34,37 @@ class AuthServiceImpl( throw BadCredentialsException("Invalid password for email: $email") } - return generateAuthResponse(user) + return generateAuthResponse(user, newFamily = true) + } + + override suspend fun logout(userId: String) { + refreshTokenService.revokeAllUserTokens(userId) } override suspend fun refreshToken(refreshToken: String): AuthResponse { - TODO("Not yet implemented") + try { + val email = jwtTokenProvider.extractUsername(refreshToken) + + val user = + userRepository.findByEmail(email) + ?: throw UsernameNotFoundException("User not found with email: $email") + + val validatedToken = + refreshTokenService.validateAndRotate( + userId = user.id!!, + tokenString = refreshToken, + ) + + return generateAuthResponse(user, newFamily = false, existingFamilyId = validatedToken.familyId) + } catch (e: SecurityException) { + throw e + } catch (e: ExpiredTokenException) { + throw e + } catch (_: TokenRevokedException) { + throw InvalidTokenException("Token has been revoked") + } catch (e: Exception) { + throw InvalidTokenException("Invalid refresh token: {$e.message}") + } } override suspend fun register( @@ -43,9 +74,19 @@ class AuthServiceImpl( TODO("Not yet implemented") } - private fun generateAuthResponse(user: User): AuthResponse { + private suspend fun generateAuthResponse( + user: User, + newFamily: Boolean = false, + existingFamilyId: String? = null, + ): AuthResponse { val accessToken = jwtTokenProvider.generateAccessToken(user) - val refreshToken = jwtTokenProvider.generateRefreshToken(user) + + val familyId = if (newFamily) UUID.randomUUID().toString() else existingFamilyId!! + val refreshToken = + refreshTokenService.createRefreshToken( + userId = user.id!!, + familyId = familyId, + ) return AuthResponse( accessToken = accessToken, diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt index 1edf3b7..5a8133c 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenService.kt @@ -3,14 +3,17 @@ package io.visus.demos.kotlinapi.domain.service import io.visus.demos.kotlinapi.domain.model.RefreshToken interface RefreshTokenService { - fun createRefreshToken( + suspend fun createRefreshToken( userId: String, familyId: String, ): String - fun validateAndRotate(tokenString: String): RefreshToken + suspend fun validateAndRotate( + userId: String, + tokenString: String, + ): RefreshToken - fun revokeAllUserTokens(userId: String) + suspend fun revokeAllUserTokens(userId: String) - fun revokeTokenFamily(familyId: String) + suspend fun revokeTokenFamily(familyId: String) } diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt new file mode 100644 index 0000000..0abb780 --- /dev/null +++ b/src/main/kotlin/io/visus/demos/kotlinapi/domain/service/RefreshTokenServiceImpl.kt @@ -0,0 +1,150 @@ +package io.visus.demos.kotlinapi.domain.service + +import io.visus.demos.kotlinapi.config.JwtProperties +import io.visus.demos.kotlinapi.domain.exception.ExpiredTokenException +import io.visus.demos.kotlinapi.domain.exception.TokenRevokedException +import io.visus.demos.kotlinapi.domain.model.JwtTokenType +import io.visus.demos.kotlinapi.domain.model.RefreshToken +import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider +import io.visus.demos.kotlinapi.infrastructure.user.RefreshTokenRepository +import io.visus.demos.kotlinapi.infrastructure.user.UserRepository +import kotlinx.coroutines.flow.collect +import org.bson.types.ObjectId +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.Instant +import java.util.Base64 +import java.util.Date +import java.util.UUID + +@Service +class RefreshTokenServiceImpl( + private val refreshTokenRepository: RefreshTokenRepository, + private val jwtTokenProvider: JwtTokenProvider, + private val userRepository: UserRepository, + private val jwtProperties: JwtProperties, +) : RefreshTokenService { + companion object { + private const val MAX_SESSIONS_PER_USER = 5 + } + + override suspend fun createRefreshToken( + userId: String, + familyId: String, + ): String { + val activeCount = refreshTokenRepository.countActive(userId) + + if (activeCount >= MAX_SESSIONS_PER_USER) { + val sessions = + refreshTokenRepository + .findActive(userId) + .sortedBy { it.createdAt } + + val oldest = sessions.first() + + oldest.isRevoked = true + oldest.revokedAt = Instant.now() + + refreshTokenRepository.save(oldest) + } + + val user = + userRepository.findById(userId) + ?: throw IllegalArgumentException("User not found: $userId") + + val jwtToken = jwtTokenProvider.generateRefreshToken(user) + + val tokenHash = hashToken(jwtToken) + + val expiresAt = + Date.from( + Instant.now().plusMillis(jwtProperties.refreshTokenExpiration), + ) + + val refreshToken = + RefreshToken( + tokenHash = tokenHash, + userId = ObjectId(userId), + expiresAt = expiresAt, + familyId = familyId, + ) + + refreshTokenRepository.save(refreshToken) + + return jwtToken + } + + override suspend fun validateAndRotate( + userId: String, + tokenString: String, + ): RefreshToken { + val user = + userRepository.findById(userId) + ?: throw IllegalArgumentException("User not found: $userId") + + if (!jwtTokenProvider.isTokenValid(tokenString, user, JwtTokenType.REFRESH)) { + throw IllegalArgumentException("Invalid JWT signature or expired") + } + + val tokenHash = hashToken(tokenString) + val storedToken = + refreshTokenRepository.findByTokenHash(tokenHash) + ?: throw IllegalArgumentException("Refresh token not found") + + if (storedToken.isRevoked) { + val familyTokens = refreshTokenRepository.findByFamilyId(storedToken.familyId) + val hasActiveTokens = + familyTokens.any { + !it.isRevoked + } + + if (hasActiveTokens) { + revokeTokenFamily(storedToken.familyId) + throw SecurityException("Token reuse detected. All sessions revoked.") + } + + throw TokenRevokedException("Token already used") + } + + if (jwtTokenProvider.isTokenExpired(tokenString)) { + throw ExpiredTokenException("Token expired") + } + + storedToken.isRevoked = true + storedToken.revokedAt = Instant.now() + + refreshTokenRepository.save(storedToken) + + return storedToken + } + + override suspend fun revokeAllUserTokens(userId: String) { + val tokens = refreshTokenRepository.findActive(userId) + + tokens.forEach { + it.isRevoked = true + it.revokedAt = Instant.now() + } + + refreshTokenRepository.saveAll(tokens).collect() + } + + override suspend fun revokeTokenFamily(familyId: String) { + val tokens = refreshTokenRepository.findByFamilyId(familyId) + + tokens.forEach { + it.isRevoked = true + it.revokedAt = Instant.now() + } + + refreshTokenRepository.saveAll(tokens).collect() + } + + private fun hashToken(token: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val bytes = digest.digest(token.toByteArray(StandardCharsets.UTF_8)) + + return Base64.getEncoder().encodeToString(bytes) + } +} diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt index 4079b2f..0dc1db9 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/security/JwtTokenProvider.kt @@ -76,7 +76,11 @@ class JwtTokenProvider( .builder() .subject(userDetails.username) .claim("type", JwtTokenType.REFRESH.value) - .issuedAt(now) + .id( + java.util.UUID + .randomUUID() + .toString(), + ).issuedAt(now) .expiration(expiry) .signWith(secretKey, Jwts.SIG.HS512) .compact() diff --git a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt index 7fa1fa9..bca752d 100644 --- a/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt +++ b/src/main/kotlin/io/visus/demos/kotlinapi/infrastructure/user/RefreshTokenRepository.kt @@ -8,7 +8,7 @@ import org.springframework.data.repository.kotlin.CoroutineCrudRepository interface RefreshTokenRepository : CoroutineCrudRepository { suspend fun deleteByUserId(userId: String): Long - @Query("{ 'userId': ?0, 'isRevoked': false }") + @Query(value = "{ 'userId': ?0, 'isRevoked': false }", count = true) suspend fun countActive(userId: String): Long suspend fun findByFamilyId(familyId: String): List