Skip to content

Implement Webauthn #8393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 65 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
cd974e6
add WebAuthn credentials to database schema
robert-oleynik Feb 11, 2025
47f52ac
setup WebAuthnCredentials DAO
robert-oleynik Feb 11, 2025
c522883
Implement WebAuthn backend
robert-oleynik Feb 12, 2025
8938d9e
add frontend webauthn support lib
Feb 12, 2025
f5130f6
use proper file extension in some files
Feb 12, 2025
09afa8a
WIP: Add passkey management page
Feb 12, 2025
86260d8
WIP: add some frontend part for passkey registration
Feb 13, 2025
1fe36d9
setup tls proxy
robert-oleynik Feb 18, 2025
391ec47
fix webauthn registration start
robert-oleynik Feb 18, 2025
aba3d99
fix webauthn registration finalize
robert-oleynik Feb 18, 2025
4f03669
fix webauthn auth start
robert-oleynik Feb 18, 2025
b9014a0
fix key id and authentication process
robert-oleynik Feb 18, 2025
3447565
fix frontend redirect
robert-oleynik Feb 18, 2025
3cf61e6
restyle login form
robert-oleynik Feb 20, 2025
dcff035
move user key id to separate datbase field
robert-oleynik Feb 20, 2025
898abda
fix authentication failures
robert-oleynik Feb 20, 2025
f68ab95
display and remove webauthn keys
robert-oleynik Feb 20, 2025
7ba0238
wrap blocking calls
robert-oleynik Feb 20, 2025
dc53bd0
increment schema version
robert-oleynik Feb 20, 2025
dcf027d
fix schema versioning
robert-oleynik Feb 20, 2025
a42a10d
fix schema field typo
robert-oleynik Feb 20, 2025
636de42
add reversion for database evolution
robert-oleynik Feb 20, 2025
5069e9f
fix compiler errors
robert-oleynik Feb 20, 2025
ff9fb7b
fix future box handling
robert-oleynik Feb 20, 2025
23a9591
fix frontend lints
robert-oleynik Feb 20, 2025
7592b13
fix api usage
robert-oleynik Feb 20, 2025
96cf3d6
add trailing ;
robert-oleynik Feb 20, 2025
ab95204
apply format to backend
robert-oleynik Feb 20, 2025
bce5a1d
fix uri
robert-oleynik Feb 20, 2025
e28d477
fix typecheck errors
robert-oleynik Feb 20, 2025
6838401
fixed frontend
robert-oleynik Feb 20, 2025
ce831fb
read origin from configuration
robert-oleynik Feb 25, 2025
dbccd1f
apply format
robert-oleynik Feb 25, 2025
e652f78
fix future exception handling
robert-oleynik Feb 25, 2025
3bdd978
rename PassKeys to Passkeys and add missing await
robert-oleynik Feb 25, 2025
f7734e4
merge manage passkeys and change password view
robert-oleynik Feb 26, 2025
c827ee5
fix frontend
robert-oleynik Feb 26, 2025
01198f5
catch WebAuthn exception on log in
robert-oleynik Feb 26, 2025
8a495fd
minor fixes
robert-oleynik Feb 26, 2025
d263bec
fix frontend
robert-oleynik Feb 26, 2025
69885d7
rework webAuthnRegistrationStart
robert-oleynik Mar 26, 2025
9987c58
add json format for WebAuthn types
robert-oleynik Mar 27, 2025
699ac9d
setup webauthn registration
robert-oleynik Mar 27, 2025
9157c18
Add webauthn4j
robert-oleynik Apr 9, 2025
e65a551
Merge branch 'master' into webauthn2
robert-oleynik Apr 10, 2025
8e4771e
Merge branch 'master' into webauthn2
robert-oleynik Apr 10, 2025
881df01
Merge branch 'webauthn2' of github.com:scalableminds/webknossos into …
robert-oleynik Apr 10, 2025
7b95eb9
Add WebAuthnPublicKeyCredentialRequestOptions
robert-oleynik Apr 10, 2025
a6ac15f
rework webauthn assertion
robert-oleynik Apr 15, 2025
5c53f6d
remove old webauthn dependencies
robert-oleynik Apr 15, 2025
6cd7b7f
Merge branch 'master' into webauthn2
robert-oleynik Apr 15, 2025
1c16b1a
Merge branch 'master' into webauthn2
robert-oleynik May 27, 2025
35db834
fix merge artifacts
robert-oleynik May 27, 2025
8814f2f
fix registration flow
robert-oleynik May 28, 2025
bf2fa71
move encoding to backend
robert-oleynik Jun 3, 2025
9034377
fix frontend
robert-oleynik Jun 3, 2025
da5c092
apply review comments
robert-oleynik Jun 4, 2025
4663fee
Merge branch 'master' into webauthn2
robert-oleynik Jun 4, 2025
50b830a
fix frontend
robert-oleynik Jun 4, 2025
1f1b0b3
apply some fixes
robert-oleynik Jun 4, 2025
2c32d15
format and lint backend
robert-oleynik Jun 4, 2025
fa78c96
use CollectionConverters
robert-oleynik Jun 4, 2025
48b5f83
apply format
robert-oleynik Jun 4, 2025
5dd64dc
fix application conf
robert-oleynik Jun 4, 2025
c449e89
add coderabbit suggestions
robert-oleynik Jun 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
- New FossilDB version `0.1.37` (`master__525:` on dockerhub) is required. [#8460](https://github.com/scalableminds/webknossos/pull/8460)

### Postgres Evolutions:

- [129-credit-transactions.sql](conf/evolutions/129-credit-transactions.sql)
- [130-replace-text-types.sql](conf/evolutions/130-replace-text-types.sql)
- [131-add-webauthn-credentials.sql](./conf/evolutions/131-add-webauthn-credentials.sql)
343 changes: 325 additions & 18 deletions app/controllers/AuthenticationController.scala

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions app/models/user/MultiUser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 139 additions & 0 deletions app/models/user/WebAuthnCredentials.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package models.user

import com.fasterxml.jackson.core.`type`.TypeReference
import com.fasterxml.jackson.annotation.{JsonCreator, JsonProperty, JsonTypeInfo}
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.webauthn4j.converter.AttestedCredentialDataConverter
import com.webauthn4j.converter.util.ObjectConverter
import com.webauthn4j.credential.CredentialRecordImpl
import com.webauthn4j.data.attestation.statement.AttestationStatement
import com.webauthn4j.data.extension.authenticator.{AuthenticationExtensionsAuthenticatorOutputs, RegistrationExtensionAuthenticatorOutput}
import net.liftweb.common.Box.tryo
import slick.lifted.Rep
import utils.sql.{SQLDAO, SqlClient}

import javax.inject.Inject
import scala.concurrent.ExecutionContext


case class WebAuthnCredential(
_id: ObjectId,
_multiUser: ObjectId,
name: String,
credentialRecord: CredentialRecordImpl,
isDeleted: Boolean,
) {
def serializeAttestationStatement(converter: ObjectConverter): Array[Byte] = {
AttestationStatementEnvelope(credentialRecord.getAttestationStatement).serialize(converter)
}

def serializeAttestedCredential(objectConverter: ObjectConverter): Array[Byte] = {
val converter = new AttestedCredentialDataConverter(objectConverter);
converter.convert(credentialRecord.getAttestedCredentialData)
}

def serializedExtensions(converter: ObjectConverter): Array[Byte] = {
converter.getCborConverter.writeValueAsBytes(credentialRecord.getAuthenticatorExtensions)
}
}

// Define the AttestationStatementEnvelope class
case class AttestationStatementEnvelope(@JsonProperty("attStmt") attestationStatement: AttestationStatement) {
// The JSON type information annotation for polymorphism
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
property = "fmt"
)
private val attStmt: AttestationStatement = attestationStatement

// Getter for the 'fmt' property
@JsonProperty("fmt")
def getFormat: String = attestationStatement.getFormat

// Getter for the AttestationStatement instance
def getAttestationStatement: AttestationStatement = attestationStatement

def serialize(converter: ObjectConverter): Array[Byte] =
converter.getJsonConverter.writeValueAsBytes(this)
}

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] = {
val objectConverter = new ObjectConverter()
val converter = objectConverter.getCborConverter
val attestedCredentialDataConverter = new AttestedCredentialDataConverter(objectConverter)
for {
envelope <- tryo(converter.readValue(r.serializedattestationstatement, new TypeReference[AttestationStatementEnvelope] {}))
attestationStatement = envelope.attestationStatement
attestedCredential <- tryo(attestedCredentialDataConverter.convert(r.serializedattestedcredential))
authenticatorExtensions <- tryo(converter.readValue(r.serializedextensions, new TypeReference[AuthenticationExtensionsAuthenticatorOutputs[RegistrationExtensionAuthenticatorOutput]] {}))
record = new CredentialRecordImpl(
attestationStatement,
null,
null,
null,
r.signaturecount.toLong,
attestedCredential,
authenticatorExtensions,
null,
null,
null
)
} yield WebAuthnCredential(ObjectId(r._Id), ObjectId(r._Multiuser), r.name, record, r.isdeleted)
}

