feat(mfa): step-up re-authentication on sensitive actions

This commit is contained in:
2026-09-16 09:01:19 +00:00
parent d54d8971b2
commit e2ff0dace9
3 changed files with 91 additions and 3 deletions
+3 -3
View File
@@ -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
+54
View File
@@ -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,
})
}
}
+34
View File
@@ -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)
}
})
}
}