diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 96bd1fa..92400c2 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -115,19 +115,19 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.POST("/secrets", createSecretGroup) apiGroup.GET("/secrets/:group", getSecretGroup) apiGroup.PUT("/secrets/:group", putSecretGroup) - apiGroup.POST("/secrets/:group/reveal", revealSecret) + apiGroup.POST("/secrets/:group/reveal", auth.RequireStepUp(), revealSecret) apiGroup.DELETE("/secrets/:group", deleteSecretGroup) apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey) apiGroup.GET("/keys", listKeys) apiGroup.POST("/keys", createKey) apiGroup.GET("/keys/:id", getKey) - apiGroup.GET("/keys/:id/private-key", getPrivateKey) + apiGroup.GET("/keys/:id/private-key", auth.RequireStepUp(), getPrivateKey) apiGroup.DELETE("/keys/:id", deleteKey) apiGroup.POST("/keys/:id/assign", assignKey) apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment) - apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect) + apiGroup.POST("/console/connect", auth.RequireStepUp(), RequireFeature("console"), consoleConnect) apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel) // MCP is mounted inside /api so that bearer auth, rate limiting, licence diff --git a/server/internal/auth/stepup.go b/server/internal/auth/stepup.go new file mode 100644 index 0000000..e57a8ff --- /dev/null +++ b/server/internal/auth/stepup.go @@ -0,0 +1,54 @@ +package auth + +import ( + "net/http" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// StepUpWindow is how long one re-authentication covers. Ten minutes is long +// enough to open several consoles in a row and short enough that a walked-away +// laptop is not a fleet-wide credential. +const StepUpWindow = 10 * time.Minute + +func stepUpFresh(sess *Session, now time.Time) bool { + if sess == nil { + return false + } + // An API token authenticates per request and has no human to prompt. + if sess.TokenID != "" { + return true + } + for _, a := range sess.AMR { + if a == "oidc" { + return true + } + } + return sess.StepUpAt != nil && now.Sub(*sess.StepUpAt) < StepUpWindow +} + +// RequireStepUp guards the actions that hand out credentials rather than +// describe them: secret reveal, private key download, console connect. +// +// It answers a machine-readable code rather than a bare 403 so web/ can open +// the re-authentication modal and retry the original request. +func RequireStepUp() gin.HandlerFunc { + return func(c *gin.Context) { + sess := GetSessionFromContext(c) + if stepUpFresh(sess, time.Now()) { + c.Next() + return + } + methods, err := services.MFAMethods(sess.InstanceID, sess.UserID) + if err != nil || len(methods) == 0 { + methods = []string{services.FactorPassword} + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "re-authentication required", + "code": "step_up_required", + "methods": methods, + }) + } +} diff --git a/server/internal/auth/stepup_test.go b/server/internal/auth/stepup_test.go new file mode 100644 index 0000000..aa60d9d --- /dev/null +++ b/server/internal/auth/stepup_test.go @@ -0,0 +1,34 @@ +package auth + +import ( + "testing" + "time" +) + +func TestStepUpFresh(t *testing.T) { + now := time.Now() + ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v } + + cases := []struct { + name string + sess *Session + want bool + }{ + {"just signed in", &Session{StepUpAt: ago(time.Minute)}, true}, + {"nine minutes ago", &Session{StepUpAt: ago(9 * time.Minute)}, true}, + {"eleven minutes ago", &Session{StepUpAt: ago(11 * time.Minute)}, false}, + {"never", &Session{}, false}, + // An API token has no human to prompt; the spec exempts it and records + // the bypass as a known limitation. + {"api token", &Session{TokenID: "tok_1"}, true}, + // An OIDC session's IdP owns authentication policy. + {"oidc session", &Session{AMR: []string{"oidc"}, StepUpAt: ago(time.Hour)}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := stepUpFresh(tc.sess, now); got != tc.want { + t.Fatalf("stepUpFresh = %v, want %v", got, tc.want) + } + }) + } +}