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/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index 444b1a69ab6..8cfdafaa475 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} @@ -11,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._ @@ -29,10 +34,29 @@ 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} +import scala.util.Try + +case class WebAuthnRegistration(name: String, key: String) +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] +} + +case class WebAuthnKeyDescriptor(id: ObjectId, name: String) +object WebAuthnKeyDescriptor { + implicit val jsonFormat: OFormat[WebAuthnKeyDescriptor] = Json.format[WebAuthnKeyDescriptor] +} class AuthenticationController @Inject()( + configuration: Configuration, actorSystem: ActorSystem, credentialsProvider: CredentialsProvider, passwordHasher: PasswordHasher, @@ -52,6 +76,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 +94,13 @@ class AuthenticationController @Inject()( private lazy val ssoKey = conf.WebKnossos.User.ssoKey + private lazy val relyingParty = { + val origin = configuration.get[String]("http.uri").split("/")(2); + 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") + private lazy val isOIDCEnabled = conf.Features.openIdConnectEnabled def register: Action[AnyContent] = Action.async { implicit request => @@ -143,6 +178,26 @@ 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() @@ -156,24 +211,8 @@ class AuthenticationController @Inject()( idF .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"))) - } + .flatMap { loginInfo => + authenticateInner(loginInfo) } .recover { case _: ProviderException => BadRequest(Messages("error.invalidCredentials")) @@ -406,6 +445,133 @@ class AuthenticationController @Inject()( } } + 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]) { + 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 None => Future.successful(Unauthorized("Authentication timeout.")) + case Some(data) => { + 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; + } + } + } + } + } + + def webauthnRegisterStart(): Action[AnyContent] = sil.SecuredAction.async { implicit request => + for { + email <- userService.emailFor(request.identity); + result <- Future { + val userIdentity = UserIdentity + .builder() + .name(email) + .displayName(request.identity.name) + .id(WebAuthnCredentialRepository.objectIdToByteArray(request.identity._multiUser)) + .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); + Ok(Json.toJson(registration.toCredentialsCreateJson)) + }(blockingContext) + } yield result; + } + + 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 { + for { + 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, + 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")) + } + } + case None => Future.successful(BadRequest("Challenge not found or expired")) + } + } + } + + 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()) + } + } + 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..0e75f3f5f3b 100644 --- a/app/models/user/MultiUser.scala +++ b/app/models/user/MultiUser.scala @@ -136,6 +136,13 @@ 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 new file mode 100644 index 00000000000..dca433c6a85 --- /dev/null +++ b/app/models/user/WebAuthnCredentials.scala @@ -0,0 +1,94 @@ +package models.user + +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 slick.lifted.Rep +import utils.sql.{SQLDAO, SqlClient} + +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, +) + +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.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]) + 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]) + 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]) + 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) + VALUES(${c._id}, ${c._multiUser}, ${c.keyId}, ${c.name}, + ${c.publicKeyCose}, ${c.signatureCount})""".asUpdate) + } yield () + + 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]) + 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 () + +} diff --git a/app/security/WebAuthnCredentialRepository.scala b/app/security/WebAuthnCredentialRepository.scala new file mode 100644 index 00000000000..49d6caf7c75 --- /dev/null +++ b/app/security/WebAuthnCredentialRepository.scala @@ -0,0 +1,122 @@ +package security + +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 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 { + 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)) +} + +/* + * UserHandle => ObjectId + * Username => User's E-Mail address + */ + +@nowarn("cat=deprecation") +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() + }) + } 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(); + } + } + + 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 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 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() + } + } + } + + 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 + } + } + +} diff --git a/conf/application.conf b/conf/application.conf index f62ca861b1b..513a885c133 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 { diff --git a/conf/evolutions/126-add-webauthn-credentials.sql b/conf/evolutions/126-add-webauthn-credentials.sql new file mode 100644 index 00000000000..446792f2b70 --- /dev/null +++ b/conf/evolutions/126-add-webauthn-credentials.sql @@ -0,0 +1,21 @@ +START TRANSACTION; + +do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 125, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql; + +CREATE TABLE webknossos.webauthnCredentials( + _id TEXT PRIMARY KEY, + _multiUser CHAR(24) NOT NULL, + keyId BYTEA NOT NULL, + name TEXT NOT NULL, + publicKeyCose BYTEA NOT NULL, + signatureCount INTEGER NOT NULL, + isDeleted BOOLEAN NOT NULL DEFAULT false, + UNIQUE (_multiUser, keyId) +); + +ALTER TABLE webknossos.webauthnCredentials + ADD FOREIGN KEY (_multiUser) REFERENCES webknossos.multiUsers(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +UPDATE webknossos.releaseInformation SET schemaVersion = 126; + +COMMIT TRANSACTION; 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; diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 558baa9cd8d..92c67810237 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -36,6 +36,15 @@ 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.webauthnRegisterStart() +POST /auth/webauthn/register/finalize controllers.AuthenticationController.webauthnRegisterFinalize() +GET /auth/webauthn/keys controllers.AuthenticationController.webauthnListKeys() +DELETE /auth/webauthn/keys controllers.AuthenticationController.webauthnRemoveKey() + # /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/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index f86b1cf31a7..2e6fe6da39a 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -1,3 +1,4 @@ +import * as webauthn from "@github/webauthn-json"; import dayjs from "dayjs"; import { V3 } from "libs/mjs"; import type { RequestOptions } from "libs/request"; @@ -90,6 +91,7 @@ import { type VoxelyticsLogLine, type VoxelyticsWorkflowListing, type VoxelyticsWorkflowReport, + type WebAuthnKeyDescriptor, type ZarrPrivateLink, } from "types/api_flow_types"; import type { ArbitraryObject } from "types/globals"; @@ -147,6 +149,46 @@ export async function loginUser(formValues: { return [activeUser, organization]; } +export async function doWebAuthnLogin(): Promise<[APIUser, APIOrganization]> { + const webAuthnAuthAssertion = await Request.receiveJSON("/api/auth/webauthn/auth/start", { + method: "POST", + }); + const response = JSON.stringify(await webauthn.get(webAuthnAuthAssertion)); + 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 { + const webAuthnRegistrationAssertion = await Request.receiveJSON( + "/api/auth/webauthn/register/start", + { + method: "POST", + }, + ).then((body) => JSON.parse(body)); + const response = JSON.stringify(await webauthn.create(webAuthnRegistrationAssertion)); + return Request.sendJSONReceiveJSON("/api/auth/webauthn/register/finalize", { + data: { name: name, key: response }, + method: "POST", + }); +} + +export async function listWebAuthnKeys(): Promise> { + return await Request.receiveJSON("/api/auth/webauthn/keys"); +} + +export async function removeWebAuthnKey(key: WebAuthnKeyDescriptor): Promise { + return await Request.sendJSONReceiveArraybuffer("/api/auth/webauthn/keys", { + method: "DELETE", + data: key, + }); +} + export async function getUsers(): Promise> { const users = await Request.receiveJSON("/api/users"); assertResponseLimit(users); 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 diff --git a/frontend/javascripts/admin/auth/change_password_view.tsx b/frontend/javascripts/admin/auth/change_password_view.tsx index e41b5d1261f..1c1a5a8bf2f 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,14 @@ import Store from "oxalis/store"; import { type RouteComponentProps, withRouter } from "react-router-dom"; const FormItem = Form.Item; const { Password } = Input; +import { + doWebAuthnRegistration, + listWebAuthnKeys, + removeWebAuthnKey, + revokeAuthToken, +} from "admin/admin_rest_api"; +import { useEffect, useState } from "react"; +import type { WebAuthnKeyDescriptor } from "types/api_flow_types"; type Props = { history: RouteComponentProps["history"]; @@ -16,8 +24,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 +63,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); + 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/login_form.tsx b/frontend/javascripts/admin/auth/login_form.tsx index a1cbef92cb9..14835bd7841 100644 --- a/frontend/javascripts/admin/auth/login_form.tsx +++ b/frontend/javascripts/admin/auth/login_form.tsx @@ -1,7 +1,8 @@ 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 Toast from "libs/toast"; import { getIsInIframe } from "libs/utils"; import messages from "messages"; import { setActiveOrganizationAction } from "oxalis/model/actions/organization_actions"; @@ -139,6 +140,28 @@ function LoginForm({ layout, onLoggedIn, hideFooter, style }: Props) { )} +
+ + + +
{hideFooter ? null : (