fix(apiserver): revoke the token on logout (bug-1946)

Cause: Logout only deleted tokens/<user>/token, a file that token
validation (ValidateHandler / GetUsernameFromToken) never reads, so a
logged-out token stayed valid until its expiry (24 h by default).

Fix: an in-memory revocation set (controllers/token_revocation.go) keyed
by the token's jti (generateJWT now sets a random uuid jti, so two logons
in the same second give different tokens) or, for tokens without a jti,
by SHA-256 of the token; each entry is kept until the token's expiry and
expired entries are purged on every revocation. Logout revokes the token;
the shared ParseTokenClaims (used by ValidateHandler, RequirePermission
and handlers via GetUsernameFromToken) rejects revoked tokens. A missing
token file no longer fails logout. Scope: per process, not persisted and
not shared between apiservers.

Tests: TestLogout_RevokesToken (middleware), TestTokenRevocations_*,
TestIntegration_PostLogoutTokenRejected (replaces the design-gap test).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 08:40:36 +00:00
parent 19a1a6f023
commit 4ce4f0b798
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
6 changed files with 256 additions and 34 deletions

View file

@ -3,7 +3,6 @@ package middleware
import (
"apiserver/common"
"apiserver/controllers"
"apiserver/models"
logger "apiserver/utils"
"bytes"
"encoding/json"
@ -13,7 +12,6 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"go.uber.org/zap"
)
@ -98,20 +96,14 @@ func validateHeader() gin.HandlerFunc {
}
}
// isValidToken verifies the token (signature, HS256 only, expiry, not
// revoked by logout) and its IP binding.
func isValidToken(tokenString string, IP string) (bool, error) {
token, err := jwt.ParseWithClaims(tokenString, &models.JWTClaims{},
controllers.JWTKeyFunc, controllers.JWTValidMethods())
claims, err := controllers.ParseTokenClaims(tokenString)
if err != nil {
return false, err
}
if claims, ok := token.Claims.(*models.JWTClaims); ok && token.Valid {
if claims.IP == IP {
return true, nil
}
}
return false, nil
return claims.IP == IP, nil
}
func validateRequestBody(ctx *gin.Context) (bool, error) {

View file

@ -1,11 +1,18 @@
package middleware
import (
"apiserver/backend/auth"
"apiserver/controllers"
"apiserver/models"
"apiserver/utils"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
"go.uber.org/zap"
)
@ -45,6 +52,81 @@ func signTestToken(t *testing.T, method jwt.SigningMethod, username, ip string)
return signed
}
// newAuthFlowRouter wires logon, logout and one protected route behind
// ValidateHandler, with a config_file provider holding alice/s3cret.
func newAuthFlowRouter(t *testing.T) *gin.Engine {
t.Helper()
setTestAuthConfig(t)
prevTop, prevProvider := utils.Global_TopDir, auth.GlobalProvider
t.Cleanup(func() { utils.Global_TopDir, auth.GlobalProvider = prevTop, prevProvider })
utils.Global_TopDir = t.TempDir()
provider, err := auth.NewConfigFileProvider([]byte(
"provider: config_file\nconfig_file:\n users:\n - username: alice\n password: s3cret\n"))
if err != nil {
t.Fatalf("NewConfigFileProvider: %v", err)
}
auth.GlobalProvider = provider
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(ValidateHandler())
authHandler := &controllers.AuthController{}
r.POST("/xce/v1/auth/logon", authHandler.Logon)
r.POST("/xce/v1/auth/logout", authHandler.Logout)
r.GET("/xce/v1/hosts", func(ctx *gin.Context) {
controllers.ReturnSuccess(ctx, http.StatusOK, "success", nil)
})
return r
}
func serve(r *gin.Engine, method, path, token, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.RemoteAddr = "10.0.0.1:4000"
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func logon(t *testing.T, r *gin.Engine) string {
t.Helper()
w := serve(r, http.MethodPost, "/xce/v1/auth/logon", "", `{"username":"alice","password":"s3cret"}`)
var resp struct {
Data models.LogonResponse `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil || resp.Data.Token == "" {
t.Fatalf("logon failed: status %d body %s", w.Code, w.Body.String())
}
return resp.Data.Token
}
// bug-1946: after logout the token is rejected; a new logon (even in the
// same second) gets a working token.
func TestLogout_RevokesToken(t *testing.T) {
r := newAuthFlowRouter(t)
token := logon(t, r)
if w := serve(r, http.MethodGet, "/xce/v1/hosts", token, ""); w.Code != http.StatusOK {
t.Fatalf("before logout: status = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := serve(r, http.MethodPost, "/xce/v1/auth/logout", token, ""); w.Code != http.StatusOK {
t.Fatalf("logout: status = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := serve(r, http.MethodGet, "/xce/v1/hosts", token, ""); w.Code != http.StatusUnauthorized {
t.Errorf("after logout: status = %d, want 401 (%s)", w.Code, w.Body.String())
}
fresh := logon(t, r)
if w := serve(r, http.MethodGet, "/xce/v1/hosts", fresh, ""); w.Code != http.StatusOK {
t.Errorf("token from a new logon: status = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// bug-1950: only the HMAC algorithm used for signing (HS256) is accepted.
func TestIsValidToken_PinsSigningAlgorithm(t *testing.T) {
setTestAuthConfig(t)