def findAllForUser(multiUserId: ObjectId)(implicit ctx: DBAccessContext): Fox[List[WebAuthnCredential]] =
for {
accessQuery <- readAccessQuery
r <- run(
q"SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $multiUserId AND $accessQuery"
.as[WebauthncredentialsRow])
parsed <- parseAll(r)
} yield parsed

def findByCredentialId(multiUserId: ObjectId, credentialId: Array[Byte])(implicit ctx: DBAccessContext): Fox[WebAuthnCredential] =
for {
accessQuery <- readAccessQuery
r <- run(q"SELECT $columns FROM webknossos.webauthncredentials WHERE _multiUser = $multiUserId AND credentialId = $credentialId AND $accessQuery"
.as[WebauthncredentialsRow])
parsed <- parseFirst(r, multiUserId)
} yield parsed

def insertOne(c: WebAuthnCredential): Fox[Unit] = {
val converter = new ObjectConverter()
val serializedAttestationStatement = c.serializeAttestationStatement(converter)
val serializedAttestedCredential = c.serializeAttestedCredential(converter)
val serializedAuthenticatorExtensions = c.serializedExtensions(converter)
val credentialId = c.credentialRecord.getAttestedCredentialData.getCredentialId
for {
_ <- run(
q"""INSERT INTO webknossos.webauthncredentials(_id, _multiUser, credentialId, name, serializedAttestationStatement, serializedAttestedCredential, serializedExtensions, signatureCount)
VALUES(${c._id}, ${c._multiUser}, ${credentialId}, ${c.name}, ${serializedAttestationStatement}, ${serializedAttestedCredential},
${serializedAuthenticatorExtensions}, ${c.credentialRecord.getCounter.toInt})""".asUpdate)
} yield ()
}

