feat(mfa): account MFA management, passkey registration and step-up
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user