From cd974e69767d373f4bf3b31dba02d446432e86fa Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 11 Feb 2025 19:15:33 +0100 Subject: [PATCH 01/40] add WebAuthn credentials to database schema --- .../126-add-webauthn-credentials.sql | 18 ++++++++++++++++++ tools/postgres/schema.sql | 11 ++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 conf/evolutions/126-add-webauthn-credentials.sql diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql new file mode 100644 index 00000000000..614ab81719d --- /dev/null +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -0,0 +1,18 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +CREATE TABLE webknossos.webauthnCredentials( + _id INTEGER PRIMARY KEY, + _multiUser CHAR(24) NOT NULL, + name TEXT NOT NULL, + publicKeyCode BYTEA NOT NULL, + signatureCount INTEGER +); + +ALTER TABLE webknossos.webauthnCredentials + ADD FOREIGN KEY (_multiUser) REFERENCES webknossos.multiUsers(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +UPDATE webknossos.releaseInformation SET schemaVersion = 127; + +COMMIT TRANSACTION; diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index ed5c00bf4d7..4e746f1e638 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -418,6 +418,14 @@ CREATE TABLE webknossos.multiUsers( CONSTRAINT nuxInfoIsJsonObject CHECK(jsonb_typeof(novelUserExperienceInfos) = 'object') ); +CREATE TABLE webknossos.webauthnCredentials( + _id INTEGER PRIMARY KEY, + _multiUser CHAR(24) NOT NULL, + name TEXT NOT NULL, + publicKeyCose BYTEA NOT NULL, + signatureCount INTEGER +); + CREATE TYPE webknossos.TOKEN_TYPES AS ENUM ('Authentication', 'DataStore', 'ResetPassword'); CREATE TYPE webknossos.USER_LOGININFO_PROVDERIDS AS ENUM ('credentials'); @@ -871,6 +879,8 @@ ALTER TABLE webknossos.aiInferences ALTER TABLE webknossos.aiModel_trainingAnnotations ADD FOREIGN KEY (_aiModel) REFERENCES webknossos.aiModels(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE, ADD FOREIGN KEY (_annotation) REFERENCES webknossos.annotations(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; +ALTER TABLE webknossos.webauthnCredentials + ADD FOREIGN KEY (_multiUser) REFERENCES webknossos.multiUsers(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; CREATE FUNCTION webknossos.countsAsTaskInstance(a webknossos.annotations) RETURNS BOOLEAN AS $$ @@ -943,4 +953,3 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER onDeleteAnnotationTrigger AFTER DELETE ON webknossos.annotations FOR EACH ROW EXECUTE PROCEDURE webknossos.onDeleteAnnotation(); - From 47f52accd706ac2fdccfbcc1273667b41362f018 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 11 Feb 2025 21:54:19 +0100 Subject: [PATCH 02/40] setup WebAuthnCredentials DAO --- app/models/user/WebAuthnCredentials.scala | 47 +++++++++++++++++++ .../126-add-webauthn-credentials.sql | 4 +- tools/postgres/schema.sql | 4 +- 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 app/models/user/WebAuthnCredentials.scala diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala new file mode 100644 index 00000000000..c02860a3792 --- /dev/null +++ b/app/models/user/WebAuthnCredentials.scala @@ -0,0 +1,47 @@ +import com.scalableminds.util.accesscontext.DBAccessContext +import com.scalableminds.util.objectid.ObjectId; +import com.scalableminds.util.tools.Fox; +import com.scalableminds.webknossos.schema.Tables._; + +import utils.sql.{SQLDAO, SqlClient}; + +import javax.inject.Inject; +import scala.concurrent.{ExecutionContext, Future} + +case class WebAuthnCredential( + _id: ObjectId, + _multiUser: ObjectId, + name: String, + publicKeyCose: Array[Byte], + signatureCount: Int, +) + +class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: ExecutionContext) + extends SQLDAO[WebAuthnCredential, WebauthncredentialsRow, Webauthncredentials](sqlClient) { + protected val collection = Webauthncredentials + + protected def parse(r: WebauthncredentialsRow): Fox[WebAuthnCredential] = + Fox.future2Fox(Future.successful( + WebAuthnCredential( + ObjectId(r._Id), + ObjectId(r._Multiuser), + r.name, + r.publickeycose, + r.signaturecount, + ) + )) + + def insertOne(c: WebAuthnCredential)(implicit ctx: DBAccessContext): Fox[Unit] = + for { + _ <- run(q"""INSERT INTO webknossos.webauthnCredentials(_id, _multiUser, + name, publicKeyCose, + signatureCount) + VALUES(${c._id}, ${c._multiUser}, ${c.name}, + ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) + } yield () + + def removeById(id: ObjectId)(implicit ctx: DBAccessContext): Fox[Unit] = + for { + _ <- run(q"""DELETE FROM webknossos.webauthnCredentials WHERE _id = ${id}""".asUpdate) + } yield() +} diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql index 614ab81719d..d3332b86bfd 100644 --- a/conf/evolutions/126-add-webauthn-credentials.sql +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -3,11 +3,11 @@ START TRANSACTION; do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; CREATE TABLE webknossos.webauthnCredentials( - _id INTEGER PRIMARY KEY, + _id TEXT PRIMARY KEY, _multiUser CHAR(24) NOT NULL, name TEXT NOT NULL, publicKeyCode BYTEA NOT NULL, - signatureCount INTEGER + signatureCount INTEGER NOT NULL ); ALTER TABLE webknossos.webauthnCredentials diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 4e746f1e638..32a0ef35980 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -419,11 +419,11 @@ CREATE TABLE webknossos.multiUsers( ); CREATE TABLE webknossos.webauthnCredentials( - _id INTEGER PRIMARY KEY, + _id TEXT PRIMARY KEY, _multiUser CHAR(24) NOT NULL, name TEXT NOT NULL, publicKeyCose BYTEA NOT NULL, - signatureCount INTEGER + signatureCount INTEGER NOT NULL ); From c52288329a202cb1b9fe789b4cf76a8a04f47116 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Wed, 12 Feb 2025 17:31:10 +0100 Subject: [PATCH 03/40] Implement WebAuthn backend --- .../AuthenticationController.scala | 110 ++++++++++++++++++ app/models/user/MultiUser.scala | 8 ++ app/models/user/WebAuthnCredentials.scala | 52 +++++++-- .../WebAuthnCredentialRepository.scala | 71 +++++++++++ .../126-add-webauthn-credentials.sql | 3 +- conf/webknossos.latest.routes | 8 ++ project/Dependencies.scala | 4 +- tools/postgres/schema.sql | 3 +- 8 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 app/security/WebAuthnCredentialRepository.scala diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 444b1a69ab6..5a9a717516a 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -3,6 +3,10 @@ package controllers import com.scalableminds.util.accesscontext.{DBAccessContext, GlobalAccessContext} import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.tools.{Fox, FoxImplicits, TextUtils} +import com.scalableminds.webknossos.datastore.storage.TemporaryStore +import com.yubico.webauthn._ +import com.yubico.webauthn.data._ +import com.yubico.webauthn.exception._ import mail.{DefaultMails, MailchimpClient, MailchimpTag, Send} import models.analytics.{AnalyticsService, InviteEvent, JoinOrganizationEvent, SignupEvent} import models.organization.{Organization, OrganizationDAO, OrganizationService} @@ -29,9 +33,16 @@ import utils.WkConf import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.security.MessageDigest +import java.util.UUID import javax.inject.Inject +import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} +case class WebAuthnRegistration(name: String, key: String) +object WebAuthnRegistration { + implicit val jsonFormat: OFormat[WebAuthnRegistration] = Json.format[WebAuthnRegistration] +} + class AuthenticationController @Inject()( actorSystem: ActorSystem, credentialsProvider: CredentialsProvider, @@ -52,6 +63,10 @@ class AuthenticationController @Inject()( openIdConnectClient: OpenIdConnectClient, initialDataService: InitialDataService, emailVerificationService: EmailVerificationService, + webAuthnCredentialRepository: WebAuthnCredentialRepository, + webAuthnCredentialDAO: WebAuthnCredentialDAO, + temporaryAssertionStore: TemporaryStore[String, AssertionRequest], + temporaryRegistrationStore: TemporaryStore[ObjectId, PublicKeyCredentialCreationOptions], sil: Silhouette[WkEnv])(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers) extends Controller with AuthForms @@ -66,6 +81,18 @@ class AuthenticationController @Inject()( private lazy val ssoKey = conf.WebKnossos.User.ssoKey + private lazy val relyingParty = { + var identity = RelyingPartyIdentity + .builder() + .id("webknossos.local:9000") // TODO: Use Host + .name("WebKnossos") + .build(); + RelyingParty.builder() + .identity(identity) + .credentialRepository(webAuthnCredentialRepository) + .build() + } + private lazy val isOIDCEnabled = conf.Features.openIdConnectEnabled def register: Action[AnyContent] = Action.async { implicit request => @@ -406,6 +433,89 @@ class AuthenticationController @Inject()( } } + def webauthnAuthStart(): Action[AnyContent] = Action{ implicit request => { + val opts = StartAssertionOptions.builder().build(); + val assertion = relyingParty.startAssertion(opts); + val sessionId = UUID.randomUUID().toString; + val cookie = Cookie("webauthn-session", sessionId, maxAge = Some(120), httpOnly = true, secure = true) + temporaryAssertionStore.insert(sessionId, assertion, Some(2 minutes)); + Ok(Json.toJson(Json.parse(assertion.toJson))).withCookies(cookie) + } + } + + def webauthnAuthFinalize(): Action[String] = Action.async(validateJson[String]) { implicit request => { + request.cookies.get("webauthn-session") match { + case Some(cookie) => + val sessionId = cookie.value + val challengeData = temporaryAssertionStore.get(sessionId) + temporaryAssertionStore.remove(sessionId) + challengeData match { + case Some(data) => + try { + val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body); + val opts = FinishAssertionOptions.builder() + .request(data) + .response(keyCredential) + .build(); + val result = relyingParty.finishAssertion(opts); + val userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle) + val loginInfo = LoginInfo("credentials", userId.toString) + loginUser(loginInfo)(request.map(_ => AnyContent())) + } catch { + case e: AssertionFailedException => Future.successful(Unauthorized("challenge failed")) + } + case None => + Future.successful(BadRequest("Challenge not found or expired")) + } + } + } + } + + def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction { implicit request => { + val userIdentity = UserIdentity.builder() + .name(request.identity.name) + .displayName(request.identity.name) + .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._id)) + .build(); + val opts = StartRegistrationOptions.builder() + .user(userIdentity) + .timeout(120000) + .build() + val registration = relyingParty.startRegistration(opts); + temporaryRegistrationStore.insert(request.identity._multiUser, registration); + Ok(Json.toJson(Json.parse(registration.toJson))) + }} + + def webauthnRegisterFinalize(): Action[WebAuthnRegistration] = sil.SecuredAction.async(validateJson[WebAuthnRegistration]) { implicit request => { + val creationOpts = temporaryRegistrationStore.get(request.identity._multiUser) + temporaryRegistrationStore.remove(request.identity._multiUser) + creationOpts match { + case Some(data) => { + val response = PublicKeyCredential.parseRegistrationResponseJson(request.body.key); + val opts = FinishRegistrationOptions.builder() + .request(data) + .response(response) + .build(); + try { + val key = relyingParty.finishRegistration(opts) + val credential = WebAuthnCredential( + ObjectId.generate, + request.identity._multiUser, + request.body.name, + key.getPublicKeyCose.getBytes, + key.getSignatureCount.toInt, + isDeleted = false, + ) + webAuthnCredentialDAO.insertOne(credential) + .map(_ => Ok("")) + } catch { + case e: RegistrationFailedException => Future.successful(BadRequest("Failed to register key")) + } + } + case None => Future.successful(BadRequest("Challenge not found or expired")) + } + }} + private lazy val absoluteOpenIdConnectCallbackURL = s"${conf.Http.uri}/api/auth/oidc/callback" def loginViaOpenIdConnect(): Action[AnyContent] = sil.UserAwareAction.async { implicit request => diff --git a/app/models/user/MultiUser.scala b/app/models/user/MultiUser.scala index 55f98691b34..9bcd9424b95 100644 --- a/app/models/user/MultiUser.scala +++ b/app/models/user/MultiUser.scala @@ -136,6 +136,14 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext (SELECT _id FROM webknossos.users WHERE _organization = $organizationId)""".asUpdate) } yield () + def findOneById(id: ObjectId)(implicit ctx: DBAccessContext): Fox[MultiUser] = + for { + accessQuery <- readAccessQuery + r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _id = $id AND $accessQuery".as[MultiusersRow]) + parsed <- parseFirst(r, id) + } yield parsed + + def findOneByEmail(email: String)(implicit ctx: DBAccessContext): Fox[MultiUser] = for { accessQuery <- readAccessQuery diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index c02860a3792..37810d0f3d3 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -1,11 +1,14 @@ -import com.scalableminds.util.accesscontext.DBAccessContext -import com.scalableminds.util.objectid.ObjectId; -import com.scalableminds.util.tools.Fox; -import com.scalableminds.webknossos.schema.Tables._; +package models.user -import utils.sql.{SQLDAO, SqlClient}; +import com.scalableminds.util.accesscontext.DBAccessContext +import com.scalableminds.util.objectid.ObjectId +import com.scalableminds.util.tools.Fox +import com.scalableminds.webknossos.schema.Tables +import com.scalableminds.webknossos.schema.Tables._ +import slick.lifted.Rep +import utils.sql.{SQLDAO, SqlClient} -import javax.inject.Inject; +import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} case class WebAuthnCredential( @@ -14,24 +17,52 @@ case class WebAuthnCredential( name: String, publicKeyCose: Array[Byte], signatureCount: Int, + isDeleted: Boolean, ) class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: ExecutionContext) extends SQLDAO[WebAuthnCredential, WebauthncredentialsRow, Webauthncredentials](sqlClient) { protected val collection = Webauthncredentials + override protected def idColumn(x: Webauthncredentials): Rep[String] = x._Id + + override protected def isDeletedColumn(x: Webauthncredentials): Rep[Boolean] = x.isdeleted + protected def parse(r: WebauthncredentialsRow): Fox[WebAuthnCredential] = - Fox.future2Fox(Future.successful( + Fox.successful( WebAuthnCredential( ObjectId(r._Id), ObjectId(r._Multiuser), r.name, r.publickeycose, r.signaturecount, + r.isdeleted ) - )) + ) + + def findAllForUser(userId: ObjectId)(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] = + for { + accessQuery <- readAccessQuery + r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + parsed <- parseAll(r) + } yield parsed - def insertOne(c: WebAuthnCredential)(implicit ctx: DBAccessContext): Fox[Unit] = + def findById(id: ObjectId)(implicit ct: DBAccessContext): Fox[WebAuthnCredential] = + for { + accessQuery <- readAccessQuery + r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) + parsed <- parseFirst(r, id) + } yield parsed + + + def findByIdAndUserId(id: ObjectId, userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = + for { + accessQuery <- readAccessQuery + r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + parsed <- parseFirst(r, id) + } yield parsed + + def insertOne(c: WebAuthnCredential): Fox[Unit] = for { _ <- run(q"""INSERT INTO webknossos.webauthnCredentials(_id, _multiUser, name, publicKeyCose, @@ -40,8 +71,9 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) } yield () - def removeById(id: ObjectId)(implicit ctx: DBAccessContext): Fox[Unit] = + def removeById(id: ObjectId): Fox[Unit] = for { _ <- run(q"""DELETE FROM webknossos.webauthnCredentials WHERE _id = ${id}""".asUpdate) } yield() + } diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala new file mode 100644 index 00000000000..3a43a49b7aa --- /dev/null +++ b/app/security/WebAuthnCredentialRepository.scala @@ -0,0 +1,71 @@ +package security + +import models.user.{MultiUserDAO,WebAuthnCredentialDAO}; + +import com.yubico.webauthn._ +import com.yubico.webauthn.data._ +import com.scalableminds.util.accesscontext.GlobalAccessContext +import com.scalableminds.util.objectid.ObjectId + +import java.util.Optional +import javax.inject.Inject +import scala.collection.JavaConverters._; + +object WebAuthnCredentialRepository { + def objectIdToByteArray(id: ObjectId): ByteArray = + new ByteArray(id.toString.getBytes()) + + def byteArrayToObjectId(arr: ByteArray): ObjectId = + new ObjectId(new String(arr.getBytes)) +} + +/* + * UserHandle => ObjectId + * Username => User's E-Mail address + */ + +class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuthnCredentialDAO: WebAuthnCredentialDAO) extends CredentialRepository { + def getCredentialIdsForUsername(email: String): java.util.Set[PublicKeyCredentialDescriptor] = { + val user = multiUserDAO.findOneByEmail(email)(GlobalAccessContext).get("Java interop") + val keys = webAuthnCredentialDAO.findAllForUser(user._id)(GlobalAccessContext).get("Java interop"); + keys.map(key => { + PublicKeyCredentialDescriptor.builder() + .id(WebAuthnCredentialRepository.objectIdToByteArray(key._id)) + .build() + }).to(Set).asJava + } + + def getUserHandleForUsername(email: String): Optional[ByteArray] = { + val user = multiUserDAO.findOneByEmail(email)(GlobalAccessContext).get("Java interop") + Optional.ofNullable(WebAuthnCredentialRepository.objectIdToByteArray(user._id)) + } + + def getUsernameForUserHandle(handle: ByteArray): Optional[String] = { + val id = WebAuthnCredentialRepository.byteArrayToObjectId(handle) + val user = multiUserDAO.findOneById(id)(GlobalAccessContext).get("Java interop") + Optional.ofNullable(user.email) + } + + def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = { + val credId = WebAuthnCredentialRepository.byteArrayToObjectId(credentialId) + val userId = WebAuthnCredentialRepository.byteArrayToObjectId(userHandle) + val credential = webAuthnCredentialDAO.findByIdAndUserId(credId, userId)(GlobalAccessContext).get("Java interop"); + Optional.ofNullable(RegisteredCredential.builder() + .credentialId(WebAuthnCredentialRepository.objectIdToByteArray(credential._id)) + .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) + .publicKeyCose(new ByteArray(credential.publicKeyCose)) + .signatureCount(credential.signatureCount) + .build()) + } + + def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { + val credential = webAuthnCredentialDAO.findById(WebAuthnCredentialRepository.byteArrayToObjectId(credentialId))(GlobalAccessContext).get("Java interop"); + Set(RegisteredCredential.builder() + .credentialId(WebAuthnCredentialRepository.objectIdToByteArray(credential._id)) + .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) + .publicKeyCose(new ByteArray(credential.publicKeyCose)) + .signatureCount(credential.signatureCount) + .build()).asJava + } + +} diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql index d3332b86bfd..c6c3a2cbf04 100644 --- a/conf/evolutions/126-add-webauthn-credentials.sql +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -7,7 +7,8 @@ CREATE TABLE webknossos.webauthnCredentials( _multiUser CHAR(24) NOT NULL, name TEXT NOT NULL, publicKeyCode BYTEA NOT NULL, - signatureCount INTEGER NOT NULL + signatureCount INTEGER NOT NULL, + isDeleted BOOLEAN NOT NULL DEFAULT false ); ALTER TABLE webknossos.webauthnCredentials diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 558baa9cd8d..1e8cf6c5295 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -36,6 +36,14 @@ POST /auth/resetPassword GET /auth/logout controllers.AuthenticationController.logout() GET /auth/sso controllers.AuthenticationController.singleSignOn(sso: String, sig: String) GET /auth/oidc/login controllers.AuthenticationController.loginViaOpenIdConnect() + +# Routes for WebAuthn +POST /auth/webauthn/auth/start controllers.AuthenticationController.webauthnAuthStart() +POST /auth/webauthn/auth/finalize controllers.AuthenticationController.webauthnAuthFinalize() + +POST /auth/webauthn/register/start controllers.AuthenticationController.webauthnAuthStart() +POST /auth/webauthn/register/finalize controllers.AuthenticationController.webauthnAuthFinalize() + # /auth/oidc/callback route is used literally in code GET /auth/oidc/callback controllers.AuthenticationController.openIdCallback() POST /auth/createOrganizationWithAdmin controllers.AuthenticationController.createOrganizationWithAdmin() diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 8294e1ad054..450aef3c246 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -104,7 +104,9 @@ object Dependencies { // SQL Queries class generation. Started with runner as slick.codegen.SourceCodeGenerator "com.typesafe.slick" %% "slick-codegen" % "3.5.1", // SQL Queries postgres specifics. not imported. - "org.postgresql" % "postgresql" % "42.7.3" + "org.postgresql" % "postgresql" % "42.7.3", + /// WebAuthn Dependencies + "com.yubico" % "webauthn-server-core" % "2.6.0", ) val dependencyOverrides: Seq[ModuleID] = Seq( diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 32a0ef35980..2095248f7dd 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -423,7 +423,8 @@ CREATE TABLE webknossos.webauthnCredentials( _multiUser CHAR(24) NOT NULL, name TEXT NOT NULL, publicKeyCose BYTEA NOT NULL, - signatureCount INTEGER NOT NULL + signatureCount INTEGER NOT NULL, + isDeleted BOOLEAN NOT NULL DEFAULT false ); From 8938d9e901891350ac04809333a8183639efcc6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 12 Feb 2025 17:48:06 +0100 Subject: [PATCH 04/40] add frontend webauthn support lib --- package.json | 1 + yarn.lock | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/package.json b/package.json index dc2d6407f9e..1017fe3b4a8 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@fortawesome/fontawesome-free": "^5.15.4", + "@github/webauthn-json": "^2.1.1", "@rehooks/document-title": "^1.0.2", "@scalableminds/prop-types": "^15.8.1", "@tanstack/query-sync-storage-persister": "4.36.1", diff --git a/yarn.lock b/yarn.lock index d82076a1a28..21787baa25b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -727,6 +727,15 @@ __metadata: languageName: node linkType: hard +"@github/webauthn-json@npm:^2.1.1": + version: 2.1.1 + resolution: "@github/webauthn-json@npm:2.1.1" + bin: + webauthn-json: dist/bin/main.js + checksum: 10c0/4423ddd1e5b74d91ded02ea551923a73c45b1ee2ee4294943aaddfc12e1929405ea1e8b4380456db829fcdc3a4d8a89bf8ee29c6a35be3889dc00236b1c96968 + languageName: node + linkType: hard + "@hypnosphi/create-react-context@npm:^0.3.1": version: 0.3.1 resolution: "@hypnosphi/create-react-context@npm:0.3.1" @@ -13938,6 +13947,7 @@ __metadata: "@dnd-kit/core": "npm:^6.1.0" "@dnd-kit/sortable": "npm:^8.0.0" "@fortawesome/fontawesome-free": "npm:^5.15.4" + "@github/webauthn-json": "npm:^2.1.1" "@redux-saga/testing-utils": "npm:^1.1.5" "@rehooks/document-title": "npm:^1.0.2" "@scalableminds/prop-types": "npm:^15.8.1" From f5130f6f735252f40f1192425a4da381e6fa7946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 12 Feb 2025 17:48:23 +0100 Subject: [PATCH 05/40] use proper file extension in some files --- .../api/{certificate_validation.tsx => certificate_validation.ts} | 0 ...sambiguate_legacy_routes.tsx => disambiguate_legacy_routes.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename frontend/javascripts/admin/api/{certificate_validation.tsx => certificate_validation.ts} (100%) rename frontend/javascripts/admin/api/{disambiguate_legacy_routes.tsx => disambiguate_legacy_routes.ts} (100%) diff --git a/frontend/javascripts/admin/api/certificate_validation.tsx b/frontend/javascripts/admin/api/certificate_validation.ts similarity index 100% rename from frontend/javascripts/admin/api/certificate_validation.tsx rename to frontend/javascripts/admin/api/certificate_validation.ts diff --git a/frontend/javascripts/admin/api/disambiguate_legacy_routes.tsx b/frontend/javascripts/admin/api/disambiguate_legacy_routes.ts similarity index 100% rename from frontend/javascripts/admin/api/disambiguate_legacy_routes.tsx rename to frontend/javascripts/admin/api/disambiguate_legacy_routes.ts From 09afa8a07add181a40fc5bf7ef3d79bc68dfa9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Wed, 12 Feb 2025 18:26:08 +0100 Subject: [PATCH 06/40] WIP: Add passkey management page --- .../javascripts/admin/auth/login_form.tsx | 12 +++ .../admin/auth/manage_passkeys_view.tsx | 76 +++++++++++++++++++ frontend/javascripts/navbar.tsx | 11 +++ frontend/javascripts/router.tsx | 6 ++ 4 files changed, 105 insertions(+) create mode 100644 frontend/javascripts/admin/auth/manage_passkeys_view.tsx diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index a1cbef92cb9..4056fdd3bb5 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -156,6 +156,18 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) { > Register Now + + Use PassKey + Forgot Password diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx new file mode 100644 index 00000000000..a7fa151a538 --- /dev/null +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -0,0 +1,76 @@ +import { CopyOutlined, SwapOutlined } from "@ant-design/icons"; +import { getAuthToken, revokeAuthToken } from "admin/admin_rest_api"; +import { Button, Col, Form, Input, Row, Space, Spin } from "antd"; +import Toast from "libs/toast"; +import type { OxalisState } from "oxalis/store"; +import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +const FormItem = Form.Item; + +function ManagePassKeyView() { + const activeUser = useSelector((state: OxalisState) => state.activeUser); + const [isLoading, setIsLoading] = useState(true); + const [currentToken, setCurrentToken] = useState(""); + const [form] = Form.useForm(); + useEffect(() => { + fetchData(); + }, []); + + async function fetchData(): Promise { + const token = await getAuthToken(); + setCurrentToken(token); + setIsLoading(false); + } + + const handleRevokeToken = async (): Promise => { + try { + setIsLoading(true); + await revokeAuthToken(); + const token = await getAuthToken(); + setCurrentToken(token); + } finally { + setIsLoading(false); + } + }; + + const copyTokenToClipboard = async () => { + await navigator.clipboard.writeText(currentToken); + Toast.success("Token copied to clipboard"); + }; + + const copyOrganizationIdToClipboard = async () => { + if (activeUser != null) { + await navigator.clipboard.writeText(activeUser.organization); + Toast.success("Organization ID copied to clipboard"); + } + }; + + return ( +
+ + +

