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
+14
View File
@@ -51,6 +51,8 @@ func RegisterRoutes(r *gin.Engine) {
r.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
r.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin)
r.POST("/auth/mfa/enrol/passkey/finish", auth.HandleEnrolPasskeyFinish)
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/:providerId/start", auth.HandleSSOStart)
@@ -99,6 +101,18 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/tokens", createToken)
apiGroup.DELETE("/tokens/:id", revokeToken)
apiGroup.GET("/me/mfa", getMyMFA)
apiGroup.POST("/me/mfa/totp/setup", auth.RequireStepUp(), setupTOTP)
apiGroup.POST("/me/mfa/totp/confirm", confirmTOTP)
apiGroup.DELETE("/me/mfa/totp", auth.RequireStepUp(), removeTOTP)
apiGroup.POST("/me/mfa/recovery/regenerate", auth.RequireStepUp(), regenerateRecoveryCodes)
apiGroup.POST("/me/passkeys/begin", auth.RequireStepUp(), auth.HandleRegisterPasskeyBegin)
apiGroup.POST("/me/passkeys/finish", auth.RequireStepUp(), auth.HandleRegisterPasskeyFinish)
apiGroup.PATCH("/me/passkeys/:id", renamePasskey)
apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey)
apiGroup.POST("/me/step-up", stepUp)
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
apiGroup.GET("/openapi.json", getOpenAPI)
apiGroup.GET("/docs", getAPIDocs)
apiGroup.GET("/docs/scalar.js", getScalarJS)
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"errors"
"net/http"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// oidcUser refuses MFA management for a user whose IdP owns authentication.
func oidcUser(c *gin.Context) bool {
u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c))
return err == nil && u.AuthSource == models.AuthOIDC
}
// getMyMFA reports this user's factors.
//
// @Summary Get my MFA status
// @Tags mfa
// @Produce json
// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool}
// @Router /me/mfa [get]
func getMyMFA(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
m, err := services.GetUserMFA(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
passkeys, err := services.ListPasskeys(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"totp_enabled": m != nil && m.TOTPConfirmedAt != nil,
"passkeys": passkeys,
"recovery_remaining": services.RecoveryCodesRemaining(m),
"require_mfa": services.RequireMFAForInstance(instanceID),
"applicable": !oidcUser(c),
})
}
// setupTOTP issues a new unconfirmed secret.
//
// @Summary Start TOTP setup
// @Tags mfa
// @Produce json
// @Success 200 {object} object{secret=string,otpauth_uri=string}
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/mfa/totp/setup [post]
func setupTOTP(c *gin.Context) {
if oidcUser(c) {
c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"})
return
}
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
issuer := "Vantage"
if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" {
issuer = inst.Name
}
secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
}
// confirmTOTP activates the pending secret and issues recovery codes if this
// is the user's first factor.
//
// @Summary Confirm TOTP setup
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{code=string} true "Six-digit code"
// @Success 200 {object} object{ok=bool,recovery_codes=[]string}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/mfa/totp/confirm [post]
func confirmTOTP(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
var body struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code required"})
return
}
if err := services.ConfirmTOTP(instanceID, userID, body.Code); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"})
return
}
services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp")
m, _ := services.GetUserMFA(instanceID, userID)
if services.RecoveryCodesRemaining(m) > 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
codes, err := services.IssueRecoveryCodes(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes})
}
// removeTOTP drops the TOTP factor. Step-up guarded at the route.
//
// @Summary Remove TOTP
// @Tags mfa
// @Produce json
// @Success 204
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/mfa/totp [delete]
func removeTOTP(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
return
}
if err := services.RemoveTOTP(instanceID, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp")
c.Status(http.StatusNoContent)
}
// regenerateRecoveryCodes invalidates the old set. Step-up guarded.
//
// @Summary Regenerate recovery codes
// @Tags mfa
// @Produce json
// @Success 200 {object} object{recovery_codes=[]string}
// @Router /me/mfa/recovery/regenerate [post]
func regenerateRecoveryCodes(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
codes, err := services.IssueRecoveryCodes(instanceID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "")
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
}
// renamePasskey and deletePasskey work on the hex credential ID.
//
// @Summary Rename a passkey
// @Tags mfa
// @Accept json
// @Produce json
// @Param id path string true "Credential ID"
// @Param body body object{name=string} true "New name"
// @Success 204
// @Router /me/passkeys/{id} [patch]
func renamePasskey(c *gin.Context) {
var body struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name)
if errors.Is(err, services.ErrNoPasskey) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
// @Summary Delete a passkey
// @Tags mfa
// @Produce json
// @Param id path string true "Credential ID"
// @Success 204
// @Failure 409 {object} object{error=string,code=string}
// @Router /me/passkeys/{id} [delete]
func deletePasskey(c *gin.Context) {
instanceID, userID := auth.InstanceID(c), auth.UserID(c)
if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"})
return
}
err := services.DeletePasskey(instanceID, userID, c.Param("id"))
if errors.Is(err, services.ErrNoPasskey) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn")
c.Status(http.StatusNoContent)
}
// resetUserMFA lets an owner or admin clear somebody else's factors.
//
// @Summary Reset another member's MFA
// @Tags mfa
// @Produce json
// @Param id path string true "User ID"
// @Success 204
// @Failure 403 {object} object{error=string}
// @Router /org/users/{id}/mfa [delete]
func resetUserMFA(c *gin.Context) {
instanceID := auth.InstanceID(c)
target, err := services.GetUserInInstance(instanceID, c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no such member"})
return
}
// An admin resetting an owner's MFA would be a promotion path: clear the
// factor, phish the password, hold the instance.
if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"})
return
}
if err := services.ClearMFA(instanceID, target.UserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email)
c.Status(http.StatusNoContent)
}
// stepUp re-authenticates the current session.
//
// @Summary Re-authenticate before a sensitive action
// @Tags mfa
// @Accept json
// @Produce json
// @Param body body object{totp=string,recovery=string,password=string} true "One factor"
// @Success 200 {object} object{ok=bool}
// @Failure 401 {object} object{error=string,code=string}
// @Router /me/step-up [post]
func stepUp(c *gin.Context) {
sess := auth.GetSessionFromContext(c)
var body struct {
TOTP string `json:"totp"`
Recovery string `json:"recovery"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
return
}
var err error
switch {
case body.TOTP != "":
err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP)
case body.Recovery != "":
err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery)
case body.Password != "":
// Password is offered only to a user with no MFA at all; accepting it
// from an enrolled user would demote step-up to what they already did.
has, herr := services.HasMFA(sess.InstanceID, sess.UserID)
if herr != nil || has {
err = services.ErrBadCode
} else {
u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID)
if uerr != nil || !services.VerifyPassword(u, body.Password) {
err = services.ErrBadCode
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"})
return
}
if err != nil {
services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"})
return
}
if err := auth.TouchStepUpFromRequest(c); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"})
return
}
services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "")
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+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.
+51
View File
@@ -377,6 +377,57 @@ func IssueRecoveryCodes(instanceID, userID string) ([]string, error) {
return plain, nil
}
var ErrMFARequiredByPolicy = errors.New("this instance requires multi-factor authentication; add another factor before removing this one")
// canRemoveFactor is pure so the rule is testable without a database.
// removing is FactorTOTP or FactorWebAuthn, and for a passkey it means one of
// the counted passkeys.
func canRemoveFactor(requireMFA, hasTOTP bool, passkeys int, removing string) error {
if !requireMFA {
return nil
}
remaining := 0
if hasTOTP && removing != FactorTOTP {
remaining++
}
switch removing {
case FactorWebAuthn:
remaining += passkeys - 1
default:
remaining += passkeys
}
if remaining > 0 {
return nil
}
return ErrMFARequiredByPolicy
}
// CheckCanRemoveFactor reads the current state and applies the rule.
func CheckCanRemoveFactor(instanceID, userID, removing string) error {
m, err := GetUserMFA(instanceID, userID)
if err != nil {
return err
}
passkeys, err := CountPasskeys(instanceID, userID)
if err != nil {
return err
}
return canRemoveFactor(RequireMFAForInstance(instanceID), m != nil && m.TOTPConfirmedAt != nil, int(passkeys), removing)
}
// RemoveTOTP clears only the TOTP factor, leaving passkeys and recovery codes.
func RemoveTOTP(instanceID, userID string) error {
ctx, cancel := mfaCtx()
defer cancel()
_, err := db.Col("user_mfa").UpdateOne(ctx,
bson.M{"instance_id": instanceID, "user_id": userID},
bson.M{
"$unset": bson.M{"totp_secret_enc": "", "totp_confirmed_at": "", "totp_pending_enc": ""},
"$set": bson.M{"updated_at": time.Now()},
})
return err
}
// RecoveryCodesRemaining counts unused codes for the account page.
func RecoveryCodesRemaining(m *models.UserMFA) int {
if m == nil {
@@ -0,0 +1,31 @@
package services
import "testing"
// Removing a factor under an MFA policy must leave at least one behind, or the
// user locks themselves out of an instance that will then demand enrolment
// they cannot complete without signing in.
func TestCanRemoveFactor(t *testing.T) {
cases := []struct {
name string
requireMFA bool
totp bool
passkeys int
removing string
wantRefusal bool
}{
{"policy off, last factor", false, true, 0, FactorTOTP, false},
{"policy on, totp plus passkey, drop totp", true, true, 1, FactorTOTP, false},
{"policy on, last totp", true, true, 0, FactorTOTP, true},
{"policy on, last passkey", true, false, 1, FactorWebAuthn, true},
{"policy on, two passkeys, drop one", true, false, 2, FactorWebAuthn, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := canRemoveFactor(tc.requireMFA, tc.totp, tc.passkeys, tc.removing)
if tc.wantRefusal != (err != nil) {
t.Fatalf("canRemoveFactor refusal = %v, want %v", err != nil, tc.wantRefusal)
}
})
}
}