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
+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)
}
})
}
}