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:
@@ -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) != "" }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user