Your PassKeys

+ TODO + +
+ + +

+ PassKeys are a new web authentication method that allows you to log in without a password in a secured way. + Microsoft Hello and Apple FaceID are examples of technologies that can be used as passkeys to log in in WEBKNOSSOS. + If you want to add a new passkey to your account use the button below. +

+ TODO: Button and so on + +
+
+ ); +} + +export default ManagePassKeyView; diff --git a/frontend/javascripts/navbar.tsx b/frontend/javascripts/navbar.tsx index 0934d62e190..d9d636c04e5 100644 --- a/frontend/javascripts/navbar.tsx +++ b/frontend/javascripts/navbar.tsx @@ -675,6 +675,17 @@ function LoggedInAvatar({ key: "resetpassword", label: Change Password, }, + { + key: "manage-passkeys", + label: ( + + Register/Manage PassKeys + + ), + }, { key: "token", label: Auth Token }, { key: "theme", diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx index edcae43c38a..bd670ef6976 100644 --- a/frontend/javascripts/router.tsx +++ b/frontend/javascripts/router.tsx @@ -63,6 +63,7 @@ import { getDatasetIdFromNameAndOrganization, getOrganizationForDataset, } from "admin/api/disambiguate_legacy_routes"; +import ManagePassKeyView from "admin/auth/manage_passkeys_view"; import VerifyEmailView from "admin/auth/verify_email_view"; import { DatasetURLImport } from "admin/dataset/dataset_url_import"; import TimeTrackingOverview from "admin/statistic/time_tracking_overview"; @@ -617,6 +618,11 @@ class ReactRouter extends React.Component { path="/auth/token" component={AuthTokenView} /> + Date: Thu, 13 Feb 2025 19:37:20 +0100 Subject: [PATCH 07/40] WIP: add some frontend part for passkey registration --- .../AuthenticationController.scala | 142 +++++++++--------- conf/webknossos.latest.routes | 4 +- frontend/javascripts/admin/admin_rest_api.ts | 27 ++++ .../javascripts/admin/auth/login_form.tsx | 6 +- .../admin/auth/manage_passkeys_view.tsx | 78 ++++++---- 5 files changed, 151 insertions(+), 106 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 5a9a717516a..074053dffda 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -87,10 +87,7 @@ class AuthenticationController @Inject()( .id("webknossos.local:9000") // TODO: Use Host .name("WebKnossos") .build(); - RelyingParty.builder() - .identity(identity) - .credentialRepository(webAuthnCredentialRepository) - .build() + RelyingParty.builder().identity(identity).credentialRepository(webAuthnCredentialRepository).build() } private lazy val isOIDCEnabled = conf.Features.openIdConnectEnabled @@ -433,7 +430,8 @@ class AuthenticationController @Inject()( } } - def webauthnAuthStart(): Action[AnyContent] = Action{ implicit request => { + def webauthnAuthStart(): Action[AnyContent] = Action { implicit request => + { val opts = StartAssertionOptions.builder().build(); val assertion = relyingParty.startAssertion(opts); val sessionId = UUID.randomUUID().toString; @@ -443,78 +441,82 @@ class AuthenticationController @Inject()( } } - def webauthnAuthFinalize(): Action[String] = Action.async(validateJson[String]) { implicit request => { - request.cookies.get("webauthn-session") match { - case Some(cookie) => - val sessionId = cookie.value - val challengeData = temporaryAssertionStore.get(sessionId) - temporaryAssertionStore.remove(sessionId) - challengeData match { - case Some(data) => - try { - val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body); - val opts = FinishAssertionOptions.builder() - .request(data) - .response(keyCredential) - .build(); - val result = relyingParty.finishAssertion(opts); - val userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle) - val loginInfo = LoginInfo("credentials", userId.toString) - loginUser(loginInfo)(request.map(_ => AnyContent())) - } catch { - case e: AssertionFailedException => Future.successful(Unauthorized("challenge failed")) - } - case None => - Future.successful(BadRequest("Challenge not found or expired")) - } + case class WebAuthnAuthResponse(assertionResponse: String) + + object WebAuthnAuthResponse { + implicit val jsonFormat: OFormat[WebAuthnAuthResponse] = Json.format[WebAuthnAuthResponse] + } + + def webauthnAuthFinalize(): Action[WebAuthnAuthResponse] = Action.async(validateJson[WebAuthnAuthResponse]) { + implicit request => + { + request.cookies.get("webauthn-session") match { + case Some(cookie) => + val sessionId = cookie.value + val challengeData = temporaryAssertionStore.get(sessionId) + temporaryAssertionStore.remove(sessionId) + challengeData match { + case Some(data) => + try { + val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.assertionResponse); + val opts = FinishAssertionOptions.builder().request(data).response(keyCredential).build(); + val result = relyingParty.finishAssertion(opts); + val userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle) + val loginInfo = LoginInfo("credentials", userId.toString) + loginUser(loginInfo)(request.map(_ => AnyContent())) + } catch { + case e: AssertionFailedException => Future.successful(Unauthorized("challenge failed")) + } + case None => + Future.successful(BadRequest("Challenge not found or expired")) + } + } } + } + + def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction { implicit request => + { + val userIdentity = UserIdentity + .builder() + .name(request.identity.name) + .displayName(request.identity.name) + .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._id)) + .build(); + val opts = StartRegistrationOptions.builder().user(userIdentity).timeout(120000).build() + val registration = relyingParty.startRegistration(opts); + temporaryRegistrationStore.insert(request.identity._multiUser, registration); + Ok(Json.toJson(Json.parse(registration.toJson))) } } - def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction { implicit request => { - val userIdentity = UserIdentity.builder() - .name(request.identity.name) - .displayName(request.identity.name) - .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._id)) - .build(); - val opts = StartRegistrationOptions.builder() - .user(userIdentity) - .timeout(120000) - .build() - val registration = relyingParty.startRegistration(opts); - temporaryRegistrationStore.insert(request.identity._multiUser, registration); - Ok(Json.toJson(Json.parse(registration.toJson))) - }} - - def webauthnRegisterFinalize(): Action[WebAuthnRegistration] = sil.SecuredAction.async(validateJson[WebAuthnRegistration]) { implicit request => { - val creationOpts = temporaryRegistrationStore.get(request.identity._multiUser) - temporaryRegistrationStore.remove(request.identity._multiUser) - creationOpts match { - case Some(data) => { - val response = PublicKeyCredential.parseRegistrationResponseJson(request.body.key); - val opts = FinishRegistrationOptions.builder() - .request(data) - .response(response) - .build(); - try { - val key = relyingParty.finishRegistration(opts) - val credential = WebAuthnCredential( - ObjectId.generate, - request.identity._multiUser, - request.body.name, - key.getPublicKeyCose.getBytes, - key.getSignatureCount.toInt, - isDeleted = false, - ) - webAuthnCredentialDAO.insertOne(credential) - .map(_ => Ok("")) - } catch { - case e: RegistrationFailedException => Future.successful(BadRequest("Failed to register key")) + def webauthnRegisterFinalize(): Action[WebAuthnRegistration] = + sil.SecuredAction.async(validateJson[WebAuthnRegistration]) { implicit request => + { + val creationOpts = temporaryRegistrationStore.get(request.identity._multiUser) + temporaryRegistrationStore.remove(request.identity._multiUser) + creationOpts match { + case Some(data) => { + val response = PublicKeyCredential.parseRegistrationResponseJson(request.body.key); + val opts = FinishRegistrationOptions.builder().request(data).response(response).build(); + try { + val key = relyingParty.finishRegistration(opts) + val credential = WebAuthnCredential( + ObjectId.generate, + request.identity._multiUser, + request.body.name, + key.getPublicKeyCose.getBytes, + key.getSignatureCount.toInt, + isDeleted = false, + ) + webAuthnCredentialDAO.insertOne(credential).map(_ => Ok("")) + } catch { + case e: RegistrationFailedException => Future.successful(BadRequest("Failed to register key")) + } + } + case None => Future.successful(BadRequest("Challenge not found or expired")) } } - case None => Future.successful(BadRequest("Challenge not found or expired")) } - }} private lazy val absoluteOpenIdConnectCallbackURL = s"${conf.Http.uri}/api/auth/oidc/callback" diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 1e8cf6c5295..f47259111d1 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -41,8 +41,8 @@ GET /auth/oidc/login POST /auth/webauthn/auth/start controllers.AuthenticationController.webauthnAuthStart() POST /auth/webauthn/auth/finalize controllers.AuthenticationController.webauthnAuthFinalize() -POST /auth/webauthn/register/start controllers.AuthenticationController.webauthnAuthStart() -POST /auth/webauthn/register/finalize controllers.AuthenticationController.webauthnAuthFinalize() +POST /auth/webauthn/register/start controllers.AuthenticationController.webauthnRegisterStart() +POST /auth/webauthn/register/finalize controllers.AuthenticationController.webauthnRegisterFinalize() # /auth/oidc/callback route is used literally in code GET /auth/oidc/callback controllers.AuthenticationController.openIdCallback() diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index f86b1cf31a7..e9ff7bb5583 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1,3 +1,4 @@ +import { get, parseRequestOptionsFromJSON } from "@github/webauthn-json/browser-ponyfill"; import dayjs from "dayjs"; import { V3 } from "libs/mjs"; import type { RequestOptions } from "libs/request"; @@ -147,6 +148,32 @@ export async function loginUser(formValues: { return [activeUser, organization]; } +export async function doWebAuthnLogin(): Promise { + const webAuthnAuthAssertion = await Request.receiveJSON("/auth/webauthn/auth/start", { + method: "POST", + }); + const options = parseRequestOptionsFromJSON(webAuthnAuthAssertion); + const response = await get(options); + return Request.sendJSONReceiveJSON("/auth/webauthn/auth/finalize", { + method: "POST", + data: { assertionResponse: response }, + }); +} + +export async function startWebAuthnRegistration(): Promise { + return Request.receiveJSON("/auth/webauthn/register/start", { method: "POST" }); +} + +export async function finalizeWebAuthnRegistration( + name: string, + key: string, +): Promise { + return Request.sendJSONReceiveJSON("/auth/webauthn/register/start", { + data: { name, key }, + method: "POST", + }); +} + export async function getUsers(): Promise> { const users = await Request.receiveJSON("/api/users"); assertResponseLimit(users); diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index 4056fdd3bb5..845786037f5 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -1,5 +1,5 @@ import { LockOutlined, MailOutlined } from "@ant-design/icons"; -import { loginUser, requestSingleSignOnLogin } from "admin/admin_rest_api"; +import { doWebAuthnLogin, loginUser, requestSingleSignOnLogin } from "admin/admin_rest_api"; import { Alert, Button, Form, Input } from "antd"; import features from "features"; import { getIsInIframe } from "libs/utils"; @@ -165,6 +165,10 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) { flexGrow: 1, whiteSpace: "nowrap", }} + onClick={async () => { + const response = await doWebAuthnLogin(); + window.location.href = response.redirect_url; + }} > Use PassKey diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx index a7fa151a538..2a7019f762b 100644 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -1,47 +1,44 @@ import { CopyOutlined, SwapOutlined } from "@ant-design/icons"; -import { getAuthToken, revokeAuthToken } from "admin/admin_rest_api"; -import { Button, Col, Form, Input, Row, Space, Spin } from "antd"; +import { + finalizeWebAuthnRegistration, + getAuthToken, + revokeAuthToken, + startWebAuthnRegistration, +} from "admin/admin_rest_api"; +import { Button, Col, Form, Input, Modal, Row, Space, Spin } from "antd"; import Toast from "libs/toast"; import type { OxalisState } from "oxalis/store"; import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; + +import { create, parseCreationOptionsFromJSON } from "@github/webauthn-json/browser-ponyfill"; + const FormItem = Form.Item; function ManagePassKeyView() { - const activeUser = useSelector((state: OxalisState) => state.activeUser); + const [isPassKeyNameModalOpen, setIsPassKeyNameModalOpen] = useState(false); + const [newPassKeyName, setNewPassKeyName] = useState(""); const [isLoading, setIsLoading] = useState(true); - const [currentToken, setCurrentToken] = useState(""); - const [form] = Form.useForm(); useEffect(() => { fetchData(); }, []); async function fetchData(): Promise { - const token = await getAuthToken(); - setCurrentToken(token); - setIsLoading(false); + // TODO: fetch list of registered passkeys } - const handleRevokeToken = async (): Promise => { + const registerNewPassKey = async () => { try { - setIsLoading(true); - await revokeAuthToken(); - const token = await getAuthToken(); - setCurrentToken(token); - } finally { - setIsLoading(false); - } - }; - - const copyTokenToClipboard = async () => { - await navigator.clipboard.writeText(currentToken); - Toast.success("Token copied to clipboard"); - }; - - const copyOrganizationIdToClipboard = async () => { - if (activeUser != null) { - await navigator.clipboard.writeText(activeUser.organization); - Toast.success("Organization ID copied to clipboard"); + setIsPassKeyNameModalOpen(false); + const json = await startWebAuthnRegistration(); + const options = parseCreationOptionsFromJSON(json); + const response = await create(options); + await finalizeWebAuthnRegistration(newPassKeyName, JSON.stringify(response)); + Toast.success("PassKey registered successfully"); + setNewPassKeyName(""); + } catch (e) { + Toast.success(`Registering new PassKey ${newPassKeyName} failed`); + console.error("Could not register new PassKey", e); } }; @@ -55,20 +52,35 @@ function ManagePassKeyView() { align="middle" > -

Your PassKeys

- TODO +

Your PassKeys

+ TODO implement pass key list with delete functionality.

- PassKeys are a new web authentication method that allows you to log in without a password in a secured way. - Microsoft Hello and Apple FaceID are examples of technologies that can be used as passkeys to log in in WEBKNOSSOS. - If you want to add a new passkey to your account use the button below. + PassKeys are a new web authentication method that allows you to log in without a + password in a secured way. Microsoft Hello and Apple FaceID are examples of technologies + that can be used as passkeys to log in in WEBKNOSSOS. If you want to add a new passkey + to your account use the button below.

- TODO: Button and so on +
+ setIsPassKeyNameModalOpen(false)} + > + setNewPassKeyName(e.target.value)} + /> + ); } From 1fe36d990f17823ee9771469f1981d6c199c4445 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 18 Feb 2025 13:10:00 +0100 Subject: [PATCH 08/40] setup tls proxy --- package.json | 2 ++ tools/gen-ssl-dev-certs.sh | 9 +++++++++ tools/proxy/proxy.js | 28 +++++++++++++++++++++++----- 3 files changed, 34 insertions(+), 5 deletions(-) create mode 100755 tools/gen-ssl-dev-certs.sh diff --git a/package.json b/package.json index 1017fe3b4a8..575680ccb68 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ }, "scripts": { "start": "node tools/proxy/proxy.js", + "start-tls": "node tools/proxy/proxy.js --tls", + "dev-gen-tls": "./tools/gen-ssl-dev-certs.sh", "build": "node --max-old-space-size=4096 node_modules/.bin/webpack --env production", "@comment build-backend": "Only check for errors in the backend code like done by the CI. This command is not needed to run WEBKNOSSOS", "build-backend": "yarn build-wk-backend && yarn build-wk-datastore && yarn build-wk-tracingstore && rm webknossos-tracingstore/conf/messages webknossos-datastore/conf/messages", diff --git a/tools/gen-ssl-dev-certs.sh b/tools/gen-ssl-dev-certs.sh new file mode 100755 index 00000000000..1988e3e310c --- /dev/null +++ b/tools/gen-ssl-dev-certs.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +mkdir -p target/ +openssl req -x509 -newkey rsa:2048 \ + -keyout target/dev.key.pem \ + -out target/dev.cert.pem \ + -sha256 -days 365 \ + -nodes \ + -subj "/C=XX/ST=Dev/O=Dev/OU=Dev/CN=webknossos.local" diff --git a/tools/proxy/proxy.js b/tools/proxy/proxy.js index 6b8077e9160..08c1de6e69a 100644 --- a/tools/proxy/proxy.js +++ b/tools/proxy/proxy.js @@ -1,13 +1,18 @@ const express = require("express"); +const fs = require("fs"); const httpProxy = require("http-proxy"); const { spawn, exec } = require("child_process"); const path = require("path"); const prefixLines = require("prefix-stream-lines"); +const https = require("https"); -const proxy = httpProxy.createProxyServer({ - proxyTimeout: 5 * 60 * 1000, // 5 min - timeout: 5 * 60 * 1000, // 5 min +const time5min = 5 * 60 * 1000; + +var proxy = httpProxy.createProxyServer({ + proxyTimeout: time5min, // 5 min + timeout: time5min, // 5 min }); + const app = express(); const ROOT = path.resolve(path.join(__dirname, "..", "..")); @@ -125,7 +130,12 @@ function toBackend(req, res) { } function toWebpackDev(req, res) { - proxy.web(req, res, { target: `http://127.0.0.1:${PORT + 2}` }); + proxy.web(req, res, { + headers: { + host: "localhost", + }, + target: `http://127.0.0.1:${PORT + 2}` + }); } function toSam(req, res) { @@ -144,5 +154,13 @@ app.all("/dist/*", toSam); app.all("/assets/bundle/*", toWebpackDev); app.all("/*", toBackend); -app.listen(PORT); +if (process.argv.includes("--tls")) { + console.log(loggingPrefix, "Using TLS") + https.createServer({ + key: fs.readFileSync("target/dev.key.pem"), + cert: fs.readFileSync("target/dev.cert.pem"), + }, app).listen(PORT) +} else { + app.listen(PORT); +} console.log(loggingPrefix, "Listening on port", PORT); From 391ec47ad285347092a1d8b6ec582e08b59fd22f Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 18 Feb 2025 15:34:43 +0100 Subject: [PATCH 09/40] fix webauthn registration start --- .../AuthenticationController.scala | 29 +++++++++-------- app/models/user/WebAuthnCredentials.scala | 12 +++---- frontend/javascripts/admin/admin_rest_api.ts | 31 ++++++++++--------- .../admin/auth/manage_passkeys_view.tsx | 13 +++----- 4 files changed, 43 insertions(+), 42 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 074053dffda..3284b69be45 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -474,19 +474,22 @@ class AuthenticationController @Inject()( } } - def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction { implicit request => - { - val userIdentity = UserIdentity - .builder() - .name(request.identity.name) - .displayName(request.identity.name) - .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._id)) - .build(); - val opts = StartRegistrationOptions.builder().user(userIdentity).timeout(120000).build() - val registration = relyingParty.startRegistration(opts); - temporaryRegistrationStore.insert(request.identity._multiUser, registration); - Ok(Json.toJson(Json.parse(registration.toJson))) - } + def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + email <- userService.emailFor(request.identity); + result <- { + val userIdentity = UserIdentity + .builder() + .name(email) + .displayName(request.identity.name) + .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._multiUser)) + .build(); + val opts = StartRegistrationOptions.builder().user(userIdentity).timeout(120000).build() + val registration = relyingParty.startRegistration(opts); + temporaryRegistrationStore.insert(request.identity._multiUser, registration); + Fox.successful(Ok(Json.toJson(registration.toCredentialsCreateJson))) + } + } yield result; } def webauthnRegisterFinalize(): Action[WebAuthnRegistration] = diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index 37810d0f3d3..dbec601643c 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -43,14 +43,14 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut def findAllForUser(userId: ObjectId)(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) parsed <- parseAll(r) } yield parsed def findById(id: ObjectId)(implicit ct: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) + r <- run(q"SELECT $columns FROM $collectionName WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) parsed <- parseFirst(r, id) } yield parsed @@ -58,22 +58,20 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut def findByIdAndUserId(id: ObjectId, userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM $existingCollectionName WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + r <- run(q"SELECT $columns FROM $collectionName WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) parsed <- parseFirst(r, id) } yield parsed def insertOne(c: WebAuthnCredential): Fox[Unit] = for { - _ <- run(q"""INSERT INTO webknossos.webauthnCredentials(_id, _multiUser, - name, publicKeyCose, - signatureCount) + _ <- run(q"""INSERT INTO $collectionName(_id, _multiUser, name, publicKeyCose, signatureCount) VALUES(${c._id}, ${c._multiUser}, ${c.name}, ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) } yield () def removeById(id: ObjectId): Fox[Unit] = for { - _ <- run(q"""DELETE FROM webknossos.webauthnCredentials WHERE _id = ${id}""".asUpdate) + _ <- run(q"""DELETE FROM $collectionName WHERE _id = ${id}""".asUpdate) } yield() } diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index e9ff7bb5583..dfc119766cd 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1,4 +1,9 @@ -import { get, parseRequestOptionsFromJSON } from "@github/webauthn-json/browser-ponyfill"; +import { + create, + get, + parseCreationOptionsFromJSON, + parseRequestOptionsFromJSON, +} from "@github/webauthn-json/browser-ponyfill"; import dayjs from "dayjs"; import { V3 } from "libs/mjs"; import type { RequestOptions } from "libs/request"; @@ -149,27 +154,25 @@ export async function loginUser(formValues: { } export async function doWebAuthnLogin(): Promise { - const webAuthnAuthAssertion = await Request.receiveJSON("/auth/webauthn/auth/start", { + const webAuthnAuthAssertion = await Request.receiveJSON("/api/auth/webauthn/auth/start", { method: "POST", - }); + }).then(body => JSON.parse(body)); const options = parseRequestOptionsFromJSON(webAuthnAuthAssertion); const response = await get(options); - return Request.sendJSONReceiveJSON("/auth/webauthn/auth/finalize", { + return Request.sendJSONReceiveJSON("/api/auth/webauthn/auth/finalize", { method: "POST", data: { assertionResponse: response }, }); } -export async function startWebAuthnRegistration(): Promise { - return Request.receiveJSON("/auth/webauthn/register/start", { method: "POST" }); -} - -export async function finalizeWebAuthnRegistration( - name: string, - key: string, -): Promise { - return Request.sendJSONReceiveJSON("/auth/webauthn/register/start", { - data: { name, key }, +export async function doWebAuthnRegistration(name: string): Promise { + const webAuthnRegistrationAssertion = await Request.receiveJSON("/api/auth/webauthn/register/start", { + method: "POST", + }).then(body => JSON.parse(body)); + const options = parseCreationOptionsFromJSON(webAuthnRegistrationAssertion); + const response = JSON.stringify(await create(options)); + return Request.sendJSONReceiveJSON("/api/auth/webauthn/register/finalize", { + data: { name, response }, method: "POST", }); } diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx index 2a7019f762b..15512849047 100644 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -1,9 +1,8 @@ import { CopyOutlined, SwapOutlined } from "@ant-design/icons"; import { - finalizeWebAuthnRegistration, getAuthToken, revokeAuthToken, - startWebAuthnRegistration, + doWebAuthnRegistration, } from "admin/admin_rest_api"; import { Button, Col, Form, Input, Modal, Row, Space, Spin } from "antd"; import Toast from "libs/toast"; @@ -11,7 +10,7 @@ import type { OxalisState } from "oxalis/store"; import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; -import { create, parseCreationOptionsFromJSON } from "@github/webauthn-json/browser-ponyfill"; +import { } from "@github/webauthn-json/browser-ponyfill"; const FormItem = Form.Item; @@ -30,14 +29,12 @@ function ManagePassKeyView() { const registerNewPassKey = async () => { try { setIsPassKeyNameModalOpen(false); - const json = await startWebAuthnRegistration(); - const options = parseCreationOptionsFromJSON(json); - const response = await create(options); - await finalizeWebAuthnRegistration(newPassKeyName, JSON.stringify(response)); + const result = doWebAuthnRegistration(newPassKeyName); + console.debug(result); Toast.success("PassKey registered successfully"); setNewPassKeyName(""); } catch (e) { - Toast.success(`Registering new PassKey ${newPassKeyName} failed`); + Toast.error(`Registering new PassKey '${newPassKeyName}' failed`); console.error("Could not register new PassKey", e); } }; From aba3d997e165706feb56d4d4e1d57ec781a35dfb Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 18 Feb 2025 16:20:13 +0100 Subject: [PATCH 10/40] fix webauthn registration finalize --- app/models/user/WebAuthnCredentials.scala | 8 +++--- .../WebAuthnCredentialRepository.scala | 26 ++++++++++--------- conf/application.conf | 2 +- frontend/javascripts/admin/admin_rest_api.ts | 2 +- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index dbec601643c..36902a24327 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -50,7 +50,7 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut def findById(id: ObjectId)(implicit ct: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM $collectionName WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) + r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) parsed <- parseFirst(r, id) } yield parsed @@ -58,20 +58,20 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut def findByIdAndUserId(id: ObjectId, userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM $collectionName WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) parsed <- parseFirst(r, id) } yield parsed def insertOne(c: WebAuthnCredential): Fox[Unit] = for { - _ <- run(q"""INSERT INTO $collectionName(_id, _multiUser, name, publicKeyCose, signatureCount) + _ <- run(q"""INSERT INTO webknossos.webauthncredentials(_id, _multiUser, name, publicKeyCose, signatureCount) VALUES(${c._id}, ${c._multiUser}, ${c.name}, ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) } yield () def removeById(id: ObjectId): Fox[Unit] = for { - _ <- run(q"""DELETE FROM $collectionName WHERE _id = ${id}""".asUpdate) + _ <- run(q"""DELETE FROM webknossos.webauthncredentials WHERE _id = ${id}""".asUpdate) } yield() } diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala index 3a43a49b7aa..abbfe8e57c1 100644 --- a/app/security/WebAuthnCredentialRepository.scala +++ b/app/security/WebAuthnCredentialRepository.scala @@ -1,15 +1,15 @@ package security -import models.user.{MultiUserDAO,WebAuthnCredentialDAO}; - +import models.user.{MultiUserDAO, WebAuthnCredential, WebAuthnCredentialDAO} import com.yubico.webauthn._ import com.yubico.webauthn.data._ import com.scalableminds.util.accesscontext.GlobalAccessContext import com.scalableminds.util.objectid.ObjectId +import net.liftweb.common.{Box, Empty, Failure, Full} import java.util.Optional import javax.inject.Inject -import scala.collection.JavaConverters._; +import scala.jdk.CollectionConverters._ object WebAuthnCredentialRepository { def objectIdToByteArray(id: ObjectId): ByteArray = @@ -58,14 +58,16 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth .build()) } - def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { - val credential = webAuthnCredentialDAO.findById(WebAuthnCredentialRepository.byteArrayToObjectId(credentialId))(GlobalAccessContext).get("Java interop"); - Set(RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.objectIdToByteArray(credential._id)) - .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) - .publicKeyCose(new ByteArray(credential.publicKeyCose)) - .signatureCount(credential.signatureCount) - .build()).asJava - } + def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = + webAuthnCredentialDAO.findById(WebAuthnCredentialRepository.byteArrayToObjectId(credentialId))(GlobalAccessContext).await("Java interop") match { + case Full(credential: WebAuthnCredential) => + Set(RegisteredCredential.builder() + .credentialId(WebAuthnCredentialRepository.objectIdToByteArray(credential._id)) + .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) + .publicKeyCose(new ByteArray(credential.publicKeyCose)) + .signatureCount(credential.signatureCount) + .build()).asJava + case Empty => Set[RegisteredCredential]().asJava + } } diff --git a/conf/application.conf b/conf/application.conf index f62ca861b1b..cb62becad58 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -1,5 +1,5 @@ http { - uri = "http://localhost:9000" + uri = "https://webknossos.local:9000" port = 9000 } diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index dfc119766cd..856cc1143f8 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -172,7 +172,7 @@ export async function doWebAuthnRegistration(name: string): Promise { const options = parseCreationOptionsFromJSON(webAuthnRegistrationAssertion); const response = JSON.stringify(await create(options)); return Request.sendJSONReceiveJSON("/api/auth/webauthn/register/finalize", { - data: { name, response }, + data: { name: name, key: response }, method: "POST", }); } From 4f036693390efd11fc2864389f6bc36e0ceeb790 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 18 Feb 2025 16:29:11 +0100 Subject: [PATCH 11/40] fix webauthn auth start --- app/controllers/AuthenticationController.scala | 2 +- frontend/javascripts/admin/admin_rest_api.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 3284b69be45..96fd10bf5dc 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -437,7 +437,7 @@ class AuthenticationController @Inject()( val sessionId = UUID.randomUUID().toString; val cookie = Cookie("webauthn-session", sessionId, maxAge = Some(120), httpOnly = true, secure = true) temporaryAssertionStore.insert(sessionId, assertion, Some(2 minutes)); - Ok(Json.toJson(Json.parse(assertion.toJson))).withCookies(cookie) + Ok(Json.toJson(Json.parse(assertion.toCredentialsGetJson))).withCookies(cookie) } } diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 856cc1143f8..dfa0ea23073 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -156,7 +156,7 @@ export async function loginUser(formValues: { export async function doWebAuthnLogin(): Promise { const webAuthnAuthAssertion = await Request.receiveJSON("/api/auth/webauthn/auth/start", { method: "POST", - }).then(body => JSON.parse(body)); + }); const options = parseRequestOptionsFromJSON(webAuthnAuthAssertion); const response = await get(options); return Request.sendJSONReceiveJSON("/api/auth/webauthn/auth/finalize", { From b9014a0d6a77b8b847cb89e020d5a329f9313438 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 18 Feb 2025 18:11:24 +0100 Subject: [PATCH 12/40] fix key id and authentication process --- .../AuthenticationController.scala | 32 ++++++++----- app/models/user/MultiUser.scala | 3 +- app/models/user/WebAuthnCredentials.scala | 10 ++--- .../WebAuthnCredentialRepository.scala | 45 ++++++++++++------- .../126-add-webauthn-credentials.sql | 5 ++- frontend/javascripts/admin/admin_rest_api.ts | 4 +- tools/postgres/schema.sql | 5 ++- 7 files changed, 63 insertions(+), 41 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 96fd10bf5dc..9c0125a4883 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -43,6 +43,11 @@ object WebAuthnRegistration { implicit val jsonFormat: OFormat[WebAuthnRegistration] = Json.format[WebAuthnRegistration] } +case class WebAuthnAuthentication(key: String) +object WebAuthnAuthentication { + implicit val jsonFormat: OFormat[WebAuthnAuthentication] = Json.format[WebAuthnAuthentication] +} + class AuthenticationController @Inject()( actorSystem: ActorSystem, credentialsProvider: CredentialsProvider, @@ -441,13 +446,7 @@ class AuthenticationController @Inject()( } } - case class WebAuthnAuthResponse(assertionResponse: String) - - object WebAuthnAuthResponse { - implicit val jsonFormat: OFormat[WebAuthnAuthResponse] = Json.format[WebAuthnAuthResponse] - } - - def webauthnAuthFinalize(): Action[WebAuthnAuthResponse] = Action.async(validateJson[WebAuthnAuthResponse]) { + def webauthnAuthFinalize(): Action[WebAuthnAuthentication] = Action.async(validateJson[WebAuthnAuthentication]) { implicit request => { request.cookies.get("webauthn-session") match { @@ -458,17 +457,18 @@ class AuthenticationController @Inject()( challengeData match { case Some(data) => try { - val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.assertionResponse); + val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.key); val opts = FinishAssertionOptions.builder().request(data).response(keyCredential).build(); val result = relyingParty.finishAssertion(opts); val userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle) + logger.info(s"webauthn authenticated ${userId}"); val loginInfo = LoginInfo("credentials", userId.toString) loginUser(loginInfo)(request.map(_ => AnyContent())) } catch { - case e: AssertionFailedException => Future.successful(Unauthorized("challenge failed")) + case e: AssertionFailedException => Future.successful(Unauthorized("Authentication failed.")) } case None => - Future.successful(BadRequest("Challenge not found or expired")) + Future.successful(BadRequest("Authentication took too long, please try again.")) } } } @@ -484,7 +484,13 @@ class AuthenticationController @Inject()( .displayName(request.identity.name) .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._multiUser)) .build(); - val opts = StartRegistrationOptions.builder().user(userIdentity).timeout(120000).build() + val opts = StartRegistrationOptions.builder() + .user(userIdentity) + .timeout(60000) + .authenticatorSelection(AuthenticatorSelectionCriteria.builder() + .residentKey(ResidentKeyRequirement.REQUIRED) + .build()) + .build() val registration = relyingParty.startRegistration(opts); temporaryRegistrationStore.insert(request.identity._multiUser, registration); Fox.successful(Ok(Json.toJson(registration.toCredentialsCreateJson))) @@ -503,14 +509,16 @@ class AuthenticationController @Inject()( val opts = FinishRegistrationOptions.builder().request(data).response(response).build(); try { val key = relyingParty.finishRegistration(opts) + logger.info(s"discoverable ${key.isDiscoverable}"); val credential = WebAuthnCredential( - ObjectId.generate, + WebAuthnCredentialRepository.byteArrayToHex(key.getKeyId.getId), request.identity._multiUser, request.body.name, key.getPublicKeyCose.getBytes, key.getSignatureCount.toInt, isDeleted = false, ) + logger.info(s"credential id ${credential._id}"); webAuthnCredentialDAO.insertOne(credential).map(_ => Ok("")) } catch { case e: RegistrationFailedException => Future.successful(BadRequest("Failed to register key")) diff --git a/app/models/user/MultiUser.scala b/app/models/user/MultiUser.scala index 9bcd9424b95..3746c19d1eb 100644 --- a/app/models/user/MultiUser.scala +++ b/app/models/user/MultiUser.scala @@ -42,7 +42,7 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext protected def idColumn(x: Multiusers): Rep[String] = x._Id protected def isDeletedColumn(x: Multiusers): Rep[Boolean] = x.isdeleted - protected def parse(r: MultiusersRow): Fox[MultiUser] = + protected def parse(r: MultiusersRow): Fox[MultiUser] = { for { novelUserExperienceInfos <- JsonHelper.parseAndValidateJson[JsObject](r.noveluserexperienceinfos).toFox theme <- Theme.fromString(r.selectedtheme).toFox @@ -60,6 +60,7 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext r.isdeleted ) } + } def insertOne(u: MultiUser): Fox[Unit] = for { diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index 36902a24327..043f6fd5d13 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -12,7 +12,7 @@ import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} case class WebAuthnCredential( - _id: ObjectId, + _id: String, _multiUser: ObjectId, name: String, publicKeyCose: Array[Byte], @@ -31,7 +31,7 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut protected def parse(r: WebauthncredentialsRow): Fox[WebAuthnCredential] = Fox.successful( WebAuthnCredential( - ObjectId(r._Id), + r._Id, ObjectId(r._Multiuser), r.name, r.publickeycose, @@ -47,15 +47,15 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut parsed <- parseAll(r) } yield parsed - def findById(id: ObjectId)(implicit ct: DBAccessContext): Fox[WebAuthnCredential] = + def listById(id: String)(implicit ct: DBAccessContext): Fox[List[WebAuthnCredential]] = for { accessQuery <- readAccessQuery r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) - parsed <- parseFirst(r, id) + parsed <- parseAll(r) } yield parsed - def findByIdAndUserId(id: ObjectId, userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = + def findByIdAndUserId(id: String, userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala index abbfe8e57c1..6456aa8eb9a 100644 --- a/app/security/WebAuthnCredentialRepository.scala +++ b/app/security/WebAuthnCredentialRepository.scala @@ -5,18 +5,20 @@ import com.yubico.webauthn._ import com.yubico.webauthn.data._ import com.scalableminds.util.accesscontext.GlobalAccessContext import com.scalableminds.util.objectid.ObjectId +import com.typesafe.scalalogging.Logger import net.liftweb.common.{Box, Empty, Failure, Full} +import org.slf4j.LoggerFactory import java.util.Optional import javax.inject.Inject import scala.jdk.CollectionConverters._ object WebAuthnCredentialRepository { - def objectIdToByteArray(id: ObjectId): ByteArray = - new ByteArray(id.toString.getBytes()) + def byteArrayToHex(arr: ByteArray): String = arr.getHex + def hexToByteArray(hex: String): ByteArray = ByteArray.fromHex(hex) - def byteArrayToObjectId(arr: ByteArray): ObjectId = - new ObjectId(new String(arr.getBytes)) + def objectIdToByteArray(id: ObjectId): ByteArray = new ByteArray(id.toString.getBytes()) + def byteArrayToObjectId(arr: ByteArray): ObjectId = new ObjectId(new String(arr.getBytes)) } /* @@ -30,7 +32,7 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth val keys = webAuthnCredentialDAO.findAllForUser(user._id)(GlobalAccessContext).get("Java interop"); keys.map(key => { PublicKeyCredentialDescriptor.builder() - .id(WebAuthnCredentialRepository.objectIdToByteArray(key._id)) + .id(WebAuthnCredentialRepository.hexToByteArray(key._id)) .build() }).to(Set).asJava } @@ -47,27 +49,36 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth } def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = { - val credId = WebAuthnCredentialRepository.byteArrayToObjectId(credentialId) + val credId = WebAuthnCredentialRepository.byteArrayToHex(credentialId) val userId = WebAuthnCredentialRepository.byteArrayToObjectId(userHandle) - val credential = webAuthnCredentialDAO.findByIdAndUserId(credId, userId)(GlobalAccessContext).get("Java interop"); + val credential = webAuthnCredentialDAO.findByIdAndUserId(credId, userId)(GlobalAccessContext).await("Java interop") match { + case Full(credential) => credential; + case Empty => return Optional.empty(); + } Optional.ofNullable(RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.objectIdToByteArray(credential._id)) + .credentialId(WebAuthnCredentialRepository.hexToByteArray(credential._id)) .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) .publicKeyCose(new ByteArray(credential.publicKeyCose)) .signatureCount(credential.signatureCount) .build()) } - def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = - webAuthnCredentialDAO.findById(WebAuthnCredentialRepository.byteArrayToObjectId(credentialId))(GlobalAccessContext).await("Java interop") match { - case Full(credential: WebAuthnCredential) => - Set(RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.objectIdToByteArray(credential._id)) - .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) - .publicKeyCose(new ByteArray(credential.publicKeyCose)) - .signatureCount(credential.signatureCount) - .build()).asJava + def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { + webAuthnCredentialDAO.listById(WebAuthnCredentialRepository.byteArrayToHex(credentialId))(GlobalAccessContext).await("Java interop") match { + case Full(credentials: List[WebAuthnCredential]) => + credentials + .map(credential => { + RegisteredCredential.builder() + .credentialId(WebAuthnCredentialRepository.hexToByteArray(credential._id)) + .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) + .publicKeyCose(new ByteArray(credential.publicKeyCose)) + .signatureCount(credential.signatureCount) + .build() + }) + .toSet + .asJava case Empty => Set[RegisteredCredential]().asJava } + } } diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql index c6c3a2cbf04..c4e57abcfbd 100644 --- a/conf/evolutions/126-add-webauthn-credentials.sql +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -3,12 +3,13 @@ START TRANSACTION; do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; CREATE TABLE webknossos.webauthnCredentials( - _id TEXT PRIMARY KEY, + _id TEXT NOT NULL, _multiUser CHAR(24) NOT NULL, name TEXT NOT NULL, publicKeyCode BYTEA NOT NULL, signatureCount INTEGER NOT NULL, - isDeleted BOOLEAN NOT NULL DEFAULT false + isDeleted BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (_id, _multiUser) ); ALTER TABLE webknossos.webauthnCredentials diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index dfa0ea23073..6e8d08fabcb 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -158,10 +158,10 @@ export async function doWebAuthnLogin(): Promise { method: "POST", }); const options = parseRequestOptionsFromJSON(webAuthnAuthAssertion); - const response = await get(options); + const response = JSON.stringify(await get(options)); return Request.sendJSONReceiveJSON("/api/auth/webauthn/auth/finalize", { method: "POST", - data: { assertionResponse: response }, + data: { key: response }, }); } diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index 2095248f7dd..6a1e3d6d0b6 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -419,12 +419,13 @@ CREATE TABLE webknossos.multiUsers( ); CREATE TABLE webknossos.webauthnCredentials( - _id TEXT PRIMARY KEY, + _id TEXT NOT NULL, _multiUser CHAR(24) NOT NULL, name TEXT NOT NULL, publicKeyCose BYTEA NOT NULL, signatureCount INTEGER NOT NULL, - isDeleted BOOLEAN NOT NULL DEFAULT false + isDeleted BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (_id, _multiUser) ); From 34475659694fae1a87572582739a260f98382b1d Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 18 Feb 2025 18:59:24 +0100 Subject: [PATCH 13/40] fix frontend redirect --- .../AuthenticationController.scala | 52 ++++++++++++------- frontend/javascripts/admin/admin_rest_api.ts | 6 ++- .../javascripts/admin/auth/login_form.tsx | 8 ++- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 9c0125a4883..fb9ba9fd20f 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -15,6 +15,7 @@ import net.liftweb.common.{Box, Empty, Failure, Full} import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.digest.{HmacAlgorithms, HmacUtils} import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.http.scaladsl.model.HttpHeader.ParsingResult.Ok import play.api.data.Form import play.api.data.Forms._ import play.api.data.validation.Constraints._ @@ -172,6 +173,27 @@ class AuthenticationController @Inject()( } } + private def authenticateInner(loginInfo: LoginInfo)(implicit header: RequestHeader): Future[Result] = { + for { + result <- userService.retrieve(loginInfo).flatMap { + case Some(user) if !user.isDeactivated => + for { + authenticator <- combinedAuthenticatorService.create(loginInfo) + value <- combinedAuthenticatorService.init(authenticator) + result <- combinedAuthenticatorService.embed(value, Ok) + _ <- Fox.runIf(conf.WebKnossos.User.EmailVerification.activated)(emailVerificationService + .assertEmailVerifiedOrResendVerificationMail(user)(GlobalAccessContext, ec)) + _ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext) + _ = userDAO.updateLastActivity(user._id)(GlobalAccessContext) + _ = logger.info(f"User ${user._id} authenticated.") + } yield result + case None => + Future.successful(BadRequest(Messages("error.noUser"))) + case Some(_) => Future.successful(BadRequest(Messages("user.deactivated"))) + } + } yield result + }; + def authenticate: Action[AnyContent] = Action.async { implicit request => signInForm .bindFromRequest() @@ -186,23 +208,7 @@ class AuthenticationController @Inject()( .map(id => Credentials(id, signInData.password)) .flatMap(credentials => credentialsProvider.authenticate(credentials)) .flatMap { - loginInfo => - userService.retrieve(loginInfo).flatMap { - case Some(user) if !user.isDeactivated => - for { - authenticator <- combinedAuthenticatorService.create(loginInfo) - value <- combinedAuthenticatorService.init(authenticator) - result <- combinedAuthenticatorService.embed(value, Ok) - _ <- Fox.runIf(conf.WebKnossos.User.EmailVerification.activated)(emailVerificationService - .assertEmailVerifiedOrResendVerificationMail(user)(GlobalAccessContext, ec)) - _ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext) - _ = userDAO.updateLastActivity(user._id)(GlobalAccessContext) - _ = logger.info(f"User ${user._id} authenticated.") - } yield result - case None => - Future.successful(BadRequest(Messages("error.noUser"))) - case Some(_) => Future.successful(BadRequest(Messages("user.deactivated"))) - } + loginInfo => authenticateInner(loginInfo) } .recover { case _: ProviderException => BadRequest(Messages("error.invalidCredentials")) @@ -462,8 +468,16 @@ class AuthenticationController @Inject()( val result = relyingParty.finishAssertion(opts); val userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle) logger.info(s"webauthn authenticated ${userId}"); - val loginInfo = LoginInfo("credentials", userId.toString) - loginUser(loginInfo)(request.map(_ => AnyContent())) + for { + multiUser <- multiUserDAO.findOne(userId)(GlobalAccessContext); + result <- multiUser._lastLoggedInIdentity match { + case Some(userId) => { + val loginInfo = LoginInfo("credentials", userId.toString); + authenticateInner(loginInfo) + } + case None => Future.successful(InternalServerError("user never logged in")) + } + } yield result; } catch { case e: AssertionFailedException => Future.successful(Unauthorized("Authentication failed.")) } diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 6e8d08fabcb..f1554ff35f4 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -159,10 +159,14 @@ export async function doWebAuthnLogin(): Promise { }); const options = parseRequestOptionsFromJSON(webAuthnAuthAssertion); const response = JSON.stringify(await get(options)); - return Request.sendJSONReceiveJSON("/api/auth/webauthn/auth/finalize", { + await Request.sendJSONReceiveJSON("/api/auth/webauthn/auth/finalize", { method: "POST", data: { key: response }, }); + + const activeUser = await getActiveUser(); + const organization = await getOrganization(activeUser.organization); + return [activeUser, organization]; } export async function doWebAuthnRegistration(name: string): Promise { diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index 845786037f5..ac81482d086 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -166,8 +166,12 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) { whiteSpace: "nowrap", }} onClick={async () => { - const response = await doWebAuthnLogin(); - window.location.href = response.redirect_url; + const [user, organization] = await doWebAuthnLogin(); + Store.dispatch(setActiveUserAction(user)); + Store.dispatch(setActiveOrganizationAction(organization)); + if (onLoggedIn) { + onLoggedIn(); + } }} > Use PassKey From 3cf61e6cab252cc81ac2c0a107a2ce84a1c8027d Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 10:26:29 +0100 Subject: [PATCH 14/40] restyle login form --- .../javascripts/admin/auth/login_form.tsx | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index ac81482d086..bec12379e6c 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -139,6 +139,20 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) { )} +
+ + + +
{hideFooter ? null : ( Register Now - { - const [user, organization] = await doWebAuthnLogin(); - Store.dispatch(setActiveUserAction(user)); - Store.dispatch(setActiveOrganizationAction(organization)); - if (onLoggedIn) { - onLoggedIn(); - } - }} - > - Use PassKey - Forgot Password From dcff03568fad42ed345fa7cfcf4d259dcbc28e6f Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 11:16:01 +0100 Subject: [PATCH 15/40] move user key id to separate datbase field --- .../AuthenticationController.scala | 3 ++- app/models/user/WebAuthnCredentials.scala | 22 ++++++++++--------- .../WebAuthnCredentialRepository.scala | 16 +++++++------- .../126-add-webauthn-credentials.sql | 5 +++-- .../javascripts/admin/auth/login_form.tsx | 2 +- tools/postgres/schema.sql | 5 +++-- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index fb9ba9fd20f..6c378e19731 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -525,8 +525,9 @@ class AuthenticationController @Inject()( val key = relyingParty.finishRegistration(opts) logger.info(s"discoverable ${key.isDiscoverable}"); val credential = WebAuthnCredential( - WebAuthnCredentialRepository.byteArrayToHex(key.getKeyId.getId), + ObjectId.generate, request.identity._multiUser, + WebAuthnCredentialRepository.byteArrayToBytes(key.getKeyId.getId), request.body.name, key.getPublicKeyCose.getBytes, key.getSignatureCount.toInt, diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index 043f6fd5d13..c15201c162d 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -12,8 +12,9 @@ import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} case class WebAuthnCredential( - _id: String, + _id: ObjectId, _multiUser: ObjectId, + keyId: Array[Byte], name: String, publicKeyCose: Array[Byte], signatureCount: Int, @@ -31,8 +32,9 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut protected def parse(r: WebauthncredentialsRow): Fox[WebAuthnCredential] = Fox.successful( WebAuthnCredential( - r._Id, + ObjectId(r._Id), ObjectId(r._Multiuser), + r.keyid, r.name, r.publickeycose, r.signaturecount, @@ -47,25 +49,25 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut parsed <- parseAll(r) } yield parsed - def listById(id: String)(implicit ct: DBAccessContext): Fox[List[WebAuthnCredential]] = + def listByKeyId(id: Array[Byte])(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _id = $id AND $accessQuery".as[WebauthncredentialsRow]) + r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE keyId = $id AND $accessQuery".as[WebauthncredentialsRow]) parsed <- parseAll(r) } yield parsed - def findByIdAndUserId(id: String, userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = + def findByKeyIdAndUserId(id: Array[Byte], userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _id = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) - parsed <- parseFirst(r, id) - } yield parsed + r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE keyId = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + parsed <- parseAll(r) + } yield parsed.head def insertOne(c: WebAuthnCredential): Fox[Unit] = for { - _ <- run(q"""INSERT INTO webknossos.webauthncredentials(_id, _multiUser, name, publicKeyCose, signatureCount) - VALUES(${c._id}, ${c._multiUser}, ${c.name}, + _ <- run(q"""INSERT INTO webknossos.webauthncredentials(_id, _multiUser, keyId, name, publicKeyCose, signatureCount) + VALUES(${c._id}, ${c._multiUser}, ${c.keyId}, ${c.name}, ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) } yield () diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala index 6456aa8eb9a..ef59303d0bd 100644 --- a/app/security/WebAuthnCredentialRepository.scala +++ b/app/security/WebAuthnCredentialRepository.scala @@ -14,8 +14,8 @@ import javax.inject.Inject import scala.jdk.CollectionConverters._ object WebAuthnCredentialRepository { - def byteArrayToHex(arr: ByteArray): String = arr.getHex - def hexToByteArray(hex: String): ByteArray = ByteArray.fromHex(hex) + def byteArrayToBytes(arr: ByteArray): Array[Byte] = arr.getBytes + def bytesToByteArray(bytes: Array[Byte]): ByteArray = new ByteArray(bytes) def objectIdToByteArray(id: ObjectId): ByteArray = new ByteArray(id.toString.getBytes()) def byteArrayToObjectId(arr: ByteArray): ObjectId = new ObjectId(new String(arr.getBytes)) @@ -32,7 +32,7 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth val keys = webAuthnCredentialDAO.findAllForUser(user._id)(GlobalAccessContext).get("Java interop"); keys.map(key => { PublicKeyCredentialDescriptor.builder() - .id(WebAuthnCredentialRepository.hexToByteArray(key._id)) + .id(WebAuthnCredentialRepository.bytesToByteArray(key.keyId)) .build() }).to(Set).asJava } @@ -49,14 +49,14 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth } def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = { - val credId = WebAuthnCredentialRepository.byteArrayToHex(credentialId) + val credId = WebAuthnCredentialRepository.byteArrayToBytes(credentialId) val userId = WebAuthnCredentialRepository.byteArrayToObjectId(userHandle) - val credential = webAuthnCredentialDAO.findByIdAndUserId(credId, userId)(GlobalAccessContext).await("Java interop") match { + val credential = webAuthnCredentialDAO.findByKeyIdAndUserId(credId, userId)(GlobalAccessContext).await("Java interop") match { case Full(credential) => credential; case Empty => return Optional.empty(); } Optional.ofNullable(RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.hexToByteArray(credential._id)) + .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) .publicKeyCose(new ByteArray(credential.publicKeyCose)) .signatureCount(credential.signatureCount) @@ -64,12 +64,12 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth } def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { - webAuthnCredentialDAO.listById(WebAuthnCredentialRepository.byteArrayToHex(credentialId))(GlobalAccessContext).await("Java interop") match { + webAuthnCredentialDAO.listByKeyId(WebAuthnCredentialRepository.byteArrayToBytes(credentialId))(GlobalAccessContext).await("Java interop") match { case Full(credentials: List[WebAuthnCredential]) => credentials .map(credential => { RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.hexToByteArray(credential._id)) + .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) .publicKeyCose(new ByteArray(credential.publicKeyCose)) .signatureCount(credential.signatureCount) diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql index c4e57abcfbd..6ed182ec823 100644 --- a/conf/evolutions/126-add-webauthn-credentials.sql +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -3,13 +3,14 @@ START TRANSACTION; do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; CREATE TABLE webknossos.webauthnCredentials( - _id TEXT NOT NULL, + _id TEXT PRIMARY KEY, _multiUser CHAR(24) NOT NULL, + keyId BYTEA NOT NULL, name TEXT NOT NULL, publicKeyCode BYTEA NOT NULL, signatureCount INTEGER NOT NULL, isDeleted BOOLEAN NOT NULL DEFAULT false, - PRIMARY KEY (_id, _multiUser) + UNIQUE (_multiUser, keyId) ); ALTER TABLE webknossos.webauthnCredentials diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index bec12379e6c..469af6ead9c 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -141,7 +141,7 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) {
- + + )} diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index ab9fb2c48a8..709d02b09bf 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -1209,3 +1209,8 @@ export type RenderAnimationOptions = { movieResolution: MOVIE_RESOLUTIONS; cameraPosition: CAMERA_POSITIONS; }; + +export type WebAuthnKeyDescriptor = { + id: string; + name: string; +} From 7ba02386daa56059b43b7abda71640b1bf1c07c0 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 13:23:22 +0100 Subject: [PATCH 18/40] wrap blocking calls --- .../AuthenticationController.scala | 40 ++++++++++--------- conf/application.conf | 3 ++ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 52c264b2520..c48bffe902a 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -100,6 +100,7 @@ class AuthenticationController @Inject()( .build(); RelyingParty.builder().identity(identity).credentialRepository(webAuthnCredentialRepository).build() } + private val blockingContext: ExecutionContext = actorSystem.dispatchers.lookup("play.context.blocking") private lazy val isOIDCEnabled = conf.Features.openIdConnectEnabled @@ -470,10 +471,9 @@ class AuthenticationController @Inject()( try { val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.key); val opts = FinishAssertionOptions.builder().request(data).response(keyCredential).build(); - val result = relyingParty.finishAssertion(opts); - val userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle) - logger.info(s"webauthn authenticated ${userId}"); for { + result <- Future { relyingParty.finishAssertion(opts) }(blockingContext); // NOTE: Prevent blocking on HTTP handler + userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle); multiUser <- multiUserDAO.findOne(userId)(GlobalAccessContext); result <- multiUser._lastLoggedInIdentity match { case Some(userId) => { @@ -496,7 +496,7 @@ class AuthenticationController @Inject()( def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction.async { implicit request => for { email <- userService.emailFor(request.identity); - result <- { + result <- Future { val userIdentity = UserIdentity .builder() .name(email) @@ -512,8 +512,8 @@ class AuthenticationController @Inject()( .build() val registration = relyingParty.startRegistration(opts); temporaryRegistrationStore.insert(request.identity._multiUser, registration); - Fox.successful(Ok(Json.toJson(registration.toCredentialsCreateJson))) - } + Ok(Json.toJson(registration.toCredentialsCreateJson)) + }(blockingContext) } yield result; } @@ -527,19 +527,21 @@ class AuthenticationController @Inject()( val response = PublicKeyCredential.parseRegistrationResponseJson(request.body.key); val opts = FinishRegistrationOptions.builder().request(data).response(response).build(); try { - val key = relyingParty.finishRegistration(opts) - logger.info(s"discoverable ${key.isDiscoverable}"); - val credential = WebAuthnCredential( - ObjectId.generate, - request.identity._multiUser, - WebAuthnCredentialRepository.byteArrayToBytes(key.getKeyId.getId), - request.body.name, - key.getPublicKeyCose.getBytes, - key.getSignatureCount.toInt, - isDeleted = false, - ) - logger.info(s"credential id ${credential._id}"); - webAuthnCredentialDAO.insertOne(credential).map(_ => Ok("")) + for { + key <- Future { relyingParty.finishRegistration(opts) }(blockingContext); + result <- { + val credential = WebAuthnCredential( + ObjectId.generate, + request.identity._multiUser, + WebAuthnCredentialRepository.byteArrayToBytes(key.getKeyId.getId), + request.body.name, + key.getPublicKeyCose.getBytes, + key.getSignatureCount.toInt, + isDeleted = false, + ) + webAuthnCredentialDAO.insertOne(credential).map(_ => Ok("")) + } + } yield result; } catch { case e: RegistrationFailedException => Future.successful(BadRequest("Failed to register key")) } diff --git a/conf/application.conf b/conf/application.conf index cb62becad58..59eaba0d9bb 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -61,6 +61,9 @@ play { timeout.idle = 2 hours timeout.connection = 2 hours } + context { + blocking = "pekko.actor.default-dispatcher" + } } pekko.actor.default-dispatcher { From dc53bd072527744bcdfc3a6654acdda93ba9c2ff Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 13:28:50 +0100 Subject: [PATCH 19/40] increment schema version --- tools/postgres/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/postgres/schema.sql b/tools/postgres/schema.sql index eb33b83897b..0ae2eaa5200 100644 --- a/tools/postgres/schema.sql +++ b/tools/postgres/schema.sql @@ -20,7 +20,7 @@ CREATE TABLE webknossos.releaseInformation ( schemaVersion BIGINT NOT NULL ); -INSERT INTO webknossos.releaseInformation(schemaVersion) values(125); +INSERT INTO webknossos.releaseInformation(schemaVersion) values(126); COMMIT TRANSACTION; From dcf027d11ca88336e69d600dabc5e9ee7c9507fd Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 13:32:27 +0100 Subject: [PATCH 20/40] fix schema versioning --- conf/evolutions/126-add-webauthn-credentials.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql index 6ed182ec823..e7ed37525cb 100644 --- a/conf/evolutions/126-add-webauthn-credentials.sql +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -1,6 +1,6 @@ START TRANSACTION; -do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 125, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; CREATE TABLE webknossos.webauthnCredentials( _id TEXT PRIMARY KEY, @@ -16,6 +16,6 @@ CREATE TABLE webknossos.webauthnCredentials( ALTER TABLE webknossos.webauthnCredentials ADD FOREIGN KEY (_multiUser) REFERENCES webknossos.multiUsers(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; -UPDATE webknossos.releaseInformation SET schemaVersion = 127; +UPDATE webknossos.releaseInformation SET schemaVersion = 126; COMMIT TRANSACTION; From a42a10d5ac724fadf870fd24ab0eb124bee2486b Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 13:36:00 +0100 Subject: [PATCH 21/40] fix schema field typo --- conf/evolutions/126-add-webauthn-credentials.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql index e7ed37525cb..446792f2b70 100644 --- a/conf/evolutions/126-add-webauthn-credentials.sql +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -7,7 +7,7 @@ CREATE TABLE webknossos.webauthnCredentials( _multiUser CHAR(24) NOT NULL, keyId BYTEA NOT NULL, name TEXT NOT NULL, - publicKeyCode BYTEA NOT NULL, + publicKeyCose BYTEA NOT NULL, signatureCount INTEGER NOT NULL, isDeleted BOOLEAN NOT NULL DEFAULT false, UNIQUE (_multiUser, keyId) From 636de4292477289d609749f55260e8325e68c285 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 13:47:17 +0100 Subject: [PATCH 22/40] add reversion for database evolution --- MIGRATIONS.unreleased.md | 2 ++ .../reversions/126-add-webauthn-credentials.sql | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 conf/evolutions/reversions/126-add-webauthn-credentials.sql diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 2982f22ad23..e227cf16723 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -18,3 +18,5 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md). - Example command for the migration: `PG_PASSWORD=myPassword python main.py --src localhost:7500 --dst localhost:7155 --num_threads 20 --postgres webknossos@localhost:5430/webknossos` ### Postgres Evolutions: + +- [126-add-webauthn-credentials.sql](./conf/evolutions/126-add-webauthn-credentials.sql) diff --git a/conf/evolutions/reversions/126-add-webauthn-credentials.sql b/conf/evolutions/reversions/126-add-webauthn-credentials.sql new file mode 100644 index 00000000000..457c5e44fe2 --- /dev/null +++ b/conf/evolutions/reversions/126-add-webauthn-credentials.sql @@ -0,0 +1,10 @@ +START TRANSACTION; + +-- This reversion might take a while because it needs to search in all annotation layer names for '$' and replace it with '' +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 126, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +DROP TABLE webknossos.webauthnCredentials; + +UPDATE webknossos.releaseInformation SET schemaVersion = 125; + +COMMIT TRANSACTION; From 5069e9ffdc85b90e90a2e5416018f3b745ba6f50 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 13:54:35 +0100 Subject: [PATCH 23/40] fix compiler errors --- app/controllers/AuthenticationController.scala | 5 ++--- app/models/user/WebAuthnCredentials.scala | 6 ++---- app/security/WebAuthnCredentialRepository.scala | 4 +--- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index c48bffe902a..6c822af1897 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -15,7 +15,6 @@ import net.liftweb.common.{Box, Empty, Failure, Full} import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.digest.{HmacAlgorithms, HmacUtils} import org.apache.pekko.actor.ActorSystem -import org.apache.pekko.http.scaladsl.model.HttpHeader.ParsingResult.Ok import play.api.data.Form import play.api.data.Forms._ import play.api.data.validation.Constraints._ @@ -93,7 +92,7 @@ class AuthenticationController @Inject()( conf.WebKnossos.User.ssoKey private lazy val relyingParty = { - var identity = RelyingPartyIdentity + val identity = RelyingPartyIdentity .builder() .id("webknossos.local:9000") // TODO: Use Host .name("WebKnossos") @@ -447,7 +446,7 @@ class AuthenticationController @Inject()( } } - def webauthnAuthStart(): Action[AnyContent] = Action { implicit request => + def webauthnAuthStart(): Action[AnyContent] = Action { implicit _ => { val opts = StartAssertionOptions.builder().build(); val assertion = relyingParty.startAssertion(opts); diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index a22eb3c32e7..d65e3cff181 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -2,15 +2,13 @@ package models.user import com.scalableminds.util.accesscontext.DBAccessContext import com.scalableminds.util.objectid.ObjectId -import com.scalableminds.util.tools.{BoxImplicits, Fox} -import com.scalableminds.webknossos.schema.Tables +import com.scalableminds.util.tools.Fox import com.scalableminds.webknossos.schema.Tables._ -import net.liftweb.common.Box import slick.lifted.Rep import utils.sql.{SQLDAO, SqlClient} import javax.inject.Inject -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.ExecutionContext case class WebAuthnCredential( _id: ObjectId, diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala index f105a3964a2..9738804593d 100644 --- a/app/security/WebAuthnCredentialRepository.scala +++ b/app/security/WebAuthnCredentialRepository.scala @@ -5,9 +5,7 @@ import com.yubico.webauthn._ import com.yubico.webauthn.data._ import com.scalableminds.util.accesscontext.GlobalAccessContext import com.scalableminds.util.objectid.ObjectId -import com.typesafe.scalalogging.Logger -import net.liftweb.common.{Box, Empty, Failure, Full} -import org.slf4j.LoggerFactory +import net.liftweb.common.{Empty, Full} import java.util.Optional import javax.inject.Inject From ff9fb7bd6fbdcc3d65d4fef3a9e23aeeaccbc52e Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 14:37:20 +0100 Subject: [PATCH 24/40] fix future box handling --- .../AuthenticationController.scala | 10 +-- .../WebAuthnCredentialRepository.scala | 68 ++++++++++++++----- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 6c822af1897..a02284cb746 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -461,12 +461,15 @@ class AuthenticationController @Inject()( implicit request => { request.cookies.get("webauthn-session") match { + case None => + Future.successful(BadRequest("Authentication took too long, please try again.")) case Some(cookie) => val sessionId = cookie.value val challengeData = temporaryAssertionStore.get(sessionId) temporaryAssertionStore.remove(sessionId) challengeData match { - case Some(data) => + case None => Future.successful(Unauthorized("Authentication timeout.")) + case Some(data) => { try { val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.key); val opts = FinishAssertionOptions.builder().request(data).response(keyCredential).build(); @@ -475,18 +478,17 @@ class AuthenticationController @Inject()( userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle); multiUser <- multiUserDAO.findOne(userId)(GlobalAccessContext); result <- multiUser._lastLoggedInIdentity match { + case None => Future.successful(InternalServerError("user never logged in")) case Some(userId) => { val loginInfo = LoginInfo("credentials", userId.toString); authenticateInner(loginInfo) } - case None => Future.successful(InternalServerError("user never logged in")) } } yield result; } catch { case e: AssertionFailedException => Future.successful(Unauthorized("Authentication failed.")) } - case None => - Future.successful(BadRequest("Authentication took too long, please try again.")) + } } } } diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala index 9738804593d..12ed66b7c52 100644 --- a/app/security/WebAuthnCredentialRepository.scala +++ b/app/security/WebAuthnCredentialRepository.scala @@ -5,10 +5,12 @@ import com.yubico.webauthn._ import com.yubico.webauthn.data._ import com.scalableminds.util.accesscontext.GlobalAccessContext import com.scalableminds.util.objectid.ObjectId -import net.liftweb.common.{Empty, Full} +import com.typesafe.scalalogging.LazyLogging +import net.liftweb.common.{Empty, Failure, Full} import java.util.Optional import javax.inject.Inject +import scala.annotation.nowarn import scala.jdk.CollectionConverters._ object WebAuthnCredentialRepository { @@ -24,21 +26,36 @@ object WebAuthnCredentialRepository { * Username => User's E-Mail address */ -class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuthnCredentialDAO: WebAuthnCredentialDAO) extends CredentialRepository { +@nowarn("cat=deprecation") +class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuthnCredentialDAO: WebAuthnCredentialDAO) extends CredentialRepository with LazyLogging { def getCredentialIdsForUsername(email: String): java.util.Set[PublicKeyCredentialDescriptor] = { - val user = multiUserDAO.findOneByEmail(email)(GlobalAccessContext).get("Java interop") - val keys = webAuthnCredentialDAO.findAllForUser(user._id)(GlobalAccessContext).get("Java interop"); - keys.map(key => { - PublicKeyCredentialDescriptor.builder() - .id(WebAuthnCredentialRepository.bytesToByteArray(key.keyId)) - .build() - }).to(Set).asJava + val result = for { + user <- multiUserDAO.findOneByEmail(email)(GlobalAccessContext).await("Java interop") + keys <- webAuthnCredentialDAO.findAllForUser(user._id)(GlobalAccessContext).await("Java interop") + creds = keys.map(key => { + PublicKeyCredentialDescriptor.builder() + .id(WebAuthnCredentialRepository.bytesToByteArray(key.keyId)) + .build() + }) + } yield creds; + result match { + case Full(creds) => creds.toSet.asJava; + case Empty => Set[PublicKeyCredentialDescriptor]().asJava; + case Failure(msg, _, _) => { + logger.error(msg); + Set[PublicKeyCredentialDescriptor]().asJava + } + } } def getUserHandleForUsername(email: String): Optional[ByteArray] = { multiUserDAO.findOneByEmail(email)(GlobalAccessContext).await("Java interop") match { case Full(user) => Optional.ofNullable(WebAuthnCredentialRepository.objectIdToByteArray(user._id)) case Empty => Optional.empty() + case Failure(msg, _, _) => { + logger.error(msg); + Optional.empty(); + } } } @@ -47,22 +64,33 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth multiUserDAO.findOneById(id)(GlobalAccessContext).await("Java interop") match { case Full(user) => Optional.ofNullable(user.email) case Empty => Optional.empty() + case Failure(msg, _, _) => { + logger.error(msg); + Optional.empty(); + } } } def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] = { val credId = WebAuthnCredentialRepository.byteArrayToBytes(credentialId) val userId = WebAuthnCredentialRepository.byteArrayToObjectId(userHandle) - val credential = webAuthnCredentialDAO.findByKeyIdAndUserId(credId, userId)(GlobalAccessContext).await("Java interop") match { - case Full(credential) => credential; - case Empty => return Optional.empty(); + val result = for { + credential <- webAuthnCredentialDAO.findByKeyIdAndUserId(credId, userId)(GlobalAccessContext).await("Java interop") + registered = RegisteredCredential.builder() + .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) + .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) + .publicKeyCose(new ByteArray((credential.publicKeyCose))) + .signatureCount(credential.signatureCount) + .build() + } yield registered; + result match { + case Full(credential) => Optional.ofNullable(credential); + case Empty => Optional.empty(); + case Failure(msg, _, _) => { + logger.error(msg); + Optional.empty() + } } - Optional.ofNullable(RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) - .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) - .publicKeyCose(new ByteArray(credential.publicKeyCose)) - .signatureCount(credential.signatureCount) - .build()) } def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { @@ -80,6 +108,10 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth .toSet .asJava case Empty => Set[RegisteredCredential]().asJava + case Failure(msg, _, _) => { + logger.error(msg); + Set[RegisteredCredential]().asJava + } } } From 23a959134397a53c26ce27fe8d8d6eb43dccda53 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 14:47:10 +0100 Subject: [PATCH 25/40] fix frontend lints --- frontend/javascripts/admin/admin_rest_api.ts | 9 +++-- .../javascripts/admin/auth/login_form.tsx | 7 ++-- .../admin/auth/manage_passkeys_view.tsx | 34 ++++++++++--------- frontend/javascripts/types/api_flow_types.ts | 2 +- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 83a1cf0bc64..3e13f0e942c 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -171,9 +171,12 @@ export async function doWebAuthnLogin(): Promise { } export async function doWebAuthnRegistration(name: string): Promise { - const webAuthnRegistrationAssertion = await Request.receiveJSON("/api/auth/webauthn/register/start", { - method: "POST", - }).then(body => JSON.parse(body)); + const webAuthnRegistrationAssertion = await Request.receiveJSON( + "/api/auth/webauthn/register/start", + { + method: "POST", + }, + ).then((body) => JSON.parse(body)); const options = parseCreationOptionsFromJSON(webAuthnRegistrationAssertion); const response = JSON.stringify(await create(options)); return Request.sendJSONReceiveJSON("/api/auth/webauthn/register/finalize", { diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index 469af6ead9c..659451b70fb 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -141,14 +141,17 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) {
- diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx index 604ade952e0..7d38829b656 100644 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -1,10 +1,10 @@ import { CopyOutlined, SwapOutlined } from "@ant-design/icons"; import { - getAuthToken, - revokeAuthToken, doWebAuthnRegistration, + getAuthToken, listWebAuthnKeys, removeWebAuthnKey, + revokeAuthToken, } from "admin/admin_rest_api"; import { Button, Col, Form, Input, Modal, Row, Space, Spin } from "antd"; import Toast from "libs/toast"; @@ -12,24 +12,22 @@ import type { OxalisState } from "oxalis/store"; import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; -import { } from "@github/webauthn-json/browser-ponyfill"; - -const FormItem = Form.Item; +import {} from "@github/webauthn-json/browser-ponyfill"; function ManagePassKeyView() { const [isPassKeyNameModalOpen, setIsPassKeyNameModalOpen] = useState(false); const [newPassKeyName, setNewPassKeyName] = useState(""); - const [isLoading, setIsLoading] = useState(true); + const [_isLoading, setIsLoading] = useState(true); const [passkeys, setPasskeys] = useState([]); useEffect(() => { fetchData(); }, []); async function fetchData(): Promise { - setIsLoading(true); - const keys = await listWebAuthnKeys(); - setPasskeys(keys); - setIsLoading(false); + setIsLoading(true); + const keys = await listWebAuthnKeys(); + setPasskeys(keys); + setIsLoading(false); } const registerNewPassKey = async () => { @@ -57,15 +55,19 @@ function ManagePassKeyView() { >

Your PassKeys

- {passkeys.map(passkey => + {passkeys.map((passkey) => ( {passkey.name} - + - )} + ))} diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 709d02b09bf..723edd0b4f3 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -1213,4 +1213,4 @@ export type RenderAnimationOptions = { export type WebAuthnKeyDescriptor = { id: string; name: string; -} +}; From 7592b13c53acb795b04141c67dba2d86006d16e7 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 15:19:41 +0100 Subject: [PATCH 26/40] fix api usage --- frontend/javascripts/admin/admin_rest_api.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 3e13f0e942c..5b51cec6f13 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1,9 +1,4 @@ -import { - create, - get, - parseCreationOptionsFromJSON, - parseRequestOptionsFromJSON, -} from "@github/webauthn-json/browser-ponyfill"; +import * as webauthn from "@github/webauthn-json"; import dayjs from "dayjs"; import { V3 } from "libs/mjs"; import type { RequestOptions } from "libs/request"; @@ -158,8 +153,7 @@ export async function doWebAuthnLogin(): Promise { const webAuthnAuthAssertion = await Request.receiveJSON("/api/auth/webauthn/auth/start", { method: "POST", }); - const options = parseRequestOptionsFromJSON(webAuthnAuthAssertion); - const response = JSON.stringify(await get(options)); + const response = JSON.stringify(await webauthn.get(webAuthnAuthAssertion)); await Request.sendJSONReceiveJSON("/api/auth/webauthn/auth/finalize", { method: "POST", data: { key: response }, @@ -177,8 +171,7 @@ export async function doWebAuthnRegistration(name: string): Promise { method: "POST", }, ).then((body) => JSON.parse(body)); - const options = parseCreationOptionsFromJSON(webAuthnRegistrationAssertion); - const response = JSON.stringify(await create(options)); + const response = JSON.stringify(await webauthn.create(webAuthnRegistrationAssertion)) return Request.sendJSONReceiveJSON("/api/auth/webauthn/register/finalize", { data: { name: name, key: response }, method: "POST", From 96cf3d6acb2a360fd067350dc8a5d5470561da36 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 15:28:40 +0100 Subject: [PATCH 27/40] add trailing ; --- frontend/javascripts/admin/admin_rest_api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 5b51cec6f13..ec3b9bb680c 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -171,7 +171,7 @@ export async function doWebAuthnRegistration(name: string): Promise { method: "POST", }, ).then((body) => JSON.parse(body)); - const response = JSON.stringify(await webauthn.create(webAuthnRegistrationAssertion)) + const response = JSON.stringify(await webauthn.create(webAuthnRegistrationAssertion)); return Request.sendJSONReceiveJSON("/api/auth/webauthn/register/finalize", { data: { name: name, key: response }, method: "POST", From ab95204b6d62503f11751eaa77d5780755704683 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 16:10:22 +0100 Subject: [PATCH 28/40] apply format to backend --- .../AuthenticationController.scala | 64 +++++++++-------- app/models/user/MultiUser.scala | 4 +- app/models/user/WebAuthnCredentials.scala | 71 ++++++++++--------- .../WebAuthnCredentialRepository.scala | 68 +++++++++--------- 4 files changed, 109 insertions(+), 98 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index a02284cb746..ef45b1539ac 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -178,7 +178,7 @@ class AuthenticationController @Inject()( } } - private def authenticateInner(loginInfo: LoginInfo)(implicit header: RequestHeader): Future[Result] = { + private def authenticateInner(loginInfo: LoginInfo)(implicit header: RequestHeader): Future[Result] = for { result <- userService.retrieve(loginInfo).flatMap { case Some(user) if !user.isDeactivated => @@ -186,8 +186,8 @@ class AuthenticationController @Inject()( authenticator <- combinedAuthenticatorService.create(loginInfo) value <- combinedAuthenticatorService.init(authenticator) result <- combinedAuthenticatorService.embed(value, Ok) - _ <- Fox.runIf(conf.WebKnossos.User.EmailVerification.activated)(emailVerificationService - .assertEmailVerifiedOrResendVerificationMail(user)(GlobalAccessContext, ec)) + _ <- Fox.runIf(conf.WebKnossos.User.EmailVerification.activated)( + emailVerificationService.assertEmailVerifiedOrResendVerificationMail(user)(GlobalAccessContext, ec)) _ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext) _ = userDAO.updateLastActivity(user._id)(GlobalAccessContext) _ = logger.info(f"User ${user._id} authenticated.") @@ -196,8 +196,7 @@ class AuthenticationController @Inject()( Future.successful(BadRequest(Messages("error.noUser"))) case Some(_) => Future.successful(BadRequest(Messages("user.deactivated"))) } - } yield result - }; + } yield result; def authenticate: Action[AnyContent] = Action.async { implicit request => signInForm @@ -212,8 +211,8 @@ class AuthenticationController @Inject()( idF .map(id => Credentials(id, signInData.password)) .flatMap(credentials => credentialsProvider.authenticate(credentials)) - .flatMap { - loginInfo => authenticateInner(loginInfo) + .flatMap { loginInfo => + authenticateInner(loginInfo) } .recover { case _: ProviderException => BadRequest(Messages("error.invalidCredentials")) @@ -446,15 +445,13 @@ class AuthenticationController @Inject()( } } - def webauthnAuthStart(): Action[AnyContent] = Action { implicit _ => - { - val opts = StartAssertionOptions.builder().build(); - val assertion = relyingParty.startAssertion(opts); - val sessionId = UUID.randomUUID().toString; - val cookie = Cookie("webauthn-session", sessionId, maxAge = Some(120), httpOnly = true, secure = true) - temporaryAssertionStore.insert(sessionId, assertion, Some(2 minutes)); - Ok(Json.toJson(Json.parse(assertion.toCredentialsGetJson))).withCookies(cookie) - } + def webauthnAuthStart(): Action[AnyContent] = Action { + val opts = StartAssertionOptions.builder().build(); + val assertion = relyingParty.startAssertion(opts); + val sessionId = UUID.randomUUID().toString; + val cookie = Cookie("webauthn-session", sessionId, maxAge = Some(120), httpOnly = true, secure = true) + temporaryAssertionStore.insert(sessionId, assertion, Some(2 minutes)); + Ok(Json.toJson(Json.parse(assertion.toCredentialsGetJson))).withCookies(cookie) } def webauthnAuthFinalize(): Action[WebAuthnAuthentication] = Action.async(validateJson[WebAuthnAuthentication]) { @@ -504,12 +501,12 @@ class AuthenticationController @Inject()( .displayName(request.identity.name) .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._multiUser)) .build(); - val opts = StartRegistrationOptions.builder() + val opts = StartRegistrationOptions + .builder() .user(userIdentity) .timeout(60000) - .authenticatorSelection(AuthenticatorSelectionCriteria.builder() - .residentKey(ResidentKeyRequirement.REQUIRED) - .build()) + .authenticatorSelection( + AuthenticatorSelectionCriteria.builder().residentKey(ResidentKeyRequirement.REQUIRED).build()) .build() val registration = relyingParty.startRegistration(opts); temporaryRegistrationStore.insert(request.identity._multiUser, registration); @@ -552,18 +549,23 @@ class AuthenticationController @Inject()( } } - def webauthnListKeys: Action[AnyContent] = sil.SecuredAction.async { implicit request => { - for { - keys <- webAuthnCredentialDAO.listKeys(request.identity._multiUser) - reducedKeys = keys.map(credential => WebAuthnKeyDescriptor(credential._id, credential.name)) - } yield Ok(Json.toJson(reducedKeys)) - }} + def webauthnListKeys: Action[AnyContent] = sil.SecuredAction.async { implicit request => + { + for { + keys <- webAuthnCredentialDAO.listKeys(request.identity._multiUser) + reducedKeys = keys.map(credential => WebAuthnKeyDescriptor(credential._id, credential.name)) + } yield Ok(Json.toJson(reducedKeys)) + } + } - def webauthnRemoveKey: Action[WebAuthnKeyDescriptor] = sil.SecuredAction.async(validateJson[WebAuthnKeyDescriptor]) { implicit request => { - for { - _ <- webAuthnCredentialDAO.removeById(request.body.id, request.identity._multiUser) - } yield Ok(Json.obj()) - }} + def webauthnRemoveKey: Action[WebAuthnKeyDescriptor] = sil.SecuredAction.async(validateJson[WebAuthnKeyDescriptor]) { + implicit request => + { + for { + _ <- webAuthnCredentialDAO.removeById(request.body.id, request.identity._multiUser) + } yield Ok(Json.obj()) + } + } private lazy val absoluteOpenIdConnectCallbackURL = s"${conf.Http.uri}/api/auth/oidc/callback" diff --git a/app/models/user/MultiUser.scala b/app/models/user/MultiUser.scala index 3746c19d1eb..0e75f3f5f3b 100644 --- a/app/models/user/MultiUser.scala +++ b/app/models/user/MultiUser.scala @@ -42,7 +42,7 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext protected def idColumn(x: Multiusers): Rep[String] = x._Id protected def isDeletedColumn(x: Multiusers): Rep[Boolean] = x.isdeleted - protected def parse(r: MultiusersRow): Fox[MultiUser] = { + protected def parse(r: MultiusersRow): Fox[MultiUser] = for { novelUserExperienceInfos <- JsonHelper.parseAndValidateJson[JsObject](r.noveluserexperienceinfos).toFox theme <- Theme.fromString(r.selectedtheme).toFox @@ -60,7 +60,6 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext r.isdeleted ) } - } def insertOne(u: MultiUser): Fox[Unit] = for { @@ -144,7 +143,6 @@ class MultiUserDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext parsed <- parseFirst(r, id) } yield parsed - def findOneByEmail(email: String)(implicit ctx: DBAccessContext): Fox[MultiUser] = for { accessQuery <- readAccessQuery diff --git a/app/models/user/WebAuthnCredentials.scala b/app/models/user/WebAuthnCredentials.scala index d65e3cff181..dca433c6a85 100644 --- a/app/models/user/WebAuthnCredentials.scala +++ b/app/models/user/WebAuthnCredentials.scala @@ -11,63 +11,68 @@ import javax.inject.Inject import scala.concurrent.ExecutionContext case class WebAuthnCredential( - _id: ObjectId, - _multiUser: ObjectId, - keyId: Array[Byte], - name: String, - publicKeyCose: Array[Byte], - signatureCount: Int, - isDeleted: Boolean, + _id: ObjectId, + _multiUser: ObjectId, + keyId: Array[Byte], + name: String, + publicKeyCose: Array[Byte], + signatureCount: Int, + isDeleted: Boolean, ) -class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: ExecutionContext) - extends SQLDAO[WebAuthnCredential, WebauthncredentialsRow, Webauthncredentials](sqlClient) { - protected val collection = Webauthncredentials +class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext) + extends SQLDAO[WebAuthnCredential, WebauthncredentialsRow, Webauthncredentials](sqlClient) { + protected val collection = Webauthncredentials - override protected def idColumn(x: Webauthncredentials): Rep[String] = x._Id + override protected def idColumn(x: Webauthncredentials): Rep[String] = x._Id - override protected def isDeletedColumn(x: Webauthncredentials): Rep[Boolean] = x.isdeleted + override protected def isDeletedColumn(x: Webauthncredentials): Rep[Boolean] = x.isdeleted - protected def parse(r: WebauthncredentialsRow): Fox[WebAuthnCredential] = - Fox.successful( - WebAuthnCredential( - ObjectId(r._Id), - ObjectId(r._Multiuser), - r.keyid, - r.name, - r.publickeycose, - r.signaturecount, - r.isdeleted - ) - ) + protected def parse(r: WebauthncredentialsRow): Fox[WebAuthnCredential] = + Fox.successful( + WebAuthnCredential( + ObjectId(r._Id), + ObjectId(r._Multiuser), + r.keyid, + r.name, + r.publickeycose, + r.signaturecount, + r.isdeleted + ) + ) def findAllForUser(userId: ObjectId)(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + r <- run( + q"SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $userId AND $accessQuery" + .as[WebauthncredentialsRow]) parsed <- parseAll(r) } yield parsed def listByKeyId(id: Array[Byte])(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE keyId = $id AND $accessQuery".as[WebauthncredentialsRow]) + r <- run( + q"SELECT $columns FROM webknossos.webauthncredentials WHERE keyId = $id AND $accessQuery" + .as[WebauthncredentialsRow]) parsed <- parseAll(r) } yield parsed - def findByKeyIdAndUserId(id: Array[Byte], userId: ObjectId)(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] = for { accessQuery <- readAccessQuery - r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE keyId = $id AND _multiUser = $userId AND $accessQuery".as[WebauthncredentialsRow]) + r <- run( + q"SELECT $columns FROM webknossos.webauthncredentials WHERE keyId = $id AND _multiUser = $userId AND $accessQuery" + .as[WebauthncredentialsRow]) parsed <- parseAll(r) first <- Fox.option2Fox(parsed.headOption) } yield first - def insertOne(c: WebAuthnCredential): Fox[Unit] = for { - _ <- run(q"""INSERT INTO webknossos.webauthncredentials(_id, _multiUser, keyId, name, publicKeyCose, signatureCount) + _ <- run( + q"""INSERT INTO webknossos.webauthncredentials(_id, _multiUser, keyId, name, publicKeyCose, signatureCount) VALUES(${c._id}, ${c._multiUser}, ${c.keyId}, ${c.name}, ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) } yield () @@ -75,13 +80,15 @@ class WebAuthnCredentialDAO @Inject()(sqlClient: SqlClient) (implicit ec: Execut def listKeys(multiUser: ObjectId)(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] = for { accessQuery <- readAccessQuery - r <- run(q"""SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $multiUser AND $accessQuery""".as[WebauthncredentialsRow]) + r <- run( + q"""SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $multiUser AND $accessQuery""" + .as[WebauthncredentialsRow]) parsed <- parseAll(r) } yield parsed def removeById(id: ObjectId, multiUser: ObjectId): Fox[Unit] = for { _ <- run(q"""DELETE FROM webknossos.webauthncredentials WHERE _id = ${id} AND _multiUser=${multiUser}""".asUpdate) - } yield() + } yield () } diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala index 12ed66b7c52..49d6caf7c75 100644 --- a/app/security/WebAuthnCredentialRepository.scala +++ b/app/security/WebAuthnCredentialRepository.scala @@ -27,20 +27,20 @@ object WebAuthnCredentialRepository { */ @nowarn("cat=deprecation") -class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuthnCredentialDAO: WebAuthnCredentialDAO) extends CredentialRepository with LazyLogging { +class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuthnCredentialDAO: WebAuthnCredentialDAO) + extends CredentialRepository + with LazyLogging { def getCredentialIdsForUsername(email: String): java.util.Set[PublicKeyCredentialDescriptor] = { val result = for { user <- multiUserDAO.findOneByEmail(email)(GlobalAccessContext).await("Java interop") keys <- webAuthnCredentialDAO.findAllForUser(user._id)(GlobalAccessContext).await("Java interop") creds = keys.map(key => { - PublicKeyCredentialDescriptor.builder() - .id(WebAuthnCredentialRepository.bytesToByteArray(key.keyId)) - .build() + PublicKeyCredentialDescriptor.builder().id(WebAuthnCredentialRepository.bytesToByteArray(key.keyId)).build() }) } yield creds; result match { case Full(creds) => creds.toSet.asJava; - case Empty => Set[PublicKeyCredentialDescriptor]().asJava; + case Empty => Set[PublicKeyCredentialDescriptor]().asJava; case Failure(msg, _, _) => { logger.error(msg); Set[PublicKeyCredentialDescriptor]().asJava @@ -48,22 +48,21 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth } } - def getUserHandleForUsername(email: String): Optional[ByteArray] = { + def getUserHandleForUsername(email: String): Optional[ByteArray] = multiUserDAO.findOneByEmail(email)(GlobalAccessContext).await("Java interop") match { case Full(user) => Optional.ofNullable(WebAuthnCredentialRepository.objectIdToByteArray(user._id)) - case Empty => Optional.empty() + case Empty => Optional.empty() case Failure(msg, _, _) => { logger.error(msg); Optional.empty(); } } - } def getUsernameForUserHandle(handle: ByteArray): Optional[String] = { val id = WebAuthnCredentialRepository.byteArrayToObjectId(handle) multiUserDAO.findOneById(id)(GlobalAccessContext).await("Java interop") match { case Full(user) => Optional.ofNullable(user.email) - case Empty => Optional.empty() + case Empty => Optional.empty() case Failure(msg, _, _) => { logger.error(msg); Optional.empty(); @@ -75,8 +74,11 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth val credId = WebAuthnCredentialRepository.byteArrayToBytes(credentialId) val userId = WebAuthnCredentialRepository.byteArrayToObjectId(userHandle) val result = for { - credential <- webAuthnCredentialDAO.findByKeyIdAndUserId(credId, userId)(GlobalAccessContext).await("Java interop") - registered = RegisteredCredential.builder() + credential <- webAuthnCredentialDAO + .findByKeyIdAndUserId(credId, userId)(GlobalAccessContext) + .await("Java interop") + registered = RegisteredCredential + .builder() .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) .publicKeyCose(new ByteArray((credential.publicKeyCose))) @@ -85,7 +87,7 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth } yield registered; result match { case Full(credential) => Optional.ofNullable(credential); - case Empty => Optional.empty(); + case Empty => Optional.empty(); case Failure(msg, _, _) => { logger.error(msg); Optional.empty() @@ -93,26 +95,28 @@ class WebAuthnCredentialRepository @Inject()(multiUserDAO: MultiUserDAO, webAuth } } - def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = { - webAuthnCredentialDAO.listByKeyId(WebAuthnCredentialRepository.byteArrayToBytes(credentialId))(GlobalAccessContext).await("Java interop") match { - case Full(credentials: List[WebAuthnCredential]) => - credentials - .map(credential => { - RegisteredCredential.builder() - .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) - .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) - .publicKeyCose(new ByteArray(credential.publicKeyCose)) - .signatureCount(credential.signatureCount) - .build() - }) - .toSet - .asJava - case Empty => Set[RegisteredCredential]().asJava - case Failure(msg, _, _) => { - logger.error(msg); - Set[RegisteredCredential]().asJava - } + def lookupAll(credentialId: ByteArray): java.util.Set[RegisteredCredential] = + webAuthnCredentialDAO + .listByKeyId(WebAuthnCredentialRepository.byteArrayToBytes(credentialId))(GlobalAccessContext) + .await("Java interop") match { + case Full(credentials: List[WebAuthnCredential]) => + credentials + .map(credential => { + RegisteredCredential + .builder() + .credentialId(WebAuthnCredentialRepository.bytesToByteArray(credential.keyId)) + .userHandle(WebAuthnCredentialRepository.objectIdToByteArray(credential._multiUser)) + .publicKeyCose(new ByteArray(credential.publicKeyCose)) + .signatureCount(credential.signatureCount) + .build() + }) + .toSet + .asJava + case Empty => Set[RegisteredCredential]().asJava + case Failure(msg, _, _) => { + logger.error(msg); + Set[RegisteredCredential]().asJava } - } + } } From bce5a1d36e8f222626a6231590aae5028f581e8a Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 16:44:52 +0100 Subject: [PATCH 29/40] fix uri --- conf/application.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/application.conf b/conf/application.conf index 59eaba0d9bb..513a885c133 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -1,5 +1,5 @@ http { - uri = "https://webknossos.local:9000" + uri = "http://localhost:9000" port = 9000 } From e28d477828cb509bfa12447438d90f2699f96b0d Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 17:31:05 +0100 Subject: [PATCH 30/40] fix typecheck errors --- frontend/javascripts/admin/admin_rest_api.ts | 2 +- frontend/javascripts/admin/auth/manage_passkeys_view.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index ec3b9bb680c..2e6fe6da39a 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -149,7 +149,7 @@ export async function loginUser(formValues: { return [activeUser, organization]; } -export async function doWebAuthnLogin(): Promise { +export async function doWebAuthnLogin(): Promise<[APIUser, APIOrganization]> { const webAuthnAuthAssertion = await Request.receiveJSON("/api/auth/webauthn/auth/start", { method: "POST", }); diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx index 7d38829b656..708e27aa974 100644 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -6,6 +6,9 @@ import { removeWebAuthnKey, revokeAuthToken, } from "admin/admin_rest_api"; +import { + WebAuthnKeyDescriptor +} from "types/api_flow_types" import { Button, Col, Form, Input, Modal, Row, Space, Spin } from "antd"; import Toast from "libs/toast"; import type { OxalisState } from "oxalis/store"; @@ -18,7 +21,7 @@ function ManagePassKeyView() { const [isPassKeyNameModalOpen, setIsPassKeyNameModalOpen] = useState(false); const [newPassKeyName, setNewPassKeyName] = useState(""); const [_isLoading, setIsLoading] = useState(true); - const [passkeys, setPasskeys] = useState([]); + const [passkeys, setPasskeys] = useState([]); useEffect(() => { fetchData(); }, []); From 6838401a3f2669d031cfc5b3b9a2e675565a16bf Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Thu, 20 Feb 2025 17:58:33 +0100 Subject: [PATCH 31/40] fixed frontend --- frontend/javascripts/admin/auth/manage_passkeys_view.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx index 708e27aa974..519d4bcfc6a 100644 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -6,14 +6,12 @@ import { removeWebAuthnKey, revokeAuthToken, } from "admin/admin_rest_api"; -import { - WebAuthnKeyDescriptor -} from "types/api_flow_types" import { Button, Col, Form, Input, Modal, Row, Space, Spin } from "antd"; import Toast from "libs/toast"; import type { OxalisState } from "oxalis/store"; import { useEffect, useState } from "react"; import { useSelector } from "react-redux"; +import type { WebAuthnKeyDescriptor } from "types/api_flow_types"; import {} from "@github/webauthn-json/browser-ponyfill"; From ce831fb0d3dee8ada41ab8018a88e5e6a513b7e4 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 25 Feb 2025 09:15:12 +0100 Subject: [PATCH 32/40] read origin from configuration --- app/controllers/AuthenticationController.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index ef45b1539ac..1234e833565 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -15,6 +15,7 @@ import net.liftweb.common.{Box, Empty, Failure, Full} import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.digest.{HmacAlgorithms, HmacUtils} import org.apache.pekko.actor.ActorSystem +import play.api.Configuration import play.api.data.Form import play.api.data.Forms._ import play.api.data.validation.Constraints._ @@ -54,6 +55,7 @@ object WebAuthnKeyDescriptor { } class AuthenticationController @Inject()( + configuration: Configuration, actorSystem: ActorSystem, credentialsProvider: CredentialsProvider, passwordHasher: PasswordHasher, @@ -92,9 +94,10 @@ class AuthenticationController @Inject()( conf.WebKnossos.User.ssoKey private lazy val relyingParty = { + val origin = configuration.get[String]("http.uri").split("/")(2); val identity = RelyingPartyIdentity .builder() - .id("webknossos.local:9000") // TODO: Use Host + .id(origin) .name("WebKnossos") .build(); RelyingParty.builder().identity(identity).credentialRepository(webAuthnCredentialRepository).build() From dbccd1f1f977406a5af9906c5bbd4de263d56648 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 25 Feb 2025 09:52:09 +0100 Subject: [PATCH 33/40] apply format --- app/controllers/AuthenticationController.scala | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 1234e833565..e82516c3bba 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -95,11 +95,7 @@ class AuthenticationController @Inject()( private lazy val relyingParty = { val origin = configuration.get[String]("http.uri").split("/")(2); - val identity = RelyingPartyIdentity - .builder() - .id(origin) - .name("WebKnossos") - .build(); + val identity = RelyingPartyIdentity.builder().id(origin).name("WebKnossos").build(); RelyingParty.builder().identity(identity).credentialRepository(webAuthnCredentialRepository).build() } private val blockingContext: ExecutionContext = actorSystem.dispatchers.lookup("play.context.blocking") From e652f78be7c13b626d16f92baf97ca89d78b7aca Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 25 Feb 2025 11:40:10 +0100 Subject: [PATCH 34/40] fix future exception handling --- .../AuthenticationController.scala | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index e82516c3bba..8cfdafaa475 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -38,6 +38,7 @@ import java.util.UUID import javax.inject.Inject import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try case class WebAuthnRegistration(name: String, key: String) object WebAuthnRegistration { @@ -466,24 +467,25 @@ class AuthenticationController @Inject()( challengeData match { case None => Future.successful(Unauthorized("Authentication timeout.")) case Some(data) => { - try { - val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.key); - val opts = FinishAssertionOptions.builder().request(data).response(keyCredential).build(); - for { - result <- Future { relyingParty.finishAssertion(opts) }(blockingContext); // NOTE: Prevent blocking on HTTP handler - userId = WebAuthnCredentialRepository.byteArrayToObjectId(result.getCredential.getUserHandle); - multiUser <- multiUserDAO.findOne(userId)(GlobalAccessContext); - result <- multiUser._lastLoggedInIdentity match { - case None => Future.successful(InternalServerError("user never logged in")) - case Some(userId) => { - val loginInfo = LoginInfo("credentials", userId.toString); - authenticateInner(loginInfo) - } + val keyCredential = PublicKeyCredential.parseAssertionResponseJson(request.body.key); + val opts = FinishAssertionOptions.builder().request(data).response(keyCredential).build(); + for { + result <- Fox + .future2Fox(Future { Try(relyingParty.finishAssertion(opts)) })(blockingContext); // NOTE: Prevent blocking on HTTP handler + assertion <- result match { + case scala.util.Success(assertion) => Fox.successful(assertion); + case scala.util.Failure(e) => Fox.failure("Authentication failed.", Full(e)); + }; + userId = WebAuthnCredentialRepository.byteArrayToObjectId(assertion.getCredential.getUserHandle); + multiUser <- multiUserDAO.findOne(userId)(GlobalAccessContext); + result <- multiUser._lastLoggedInIdentity match { + case None => Future.successful(InternalServerError("user never logged in")) + case Some(userId) => { + val loginInfo = LoginInfo("credentials", userId.toString); + authenticateInner(loginInfo) } - } yield result; - } catch { - case e: AssertionFailedException => Future.successful(Unauthorized("Authentication failed.")) - } + } + } yield result; } } } @@ -525,7 +527,11 @@ class AuthenticationController @Inject()( val opts = FinishRegistrationOptions.builder().request(data).response(response).build(); try { for { - key <- Future { relyingParty.finishRegistration(opts) }(blockingContext); + preKey <- Fox.future2Fox(Future { Try(relyingParty.finishRegistration(opts)) })(blockingContext); + key <- preKey match { + case scala.util.Success(key) => Fox.successful(key) + case scala.util.Failure(e) => Fox.failure("Registration failed", Full(e)) + }; result <- { val credential = WebAuthnCredential( ObjectId.generate, From 3bdd978087e9cc2633dc2d7b525c416d0bdc900c Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Tue, 25 Feb 2025 11:43:41 +0100 Subject: [PATCH 35/40] rename PassKeys to Passkeys and add missing await --- frontend/javascripts/admin/auth/manage_passkeys_view.tsx | 8 ++++---- frontend/javascripts/navbar.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx index 519d4bcfc6a..83ae52864df 100644 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx @@ -34,7 +34,7 @@ function ManagePassKeyView() { const registerNewPassKey = async () => { try { setIsPassKeyNameModalOpen(false); - const result = doWebAuthnRegistration(newPassKeyName); + const result = await doWebAuthnRegistration(newPassKeyName); console.debug(result); Toast.success("PassKey registered successfully"); setNewPassKeyName(""); @@ -55,7 +55,7 @@ function ManagePassKeyView() { align="middle" > -

Your PassKeys

+

Your Passkeys

{passkeys.map((passkey) => ( {passkey.name} @@ -74,13 +74,13 @@ function ManagePassKeyView() {

- PassKeys are a new web authentication method that allows you to log in without a + Passkeys are a new web authentication method that allows you to log in without a password in a secured way. Microsoft Hello and Apple FaceID are examples of technologies that can be used as passkeys to log in in WEBKNOSSOS. If you want to add a new passkey to your account use the button below.

diff --git a/frontend/javascripts/navbar.tsx b/frontend/javascripts/navbar.tsx index d9d636c04e5..a57f1cb7970 100644 --- a/frontend/javascripts/navbar.tsx +++ b/frontend/javascripts/navbar.tsx @@ -682,7 +682,7 @@ function LoggedInAvatar({ to="/auth/passKey" title="Register and manage Passkeys and Login via Windows Hello" > - Register/Manage PassKeys + Manage Passkeys ), }, From f7734e48b019997339d463ddd115d999cb113c78 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Wed, 26 Feb 2025 15:28:44 +0100 Subject: [PATCH 36/40] merge manage passkeys and change password view --- .../admin/auth/change_password_view.tsx | 306 +++++++++++------- .../admin/auth/manage_passkeys_view.tsx | 103 ------ frontend/javascripts/navbar.tsx | 11 - frontend/javascripts/router.tsx | 6 - 4 files changed, 197 insertions(+), 229 deletions(-) delete mode 100644 frontend/javascripts/admin/auth/manage_passkeys_view.tsx diff --git a/frontend/javascripts/admin/auth/change_password_view.tsx b/frontend/javascripts/admin/auth/change_password_view.tsx index e41b5d1261f..fe1615a8794 100644 --- a/frontend/javascripts/admin/auth/change_password_view.tsx +++ b/frontend/javascripts/admin/auth/change_password_view.tsx @@ -1,5 +1,5 @@ import { LockOutlined } from "@ant-design/icons"; -import { Alert, Button, Col, Form, Input, Row } from "antd"; +import { Alert, Button, Col, Form, Input, Modal, Row } from "antd"; import Request from "libs/request"; import Toast from "libs/toast"; import messages from "messages"; @@ -8,6 +8,13 @@ import Store from "oxalis/store"; import { type RouteComponentProps, withRouter } from "react-router-dom"; const FormItem = Form.Item; const { Password } = Input; +import { useEffect, useState } from "react"; +import { + doWebAuthnRegistration, + listWebAuthnKeys, + removeWebAuthnKey, + revokeAuthToken, +} from "admin/admin_rest_api"; type Props = { history: RouteComponentProps["history"]; @@ -16,8 +23,19 @@ type Props = { const MIN_PASSWORD_LENGTH = 8; function ChangePasswordView({ history }: Props) { + // Password Form const [form] = Form.useForm(); + /// Passkeys + const [isPasskeyNameModalOpen, setIsPasskeyNameModalOpen] = useState(false); + const [newPasskeyName, setNewPasskeyName] = useState(""); + const [passkeys, setPasskeys] = useState([]); + const [_isLoadingPasskeys, setIsLoadingPasskeys] = useState(false); + + useEffect(() => { + fetchPasskeys(); + }, []); + function onFinish(formValues: Record) { Request.sendJSONReceiveJSON("/api/auth/changePassword", { data: formValues, @@ -44,117 +62,187 @@ function ChangePasswordView({ history }: Props) { return Promise.resolve(); } + async function fetchPasskeys(): Promise { + setIsLoadingPasskeys(true); + const keys = await listWebAuthnKeys(); + setPasskeys(keys); + setIsLoadingPasskeys(false); + } + + const registerNewPasskey = async () => { + try { + setIsPasskeyNameModalOpen(false); + const result = await doWebAuthnRegistration(newPasskeyName); + Toast.success("Passkey registered successfully"); + setNewPasskeyName(""); + await fetchPasskeys(); + } catch (e) { + Toast.error(`Registering new Passkey '${newPasskeyName}' failed`); + console.error("Could not register new Passkey", e); + } + }; + return ( - - -

Change Password

- -
- - - } - placeholder="Old Password" - /> - - - checkPasswordsAreMatching(value, ["password", "password2"]), - }, - ]} - > - - } - placeholder="New Password" - /> - - - checkPasswordsAreMatching(value, ["password", "password1"]), - }, - ]} - > - - } - placeholder="Confirm New Password" - /> - - - + +
+ +
+ + +

Your Passkeys

+

+ Passkeys are a new web authentication method that allows you to log in without a + password in a secured way. Microsoft Hello and Apple FaceID are examples of technologies + that can be used as passkeys to log in in WEBKNOSSOS. If you want to add a new passkey + to your account use the button below. +

+ + {passkeys.map((passkey) => ( + + {passkey.name} + + + ))} +
+ - - - - +
+ +
+ setIsPasskeyNameModalOpen(false)} + > + setNewPasskeyName(e.target.value)} + /> + +
); } diff --git a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx b/frontend/javascripts/admin/auth/manage_passkeys_view.tsx deleted file mode 100644 index 83ae52864df..00000000000 --- a/frontend/javascripts/admin/auth/manage_passkeys_view.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { CopyOutlined, SwapOutlined } from "@ant-design/icons"; -import { - doWebAuthnRegistration, - getAuthToken, - listWebAuthnKeys, - removeWebAuthnKey, - revokeAuthToken, -} from "admin/admin_rest_api"; -import { Button, Col, Form, Input, Modal, Row, Space, Spin } from "antd"; -import Toast from "libs/toast"; -import type { OxalisState } from "oxalis/store"; -import { useEffect, useState } from "react"; -import { useSelector } from "react-redux"; -import type { WebAuthnKeyDescriptor } from "types/api_flow_types"; - -import {} from "@github/webauthn-json/browser-ponyfill"; - -function ManagePassKeyView() { - const [isPassKeyNameModalOpen, setIsPassKeyNameModalOpen] = useState(false); - const [newPassKeyName, setNewPassKeyName] = useState(""); - const [_isLoading, setIsLoading] = useState(true); - const [passkeys, setPasskeys] = useState([]); - useEffect(() => { - fetchData(); - }, []); - - async function fetchData(): Promise { - setIsLoading(true); - const keys = await listWebAuthnKeys(); - setPasskeys(keys); - setIsLoading(false); - } - - const registerNewPassKey = async () => { - try { - setIsPassKeyNameModalOpen(false); - const result = await doWebAuthnRegistration(newPassKeyName); - console.debug(result); - Toast.success("PassKey registered successfully"); - setNewPassKeyName(""); - await fetchData(); - } catch (e) { - Toast.error(`Registering new PassKey '${newPassKeyName}' failed`); - console.error("Could not register new PassKey", e); - } - }; - - return ( -
- - -

Your Passkeys

- {passkeys.map((passkey) => ( - - {passkey.name} - - - ))} - -
- - -

- Passkeys are a new web authentication method that allows you to log in without a - password in a secured way. Microsoft Hello and Apple FaceID are examples of technologies - that can be used as passkeys to log in in WEBKNOSSOS. If you want to add a new passkey - to your account use the button below. -

- - -
- setIsPassKeyNameModalOpen(false)} - > - setNewPassKeyName(e.target.value)} - /> - -
- ); -} - -export default ManagePassKeyView; diff --git a/frontend/javascripts/navbar.tsx b/frontend/javascripts/navbar.tsx index a57f1cb7970..0934d62e190 100644 --- a/frontend/javascripts/navbar.tsx +++ b/frontend/javascripts/navbar.tsx @@ -675,17 +675,6 @@ function LoggedInAvatar({ key: "resetpassword", label: Change Password, }, - { - key: "manage-passkeys", - label: ( - - Manage Passkeys - - ), - }, { key: "token", label: Auth Token }, { key: "theme", diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx index bd670ef6976..edcae43c38a 100644 --- a/frontend/javascripts/router.tsx +++ b/frontend/javascripts/router.tsx @@ -63,7 +63,6 @@ import { getDatasetIdFromNameAndOrganization, getOrganizationForDataset, } from "admin/api/disambiguate_legacy_routes"; -import ManagePassKeyView from "admin/auth/manage_passkeys_view"; import VerifyEmailView from "admin/auth/verify_email_view"; import { DatasetURLImport } from "admin/dataset/dataset_url_import"; import TimeTrackingOverview from "admin/statistic/time_tracking_overview"; @@ -618,11 +617,6 @@ class ReactRouter extends React.Component { path="/auth/token" component={AuthTokenView} /> - Date: Wed, 26 Feb 2025 15:31:32 +0100 Subject: [PATCH 37/40] fix frontend --- frontend/javascripts/admin/auth/change_password_view.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/admin/auth/change_password_view.tsx b/frontend/javascripts/admin/auth/change_password_view.tsx index fe1615a8794..3a5262cd18e 100644 --- a/frontend/javascripts/admin/auth/change_password_view.tsx +++ b/frontend/javascripts/admin/auth/change_password_view.tsx @@ -8,13 +8,13 @@ import Store from "oxalis/store"; import { type RouteComponentProps, withRouter } from "react-router-dom"; const FormItem = Form.Item; const { Password } = Input; -import { useEffect, useState } from "react"; import { doWebAuthnRegistration, listWebAuthnKeys, removeWebAuthnKey, revokeAuthToken, } from "admin/admin_rest_api"; +import { useEffect, useState } from "react"; type Props = { history: RouteComponentProps["history"]; @@ -30,7 +30,7 @@ function ChangePasswordView({ history }: Props) { const [isPasskeyNameModalOpen, setIsPasskeyNameModalOpen] = useState(false); const [newPasskeyName, setNewPasskeyName] = useState(""); const [passkeys, setPasskeys] = useState([]); - const [_isLoadingPasskeys, setIsLoadingPasskeys] = useState(false); + const [_isLoadingPasskeys, setIsLoadingPasskeys] = useState(false); useEffect(() => { fetchPasskeys(); @@ -72,7 +72,7 @@ function ChangePasswordView({ history }: Props) { const registerNewPasskey = async () => { try { setIsPasskeyNameModalOpen(false); - const result = await doWebAuthnRegistration(newPasskeyName); + await doWebAuthnRegistration(newPasskeyName); Toast.success("Passkey registered successfully"); setNewPasskeyName(""); await fetchPasskeys(); @@ -223,7 +223,7 @@ function ChangePasswordView({ history }: Props) { ))} -
+
From 01198f51268f4a6de26cc1395a1191c1730e1eb3 Mon Sep 17 00:00:00 2001 From: Robert Oleynik Date: Wed, 26 Feb 2025 16:04:09 +0100 Subject: [PATCH 38/40] catch WebAuthn exception on log in --- frontend/javascripts/admin/auth/login_form.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/admin/auth/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index 659451b70fb..9f23a9b4ff3 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -144,11 +144,16 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) {