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
+2
View File
@@ -45,6 +45,8 @@ func RegisterRoutes(r *gin.Engine) {
r.POST("/auth/login", auth.HandleLocalLogin)
r.POST("/auth/mfa/totp", auth.HandleMFATOTP)
r.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
r.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
r.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
r.POST("/auth/logout", auth.HandleLogout)
+251
View File
@@ -0,0 +1,251 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/redis/go-redis/v9"
)
const (
ceremonyPrefix = "km:wa:"
ceremonyTTL = 5 * time.Minute
)
// rpConfig derives the relying party from the request. The RP ID is the host
// without its port - WebAuthn forbids a port there - while the origin keeps it.
//
// This is why the reverse proxy must preserve Host: a proxy rewriting it makes
// every passkey on the instance fail to verify, with no error that says so.
func rpConfig(c *gin.Context) (string, string) {
host := c.Request.Host
rpID := host
if h, _, err := net.SplitHostPort(host); err == nil {
rpID = h
}
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
// Only development is ever plain HTTP; WebAuthn permits it on localhost.
scheme = "http"
}
return rpID, scheme + "://" + host
}
func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) {
rpID, origin := rpConfig(c)
return webauthn.New(&webauthn.Config{
RPDisplayName: "Vantage",
RPID: rpID,
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
}
// waUser adapts our records to the library's interface. The handle is random
// and per-user: a resident credential hands its user handle to any origin that
// asks, so the user ID must not be it.
type waUser struct {
handle []byte
name string
credentials []webauthn.Credential
}
func (u waUser) WebAuthnID() []byte { return u.handle }
func (u waUser) WebAuthnName() string { return u.name }
func (u waUser) WebAuthnDisplayName() string { return u.name }
func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func toLibCredential(c models.WebAuthnCredential) webauthn.Credential {
return webauthn.Credential{
ID: c.CredentialID,
PublicKey: c.PublicKey,
AttestationType: "none",
Authenticator: webauthn.Authenticator{
AAGUID: c.AAGUID,
SignCount: c.SignCount,
},
}
}
func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) {
id, err := randomHex(32)
if err != nil {
return "", err
}
blob, err := json.Marshal(data)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil {
return "", err
}
return id, nil
}
// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so
// it is deleted as it is read.
func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) {
blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrTicketExpired
}
if err != nil {
return nil, err
}
var data webauthn.SessionData
if err := json.Unmarshal(blob, &data); err != nil {
return nil, ErrTicketExpired
}
return &data, nil
}
// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in.
//
// @Summary Begin passkey verification during sign-in
// @Tags auth
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/webauthn/begin [post]
func HandleMFAWebAuthnBegin(c *gin.Context) {
t, _, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
creds, err := services.ListPasskeys(t.InstanceID, t.UserID)
if err != nil || len(creds) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"})
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
lib := make([]webauthn.Credential, 0, len(creds))
for _, cr := range creds {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleMFAWebAuthnFinish verifies the assertion and signs the user in.
//
// @Summary Complete sign-in with a passkey
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,credential=object} true "Assertion"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/webauthn/finish [post]
func HandleMFAWebAuthnFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeVerify)
if !ok {
return
}
cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email)
if err != nil {
left, ferr := FailTicket(c.Request.Context(), ticketID)
services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn")
if ferr != nil || left == 0 {
abortTicketExpired(c)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left,
})
return
}
_ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount)
u, err := services.GetUserInInstance(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = DeleteTicket(c.Request.Context(), ticketID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// finishAssertion is shared by second-factor sign-in, passwordless sign-in and
// step-up, so the verification rules (user verification, clone detection,
// instance scope) exist once.
func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) {
var body struct {
CeremonyID string `json:"ceremony_id"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
return nil, errors.New("assertion required")
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
return nil, err
}
parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential))
if err != nil {
return nil, err
}
stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID)
if err != nil {
return nil, err
}
if userID != "" && stored.UserID != userID {
return nil, errors.New("credential belongs to another user")
}
handle, err := services.WebAuthnHandle(instanceID, stored.UserID)
if err != nil {
return nil, err
}
w, err := webAuthnFor(c)
if err != nil {
return nil, err
}
user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}}
cred, err := w.ValidateLogin(user, *sessionData, parsed)
if err != nil {
return nil, err
}
if !cred.Flags.UserVerified {
return nil, errors.New("user verification was not performed")
}
// A counter that fails to advance is the library's clone signal. Zero on
// both sides means the authenticator does not keep one, which is normal.
if cred.Authenticator.CloneWarning {
return nil, errors.New("authenticator may be cloned")
}
return cred, nil
}
+36
View File
@@ -0,0 +1,36 @@
package auth
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it
// silently makes every existing passkey unusable - so the port-stripping and
// scheme rules are pinned here.
func TestRPConfig(t *testing.T) {
cases := []struct {
name, host, proto string
wantID, wantOrig string
}{
{"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"},
{"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"},
{"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil)
c.Request.Host = tc.host
if tc.proto != "" {
c.Request.Header.Set("X-Forwarded-Proto", tc.proto)
}
id, origin := rpConfig(c)
if id != tc.wantID || origin != tc.wantOrig {
t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig)
}
})
}
}
+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
}