feat(mfa): passkey verification as a second factor

This commit is contained in:
2026-09-16 08:48:39 +00:00
parent 1445af11ab
commit 14a1cdb2b0
6 changed files with 428 additions and 3 deletions
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"encoding/hex"
"errors"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var ErrNoPasskey = errors.New("no such passkey")
func ListPasskeys(instanceID, userID string) ([]models.WebAuthnCredential, error) {
ctx, cancel := mfaCtx()
defer cancel()
cur, err := db.Col("webauthn_credentials").Find(ctx,
bson.M{"instance_id": instanceID, "user_id": userID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
out := []models.WebAuthnCredential{}
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// GetPasskeyByCredentialID resolves a credential inside one instance. The
// instance scope is not optional: an unscoped lookup would let a credential
// registered on one tenant assert on another.
func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuthnCredential, error) {
ctx, cancel := mfaCtx()
defer cancel()
var c models.WebAuthnCredential
err := db.Col("webauthn_credentials").FindOne(ctx,
bson.M{"instance_id": instanceID, "credential_id": credID}).Decode(&c)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNoPasskey
}
if err != nil {
return nil, err
}
return &c, nil
}
func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string) error {
ctx, cancel := mfaCtx()
defer cancel()
if name == "" {
name = "Passkey"
}
_, err := db.Col("webauthn_credentials").InsertOne(ctx, models.WebAuthnCredential{
InstanceID: instanceID,
UserID: userID,
CredentialID: credID,
CredentialIDHex: hex.EncodeToString(credID),
PublicKey: publicKey,
AAGUID: aaguid,
SignCount: signCount,
Transports: transports,
Name: name,
CreatedAt: time.Now(),
})
return err
}
// TouchPasskey records use and the new signature counter. A counter that fails
// to advance can mean a cloned authenticator, so the caller checks it before
// calling this.
func TouchPasskey(instanceID string, credID []byte, signCount uint32) error {
ctx, cancel := mfaCtx()
defer cancel()
now := time.Now()
_, err := db.Col("webauthn_credentials").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "credential_id": credID},
bson.M{"$set": bson.M{"sign_count": signCount, "last_used_at": now}})
return err
}
func RenamePasskey(instanceID, userID, credIDHex, name string) error {
ctx, cancel := mfaCtx()
defer cancel()
res, err := db.Col("webauthn_credentials").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex},
bson.M{"$set": bson.M{"name": name}})
if err != nil {
return err
}
if res.MatchedCount == 0 {
return ErrNoPasskey
}
return nil
}
func DeletePasskey(instanceID, userID, credIDHex string) error {
ctx, cancel := mfaCtx()
defer cancel()
res, err := db.Col("webauthn_credentials").DeleteOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex})
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNoPasskey
}
return nil
}