feat(mfa): require_mfa policy, sign-in rate limit and MFA column
This commit is contained in:
@@ -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,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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user