diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 2f95b9b..1854c57 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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}) } diff --git a/server/internal/api/instance.go b/server/internal/api/instance.go index 24dc3a5..1ef0523 100644 --- a/server/internal/api/instance.go +++ b/server/internal/api/instance.go @@ -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 { diff --git a/server/internal/api/ratelimit_auth.go b/server/internal/api/ratelimit_auth.go new file mode 100644 index 0000000..aaeef7f --- /dev/null +++ b/server/internal/api/ratelimit_auth.go @@ -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() + } +} diff --git a/server/internal/services/mfa.go b/server/internal/services/mfa.go index db5e540..b0a42d8 100644 --- a/server/internal/services/mfa.go +++ b/server/internal/services/mfa.go @@ -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) { diff --git a/server/internal/services/settings.go b/server/internal/services/settings.go index 2747608..0436955 100644 --- a/server/internal/services/settings.go +++ b/server/internal/services/settings.go @@ -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 }