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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user