From f87626cf1769c468d0659dabd00a295015c44821 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 16 Sep 2026 09:08:24 +0000 Subject: [PATCH] feat(mfa): account MFA management, passkey registration and step-up --- server/internal/api/handlers.go | 14 + server/internal/api/mfa.go | 294 ++++++++++++++++++++ server/internal/auth/mfa_enrol.go | 121 ++++++++ server/internal/auth/stepup.go | 12 + server/internal/auth/webauthn.go | 117 ++++++++ server/internal/services/mfa.go | 51 ++++ server/internal/services/mfa_policy_test.go | 31 +++ 7 files changed, 640 insertions(+) create mode 100644 server/internal/api/mfa.go create mode 100644 server/internal/services/mfa_policy_test.go diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 92400c2..2f95b9b 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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) diff --git a/server/internal/api/mfa.go b/server/internal/api/mfa.go new file mode 100644 index 0000000..e6aaf14 --- /dev/null +++ b/server/internal/api/mfa.go @@ -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}) +} diff --git a/server/internal/auth/mfa_enrol.go b/server/internal/auth/mfa_enrol.go index 3fac4fc..d009a28 100644 --- a/server/internal/auth/mfa_enrol.go +++ b/server/internal/auth/mfa_enrol.go @@ -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) +} diff --git a/server/internal/auth/stepup.go b/server/internal/auth/stepup.go index 9fd6a31..ce4765e 100644 --- a/server/internal/auth/stepup.go +++ b/server/internal/auth/stepup.go @@ -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) +} diff --git a/server/internal/auth/webauthn.go b/server/internal/auth/webauthn.go index 34a1c63..68e6977 100644 --- a/server/internal/auth/webauthn.go +++ b/server/internal/auth/webauthn.go @@ -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. diff --git a/server/internal/services/mfa.go b/server/internal/services/mfa.go index 2b3ac54..db5e540 100644 --- a/server/internal/services/mfa.go +++ b/server/internal/services/mfa.go @@ -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 { diff --git a/server/internal/services/mfa_policy_test.go b/server/internal/services/mfa_policy_test.go new file mode 100644 index 0000000..0a502d5 --- /dev/null +++ b/server/internal/services/mfa_policy_test.go @@ -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) + } + }) + } +}