app/aws/PasskeyChallengeDB.scala (118 lines of code) (raw):
package aws
import aws.PasskeyChallengeDB.UserChallenge.toDynamoItem
import com.gu.googleauth.UserIdentity
import com.webauthn4j.data.client.challenge.{Challenge, DefaultChallenge}
import com.webauthn4j.util.Base64UrlUtil
import models.JanusException
import play.api.http.Status.INTERNAL_SERVER_ERROR
import software.amazon.awssdk.services.dynamodb.DynamoDbClient
import software.amazon.awssdk.services.dynamodb.model._
import java.nio.charset.StandardCharsets.UTF_8
import java.time.Instant
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Try}
object PasskeyChallengeDB {
private[aws] val tableName = "PasskeyChallenges"
case class UserChallenge(
user: UserIdentity,
challenge: Challenge,
// TTL can take up to 48 hours to take effect so this will be a fallback rather than something to rely on
expiresAt: Instant = Instant.now().plusSeconds(60)
)
object UserChallenge {
def toDynamoItem(
userChallenge: UserChallenge
): Map[String, AttributeValue] = {
Map(
"username" -> AttributeValue.fromS(userChallenge.user.username),
"challenge" -> AttributeValue.fromS(
Base64UrlUtil.encodeToString(userChallenge.challenge.getValue)
),
"expiresAt" -> AttributeValue.fromN(
userChallenge.expiresAt.getEpochSecond.toString
)
)
}
}
def insert(
userChallenge: UserChallenge
)(implicit dynamoDB: DynamoDbClient): Try[Unit] =
Try {
val item = toDynamoItem(userChallenge)
val request =
PutItemRequest.builder().tableName(tableName).item(item.asJava).build()
dynamoDB.putItem(request)
()
}.recoverWith(exception =>
Failure(
JanusException(
userMessage = "Failed to store challenge",
engineerMessage =
s"Failed to store challenge for user ${userChallenge.user.username}: ${exception.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(exception)
)
)
)
def loadChallenge(
user: UserIdentity
)(implicit dynamoDB: DynamoDbClient): Try[GetItemResponse] = {
Try {
val key = Map("username" -> AttributeValue.fromS(user.username))
val request =
GetItemRequest.builder().tableName(tableName).key(key.asJava).build()
dynamoDB.getItem(request)
}.recoverWith(err =>
Failure(
JanusException(
userMessage = "Failed to load challenge",
engineerMessage =
s"Failed to load challenge for user ${user.username}: ${err.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(err)
)
)
)
}
def extractChallenge(
response: GetItemResponse,
user: UserIdentity
): Try[Challenge] = {
if (response.hasItem) {
Try {
val item = response.item()
val challenge =
Base64UrlUtil.decode(item.get("challenge").s().getBytes(UTF_8))
new DefaultChallenge(challenge)
}
} else {
Failure(
JanusException(
userMessage = "Challenge not found",
engineerMessage = s"Challenge not found for user ${user.username}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = None
)
)
}
}
def delete(
user: UserIdentity
)(implicit dynamoDB: DynamoDbClient): Try[Unit] =
Try {
val key = Map("username" -> AttributeValue.fromS(user.username))
val request =
DeleteItemRequest.builder().tableName(tableName).key(key.asJava).build()
dynamoDB.deleteItem(request)
()
}.recoverWith(exception =>
Failure(
JanusException(
userMessage = "Failed to store challenge",
engineerMessage =
s"Failed to delete challenge for user ${user.username}: ${exception.getMessage}",
httpCode = INTERNAL_SERVER_ERROR,
causedBy = Some(exception)
)
)
)
}