feat(mfa): passkey verification as a second factor
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user