app/controllers/PasskeyController.scala (127 lines of code) (raw):
package controllers
import aws.PasskeyChallengeDB.UserChallenge
import aws.{PasskeyChallengeDB, PasskeyDB}
import com.gu.googleauth.AuthAction
import com.gu.googleauth.AuthAction.UserIdentityRequest
import com.gu.janus.model.JanusData
import logic.AccountOrdering.orderedAccountAccess
import logic.UserAccess.{userAccess, username}
import logic.{Date, Favourites, Passkey}
import models.JanusException
import models.JanusException.throwableWrites
import models.Passkey._
import play.api.libs.json.Json.toJson
import play.api.libs.json._
import play.api.mvc._
import play.api.{Logging, Mode}
import software.amazon.awssdk.services.dynamodb.DynamoDbClient
import java.time.{ZoneId, ZonedDateTime}
import scala.concurrent.ExecutionContext
import scala.util.{Failure, Success, Try}
/** Controller for handling passkey registration and authentication. */
class PasskeyController(
controllerComponents: ControllerComponents,
authAction: AuthAction[AnyContent],
host: String,
janusData: JanusData
)(implicit
dynamoDb: DynamoDbClient,
mode: Mode,
assetsFinder: AssetsFinder,
ec: ExecutionContext
) extends AbstractController(controllerComponents)
with Logging {
// To handle API responses with no return value
implicit val unitWrites: Writes[Unit] =
Writes(_ => Json.obj("status" -> "success"))
private def passkeyAuthAction
: ActionBuilder[UserIdentityRequest, AnyContent] =
authAction.andThen(new PasskeyAuthFilter(host))
private val appName = mode match {
case Mode.Dev => "Janus-Dev"
case Mode.Test => "Janus-Test"
case Mode.Prod => "Janus-Prod"
}
private def apiResponse[A](
action: => Try[A]
)(implicit writes: Writes[A]): Result =
action match {
case Failure(err: JanusException) =>
logger.error(err.engineerMessage, err.causedBy.orNull)
Status(err.httpCode)(toJson(err))
case Failure(err) =>
logger.error(err.getMessage, err)
Status(INTERNAL_SERVER_ERROR)(toJson(err))
case Success(a) => Ok(toJson(a))
}
/** See
* [[https://webauthn4j.github.io/webauthn4j/en/#generating-a-webauthn-credential-key-pair]].
*/
def registrationOptions: Action[Unit] = authAction(parse.empty) { request =>
apiResponse(
for {
options <- Passkey.registrationOptions(appName, host, request.user)
_ <- PasskeyChallengeDB.insert(
UserChallenge(request.user, options.getChallenge)
)
_ = logger.info(
s"Created registration options for user ${request.user.username}"
)
} yield options
)
}
/** See
* [[https://webauthn4j.github.io/webauthn4j/en/#registering-the-webauthn-public-key-credential-on-the-server]].
*/
def register: Action[JsValue] = authAction(parse.json) { request =>
apiResponse(
for {
challengeResponse <- PasskeyChallengeDB.loadChallenge(request.user)
challenge <- PasskeyChallengeDB.extractChallenge(
challengeResponse,
request.user
)
body = request.body.toString()
credRecord <- Passkey.verifiedRegistration(host, challenge, body)
_ <- PasskeyDB.insert(request.user, credRecord)
_ <- PasskeyChallengeDB.delete(request.user)
_ = logger.info(s"Registered passkey for user ${request.user.username}")
} yield ()
)
}
/** See
* [[https://webauthn4j.github.io/webauthn4j/en/#generating-a-webauthn-assertion]].
*/
def authenticationOptions: Action[Unit] = authAction(parse.empty) { request =>
apiResponse(
for {
options <- Passkey.authenticationOptions(request.user)
_ <- PasskeyChallengeDB.insert(
UserChallenge(request.user, options.getChallenge)
)
_ = logger.info(
s"Created authentication options for user ${request.user.username}"
)
} yield options
)
}
// To be removed when passkeyAuthAction has been applied to real endpoints
def protectedCredentialsPage: Action[AnyContent] = passkeyAuthAction { _ =>
Ok("This is the protected page you're authorised to see.")
}
// To be removed when passkeyAuthAction has been applied to real endpoints
def pretendAwsConsole: Action[AnyContent] = Action {
Ok("This is the pretend AWS console.")
}
// To be removed when passkeyAuthAction has been applied to real endpoints
def protectedRedirect: Action[AnyContent] = passkeyAuthAction { _ =>
Redirect("/passkey/pretend-aws-console")
}
// To be removed when passkeyAuthAction has been applied to real endpoints
def mockHome: Action[AnyContent] = authAction { implicit request =>
val displayMode =
Date.displayMode(ZonedDateTime.now(ZoneId.of("Europe/London")))
(for {
permissions <- userAccess(username(request.user), janusData.access)
favourites = Favourites.fromCookie(request.cookies.get("favourites"))
awsAccountAccess = orderedAccountAccess(permissions, favourites)
} yield {
Ok(
views.html.passkeymock.index(
awsAccountAccess,
request.user,
janusData,
displayMode
)
)
}).getOrElse(Ok(views.html.noPermissions(request.user, janusData)))
}
def showUserAccountPage: Action[AnyContent] = authAction { implicit request =>
Ok(views.html.userAccount(request.user, janusData))
}
}