def updateSignCount(c: WebAuthnCredential): Fox[Unit] = {
val signatureCount = c.credentialRecord.getCounter
for {
_ <- run(q"""UPDATE webknossos.webauthncredentials SET signatureCount = $signatureCount WHERE _id = ${c._id}""".asUpdate)
} yield ()
}

def removeById(id: ObjectId, multiUser: ObjectId): Fox[Unit] =
for {
_ <- run(q"""DELETE FROM webknossos.webauthncredentials WHERE _id = ${id} AND _multiUser=${multiUser}""".asUpdate)
} yield ()

}
3 changes: 3 additions & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ play {
timeout.idle = 2 hours
timeout.connection = 2 hours
}
context {
blocking = "pekko.actor.default-dispatcher"
}
}

pekko.actor.default-dispatcher {
Expand Down
23 changes: 23 additions & 0 deletions conf/evolutions/131-add-webauthn-credentials.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
START TRANSACTION;

do $$ begin ASSERT (select schemaVersion from webknossos.releaseInformation) = 130, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;

CREATE TABLE webknossos.webauthnCredentials(
_id TEXT PRIMARY KEY,
_multiUser CHAR(24) NOT NULL,
credentialId BYTEA NOT NULL,
name TEXT NOT NULL,
serializedAttestationStatement BYTEA NOT NULL,
serializedAttestedCredential BYTEA NOT NULL,
serializedExtensions BYTEA NOT NULL,
signatureCount INTEGER NOT NULL,
isDeleted BOOLEAN NOT NULL DEFAULT false,
UNIQUE (_id, credentialId)
);

ALTER TABLE webknossos.webauthnCredentials
ADD FOREIGN KEY (_multiUser) REFERENCES webknossos.multiUsers(_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE;

UPDATE webknossos.releaseInformation SET schemaVersion = 131;

COMMIT TRANSACTION;
10 changes: 10 additions & 0 deletions conf/evolutions/reversions/131-add-webauthn-credentials.sql
Original file line number Diff line number Diff line change
@@ -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) = 131, 'Previous schema version mismatch'; end; $$ LANGUAGE plpgsql;

DROP TABLE webknossos.webauthnCredentials;

UPDATE webknossos.releaseInformation SET schemaVersion = 130;

COMMIT TRANSACTION;
9 changes: 9 additions & 0 deletions conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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()
Expand Down
42 changes: 42 additions & 0 deletions frontend/javascripts/admin/admin_rest_api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<any> {
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<Array<WebAuthnKeyDescriptor>> {
return await Request.receiveJSON("/api/auth/webauthn/keys");
}

export async function removeWebAuthnKey(key: WebAuthnKeyDescriptor): Promise<any> {
return await Request.sendJSONReceiveArraybuffer("/api/auth/webauthn/keys", {
method: "DELETE",
data: key,
});
}

export async function getUsers(): Promise<Array<APIUser>> {
const users = await Request.receiveJSON("/api/users");
assertResponseLimit(users);
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh thanks for the renaming / fixing the file type ending 🙏

File renamed without changes.
Loading