feat: Authenticate the API with a bearer token as well as a cookie

One middleware, two ways to arrive at the same *Session, so every handler,
role guard, licence gate and audit call is untouched. The host guard
applies to both: a token carries an instance, and the tenant boundary must
not have a token-shaped hole in it.

The effective role is min(user, token) recomputed per request, so demoting
somebody demotes their tokens with them. A stale cookie beside a valid
bearer falls through rather than refusing a credential that would work.
This commit is contained in:
2026-08-12 14:32:40 +00:00
parent 72e5228351
commit a5f9fca59e
2 changed files with 127 additions and 8 deletions
+119 -8
View File
@@ -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) != "" }
+8
View File
@@ -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