feat(mfa): require_mfa policy, sign-in rate limit and MFA column

This commit is contained in:
2026-09-16 09:14:16 +00:00
parent f87626cf17
commit d8597ee3ae
5 changed files with 136 additions and 15 deletions
+32 -13
View File
@@ -42,17 +42,25 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
r.POST("/auth/bootstrap", auth.HandleBootstrap)
r.POST("/auth/login", auth.HandleLocalLogin)
r.POST("/auth/mfa/totp", auth.HandleMFATOTP)
r.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
r.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
r.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
r.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin)
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)
// Every unauthenticated sign-in and enrolment step lives behind
// RateLimitAuth: without it, the five-attempt cap on a single ticket is
// trivially sidestepped by starting a fresh sign-in each time.
authGroup := r.Group("", RateLimitAuth())
{
authGroup.POST("/auth/login", auth.HandleLocalLogin)
authGroup.POST("/auth/mfa/totp", auth.HandleMFATOTP)
authGroup.POST("/auth/mfa/recovery", auth.HandleMFARecovery)
authGroup.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin)
authGroup.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish)
authGroup.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin)
authGroup.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish)
authGroup.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup)
authGroup.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm)
authGroup.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin)
authGroup.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)
@@ -110,7 +118,7 @@ func RegisterRoutes(r *gin.Engine) {
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.POST("/me/step-up", RateLimitAuth(), stepUp)
apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA)
apiGroup.GET("/openapi.json", getOpenAPI)
@@ -948,6 +956,7 @@ func saveSettings(c *gin.Context) {
Alerts models.AlertSettings `json:"alerts"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
LocalLoginEnabled *bool `json:"local_login_enabled"`
RequireMFA *bool `json:"require_mfa"`
APITokenMaxDays *int `json:"api_token_max_days"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -958,7 +967,13 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_token_max_days cannot be negative"})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.APITokenMaxDays); err != nil {
// The MFA requirement gates every future sign-in, so only an owner may
// change it; an admin can still save the rest of this endpoint's settings.
if body.RequireMFA != nil && auth.Role(c) != models.RoleOwner {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change the MFA requirement"})
return
}
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.WorkflowLogRetentionDays, body.LocalLoginEnabled, body.RequireMFA, body.APITokenMaxDays); err != nil {
if errors.Is(err, services.ErrLockout) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "local_login_required"})
return
@@ -971,6 +986,10 @@ func saveSettings(c *gin.Context) {
services.LogEvent(auth.InstanceID(c), "settings.token_policy_updated", actorFromCtx(c), "", "",
fmt.Sprintf("API token maximum lifetime set to %d day(s); 0 means no cap", *body.APITokenMaxDays))
}
if body.RequireMFA != nil {
services.LogEvent(auth.InstanceID(c), "settings.require_mfa", actorFromCtx(c), "", "",
fmt.Sprintf("enabled=%v", *body.RequireMFA))
}
c.JSON(http.StatusOK, SavedResponse{Saved: true})
}
+20 -1
View File
@@ -20,13 +20,32 @@ import (
// @Security cookieAuth
// @Security bearerAuth
// @Router /instance/users [get]
// instanceUserResponse wraps a member with whether they hold an MFA factor,
// for the settings page's member column and reset action. A wrapper rather
// than a field on models.User because User is shared with Vantage HQ.
type instanceUserResponse struct {
models.User `bson:",inline"`
MFAEnabled bool `json:"mfa_enabled"`
}
func listInstanceUsers(c *gin.Context) {
users, err := services.ListUsers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
// One aggregate over two small collections beats N round trips for a
// member list that renders on every settings page load.
enabled, err := services.UsersWithMFA(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]instanceUserResponse, 0, len(users))
for _, u := range users {
out = append(out, instanceUserResponse{User: u, MFAEnabled: enabled[u.UserID]})
}
c.JSON(http.StatusOK, out)
}
func actorMayGrantOwner(c *gin.Context) bool {
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"fmt"
"net/http"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// authRateLimit is per client address per minute. It bounds how many tickets an
// attacker can start; the ticket's own five-attempt cap bounds guesses inside
// one. Neither alone is enough.
const authRateLimit = 20
// RateLimitAuth guards every unauthenticated sign-in endpoint. Without it, the
// per-ticket cap is trivially sidestepped by starting a new sign-in each time.
func RateLimitAuth() gin.HandlerFunc {
return func(c *gin.Context) {
rdb := services.RedisClient
if rdb == nil {
c.Next()
return
}
window := time.Now().Unix() / 60
key := fmt.Sprintf("km:rl:auth:%s:%d", c.ClientIP(), window)
ctx := c.Request.Context()
n, err := rdb.Incr(ctx, key).Result()
if err != nil {
// A limiter that cannot reach Redis must not lock out sign-in: fail
// open rather than turn a Redis blip into a second outage.
c.Next()
return
}
if n == 1 {
rdb.Expire(ctx, key, time.Minute)
}
if n > authRateLimit {
c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "too many sign-in attempts; try again in a minute",
})
return
}
c.Next()
}
}
+32
View File
@@ -47,6 +47,38 @@ func mfaCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// UsersWithMFA returns the set of user IDs in this instance holding a factor.
// One query per collection, not one per member, so the member list stays
// cheap however many users an instance has.
func UsersWithMFA(instanceID string) (map[string]bool, error) {
ctx, cancel := mfaCtx()
defer cancel()
out := map[string]bool{}
cur, err := db.Col("user_mfa").Find(ctx,
bson.M{"instance_id": instanceID, "totp_confirmed_at": bson.M{"$exists": true}})
if err != nil {
return nil, err
}
var rows []models.UserMFA
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
for _, r := range rows {
out[r.UserID] = true
}
var userIDs []string
if err := db.Col("webauthn_credentials").Distinct(ctx, "user_id",
bson.M{"instance_id": instanceID}).Decode(&userIDs); err != nil {
return nil, err
}
for _, id := range userIDs {
out[id] = true
}
return out, nil
}
// GenerateRecoveryCodes returns the codes to show the user once, and the
// hashed records to store. The plaintext is never persisted.
func GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error) {
+4 -1
View File
@@ -119,7 +119,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
return s.InstanceID, true
}
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, apiTokenMaxDays *int) error {
func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays *int, localLoginEnabled *bool, requireMFA *bool, apiTokenMaxDays *int) error {
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
@@ -149,6 +149,9 @@ func SaveSettings(instanceID string, alerts models.AlertSettings, retentionDays
if localLoginEnabled != nil {
set["local_login_enabled"] = *localLoginEnabled
}
if requireMFA != nil {
set["require_mfa"] = *requireMFA
}
if apiTokenMaxDays != nil {
set["api_token_max_days"] = *apiTokenMaxDays
}