feat(mfa): step-up re-authentication on sensitive actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user