diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala index 18814b9..a32c18d 100644 --- a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala +++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala @@ -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], diff --git a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala index be992f4..7307bed 100644 --- a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala +++ b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala @@ -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) diff --git a/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala b/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala index e6c8218..f0672d5 100644 --- a/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala +++ b/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala @@ -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" diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala index c24d634..df4d367 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala @@ -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) => diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala index 04c1f95..b7d8569 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala @@ -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" diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala index 7ea3f1c..834561f 100644 --- a/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala +++ b/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala @@ -32,7 +32,24 @@ class AccountResourceTest: .`then`() .statusCode(200) .extract() - .path[String]("token") + .path[String]("accessToken") + + private def registerAndLoginPair(username: String): (String, String) = + givenRequest() + .body(registerBody(username)) + .when() + .post("/api/account") + .`then`() + .statusCode(200) + val resp = givenRequest() + .body(loginBody(username)) + .when() + .post("/api/account/login") + .`then`() + .statusCode(200) + .extract() + .response() + (resp.path[String]("accessToken"), resp.path[String]("refreshToken")) @Test def registerReturns200(): Unit = @@ -57,7 +74,7 @@ class AccountResourceTest: .body("error", containsString("bob")) @Test - def loginReturns200WithToken(): Unit = + def loginReturns200WithTokenPair(): Unit = givenRequest().body(registerBody("charlie")).when().post("/api/account") givenRequest() .body(loginBody("charlie")) @@ -65,7 +82,8 @@ class AccountResourceTest: .post("/api/account/login") .`then`() .statusCode(200) - .body("token", notNullValue()) + .body("accessToken", notNullValue()) + .body("refreshToken", notNullValue()) @Test def loginUnauthorizedOnWrongPassword(): Unit = @@ -105,3 +123,34 @@ class AccountResourceTest: .get("/api/account/doesnotexist") .`then`() .statusCode(404) + + @Test + def refreshReturnsNewTokenPair(): Unit = + val (_, refreshToken) = registerAndLoginPair("refresh_user") + givenRequest() + .body(s"""{"refreshToken":"$refreshToken"}""") + .when() + .post("/api/account/refresh") + .`then`() + .statusCode(200) + .body("accessToken", notNullValue()) + .body("refreshToken", notNullValue()) + + @Test + def refreshWithInvalidTokenReturns401(): Unit = + givenRequest() + .body("""{"refreshToken":"invalid.token.value"}""") + .when() + .post("/api/account/refresh") + .`then`() + .statusCode(401) + + @Test + def refreshWithAccessTokenReturns401(): Unit = + val accessToken = registerAndLogin("refresh_bad_type") + givenRequest() + .body(s"""{"refreshToken":"$accessToken"}""") + .when() + .post("/api/account/refresh") + .`then`() + .statusCode(401)