feat(mfa): account MFA management, passkey registration and step-up

This commit is contained in:
2026-09-16 09:08:24 +00:00
parent bd0639acfa
commit f87626cf17
7 changed files with 640 additions and 0 deletions
+121
View File
@@ -1,10 +1,14 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"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"
)
// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA
@@ -91,3 +95,120 @@ func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) {
services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor)
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
// HandleEnrolPasskeyBegin starts forced passkey enrolment during sign-in.
// Identical to HandleRegisterPasskeyBegin except the user comes from the
// enrol-only ticket rather than a session, since none exists yet.
//
// @Summary Begin forced passkey enrolment 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/enrol/passkey/begin [post]
func HandleEnrolPasskeyBegin(c *gin.Context) {
t, _, ok := ticketFromRequest(c, scopeEnrol)
if !ok {
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
existing, err := services.ListPasskeys(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
lib := make([]webauthn.Credential, 0, len(existing))
for _, cr := range existing {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
options, sessionData, err := w.BeginRegistration(
waUser{handle: handle, name: t.Email, credentials: lib},
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleEnrolPasskeyFinish stores the new credential and finishes forced
// enrolment, minting the session that HandleEnrolTOTPConfirm also produces.
//
// @Summary Complete forced passkey enrolment and sign in
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /auth/mfa/enrol/passkey/finish [post]
func HandleEnrolPasskeyFinish(c *gin.Context) {
t, ticketID, ok := ticketFromRequest(c, scopeEnrol)
if !ok {
return
}
var body struct {
CeremonyID string `json:"ceremony_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
return
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that enrolment expired", "code": "mfa_ticket_expired"})
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
return
}
handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish enrolment"})
return
}
cred, err := w.CreateCredential(waUser{handle: handle, name: t.Email}, *sessionData, parsed)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
if !cred.Flags.UserVerified {
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, tr := range parsed.Response.Transports {
transports = append(transports, string(tr))
}
if err := services.SavePasskey(t.InstanceID, t.UserID, body.Name,
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
transports); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
return
}
finishEnrolment(c, t, ticketID, services.FactorWebAuthn)
}
+12
View File
@@ -56,3 +56,15 @@ func RequireStepUp() gin.HandlerFunc {
})
}
}
// TouchStepUpFromRequest records a fresh step-up against the session the
// request arrived on. It lives in auth because the cookie name and the Redis
// key are this package's business.
func TouchStepUpFromRequest(c *gin.Context) error {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
return err
}
sess := GetSessionFromContext(c)
return TouchStepUp(c.Request.Context(), cookie.Value, sess)
}
+117
View File
@@ -200,6 +200,123 @@ func HandleMFAWebAuthnFinish(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// HandleRegisterPasskeyBegin starts registration for the signed-in user.
//
// @Summary Begin passkey registration
// @Tags mfa
// @Produce json
// @Success 200 {object} object{publicKey=object,ceremony_id=string}
// @Router /me/passkeys/begin [post]
func HandleRegisterPasskeyBegin(c *gin.Context) {
sess := GetSessionFromContext(c)
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
lib := make([]webauthn.Credential, 0, len(existing))
for _, cr := range existing {
lib = append(lib, toLibCredential(cr))
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
options, sessionData, err := w.BeginRegistration(
waUser{handle: handle, name: sess.Email, credentials: lib},
webauthn.WithExclusions(webauthn.Credentials(lib).CredentialDescriptors()),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
id, err := saveCeremony(c.Request.Context(), sessionData)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"})
return
}
c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id})
}
// HandleRegisterPasskeyFinish stores the new credential.
//
// @Summary Complete passkey registration
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Router /me/passkeys/finish [post]
func HandleRegisterPasskeyFinish(c *gin.Context) {
sess := GetSessionFromContext(c)
var body struct {
CeremonyID string `json:"ceremony_id"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"})
return
}
sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"})
return
}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"})
return
}
handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
return
}
w, err := webAuthnFor(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"})
return
}
cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"})
return
}
if !cred.Flags.UserVerified {
c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"})
return
}
transports := make([]string, 0, len(parsed.Response.Transports))
for _, t := range parsed.Response.Transports {
transports = append(transports, string(t))
}
if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name,
cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount,
transports); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"})
return
}
services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn")
// A first factor earns recovery codes; later ones do not reissue them.
m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID)
if services.RecoveryCodesRemaining(m) == 0 {
codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID)
if err == nil {
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
return
}
}
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.