refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func useTestSessionSecret(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := common.SessionSecret
|
||||
common.SessionSecret = "test-session-secret-with-sufficient-entropy"
|
||||
t.Cleanup(func() { common.SessionSecret = previous })
|
||||
}
|
||||
|
||||
func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
|
||||
useTestSessionSecret(t)
|
||||
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
|
||||
|
||||
token, expiresAt, err := IssueAccessToken(identity)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, expiresAt)
|
||||
|
||||
parsed, err := ParseAccessToken(token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, identity, parsed)
|
||||
|
||||
proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"})
|
||||
require.NoError(t, err)
|
||||
_, err = ParseAccessToken(proof)
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
||||
}
|
||||
|
||||
func TestAccessTokenRejectsTampering(t *testing.T) {
|
||||
useTestSessionSecret(t)
|
||||
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1}
|
||||
token, _, err := IssueAccessToken(identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
tamperAt := len(token) - 2
|
||||
replacement := "x"
|
||||
if token[tamperAt] == 'x' {
|
||||
replacement = "y"
|
||||
}
|
||||
tampered := token[:tamperAt] + replacement + token[tamperAt+1:]
|
||||
_, err = ParseAccessToken(tampered)
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
||||
|
||||
_, internal, err := ParseDashboardAccessToken(tampered)
|
||||
assert.True(t, internal)
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
||||
}
|
||||
|
||||
func TestDashboardAccessTokenClassification(t *testing.T) {
|
||||
useTestSessionSecret(t)
|
||||
|
||||
identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, internal)
|
||||
assert.Empty(t, identity)
|
||||
|
||||
external := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": "external-issuer",
|
||||
"aud": authTokenAudience,
|
||||
"exp": time.Now().Add(time.Minute).Unix(),
|
||||
})
|
||||
externalRaw, err := external.SignedString([]byte("external-secret"))
|
||||
require.NoError(t, err)
|
||||
_, internal, err = ParseDashboardAccessToken(externalRaw)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, internal)
|
||||
|
||||
unknownUse := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"iss": authTokenIssuer,
|
||||
"aud": authTokenAudience,
|
||||
"token_use": "third_party",
|
||||
"exp": time.Now().Add(time.Minute).Unix(),
|
||||
})
|
||||
unknownUseRaw, err := unknownUse.SignedString([]byte("external-secret"))
|
||||
require.NoError(t, err)
|
||||
_, internal, err = ParseDashboardAccessToken(unknownUseRaw)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, internal)
|
||||
|
||||
proof, _, err := IssueSecurityProof(AuthIdentity{
|
||||
UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1,
|
||||
}, "2fa", []string{"channel.key.read"})
|
||||
require.NoError(t, err)
|
||||
_, internal, err = ParseDashboardAccessToken(proof)
|
||||
assert.True(t, internal)
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
|
||||
|
||||
expiredClaims := authClaims{
|
||||
TokenUse: accessTokenUse,
|
||||
SessionID: "expired-session",
|
||||
UserAuthVersion: 1,
|
||||
SessionVersion: 1,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: authTokenIssuer,
|
||||
Subject: "42",
|
||||
Audience: jwt.ClaimStrings{authTokenAudience},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
|
||||
ID: "expired-token",
|
||||
},
|
||||
}
|
||||
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims).SignedString(authSigningKey(accessTokenUse))
|
||||
require.NoError(t, err)
|
||||
_, internal, err = ParseDashboardAccessToken(expired)
|
||||
assert.True(t, internal)
|
||||
assert.ErrorIs(t, err, ErrAuthTokenExpired)
|
||||
}
|
||||
|
||||
func TestSecurityProofBindsIdentityMethodAndScope(t *testing.T) {
|
||||
useTestSessionSecret(t)
|
||||
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
|
||||
proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"})
|
||||
require.NoError(t, err)
|
||||
|
||||
method, err := VerifySecurityProof(proof, identity, "channel.key.read", []string{"2fa", "passkey"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2fa", method)
|
||||
|
||||
_, err = VerifySecurityProof(proof, identity, "passkey.delete", []string{"2fa"})
|
||||
assert.ErrorIs(t, err, ErrProofScope)
|
||||
|
||||
_, err = VerifySecurityProof(proof, identity, "channel.key.read", []string{"passkey"})
|
||||
assert.ErrorIs(t, err, ErrProofMethod)
|
||||
|
||||
otherSession := identity
|
||||
otherSession.SessionID = "session-2"
|
||||
_, err = VerifySecurityProof(proof, otherSession, "channel.key.read", []string{"2fa"})
|
||||
assert.True(t, errors.Is(err, ErrAuthTokenInvalid))
|
||||
}
|
||||
Reference in New Issue
Block a user