From c41d91cf8eabae259ec8346cabb10d9a7afa5064 Mon Sep 17 00:00:00 2001 From: P William Hozier Date: Wed, 14 May 2025 22:07:53 -0400 Subject: [PATCH] feat: implement passwordless flows (otp) --- README.md | 23 +- .../play4s/game/sudoku/errors.smithy | 14 -- .../play4s/internal/auth/errors.smithy | 31 +++ .../play4s/internal/auth/model.smithy | 51 +++- .../internal/auth/service-auth-api.smithy | 34 ++- .../play4s/play4s-service.smithy | 4 +- .../play4s/MainApp.scala | 18 +- .../play4s/Middleware.scala | 2 +- .../play4s/api/AuthService.scala | 23 +- .../play4s/auth/DefaultAuthProvider.scala | 29 ++- .../play4s/auth/DefaultJwtProvider.scala | 20 +- .../play4s/auth/DefaultKeyStoreBackend.scala | 18 +- .../play4s/auth/DefaultOtpProvider.scala | 223 ++++++++++++++++++ .../sudoku/parser/WebSudokuExtractor.scala | 4 +- .../play4s/auth/DefaultAuthProviderSpec.scala | 13 +- .../play4s/auth/DefaultJwtProviderSpec.scala | 23 +- .../play4s/auth/DefaultOtpProviderSpec.scala | 37 +++ .../play4s/auth/MiddlewareSpec.scala | 3 +- 18 files changed, 490 insertions(+), 80 deletions(-) create mode 100644 api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/errors.smithy create mode 100644 app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProvider.scala create mode 100644 tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProviderSpec.scala diff --git a/README.md b/README.md index fd6ea939..519753c0 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,18 @@ ![TLS Enabled](https://img.shields.io/badge/TLS-enabled-brightgreen?style=flat-square&logo=letsencrypt&logoColor=white) -###### (a) Current Development -This project focuses on exploring and implementing various puzzle-solving algorithms, with a primary emphasis on Sudoku puzzles. The backtracking algorithm is one of the key implementations, and recent refactors have made all compute solutions accessible as a service. +###### (a) Introduction: From Puzzle Solving to Platform — A Functional and Secure Compute Layer + +`play4s-service` and its supporting modules are built atop [cats-effect](https://github.com/typelevel/cats-effect) and +[fs2](https://github.com/functional-streams-for-scala/fs2), fully embracing the foundational principles of functional programming. This design emphasizes purity, composability, and effect management—enabling precise control over resource lifecycles, concurrency, and asynchronous computations in a principled, type-safe manner. + +What began as an exploration into puzzle-solving—specifically Sudoku—has progressively transformed into a more expansive and versatile compute platform. Core algorithms have been rearchitected through a functional-first lens, enabling advanced features such as parallel solver execution during compute requests. A prime example is the constraint propagation algorithm, which drastically reduces the search space to accelerate resolution. Leveraging the concurrency model offered by Cats Effect, we can benchmark this solver by racing it against an alternative implementation adhering to the same trait—automatically short-circuiting as soon as the quicker solution emerges. This not only demonstrates tangible performance improvements but also guarantees timely and accurate results. + +This technical evolution represents a key inflection point: solver logic has been modularized into composable, service-oriented units, forming a scalable foundation for future expansion. These services now underpin a robust, high-performance computation layer that is both adaptable and maintainable. + +Complementing this architecture is a hardened authentication stack that blends API key and OTP-based verification, JWT token lifecycle governance, and secure credential storage. Together, these safeguards ensure an uncompromising streamlined, secure interaction model—for both consumers of the service and the developers who extend it. + ###### (b) Performance Progress and Example Analytics @@ -109,11 +118,13 @@ To retrieve the latest Load Balancer endpoint, re-trigger the GitHub Actions wor | Endpoint Path | Description | | ------------------------------------- | ------------------------------- | -| `/internal/auth/token` | JWT generation endpoint | -| `/internal/meta/health` | Health check endpoint | -| `/internal/meta/version` | Runtime build details endpoint | +| `/internal/auth/token` | JWT generation endpoint | +| `/internal/auth/otp/initiate` | OTP initiation endpoint | +| `/internal/auth/otp/authorize` | OTP authorization endpoint | +| `/internal/meta/health` | Health check endpoint | +| `/internal/meta/version` | Runtime build details endpoint | | `/internal/game/sudoku/hints` | Generate playable cell hints | -| `/internal/game/sudoku/metrics` | Retrieve metrics about computations | +| `/internal/game/sudoku/metrics` | Retrieve metrics about computations | | `/internal/game/sudoku/solve` | Developer endpoint | | `/public/game/sudoku/solve` | Sudoku puzzle-solving endpoint | diff --git a/api/src/main/smithy/com/theproductcollectiveco/play4s/game/sudoku/errors.smithy b/api/src/main/smithy/com/theproductcollectiveco/play4s/game/sudoku/errors.smithy index 6e2b1a0c..8918d723 100644 --- a/api/src/main/smithy/com/theproductcollectiveco/play4s/game/sudoku/errors.smithy +++ b/api/src/main/smithy/com/theproductcollectiveco/play4s/game/sudoku/errors.smithy @@ -9,19 +9,12 @@ structure InvalidInputError { description: String } -@error("client") -@httpError(401) -structure AuthError { - @required - description: String -} @error("client") @httpError(403) structure ForbiddenError { description: String } - @error("client") @httpError(422) structure NoSolutionFoundError { @@ -43,13 +36,6 @@ structure InitialStateSettingError { description: String } -@error("server") -@httpError(500) -structure DecodeFailureError { - @required - description: String -} - @error("server") @httpError(500) structure InternalServerError { diff --git a/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/errors.smithy b/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/errors.smithy new file mode 100644 index 00000000..f0fd0b6d --- /dev/null +++ b/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/errors.smithy @@ -0,0 +1,31 @@ +$version: "2" + +namespace com.theproductcollectiveco.play4s.internal.auth + +@error("client") +@httpError(401) +structure AuthError { + @required + description: String +} + +@error("client") +@httpError(429) +structure AuthEligibilityError { + @required + description: String +} + +@error("server") +@httpError(500) +structure DecodeFailureError { + @required + description: String +} + +@error("server") +@httpError(500) +structure AuthProcessingError { + @required + description: String +} \ No newline at end of file diff --git a/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/model.smithy b/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/model.smithy index 2ab2a972..88813f97 100644 --- a/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/model.smithy +++ b/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/model.smithy @@ -2,6 +2,11 @@ $version: "2" namespace com.theproductcollectiveco.play4s.internal.auth +structure Alias { + @required + value: String +} + structure Token { @required value: String @@ -30,17 +35,19 @@ structure Payload { issuedAt: Long @required roles: RolesList + @pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") @required tokenId: String metadata: MetadataList } union GenericHandle { - emailAddress: EmailAddress + contact: Contact username: Username } structure EmailAddress { + @pattern("^(?=.{1,320}$)(?=.{1,64}@.{1,255}$)([A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)$") @required value: String } @@ -50,6 +57,48 @@ structure Username { value: String } +structure PhoneNumber { + @pattern("^\\+?[0-9]{7,15}$") + @required + value: String +} + +structure OtpSession { + @required + otp: Otp + otpContext: OtpContext + @required + expiresAt: Timestamp + @default(0) + @required + initiateAttempts: Integer + @default(0) + @required + validateAttempts: Integer + @default(false) + @required + isRedeemed: Boolean + @required + handle: GenericHandle +} + + +structure Otp { + @required + @pattern("^[A-Za-z0-9]{6}$") + value: String +} + +structure OtpContext { + @required + value: String +} + +union Contact { + emailAddress: EmailAddress + phoneNumber: PhoneNumber +} + list RolesList { member: String } diff --git a/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/service-auth-api.smithy b/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/service-auth-api.smithy index 4c0c1a06..4441a0cd 100644 --- a/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/service-auth-api.smithy +++ b/api/src/main/smithy/com/theproductcollectiveco/play4s/internal/auth/service-auth-api.smithy @@ -2,16 +2,25 @@ $version: "2" namespace com.theproductcollectiveco.play4s.internal.auth +use com.theproductcollectiveco.play4s.internal.auth#Otp use com.theproductcollectiveco.play4s.internal.auth#Token use com.theproductcollectiveco.play4s.internal.auth#GenericHandle +use com.theproductcollectiveco.play4s.internal.auth#AuthError +use com.theproductcollectiveco.play4s.internal.auth#AuthEligibilityError +use com.theproductcollectiveco.play4s.internal.auth#AuthProcessingError +use com.theproductcollectiveco.play4s.game.sudoku#InvalidInputError + use alloy#simpleRestJson @simpleRestJson service ServiceAuthApi { operations: [ - RequestToken - ] + RequestToken, + InitiateOtp, + RedeemOtp + ], + errors: [AuthError, AuthEligibilityError, AuthProcessingError, InvalidInputError] } @readonly @@ -23,3 +32,24 @@ operation RequestToken { } output: Token } + +@readonly +@http(method: "POST", uri: "/internal/auth/otp/initiate", code: 201) +operation InitiateOtp { + input := { + @required + requester: GenericHandle + } +} + +@readonly +@http(method: "POST", uri: "/internal/auth/otp/authorize") +operation RedeemOtp { + input := { + @required + requester: GenericHandle + @required + otp: Otp + } + output: Token +} diff --git a/api/src/main/smithy/com/theproductcollectiveco/play4s/play4s-service.smithy b/api/src/main/smithy/com/theproductcollectiveco/play4s/play4s-service.smithy index 70ded63c..e13ebad8 100644 --- a/api/src/main/smithy/com/theproductcollectiveco/play4s/play4s-service.smithy +++ b/api/src/main/smithy/com/theproductcollectiveco/play4s/play4s-service.smithy @@ -6,14 +6,14 @@ use alloy#simpleRestJson use smithy.api#httpApiKeyAuth use smithy.api#auth -use com.theproductcollectiveco.play4s.game.sudoku#AuthError +use com.theproductcollectiveco.play4s.internal.auth#AuthError use com.theproductcollectiveco.play4s.game.sudoku#ForbiddenError use com.theproductcollectiveco.play4s.game.sudoku#InvalidInputError use com.theproductcollectiveco.play4s.game.sudoku#NoSolutionFoundError use com.theproductcollectiveco.play4s.game.sudoku#BoardNotCreatedError use com.theproductcollectiveco.play4s.game.sudoku#InitialStateSettingError use com.theproductcollectiveco.play4s.game.sudoku#InternalServerError -use com.theproductcollectiveco.play4s.game.sudoku#DecodeFailureError +use com.theproductcollectiveco.play4s.internal.auth#DecodeFailureError use com.theproductcollectiveco.play4s.game.sudoku.public#ComputeSudoku use com.theproductcollectiveco.play4s.game.sudoku.internal#ComputeSudokuDeveloperMode use com.theproductcollectiveco.play4s.game.sudoku.internal#GetSudokuHints diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/MainApp.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/MainApp.scala index 173b9391..48bbd400 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/MainApp.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/MainApp.scala @@ -3,17 +3,25 @@ package com.theproductcollectiveco.play4s import cats.effect.{Clock, IO, Resource, ResourceApp} import cats.effect.IO.asyncForIO import cats.effect.implicits.* -import cats.effect.std.{SecureRandom, UUIDGen} +import cats.effect.std.{SecureRandom, Supervisor, UUIDGen} import cats.syntax.all.* import com.theproductcollectiveco.play4s.Middleware.{routes, secureRoutes} import com.theproductcollectiveco.play4s.Play4sApi import com.theproductcollectiveco.play4s.api.{AuthService, HealthService, Play4sService} -import com.theproductcollectiveco.play4s.auth.{AuthProvider, DefaultAuthProvider, DefaultJwtProvider, DefaultKeyStoreBackend, JwtProvider} +import com.theproductcollectiveco.play4s.auth.{ + AuthProvider, + DefaultAuthProvider, + DefaultJwtProvider, + DefaultKeyStoreBackend, + DefaultOtpProvider, + JwtProvider, + OtpProvider, +} import com.theproductcollectiveco.play4s.auth.DefaultJwtProvider.* import com.theproductcollectiveco.play4s.config.{ApiKeyStoreConfig, AppConfig, AuthConfig} import com.theproductcollectiveco.play4s.game.sudoku.core.{BacktrackingAlgorithm, ConstraintPropagationAlgorithm, Orchestrator} import com.theproductcollectiveco.play4s.game.sudoku.parser.{GoogleCloudClient, TraceClient} -import com.theproductcollectiveco.play4s.internal.auth.ServiceAuthApi +import com.theproductcollectiveco.play4s.internal.auth.{Alias, ServiceAuthApi} import com.theproductcollectiveco.play4s.internal.meta.health.ServiceMetaApi import fs2.io.file.Files import org.http4s.ember.server.EmberServerBuilder @@ -37,8 +45,10 @@ object MainApp extends ResourceApp.Forever { _ <- authProvider.storeCredentials(googleCloudApiKey, googlePath.mkString) _ <- authProvider.storeCredentials(keystore, keystorePath.mkString) tlsContext <- authProvider.tlsContextResource(summon[AppConfig].apiKeyStore.keyStoreManagement) - _ <- authProvider.initializeSecret(alias = "jwtSigningSecret", summon[AppConfig].apiKeyStore.keyStoreManagement).toResource + _ <- authProvider.initializeSecret(alias = Alias("jwtSigningSecret"), summon[AppConfig].apiKeyStore.keyStoreManagement).toResource given AuthProvider[IO] = authProvider + given Supervisor[IO] <- Supervisor[IO] + given OtpProvider[IO] = DefaultOtpProvider[IO](summon[AppConfig], authProvider) given JwtProvider[IO] = DefaultJwtProvider[IO](summon[AppConfig], authProvider) given Metrics[IO] <- Metrics.make[IO].toResource given SecureRandom[IO] <- SecureRandom.javaSecuritySecureRandom[IO].toResource diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/Middleware.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/Middleware.scala index 5af1f811..a3941015 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/Middleware.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/Middleware.scala @@ -4,7 +4,7 @@ import cats.data.{Kleisli, OptionT} import cats.effect.{Async, Resource} import cats.syntax.all.* import com.theproductcollectiveco.play4s.auth.JwtProvider -import com.theproductcollectiveco.play4s.game.sudoku.AuthError +import com.theproductcollectiveco.play4s.internal.auth.AuthError import io.circe.Encoder import io.circe.generic.auto.* import org.http4s.* diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/api/AuthService.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/api/AuthService.scala index 88aeb340..338007be 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/api/AuthService.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/api/AuthService.scala @@ -3,12 +3,14 @@ package com.theproductcollectiveco.play4s.api import cats.effect.{Async, Clock} import cats.effect.std.{SecureRandom, UUIDGen} import cats.implicits.* -import com.theproductcollectiveco.play4s.auth.JwtProvider -import com.theproductcollectiveco.play4s.internal.auth.{GenericHandle, ServiceAuthApi, Token} +import com.theproductcollectiveco.play4s.auth.{JwtProvider, OtpProvider} +import com.theproductcollectiveco.play4s.game.sudoku.InvalidInputError +import com.theproductcollectiveco.play4s.internal.auth.{GenericHandle, Otp, ServiceAuthApi, Token} +import org.typelevel.log4cats.Logger object AuthService { - def make[F[_]: Async](using provider: JwtProvider[F]): ServiceAuthApi[F] = + def make[F[_]: Async: Logger](using jwtProvider: JwtProvider[F], otpProvider: OtpProvider[F]): ServiceAuthApi[F] = new ServiceAuthApi[F] { override def requestToken(requester: GenericHandle): F[Token] = @@ -16,7 +18,7 @@ object AuthService { given SecureRandom[F] = security UUIDGen.fromSecureRandom.randomUUID.map(_.toString).flatMap { uuid => Clock[F].realTimeInstant.flatMap { now => - provider + jwtProvider .generateJwt( handle = requester, expiration = now.getEpochSecond + 300, @@ -30,6 +32,19 @@ object AuthService { } } } + + override def initiateOtp(requester: GenericHandle): F[Unit] = + for { + otp <- otpProvider.initiateOtp(requester) + // todo: send the otp via sms or email client integration + _ <- Logger[F].info(s"Initialized otp: $otp") + } yield () + + override def redeemOtp(requester: GenericHandle, otp: Otp): F[Token] = + otpProvider + .verifyOtpEligibility(requester, otp) + .ifM(requestToken(requester), InvalidInputError("Invalid OTP supplied").raiseError) + } } diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProvider.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProvider.scala index 531e9717..3128c5f2 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProvider.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProvider.scala @@ -3,16 +3,19 @@ package com.theproductcollectiveco.play4s.auth import cats.effect.Ref import cats.effect.implicits.* import cats.effect.kernel.{Async, Resource} +import cats.effect.std.{SecureRandom, UUIDGen} import cats.syntax.all.* import ciris.Secret import com.theproductcollectiveco.play4s.config.{toSanitizedValue, AuthConfig} +import com.theproductcollectiveco.play4s.internal.auth.{Alias, AuthProcessingError} import fs2.io.file.{Files, Path} import fs2.io.net.tls.TLSContext import javax.net.ssl.SSLContext trait AuthProvider[F[_]] { - def retrieveSecret(alias: String, authConfig: AuthConfig): F[String] - def initializeSecret(alias: String, authConfig: AuthConfig): F[Unit] + def retrieveSecret(alias: Alias, authConfig: AuthConfig): F[Secret[String]] + def initializeSecret(alias: Alias, authConfig: AuthConfig, providedSecret: Option[String] = None): F[Unit] + def removeSecret(alias: Alias, authConfig: AuthConfig): F[Unit] def storeCredentials(secret: Secret[String], filePath: String)(using Files[F]): Resource[F, Unit] def sslContextResource(authConfig: AuthConfig)(using Files[F]): Resource[F, SSLContext] def tlsContextResource(authConfig: AuthConfig)(using Files[F]): Resource[F, TLSContext[F]] @@ -52,20 +55,26 @@ object DefaultAuthProvider { override def tlsContextResource(authConfig: AuthConfig)(using Files[F]): Resource[F, TLSContext[F]] = sslContextResource(authConfig).map(TLSContext.Builder.forAsync.fromSSLContext) - override def retrieveSecret(alias: String, authConfig: AuthConfig): F[String] = + override def retrieveSecret(alias: Alias, authConfig: AuthConfig): F[Secret[String]] = getOrLoadKeyStore(authConfig) .flatMap(_.retrieve(alias)) - .flatMap(_.liftTo[F](new RuntimeException(s"Secret with alias '$alias' not found in keystore"))) + .map(_.map(Secret(_))) + .flatMap(_.liftTo[F](AuthProcessingError(s"Secret with alias '$alias' not found in keystore"))) - override def initializeSecret(alias: String, authConfig: AuthConfig): F[Unit] = + override def removeSecret(alias: Alias, authConfig: AuthConfig): F[Unit] = + getOrLoadKeyStore(authConfig) + .flatMap(_.delete(alias)) + + override def initializeSecret(alias: Alias, authConfig: AuthConfig, providedSecret: Option[String] = None): F[Unit] = getOrLoadKeyStore(authConfig).flatMap { loaded => - loaded.retrieve(alias).flatMap { - case Some(_) => Async[F].unit // Already exists, do nothing - case None => - val newSecret = java.util.UUID.randomUUID().toString - loaded.store(alias, newSecret) + SecureRandom.javaSecuritySecureRandom[F].flatMap { security => + given SecureRandom[F] = security + UUIDGen.fromSecureRandom.randomUUID.map(_.toString).flatMap { uuid => + loaded.retrieve(alias).flatMap { case Some(_) | None => loaded.store(alias, providedSecret.getOrElse(uuid)) } + } } } + } } diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProvider.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProvider.scala index c18cd9d9..ab708bc8 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProvider.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProvider.scala @@ -3,7 +3,7 @@ package com.theproductcollectiveco.play4s.auth import cats.effect.{Async, Clock} import cats.syntax.all.* import com.theproductcollectiveco.play4s.config.{peek, AppConfig} -import com.theproductcollectiveco.play4s.internal.auth.{GenericHandle, Grant, MagicLink, Metadata, Payload, Token} +import com.theproductcollectiveco.play4s.internal.auth.{Alias, AuthProcessingError, GenericHandle, Grant, MagicLink, Metadata, Payload, Token} import io.circe.{Encoder, Json} import io.circe.generic.auto.* import io.circe.syntax.* @@ -52,20 +52,20 @@ object DefaultJwtProvider { override def decodeJwt(token: Token): F[Json] = for { - jwtSigningSecret <- authProvider.retrieveSecret(alias = "jwtSigningSecret", authConfig = appConfig.apiKeyStore.keyStoreManagement) + jwtSigningSecret <- authProvider.retrieveSecret(alias = Alias("jwtSigningSecret"), authConfig = appConfig.apiKeyStore.keyStoreManagement) payload <- Async[F] .fromTry( JwtCirce.decodeJson( token.value, - jwtSigningSecret, + jwtSigningSecret.value, Seq(JwtAlgorithm.HS256), - JwtOptions(signature = true, expiration = true, notBefore = true, leeway = 1200), + JwtOptions(signature = true, expiration = true, notBefore = true, leeway = 600), ) ) .adaptError { - case e: JwtExpirationException => new RuntimeException("JWT has expired", e) - case e => new RuntimeException("Invalid JWT", e) + case e: JwtExpirationException => AuthProcessingError(s"Reason: JWT has expired. Details: $e") + case e => AuthProcessingError(s"Reason: Invalid JWT. Details: $e") } } yield payload @@ -79,7 +79,7 @@ object DefaultJwtProvider { override def isPrimaryAuth: F[Boolean] = Async[F] - .fromOption(appConfig.runtime.withJwt, new RuntimeException("Missing primary authentication mechanism configuration")) + .fromOption(appConfig.runtime.withJwt, AuthProcessingError("Missing primary authentication mechanism configuration")) override def generateJwt( handle: GenericHandle, @@ -92,8 +92,8 @@ object DefaultJwtProvider { issuer: String, ): F[Token] = authProvider - .retrieveSecret(alias = "jwtSigningSecret", authConfig = appConfig.apiKeyStore.keyStoreManagement) - .flatMap { secretWithAliasjwtSecretKey => + .retrieveSecret(alias = Alias("jwtSigningSecret"), authConfig = appConfig.apiKeyStore.keyStoreManagement) + .flatMap { symmetricSigningKey => Async[F].delay { Token( JwtCirce.encode( @@ -104,7 +104,7 @@ object DefaultJwtProvider { issuer, ) ).asJson.noSpaces, - secretWithAliasjwtSecretKey, + symmetricSigningKey.value, JwtAlgorithm.HS256, ) ) diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultKeyStoreBackend.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultKeyStoreBackend.scala index 4e277d29..10369bae 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultKeyStoreBackend.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultKeyStoreBackend.scala @@ -4,6 +4,7 @@ import cats.effect.{Async, Resource, Sync} import cats.effect.implicits.* import cats.syntax.all.* import com.theproductcollectiveco.play4s.config.{peek, AuthConfig} +import com.theproductcollectiveco.play4s.internal.auth.Alias import fs2.io.file.{Files, Path} import javax.net.ssl.{KeyManagerFactory, SSLContext} @@ -14,8 +15,9 @@ trait KeyStoreBackend[F[_]] { } trait LoadedKeyStore[F[_]] { - def retrieve(alias: String): F[Option[String]] - def store(alias: String, secret: String): F[Unit] + def retrieve(alias: Alias): F[Option[String]] + def store(alias: Alias, secret: String): F[Unit] + def delete(alias: Alias): F[Unit] def createSSLContext: F[SSLContext] } @@ -46,21 +48,25 @@ object DefaultKeyStoreBackend { private def protection = new KeyStore.PasswordProtection(password.toCharArray) - override def retrieve(alias: String): F[Option[String]] = + override def retrieve(alias: Alias): F[Option[String]] = Sync[F].delay { - Option(keystore.getEntry(alias, protection)) match { + Option(keystore.getEntry(alias.value, protection)) match { case Some(entry: KeyStore.SecretKeyEntry) => Some(new String(entry.getSecretKey.getEncoded)) case _ => None } } - override def store(alias: String, secret: String): F[Unit] = + override def store(alias: Alias, secret: String): F[Unit] = Sync[F].delay { val secretKey = new javax.crypto.spec.SecretKeySpec(secret.replaceAll("[\\r\\n]", "").getBytes("UTF-8"), "HmacSHA256") val entry = new KeyStore.SecretKeyEntry(secretKey) - keystore.setEntry(alias, entry, protection) + keystore.setEntry(alias.value, entry, protection) } + override def delete(alias: Alias): F[Unit] = + Sync[F].delay: + keystore.deleteEntry(alias.value) + override def createSSLContext: F[SSLContext] = Async[F].delay { val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProvider.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProvider.scala new file mode 100644 index 00000000..d715c9be --- /dev/null +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProvider.scala @@ -0,0 +1,223 @@ +package com.theproductcollectiveco.play4s.auth + +import cats.effect.{Async, Clock, MonadCancelThrow} +import cats.effect.implicits.* +import cats.effect.std.{SecureRandom, Supervisor} +import cats.syntax.all.* +import com.theproductcollectiveco.play4s.config.AppConfig +import com.theproductcollectiveco.play4s.internal.auth.{AuthEligibilityError, AuthProcessingError, *} +import com.theproductcollectiveco.play4s.internal.auth.Contact.{EmailAddressCase, PhoneNumberCase} +import io.circe.{Encoder, Json} +import io.circe.generic.auto.* +import io.circe.syntax.* +import org.typelevel.log4cats.Logger +import smithy4s.Timestamp + +import java.time.temporal.ChronoUnit + +trait OtpProvider[F[_]] { + + def initiateOtp( + handle: GenericHandle, + expiryWindow: Long = 5L * 60, + expiryUnit: ChronoUnit = ChronoUnit.SECONDS, + ): F[Otp] + + def verifyOtpEligibility(handle: GenericHandle, otp: Otp): F[Boolean] +} + +object DefaultOtpProvider { + + def apply[F[_]: Async: MonadCancelThrow: Clock: Supervisor: Logger](appConfig: AppConfig, authProvider: AuthProvider[F]): OtpProvider[F] = + new OtpProvider[F] { + + override def initiateOtp( + handle: GenericHandle, + expiryWindow: Long = 5L * 60, + expiryUnit: ChronoUnit = ChronoUnit.SECONDS, + ): F[Otp] = + handle.validate.flatMap { + OtpProviderOps.make[F](appConfig, authProvider, _).pure.flatMap { + _.initializeNewOtpSession(handle, expiryWindow, expiryUnit) + } + } + + override def verifyOtpEligibility(handle: GenericHandle, otp: Otp): F[Boolean] = + handle.validate.flatMap { + OtpProviderOps.make[F](appConfig, authProvider, _).pure.flatMap { + _.verifyOtpEligibilityWithSession(handle, otp) + } + } + } + +} + +trait OtpProviderOps[F[_]] { + + def initializeNewOtpSession( + handle: GenericHandle, + expiryWindow: Long, + expiryUnit: ChronoUnit, + ): F[Otp] + + def verifyOtpEligibilityWithSession( + handle: GenericHandle, + otp: Otp, + ): F[Boolean] + + def updateOtpSession( + session: OtpSession, + initiateAttempts: Option[Int] = None, + validateAttempts: Option[Int] = None, + isRedeemed: Option[Boolean] = None, + ): F[Unit] + + def retrieveOtpSession: F[OtpSession] +} + +object OtpProviderOps { + + def make[F[_]: Async: Logger](appConfig: AppConfig, authProvider: AuthProvider[F], supported: EmailAddress | PhoneNumber): OtpProviderOps[F] = + new OtpProviderOps[F] { + + override def initializeNewOtpSession( + handle: GenericHandle, + expiryWindow: Long, + expiryUnit: ChronoUnit, + ): F[Otp] = + retrieveOtpSession + .flatMap { otpSession => + for { + _ <- runEligibilityChecks(otpSession) + _ <- updateOtpSession(otpSession, initiateAttempts = otpSession.initiateAttempts.some.map(_ + 1)) + } yield otpSession.otp + } + .recoverWith { case _: AuthProcessingError => + Logger[F].debug("No existing OTP session found. Initializing a new session...") *> + generateAndStoreOtp(handle, expiryWindow, expiryUnit) + } + + private def generateAndStoreOtp( + handle: GenericHandle, + expiryWindow: Long, + expiryUnit: ChronoUnit, + ): F[Otp] = + for { + secureRandom <- SecureRandom.javaSecuritySecureRandom[F] + randomInt <- secureRandom.nextIntBounded(1000000) + generatedOtp = f"$randomInt%06d" + now <- Clock[F].realTimeInstant + otpSession = + OtpSession( + otp = Otp(generatedOtp), + expiresAt = Timestamp.fromInstant(now.plus(expiryWindow, expiryUnit)), + handle = handle, + initiateAttempts = 1, + ) + _ <- + authProvider.initializeSecret( + alias = supported.toAlias, + authConfig = appConfig.apiKeyStore.keyStoreManagement, + providedSecret = otpSession.asJson.noSpaces.some, + ) + } yield Otp(generatedOtp) + + override def verifyOtpEligibilityWithSession( + handle: GenericHandle, + otp: Otp, + ): F[Boolean] = + for { + otpSession <- retrieveOtpSession.handleErrorWith(e => Logger[F].debug(e)("Failed to retrieve OTP session") *> e.raiseError) + isEligible <- runEligibilityChecks(otpSession, otp.some) + _ <- updateOtpSession(otpSession, validateAttempts = otpSession.validateAttempts.some.map(_ + 1)) + _ <- if isEligible then markOtpAsRedeemed(otpSession) else Async[F].unit + _ <- Logger[F].debug(s"OTP validation result: $isEligible for user: $handle") + } yield isEligible + + private def markOtpAsRedeemed(session: OtpSession): F[Unit] = + Supervisor[F](await = false).use { + _.supervise { + updateOtpSession(session, isRedeemed = true.some) *> + retrieveOtpSession.flatMap { + runEligibilityChecks(_).void + .handleErrorWith(_.getMessage.pure.flatMap(Logger[F].debug(_))) + } + }.flatMap(_.joinWithUnit) + } + + override def updateOtpSession( + session: OtpSession, + initiateAttempts: Option[Int] = None, + validateAttempts: Option[Int] = None, + isRedeemed: Option[Boolean] = None, + ): F[Unit] = + authProvider + .initializeSecret( + supported.toAlias, + appConfig.apiKeyStore.keyStoreManagement, + session + .copy( + initiateAttempts = initiateAttempts.getOrElse(session.initiateAttempts), + validateAttempts = validateAttempts.getOrElse(session.validateAttempts), + isRedeemed = isRedeemed.getOrElse(session.isRedeemed), + ) + .asJson + .noSpaces + .some, + ) + .void + + override def retrieveOtpSession: F[OtpSession] = + authProvider + .retrieveSecret(supported.toAlias, appConfig.apiKeyStore.keyStoreManagement) + .map(_.value) + .flatMap(io.circe.parser.decode[OtpSession](_).liftTo[F]) + + private def runEligibilityChecks( + session: OtpSession, + providedOtp: Option[Otp] = None, + ): F[Boolean] = + for { + now <- Clock[F].realTimeInstant + result <- + Async[F] + .fromEither { + providedOtp + .fold( + session.initiateAttempts.>=(5) -> AuthEligibilityError("Maximum number of initiate attempts exceeded.").asLeft[Boolean] :: Nil + )( + session.expiresAt.toInstant.isBefore(now) -> AuthEligibilityError("OTP has expired.").asLeft[Boolean] :: + session.isRedeemed -> AuthEligibilityError("OTP has been redeemed.").asLeft[Boolean] :: + session.validateAttempts.>=(5) -> AuthEligibilityError("Maximum number of validate attempts exceeded.").asLeft[Boolean] :: + !session.otp.equals(_) -> false.asRight[AuthEligibilityError] :: Nil + ) + .collectFirst { case (true, result) => result } + .getOrElse(true.asRight[AuthEligibilityError]) + } + .onError { + authProvider.removeSecret(supported.toAlias, appConfig.apiKeyStore.keyStoreManagement) *> _.raiseError + } + } yield result + + } + +} + +extension (handle: GenericHandle) + + def validate[F[_]: Async]: F[EmailAddress | PhoneNumber] = + Async[F].fromEither { + handle match { + case GenericHandle.ContactCase(EmailAddressCase(emailAddress)) => emailAddress.asRight + case GenericHandle.ContactCase(PhoneNumberCase(phoneNumber)) => phoneNumber.asRight + case _ => AuthEligibilityError(s"Unsupported handle type: $handle").asLeft + } + } + +extension (supported: EmailAddress | PhoneNumber) def toAlias: Alias = Alias(supported.asJson.noSpaces) + +given Encoder[EmailAddress | PhoneNumber] = + Encoder.instance { + case email: EmailAddress => Json.obj("EmailAddress" -> email.asJson) + case phone: PhoneNumber => Json.obj("PhoneNumber" -> phone.asJson) + } diff --git a/app/src/main/scala/com/theproductcollectiveco/play4s/game/sudoku/parser/WebSudokuExtractor.scala b/app/src/main/scala/com/theproductcollectiveco/play4s/game/sudoku/parser/WebSudokuExtractor.scala index 7520f2d7..c7ce80eb 100644 --- a/app/src/main/scala/com/theproductcollectiveco/play4s/game/sudoku/parser/WebSudokuExtractor.scala +++ b/app/src/main/scala/com/theproductcollectiveco/play4s/game/sudoku/parser/WebSudokuExtractor.scala @@ -2,7 +2,7 @@ package com.theproductcollectiveco.play4s.game.sudoku.parser import cats.effect.{Async, Sync} import cats.syntax.all.* -import com.theproductcollectiveco.play4s.game.sudoku.{DecodeFailureError, InvalidInputError} +import com.theproductcollectiveco.play4s.game.sudoku.{InitialStateSettingError, InvalidInputError} import org.http4s.{Header, Method, Request, Uri} import org.http4s.client.Client import org.typelevel.ci.* @@ -57,7 +57,7 @@ object WebSudokuExtractor { ).view .flatMap(_.findFirstMatchIn(html).map(_.group(1))) .headOption - .liftTo[F](DecodeFailureError(s"Missing $errorMsg")) + .liftTo[F](InitialStateSettingError(s"Missing $errorMsg")) private def validateSolutionAndMask(cheat: String, mask: String): F[(String, String)] = (cheat, mask).pure diff --git a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProviderSpec.scala b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProviderSpec.scala index c957b9e8..37e4eb85 100644 --- a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProviderSpec.scala +++ b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultAuthProviderSpec.scala @@ -2,6 +2,7 @@ package com.theproductcollectiveco.play4s.auth import cats.effect.* import com.theproductcollectiveco.play4s.config.AppConfig +import com.theproductcollectiveco.play4s.internal.auth.Alias import com.theproductcollectiveco.play4s.tools.SpecKit.Tasks.setupAuthProvider import fs2.io.file.Files import org.typelevel.log4cats.Logger @@ -12,12 +13,12 @@ object DefaultAuthProviderSpec extends SimpleIOSuite { test("initializeSecret and retrieveSecret should retrieve existing secret or create one") { for { - appConfig <- AppConfig.load[IO] - given Logger[IO] = Slf4jLogger.getLogger[IO] - authProvider <- setupAuthProvider(appConfig) - _ <- authProvider.initializeSecret("jwtSigningSecret", appConfig.apiKeyStore.keyStoreManagement) - secretWithAlias <- authProvider.retrieveSecret("jwtSigningSecret", appConfig.apiKeyStore.keyStoreManagement) - } yield expect(secretWithAlias.nonEmpty) + appConfig <- AppConfig.load[IO] + given Logger[IO] = Slf4jLogger.getLogger[IO] + authProvider <- setupAuthProvider(appConfig) + _ <- authProvider.initializeSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) + symmetricSigningKey <- authProvider.retrieveSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) + } yield expect(symmetricSigningKey.value.nonEmpty) } } diff --git a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProviderSpec.scala b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProviderSpec.scala index 6e11cc37..963ef373 100644 --- a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProviderSpec.scala +++ b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultJwtProviderSpec.scala @@ -4,6 +4,7 @@ import cats.effect.* import cats.effect.std.UUIDGen import com.theproductcollectiveco.play4s.auth.DefaultJwtProvider.* import com.theproductcollectiveco.play4s.config.AppConfig +import com.theproductcollectiveco.play4s.internal.auth.Alias import com.theproductcollectiveco.play4s.tools.SpecKit.Tasks.{requestTestToken, setupAuthProvider} import fs2.io.file.Files import io.circe.syntax.* @@ -15,18 +16,18 @@ object DefaultJwtProviderSpec extends SimpleIOSuite { test("generateJwt and decodeJwt round-trip correctly") { for { - appConfig <- AppConfig.load[IO] - given Logger[IO] = Slf4jLogger.getLogger[IO] - authProvider <- setupAuthProvider(appConfig) - jwtProvider = DefaultJwtProvider[IO](appConfig, authProvider) - _ <- authProvider.initializeSecret("jwtSigningSecret", appConfig.apiKeyStore.keyStoreManagement) - secretWithAlias <- authProvider.retrieveSecret("jwtSigningSecret", appConfig.apiKeyStore.keyStoreManagement) - token <- requestTestToken(jwtProvider) - decodedJson <- jwtProvider.decodeJwt(token) - _ <- + appConfig <- AppConfig.load[IO] + given Logger[IO] = Slf4jLogger.getLogger[IO] + authProvider <- setupAuthProvider(appConfig) + jwtProvider = DefaultJwtProvider[IO](appConfig, authProvider) + _ <- authProvider.initializeSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) + symmetricSigningKey <- authProvider.retrieveSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) + token <- requestTestToken(jwtProvider) + decodedJson <- jwtProvider.decodeJwt(token) + _ <- Logger[IO].info: - Map("jwtSigningSecret" -> secretWithAlias.asJson, "token" -> token.value.asJson, "decodedJson" -> decodedJson).asJson.noSpaces - username <- + Map("jwtSigningSecret" -> symmetricSigningKey.value.asJson, "token" -> token.value.asJson, "decodedJson" -> decodedJson).asJson.noSpaces + username <- IO.fromEither: decodedJson.hcursor .downField("magicLink") diff --git a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProviderSpec.scala b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProviderSpec.scala new file mode 100644 index 00000000..58d53ebb --- /dev/null +++ b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/DefaultOtpProviderSpec.scala @@ -0,0 +1,37 @@ +package com.theproductcollectiveco.play4s.auth + +import cats.effect.* +import cats.effect.std.Supervisor +import com.theproductcollectiveco.play4s.config.AppConfig +import com.theproductcollectiveco.play4s.internal.auth.{Alias, *} +import com.theproductcollectiveco.play4s.internal.auth.Contact.EmailAddressCase +import com.theproductcollectiveco.play4s.tools.SpecKit.Tasks.setupAuthProvider +import fs2.io.file.Files +import io.circe.Encoder +import io.circe.generic.auto.* +import io.circe.syntax.* +import org.typelevel.log4cats.Logger +import org.typelevel.log4cats.slf4j.Slf4jLogger +import weaver.SimpleIOSuite + +object DefaultOtpProviderSpec extends SimpleIOSuite { + + test("initiateOtp and verifyOtpEligibility round-trip correctly") { + for { + appConfig <- AppConfig.load[IO] + given Logger[IO] = Slf4jLogger.getLogger[IO] + given Supervisor[IO] <- Supervisor[IO].use(IO(_)) + authProvider <- setupAuthProvider(appConfig) + otpProvider = DefaultOtpProvider[IO](appConfig, authProvider) + requester = GenericHandle.contact(Contact.emailAddress(EmailAddress("test-user-id@icloud.com"))) + _ <- authProvider.initializeSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) + symmetricSigningKey <- authProvider.retrieveSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) + otp <- otpProvider.initiateOtp(requester) + isEligible <- otpProvider.verifyOtpEligibility(requester, otp) + _ <- + Logger[IO].info: + Map("jwtSigningSecret" -> symmetricSigningKey.value.asJson, "requester" -> requester.asJson, "otp" -> otp.asJson).asJson.noSpaces + } yield expect(isEligible) + } + +} diff --git a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/MiddlewareSpec.scala b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/MiddlewareSpec.scala index d62a5098..6e3aac9a 100644 --- a/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/MiddlewareSpec.scala +++ b/tests/src/test/scala/com/theproductcollectiveco/play4s/auth/MiddlewareSpec.scala @@ -8,6 +8,7 @@ import com.theproductcollectiveco.play4s.api.HealthService import com.theproductcollectiveco.play4s.auth.* import com.theproductcollectiveco.play4s.auth.DefaultJwtProvider.* import com.theproductcollectiveco.play4s.config.AppConfig +import com.theproductcollectiveco.play4s.internal.auth.Alias import com.theproductcollectiveco.play4s.tools.SpecKit.Tasks.{requestTestToken, setupAuthProvider} import fs2.io.file.Files import org.http4s.* @@ -26,7 +27,7 @@ object MiddlewareSpec extends SimpleIOSuite { given AppConfig = appConfig authProvider <- setupAuthProvider(appConfig) given JwtProvider[IO] = DefaultJwtProvider[IO](appConfig, authProvider) - _ <- authProvider.initializeSecret("jwtSigningSecret", appConfig.apiKeyStore.keyStoreManagement) + _ <- authProvider.initializeSecret(Alias("jwtSigningSecret"), appConfig.apiKeyStore.keyStoreManagement) token <- requestTestToken(summon[JwtProvider[IO]]) healthService = HealthService.make[IO] securedApp = healthService.secureRoutes.orNotFound