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