diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index ac2022d..d50836f 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -1,8 +1,12 @@ package auth import ( + "errors" + "fmt" "net/http" + "strings" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" "github.com/gin-gonic/gin" ) @@ -48,17 +52,20 @@ func RequireRole(roles ...string) gin.HandlerFunc { } } +// Middleware authenticates a request by session cookie or by API token. +// +// Both paths end by putting a *Session in the context, which is why no handler, +// role guard, licence gate or audit call needed changing: the token path is a +// second way to arrive at the same value, not a second way through the API. func Middleware() gin.HandlerFunc { return func(c *gin.Context) { - cookie, err := c.Request.Cookie(sessionCookieName) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) - return + sess, ok := sessionFromCookie(c) + if !ok { + sess, ok = sessionFromToken(c) } - - sess, err := GetSession(c.Request.Context(), cookie.Value) - if err != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + if !ok { + // sessionFromCookie and sessionFromToken have already written the + // response describing which credential failed and why. return } @@ -69,6 +76,8 @@ func Middleware() gin.HandlerFunc { c.Set(ctxSessionKey, sess) + // The host guard applies to both credential kinds. A token carries an + // instance, and the tenant boundary must not have a token-shaped hole. if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"}) return @@ -77,3 +86,105 @@ func Middleware() gin.HandlerFunc { c.Next() } } + +// sessionFromCookie returns false without writing a response when there is no +// cookie at all, so the token path gets its turn. It writes and aborts only +// when a cookie was presented and was not usable. +func sessionFromCookie(c *gin.Context) (*Session, bool) { + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + return nil, false + } + sess, err := GetSession(c.Request.Context(), cookie.Value) + if err != nil { + // A stale cookie plus a valid bearer token is a real combination — + // a browser tab left open beside a curl. Fall through rather than + // refusing a credential that would have worked. + if bearerToken(c) != "" { + return nil, false + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return nil, false + } + return sess, true +} + +func bearerToken(c *gin.Context) string { + const prefix = "Bearer " + h := c.GetHeader("Authorization") + if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "" + } + return h[len(prefix):] +} + +func sessionFromToken(c *gin.Context) (*Session, bool) { + raw := bearerToken(c) + if raw == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return nil, false + } + + tok, err := services.ResolveAPIToken(raw) + if errors.Is(err, services.ErrTokenExpired) { + // Recorded rather than only refused: an expired token still being + // presented is how a forgotten CI job becomes visible. + services.LogEvent(tok.InstanceID, "token.expired_use", tok.Name, "", "", + fmt.Sprintf("expired token '%s' was used", tok.Name)) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token expired", "code": "token_expired"}) + return nil, false + } + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return nil, false + } + + user, err := services.GetUserInInstance(tok.InstanceID, tok.UserID) + if err != nil { + // The owner is gone. DeleteUser removes tokens, so this is the + // belt-and-braces path for a row deleted some other way. + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return nil, false + } + + services.TouchAPIToken(tok) + + return &Session{ + UserID: tok.UserID, + InstanceID: tok.InstanceID, + // Recomputed per request, so demoting the person demotes the token. + Role: services.LowerRole(user.Role, tok.Role), + Email: user.Email, + Name: user.Email, + TokenID: tok.TokenID, + TokenName: tok.Name, + Scopes: tok.Scopes, + }, true +} + +// TokenID is empty for a cookie session and the token's ID for a token +// request. It is what lets audit detail record which credential acted. +func TokenID(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.TokenID + } + return "" +} + +func TokenName(c *gin.Context) string { + if s := GetSessionFromContext(c); s != nil { + return s.TokenName + } + return "" +} + +func Scopes(c *gin.Context) []string { + if s := GetSessionFromContext(c); s != nil { + return s.Scopes + } + return nil +} + +// IsToken reports whether this request authenticated with an API token rather +// than a browser session. +func IsToken(c *gin.Context) bool { return TokenID(c) != "" } diff --git a/server/internal/auth/session.go b/server/internal/auth/session.go index 1959731..76e072e 100644 --- a/server/internal/auth/session.go +++ b/server/internal/auth/session.go @@ -22,6 +22,14 @@ type Session struct { Role string `json:"role"` Email string `json:"email"` Name string `json:"name"` + + // The three fields below are set only when the request authenticated with + // an API token. They are never persisted to Redis — a token authenticates + // per request and mints no session, so a revoked token stops working + // immediately rather than at the end of a session TTL. + TokenID string `json:"-"` + TokenName string `json:"-"` + Scopes []string `json:"-"` } var rdb *redis.Client