chore(auth): refresh token and initial logout (broken) support
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -51,6 +51,7 @@ class SecurityConfig {
|
|||||||
"/api/v1/auth/refresh",
|
"/api/v1/auth/refresh",
|
||||||
"/api/v1/auth/register",
|
"/api/v1/auth/register",
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
.pathMatchers("/api/v1/auth/logout").authenticated()
|
||||||
.anyExchange()
|
.anyExchange()
|
||||||
.authenticated()
|
.authenticated()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.media.Schema
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
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.swagger.v3.oas.annotations.tags.Tag
|
||||||
import io.visus.demos.kotlinapi.api.ApiVersion
|
import io.visus.demos.kotlinapi.api.ApiVersion
|
||||||
import io.visus.demos.kotlinapi.api.dto.AuthResponse
|
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.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 io.visus.demos.kotlinapi.domain.service.AuthService
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
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.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -39,16 +44,12 @@ class AuthController(
|
|||||||
ApiResponse(
|
ApiResponse(
|
||||||
responseCode = "400",
|
responseCode = "400",
|
||||||
description = "Invalid request payload",
|
description = "Invalid request payload",
|
||||||
content = [Content(schema = Schema(implementation = AuthResponse::class))],
|
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
|
||||||
),
|
),
|
||||||
ApiResponse(
|
ApiResponse(
|
||||||
responseCode = "401",
|
responseCode = "401",
|
||||||
description = "Unauthorized, invalid email or password",
|
description = "Unauthorized, invalid email or password",
|
||||||
content = [Content(schema = Schema(implementation = AuthResponse::class))],
|
content = [Content(schema = Schema(implementation = ErrorResponse::class))],
|
||||||
),
|
|
||||||
ApiResponse(
|
|
||||||
responseCode = "403",
|
|
||||||
description = "Forbidden",
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -58,4 +59,62 @@ class AuthController(
|
|||||||
authService.login(request.email, request.password).let {
|
authService.login(request.email, request.password).let {
|
||||||
ResponseEntity.ok(it)
|
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<Unit> {
|
||||||
|
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<AuthResponse> {
|
||||||
|
val response = authService.refreshToken(request.refreshToken)
|
||||||
|
return ResponseEntity.ok(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.visus.demos.kotlinapi.domain.model
|
package io.visus.demos.kotlinapi.domain.model
|
||||||
|
|
||||||
|
import org.bson.types.ObjectId
|
||||||
import org.springframework.data.annotation.CreatedDate
|
import org.springframework.data.annotation.CreatedDate
|
||||||
import org.springframework.data.annotation.Id
|
import org.springframework.data.annotation.Id
|
||||||
import org.springframework.data.annotation.LastModifiedDate
|
import org.springframework.data.annotation.LastModifiedDate
|
||||||
@@ -15,7 +16,7 @@ data class RefreshToken(
|
|||||||
@Indexed(unique = true)
|
@Indexed(unique = true)
|
||||||
val tokenHash: String,
|
val tokenHash: String,
|
||||||
@Indexed
|
@Indexed
|
||||||
val userId: String,
|
val userId: ObjectId,
|
||||||
@Indexed(expireAfter = "30d")
|
@Indexed(expireAfter = "30d")
|
||||||
val expiresAt: Date,
|
val expiresAt: Date,
|
||||||
var isRevoked: Boolean = false,
|
var isRevoked: Boolean = false,
|
||||||
@@ -25,4 +26,5 @@ data class RefreshToken(
|
|||||||
val createdAt: Instant? = null,
|
val createdAt: Instant? = null,
|
||||||
@LastModifiedDate
|
@LastModifiedDate
|
||||||
val updatedAt: Instant? = null,
|
val updatedAt: Instant? = null,
|
||||||
|
var revokedAt: Instant? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface AuthService {
|
|||||||
password: String,
|
password: String,
|
||||||
): AuthResponse
|
): AuthResponse
|
||||||
|
|
||||||
|
suspend fun logout(userId: String)
|
||||||
|
|
||||||
suspend fun refreshToken(refreshToken: String): AuthResponse
|
suspend fun refreshToken(refreshToken: String): AuthResponse
|
||||||
|
|
||||||
suspend fun register(
|
suspend fun register(
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package io.visus.demos.kotlinapi.domain.service
|
|||||||
|
|
||||||
import io.visus.demos.kotlinapi.api.dto.AuthResponse
|
import io.visus.demos.kotlinapi.api.dto.AuthResponse
|
||||||
import io.visus.demos.kotlinapi.config.JwtProperties
|
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.domain.model.User
|
||||||
import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider
|
import io.visus.demos.kotlinapi.infrastructure.security.JwtTokenProvider
|
||||||
import io.visus.demos.kotlinapi.infrastructure.user.UserRepository
|
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.core.userdetails.UsernameNotFoundException
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AuthServiceImpl(
|
class AuthServiceImpl(
|
||||||
@@ -16,6 +20,7 @@ class AuthServiceImpl(
|
|||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val jwtTokenProvider: JwtTokenProvider,
|
private val jwtTokenProvider: JwtTokenProvider,
|
||||||
private val jwtProperties: JwtProperties,
|
private val jwtProperties: JwtProperties,
|
||||||
|
private val refreshTokenService: RefreshTokenService,
|
||||||
) : AuthService {
|
) : AuthService {
|
||||||
override suspend fun login(
|
override suspend fun login(
|
||||||
email: String,
|
email: String,
|
||||||
@@ -29,11 +34,37 @@ class AuthServiceImpl(
|
|||||||
throw BadCredentialsException("Invalid password for email: $email")
|
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 {
|
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(
|
override suspend fun register(
|
||||||
@@ -43,9 +74,19 @@ class AuthServiceImpl(
|
|||||||
TODO("Not yet implemented")
|
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 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(
|
return AuthResponse(
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package io.visus.demos.kotlinapi.domain.service
|
|||||||
import io.visus.demos.kotlinapi.domain.model.RefreshToken
|
import io.visus.demos.kotlinapi.domain.model.RefreshToken
|
||||||
|
|
||||||
interface RefreshTokenService {
|
interface RefreshTokenService {
|
||||||
fun createRefreshToken(
|
suspend fun createRefreshToken(
|
||||||
userId: String,
|
userId: String,
|
||||||
familyId: String,
|
familyId: String,
|
||||||
): 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -76,7 +76,11 @@ class JwtTokenProvider(
|
|||||||
.builder()
|
.builder()
|
||||||
.subject(userDetails.username)
|
.subject(userDetails.username)
|
||||||
.claim("type", JwtTokenType.REFRESH.value)
|
.claim("type", JwtTokenType.REFRESH.value)
|
||||||
.issuedAt(now)
|
.id(
|
||||||
|
java.util.UUID
|
||||||
|
.randomUUID()
|
||||||
|
.toString(),
|
||||||
|
).issuedAt(now)
|
||||||
.expiration(expiry)
|
.expiration(expiry)
|
||||||
.signWith(secretKey, Jwts.SIG.HS512)
|
.signWith(secretKey, Jwts.SIG.HS512)
|
||||||
.compact()
|
.compact()
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import org.springframework.data.repository.kotlin.CoroutineCrudRepository
|
|||||||
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
|
interface RefreshTokenRepository : CoroutineCrudRepository<RefreshToken, String> {
|
||||||
suspend fun deleteByUserId(userId: String): Long
|
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 countActive(userId: String): Long
|
||||||
|
|
||||||
suspend fun findByFamilyId(familyId: String): List<RefreshToken>
|
suspend fun findByFamilyId(familyId: String): List<RefreshToken>
|
||||||
|
|||||||
Reference in New Issue
Block a user