feat(account): implement token pair handling for login and refresh endpoints
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-05-19 14:40:35 +02:00
parent ec22a9585e
commit 9296db88b7
6 changed files with 108 additions and 16 deletions
@@ -25,7 +25,8 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[DeclineReason],
classOf[TimeControl],
classOf[LoginRequest],
classOf[TokenResponse],
classOf[RefreshRequest],
classOf[TokenPairResponse],
classOf[PlayerInfo],
classOf[PublicAccountDto],
classOf[BotAccountDto],
@@ -4,7 +4,9 @@ case class RegisterRequest(username: String, email: String, password: String)
case class LoginRequest(username: String, password: String)
case class TokenResponse(token: String)
case class RefreshRequest(refreshToken: String)
case class TokenPairResponse(accessToken: String, refreshToken: String)
case class PlayerInfo(id: String, name: String, rating: Int)
@@ -4,6 +4,7 @@ enum AccountError:
case UsernameTaken(username: String)
case EmailAlreadyRegistered(email: String)
case InvalidCredentials
case InvalidRefreshToken
case UserNotFound
case BotNotFound
case BotLimitExceeded
@@ -15,6 +16,7 @@ enum AccountError:
case UsernameTaken(u) => s"Username '$u' is already taken"
case EmailAlreadyRegistered(e) => s"Email '$e' is already registered"
case InvalidCredentials => "Invalid credentials"
case InvalidRefreshToken => "Invalid or expired refresh token"
case UserNotFound => "User not found"
case BotNotFound => "Bot account not found"
case BotLimitExceeded => "Maximum of 5 bot accounts per user exceeded"
@@ -40,8 +40,19 @@ class AccountResource:
@Path("/login")
def login(req: LoginRequest): Response =
accountService.login(req) match
case Right(token) =>
Response.ok(TokenResponse(token)).build()
case Right((accessToken, refreshToken)) =>
Response.ok(TokenPairResponse(accessToken, refreshToken)).build()
case Left(AccountError.UserBanned) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
case Left(error) =>
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
@POST
@Path("/refresh")
def refresh(req: RefreshRequest): Response =
accountService.refresh(req.refreshToken) match
case Right((accessToken, refreshToken)) =>
Response.ok(TokenPairResponse(accessToken, refreshToken)).build()
case Left(AccountError.UserBanned) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
case Left(error) =>
@@ -6,6 +6,7 @@ import de.nowchess.account.error.AccountError
import de.nowchess.account.repository.{BotAccountRepository, OfficialBotAccountRepository, UserAccountRepository}
import io.micrometer.core.instrument.MeterRegistry
import io.quarkus.elytron.security.common.BcryptUtil
import io.smallrye.jwt.auth.principal.JWTParser
import io.smallrye.jwt.build.Jwt
import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped
@@ -34,6 +35,9 @@ class AccountService:
@Inject
var meterRegistry: MeterRegistry = uninitialized
@Inject
var jwtParser: JWTParser = uninitialized
// scalafix:on
@PostConstruct
@@ -66,7 +70,7 @@ class AccountService:
log.infof("User %s registered successfully", req.username)
Right(account)
def login(req: LoginRequest): Either[AccountError, String] =
def login(req: LoginRequest): Either[AccountError, (String, String)] =
val result = authenticateUser(req)
result match
case Right(_) => meterRegistry.counter("nowchess.auth.logins", "result", "success").increment()
@@ -75,7 +79,7 @@ class AccountService:
meterRegistry.counter("nowchess.auth.login.failures", "reason", loginFailureReason(error)).increment()
result
private def authenticateUser(req: LoginRequest): Either[AccountError, String] =
private def authenticateUser(req: LoginRequest): Either[AccountError, (String, String)] =
userAccountRepository.findByUsername(req.username) match
case None =>
log.warnf("Login failed for unknown user %s", req.username)
@@ -89,16 +93,39 @@ class AccountService:
Left(AccountError.UserBanned)
else
log.infof("User %s logged in successfully", req.username)
Right(
Jwt
.issuer("nowchess")
.subject(account.id.toString)
.claim("username", account.username)
.sign(),
)
Right((generateAccessToken(account), generateRefreshToken(account.id)))
def refresh(refreshToken: String): Either[AccountError, (String, String)] =
try
val parsed = jwtParser.parse(refreshToken)
if parsed.getClaim[String]("type") != "refresh" then Left(AccountError.InvalidRefreshToken)
else
val userId = UUID.fromString(parsed.getSubject)
userAccountRepository.findById(userId) match
case None => Left(AccountError.UserNotFound)
case Some(u) if u.banned => Left(AccountError.UserBanned)
case Some(u) => Right((generateAccessToken(u), generateRefreshToken(u.id)))
catch case _: Throwable => Left(AccountError.InvalidRefreshToken)
private def generateAccessToken(account: UserAccount): String =
Jwt
.issuer("nowchess")
.subject(account.id.toString)
.claim("username", account.username)
.expiresIn(3600)
.sign()
private def generateRefreshToken(userId: UUID): String =
Jwt
.issuer("nowchess")
.subject(userId.toString)
.claim("type", "refresh")
.expiresIn(30L * 24 * 3600)
.sign()
private def loginFailureReason(error: AccountError): String = error match
case AccountError.InvalidCredentials => "invalid_credentials"
case AccountError.InvalidRefreshToken => "invalid_refresh_token"
case AccountError.UserBanned => "user_banned"
case AccountError.UsernameTaken(_) => "username_taken"
case AccountError.EmailAlreadyRegistered(_) => "email_registered"