feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed) - 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引) - gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken - config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration - rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh - 14测试+真实二进制e2e, build/vet/test绿+交叉编译 - Docs/api-client v0.25 + api-reference §2.3/2.4 同步
This commit is contained in:
1 parent
7ecb43bd5d
commit
026b27b310
17 files changed
+1237
-13
No files matched your search
@@ -45,6 +45,10 @@ var (
|
||||
ErrUnauthorized = errors.New("auth: unauthorized")
|
||||
// ErrForbidden reports a valid credential with insufficient privilege.
|
||||
ErrForbidden = errors.New("auth: forbidden")
|
||||
// ErrInvalidGrant reports an unknown, revoked, or expired refresh token.
|
||||
ErrInvalidGrant = errors.New("auth: invalid refresh grant")
|
||||
// ErrNilDB reports a nil *sql.DB passed to a store constructor.
|
||||
ErrNilDB = errors.New("auth: nil database")
|
||||
)
|
||||
|
||||
// Package-wide constants. `source` is the log provenance tag used by every
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Opaque refresh-token parameters and the canonical schema. The refresh secret
|
||||
// is 32 random bytes (256 bits), so a plain unsalted SHA-256 digest is a sound
|
||||
// one-way store: there is no low-entropy input to brute-force, and the digest is
|
||||
// looked up through a unique index, never compared byte-by-byte in Go.
|
||||
const (
|
||||
refreshTokenBytes = 32
|
||||
refreshIDBytes = 16
|
||||
refreshTimeLayout = time.RFC3339Nano
|
||||
|
||||
createRefreshTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)`
|
||||
createRefreshUserIndexSQL = `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id
|
||||
ON refresh_tokens (user_id)`
|
||||
createRefreshExpiryIndexSQL = `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at
|
||||
ON refresh_tokens (expires_at)`
|
||||
insertRefreshSQL = `INSERT INTO refresh_tokens (id, user_id, role, token_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
selectRefreshByHashSQL = `SELECT id, user_id, role, expires_at, revoked_at
|
||||
FROM refresh_tokens WHERE token_hash = ?`
|
||||
revokeRefreshByIDSQL = `UPDATE refresh_tokens SET revoked_at = ?, updated_at = ?
|
||||
WHERE id = ? AND revoked_at IS NULL`
|
||||
revokeRefreshByHashSQL = `UPDATE refresh_tokens SET revoked_at = ?, updated_at = ?
|
||||
WHERE token_hash = ? AND revoked_at IS NULL`
|
||||
deleteRefreshAllSQL = `DELETE FROM refresh_tokens`
|
||||
deleteRefreshByUserSQL = `DELETE FROM refresh_tokens WHERE user_id = ?`
|
||||
pruneRefreshSQL = `DELETE FROM refresh_tokens WHERE expires_at < ? OR (revoked_at IS NOT NULL AND revoked_at < ?)`
|
||||
)
|
||||
|
||||
// RefreshToken is the stored metadata of a refresh grant. It never carries the
|
||||
// secret: only the SHA-256 hash of the opaque token is persisted.
|
||||
type RefreshToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
Role Role
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// RotateOptions configures a refresh call. Rotate enables single-use rotation:
|
||||
// the presented token is revoked and replaced by a fresh one. When false the
|
||||
// presented token stays valid and no replacement is minted (NewToken is empty),
|
||||
// which is the opt-out for clients that cannot persist a rotated token.
|
||||
type RotateOptions struct {
|
||||
TTL time.Duration
|
||||
Rotate bool
|
||||
}
|
||||
|
||||
// RefreshStore persists opaque refresh tokens in SQLite. Only the SHA-256 hash
|
||||
// of a token is stored; the plaintext is returned once by Issue/Rotate and never
|
||||
// written to the database or a log. It is safe for concurrent use by virtue of
|
||||
// the underlying *sql.DB.
|
||||
type RefreshStore struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewRefreshStore builds a SQLite-backed refresh-token store over db, ensuring
|
||||
// the refresh_tokens table and indexes exist. The DDL mirrors migration 0008
|
||||
// and is idempotent, so the store is self-contained for tests while the
|
||||
// migration remains the authoritative path for an existing database. db must be
|
||||
// live; nil returns ErrNilDB. log may be nil.
|
||||
func NewRefreshStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*RefreshStore, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
for _, ddl := range []string{createRefreshTableSQL, createRefreshUserIndexSQL, createRefreshExpiryIndexSQL} {
|
||||
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||||
return nil, fmt.Errorf("auth: ensure refresh_tokens schema: %w", err)
|
||||
}
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &RefreshStore{db: db, log: logger.WithSource(log, source)}, nil
|
||||
}
|
||||
|
||||
// Issue mints a new opaque refresh token for userID/role, stores only its hash,
|
||||
// and returns the plaintext (visible exactly once) plus its expiry. Expired or
|
||||
// long-revoked rows are pruned best-effort so the table stays bounded.
|
||||
func (s *RefreshStore) Issue(ctx context.Context, userID string, role Role, ttl time.Duration) (string, time.Time, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return "", time.Time{}, fmt.Errorf("%w: empty user id", ErrInvalidGrant)
|
||||
}
|
||||
if !role.Valid() {
|
||||
return "", time.Time{}, fmt.Errorf("%w: %q", ErrInvalidRole, role)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return "", time.Time{}, fmt.Errorf("%w: non-positive ttl", ErrInvalidGrant)
|
||||
}
|
||||
plaintext, hash, err := newRefreshToken()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
id, err := newRefreshID()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
expires := time.Now().UTC().Add(ttl)
|
||||
if _, err := s.db.ExecContext(ctx, insertRefreshSQL, id, userID, string(role), hash, formatRefreshTime(expires)); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("auth: insert refresh token: %w", err)
|
||||
}
|
||||
s.pruneExpired(ctx)
|
||||
return plaintext, expires, nil
|
||||
}
|
||||
|
||||
// Rotate validates plaintext (known, unrevoked, unexpired) and, when opts.Rotate
|
||||
// is true, atomically revokes it and issues a replacement. It returns the grant
|
||||
// metadata and the new plaintext token (empty when rotation is disabled). An
|
||||
// unknown, revoked, or expired token yields ErrInvalidGrant. The transaction
|
||||
// makes rotation single-use under concurrent replay: the second caller's revoke
|
||||
// affects no row and fails closed.
|
||||
func (s *RefreshStore) Rotate(ctx context.Context, plaintext string, opts RotateOptions) (RefreshToken, string, error) {
|
||||
if strings.TrimSpace(plaintext) == "" {
|
||||
return RefreshToken{}, "", fmt.Errorf("%w: empty token", ErrInvalidGrant)
|
||||
}
|
||||
if opts.Rotate && opts.TTL <= 0 {
|
||||
return RefreshToken{}, "", fmt.Errorf("%w: non-positive ttl", ErrInvalidGrant)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RefreshToken{}, "", fmt.Errorf("auth: begin refresh rotate: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
grant, revokedAt, err := selectRefreshGrant(ctx, tx, hashRefreshToken(plaintext))
|
||||
if err != nil {
|
||||
return RefreshToken{}, "", err
|
||||
}
|
||||
if revokedAt.Valid {
|
||||
return RefreshToken{}, "", fmt.Errorf("%w: revoked", ErrInvalidGrant)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !grant.ExpiresAt.After(now) {
|
||||
return RefreshToken{}, "", fmt.Errorf("%w: expired", ErrInvalidGrant)
|
||||
}
|
||||
if !opts.Rotate {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RefreshToken{}, "", fmt.Errorf("auth: commit refresh: %w", err)
|
||||
}
|
||||
return grant, "", nil
|
||||
}
|
||||
if err := revokeRefreshRow(ctx, tx, grant.ID, now); err != nil {
|
||||
return RefreshToken{}, "", err
|
||||
}
|
||||
newPlaintext, newHash, err := newRefreshToken()
|
||||
if err != nil {
|
||||
return RefreshToken{}, "", err
|
||||
}
|
||||
newID, err := newRefreshID()
|
||||
if err != nil {
|
||||
return RefreshToken{}, "", err
|
||||
}
|
||||
newExpires := now.Add(opts.TTL)
|
||||
if _, err := tx.ExecContext(ctx, insertRefreshSQL, newID, grant.UserID, string(grant.Role), newHash, formatRefreshTime(newExpires)); err != nil {
|
||||
return RefreshToken{}, "", fmt.Errorf("auth: insert rotated refresh token: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RefreshToken{}, "", fmt.Errorf("auth: commit refresh rotate: %w", err)
|
||||
}
|
||||
return RefreshToken{ID: newID, UserID: grant.UserID, Role: grant.Role, ExpiresAt: newExpires}, newPlaintext, nil
|
||||
}
|
||||
|
||||
// Revoke marks the refresh token matching plaintext as revoked, returning true
|
||||
// when a live token was found. It is idempotent: revoking an unknown or already
|
||||
// revoked token is a no-op returning false.
|
||||
func (s *RefreshStore) Revoke(ctx context.Context, plaintext string) (bool, error) {
|
||||
if strings.TrimSpace(plaintext) == "" {
|
||||
return false, nil
|
||||
}
|
||||
now := formatRefreshTime(time.Now().UTC())
|
||||
res, err := s.db.ExecContext(ctx, revokeRefreshByHashSQL, now, now, hashRefreshToken(plaintext))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: revoke refresh token: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: revoke refresh token: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// RevokeAll deletes every refresh token and returns how many rows were removed.
|
||||
// It is the global invalidation used when the JWT signing secret is rotated:
|
||||
// credential rotation must invalidate every long-lived grant, not only the
|
||||
// short-lived access tokens. Deleting (rather than marking revoked) is exact and
|
||||
// leaves no valid row behind.
|
||||
func (s *RefreshStore) RevokeAll(ctx context.Context) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, deleteRefreshAllSQL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: revoke all refresh tokens: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: revoke all refresh tokens: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RevokeByUser deletes every refresh token belonging to userID and returns how
|
||||
// many rows were removed. It backs logout by access token, where the individual
|
||||
// refresh token is unknown: the account is signed out on every device.
|
||||
func (s *RefreshStore) RevokeByUser(ctx context.Context, userID string) (int64, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return 0, fmt.Errorf("%w: empty user id", ErrInvalidGrant)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, deleteRefreshByUserSQL, userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: revoke user refresh tokens: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: revoke user refresh tokens: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// pruneExpired deletes rows that are expired or were revoked long ago. It is
|
||||
// best-effort: a failure is logged and never fails the Issue that triggered it.
|
||||
func (s *RefreshStore) pruneExpired(ctx context.Context) {
|
||||
cutoff := formatRefreshTime(time.Now().UTC())
|
||||
if _, err := s.db.ExecContext(ctx, pruneRefreshSQL, cutoff, cutoff); err != nil {
|
||||
s.log.Warn("refresh token prune failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// selectRefreshGrant loads the grant for hash, returning ErrInvalidGrant when no
|
||||
// row matches. The query runs on tx so the caller's rotate transaction observes
|
||||
// a stable row.
|
||||
func selectRefreshGrant(ctx context.Context, tx *sql.Tx, hash string) (RefreshToken, sql.NullString, error) {
|
||||
var (
|
||||
grant RefreshToken
|
||||
role string
|
||||
expiresAt string
|
||||
revokedAt sql.NullString
|
||||
)
|
||||
err := tx.QueryRowContext(ctx, selectRefreshByHashSQL, hash).
|
||||
Scan(&grant.ID, &grant.UserID, &role, &expiresAt, &revokedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RefreshToken{}, sql.NullString{}, fmt.Errorf("%w: unknown token", ErrInvalidGrant)
|
||||
}
|
||||
if err != nil {
|
||||
return RefreshToken{}, sql.NullString{}, fmt.Errorf("auth: lookup refresh token: %w", err)
|
||||
}
|
||||
parsedRole, err := ParseRole(role)
|
||||
if err != nil {
|
||||
return RefreshToken{}, sql.NullString{}, fmt.Errorf("auth: stored refresh role: %w", err)
|
||||
}
|
||||
expiry, err := time.Parse(refreshTimeLayout, expiresAt)
|
||||
if err != nil {
|
||||
return RefreshToken{}, sql.NullString{}, fmt.Errorf("auth: parse refresh expiry: %w", err)
|
||||
}
|
||||
grant.Role = parsedRole
|
||||
grant.ExpiresAt = expiry
|
||||
return grant, revokedAt, nil
|
||||
}
|
||||
|
||||
// revokeRefreshRow marks the grant row revoked inside tx, failing closed with
|
||||
// ErrInvalidGrant when no live row was updated (a concurrent rotate won the race).
|
||||
func revokeRefreshRow(ctx context.Context, tx *sql.Tx, id string, now time.Time) error {
|
||||
stamp := formatRefreshTime(now)
|
||||
res, err := tx.ExecContext(ctx, revokeRefreshByIDSQL, stamp, stamp, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: revoke rotated refresh token: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: revoke rotated refresh token: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: already used", ErrInvalidGrant)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newRefreshToken returns a fresh opaque token and its stored hash.
|
||||
func newRefreshToken() (string, string, error) {
|
||||
raw := make([]byte, refreshTokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate refresh token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
return token, hashRefreshToken(token), nil
|
||||
}
|
||||
|
||||
// newRefreshID returns a random 128-bit hex row id.
|
||||
func newRefreshID() (string, error) {
|
||||
raw := make([]byte, refreshIDBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("auth: generate refresh id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// hashRefreshToken returns the hex SHA-256 of token, the only form persisted.
|
||||
func hashRefreshToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// formatRefreshTime renders t as UTC RFC3339Nano for storage.
|
||||
func formatRefreshTime(t time.Time) string {
|
||||
return t.UTC().Format(refreshTimeLayout)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// newTestRefreshStore opens an isolated in-memory SQLite database, applies the
|
||||
// embedded migrations, and returns a store over it. It returns the *sql.DB too
|
||||
// so tests can assert what was actually persisted.
|
||||
func newTestRefreshStore(t *testing.T) (*RefreshStore, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
sqlDB, err := db.Open(ctx, ":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
|
||||
t.Fatalf("migrate up: %v", err)
|
||||
}
|
||||
store, err := NewRefreshStore(ctx, sqlDB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new refresh store: %v", err)
|
||||
}
|
||||
return store, sqlDB
|
||||
}
|
||||
|
||||
func TestRefreshIssueStoresHashOnly(t *testing.T) {
|
||||
store, sqlDB := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
token, expires, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("issued token is empty")
|
||||
}
|
||||
if !expires.After(time.Now()) {
|
||||
t.Fatalf("expiry %v is not in the future", expires)
|
||||
}
|
||||
|
||||
var hash, userID, role string
|
||||
if err := sqlDB.QueryRowContext(ctx,
|
||||
"SELECT token_hash, user_id, role FROM refresh_tokens").Scan(&hash, &userID, &role); err != nil {
|
||||
t.Fatalf("select stored row: %v", err)
|
||||
}
|
||||
if hash == token {
|
||||
t.Fatal("refresh token stored in plaintext")
|
||||
}
|
||||
if want := hashRefreshToken(token); hash != want {
|
||||
t.Fatalf("stored hash = %q, want sha256 %q", hash, want)
|
||||
}
|
||||
if userID != "admin" || role != string(RoleOwner) {
|
||||
t.Fatalf("stored row = (%q, %q), want (admin, owner)", userID, role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotateReplacesToken(t *testing.T) {
|
||||
store, _ := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
old, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
grant, newToken, err := store.Rotate(ctx, old, RotateOptions{TTL: time.Hour, Rotate: true})
|
||||
if err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
if newToken == "" || newToken == old {
|
||||
t.Fatalf("rotated token = %q, want a fresh non-empty token", newToken)
|
||||
}
|
||||
if grant.UserID != "admin" || grant.Role != RoleOwner {
|
||||
t.Fatalf("grant = %+v, want admin/owner", grant)
|
||||
}
|
||||
if !grant.ExpiresAt.After(time.Now()) {
|
||||
t.Fatalf("rotated expiry %v is not in the future", grant.ExpiresAt)
|
||||
}
|
||||
// The old token is single-use: replaying it must fail closed.
|
||||
if _, _, err := store.Rotate(ctx, old, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) {
|
||||
t.Fatalf("replay old token err = %v, want ErrInvalidGrant", err)
|
||||
}
|
||||
// The replacement is live.
|
||||
if _, _, err := store.Rotate(ctx, newToken, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil {
|
||||
t.Fatalf("rotate replacement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotateNoRotationKeepsToken(t *testing.T) {
|
||||
store, _ := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
token, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if _, newToken, err := store.Rotate(ctx, token, RotateOptions{Rotate: false}); err != nil || newToken != "" {
|
||||
t.Fatalf("no-rotate = (%q, %v), want empty token and nil error", newToken, err)
|
||||
}
|
||||
// The presented token must still be usable.
|
||||
if _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil {
|
||||
t.Fatalf("rotate after no-rotate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotateInvalidGrant(t *testing.T) {
|
||||
store, sqlDB := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
expired, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue expired: %v", err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-time.Minute).Format(refreshTimeLayout)
|
||||
if _, err := sqlDB.ExecContext(ctx,
|
||||
"UPDATE refresh_tokens SET expires_at = ? WHERE token_hash = ?", past, hashRefreshToken(expired)); err != nil {
|
||||
t.Fatalf("age token: %v", err)
|
||||
}
|
||||
|
||||
revoked, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue revoked: %v", err)
|
||||
}
|
||||
if ok, err := store.Revoke(ctx, revoked); err != nil || !ok {
|
||||
t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"garbage": "not-a-real-token",
|
||||
"expired": expired,
|
||||
"revoked": revoked,
|
||||
}
|
||||
for name, token := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true})
|
||||
if !errors.Is(err, ErrInvalidGrant) {
|
||||
t.Fatalf("rotate(%q) err = %v, want ErrInvalidGrant", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRevokeAll(t *testing.T) {
|
||||
store, _ := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue first: %v", err)
|
||||
}
|
||||
second, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue second: %v", err)
|
||||
}
|
||||
n, err := store.RevokeAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("revoke all: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("revoked %d rows, want 2", n)
|
||||
}
|
||||
for _, token := range []string{first, second} {
|
||||
if _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) {
|
||||
t.Fatalf("rotate after revoke-all err = %v, want ErrInvalidGrant", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRevokeByUser(t *testing.T) {
|
||||
store, _ := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
adminToken, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue admin: %v", err)
|
||||
}
|
||||
otherToken, _, err := store.Issue(ctx, "other", RoleViewer, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue other: %v", err)
|
||||
}
|
||||
n, err := store.RevokeByUser(ctx, "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("revoke by user: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("revoked %d rows, want 1", n)
|
||||
}
|
||||
if _, _, err := store.Rotate(ctx, adminToken, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) {
|
||||
t.Fatalf("admin rotate after revoke err = %v, want ErrInvalidGrant", err)
|
||||
}
|
||||
if _, _, err := store.Rotate(ctx, otherToken, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil {
|
||||
t.Fatalf("other rotate must be unaffected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRevokeIsIdempotent(t *testing.T) {
|
||||
store, _ := newTestRefreshStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
token, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if ok, err := store.Revoke(ctx, token); err != nil || !ok {
|
||||
t.Fatalf("first revoke = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
if ok, err := store.Revoke(ctx, token); err != nil || ok {
|
||||
t.Fatalf("second revoke = (%v, %v), want (false, nil)", ok, err)
|
||||
}
|
||||
if ok, err := store.Revoke(ctx, "unknown-token"); err != nil || ok {
|
||||
t.Fatalf("unknown revoke = (%v, %v), want (false, nil)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSchemaIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlDB, err := db.Open(ctx, ":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
|
||||
t.Fatalf("first migrate up: %v", err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
|
||||
t.Fatalf("second migrate up: %v", err)
|
||||
}
|
||||
var name string
|
||||
if err := sqlDB.QueryRowContext(ctx,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'").Scan(&name); err != nil {
|
||||
t.Fatalf("refresh_tokens table missing: %v", err)
|
||||
}
|
||||
// The store constructor re-issues the same DDL; it must be a no-op.
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := NewRefreshStore(ctx, sqlDB, nil); err != nil {
|
||||
t.Fatalf("NewRefreshStore #%d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
// Down then up must also round-trip.
|
||||
if err := db.MigrateDown(ctx, sqlDB, 1, nil); err != nil {
|
||||
t.Fatalf("migrate down: %v", err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
|
||||
t.Fatalf("migrate up after down: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config is the root configuration tree.
|
||||
@@ -48,6 +49,19 @@ type AuthConfig struct {
|
||||
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
|
||||
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
|
||||
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
|
||||
|
||||
// RefreshTTL is how long an issued opaque refresh token stays valid. It is a
|
||||
// Go duration string (e.g. "720h" = 30 days). Access tokens keep their 24h
|
||||
// lifetime; the refresh token is the long-lived credential that renews them.
|
||||
RefreshTTL time.Duration `env:"AUTH_REFRESH_TTL" default:"720h"`
|
||||
|
||||
// RefreshRotateDisabled opts out of single-use refresh rotation. Rotation is
|
||||
// on by default (the safe default): a successful /auth/refresh revokes the
|
||||
// presented token and returns a fresh one, so a replayed token fails. Set
|
||||
// AUTH_REFRESH_ROTATE_DISABLED=true only for clients that cannot persist a
|
||||
// rotated token. The field is negative-named so its zero value (an
|
||||
// unset/undecoded config) means rotation is enabled.
|
||||
RefreshRotateDisabled bool `env:"AUTH_REFRESH_ROTATE_DISABLED" default:"false"`
|
||||
}
|
||||
|
||||
// DBConfig holds database connection settings. SQLite is the default; PGURL is
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeEnvFile(t *testing.T, content string) string {
|
||||
@@ -130,6 +131,38 @@ func TestLoad_MissingDotEnvIsTolerated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AuthRefreshDefaults(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Auth.RefreshTTL != 720*time.Hour {
|
||||
t.Errorf("Auth.RefreshTTL = %v, want 720h", cfg.Auth.RefreshTTL)
|
||||
}
|
||||
if cfg.Auth.RefreshRotateDisabled {
|
||||
t.Errorf("Auth.RefreshRotateDisabled = true, want false (rotation on by default)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AuthRefreshEnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("AUTH_REFRESH_TTL", "30m")
|
||||
t.Setenv("AUTH_REFRESH_ROTATE_DISABLED", "true")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.Auth.RefreshTTL != 30*time.Minute {
|
||||
t.Errorf("Auth.RefreshTTL = %v, want 30m", cfg.Auth.RefreshTTL)
|
||||
}
|
||||
if !cfg.Auth.RefreshRotateDisabled {
|
||||
t.Errorf("Auth.RefreshRotateDisabled = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CapacityDefaults(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
|
||||
@@ -144,7 +177,6 @@ func TestLoad_CapacityDefaults(t *testing.T) {
|
||||
t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CapacityEnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("CAPACITY_ENABLED", "true")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// decodeValue populates the struct pointed to by v. Precedence, highest first:
|
||||
@@ -73,6 +74,11 @@ func setField(fv reflect.Value, raw string) error {
|
||||
case reflect.Bool:
|
||||
return setBool(fv, raw)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
// time.Duration is an int64 kind but is configured as a Go duration
|
||||
// string (e.g. "720h"), so it must be parsed before the integer path.
|
||||
if fv.Type() == reflect.TypeOf(time.Duration(0)) {
|
||||
return setDuration(fv, raw)
|
||||
}
|
||||
return setInt(fv, raw)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return setFloat(fv, raw)
|
||||
@@ -122,6 +128,21 @@ func setFloat(fv reflect.Value, raw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDuration parses a Go duration string (e.g. "720h", "30m") into a
|
||||
// time.Duration field. An empty value sets the zero duration.
|
||||
func setDuration(fv reflect.Value, raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
fv.SetInt(0)
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration %q", raw)
|
||||
}
|
||||
fv.SetInt(int64(d))
|
||||
return nil
|
||||
}
|
||||
|
||||
func setStringSlice(fv reflect.Value, raw string) error {
|
||||
if fv.Type().Elem().Kind() != reflect.String {
|
||||
return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind())
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 0008_refresh_tokens down: drop the refresh-token table (and, implicitly, its
|
||||
-- indexes). Revoked grants carry no audit value once the feature is rolled back.
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 0008_refresh_tokens: opaque refresh tokens backing POST /api/v1/auth/refresh
|
||||
-- (B2). The long-lived credential that renews the 24h access token, so a client
|
||||
-- can stay signed in without storing the account password.
|
||||
--
|
||||
-- Only the SHA-256 hash of a token is stored (token_hash); the plaintext is
|
||||
-- returned once at login/refresh and never persisted or logged. revoked_at is
|
||||
-- NULL while a grant is live; expiry is validated in Go after the hash lookup.
|
||||
-- The canonical schema is also re-issued idempotently by auth.NewRefreshStore so
|
||||
-- an in-memory store used by tests stays self-contained; this migration is the
|
||||
-- authoritative path for an existing database.
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id
|
||||
ON refresh_tokens (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at
|
||||
ON refresh_tokens (expires_at);
|
||||
@@ -71,10 +71,27 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret"))
|
||||
return
|
||||
}
|
||||
// Credential rotation must invalidate every long-lived grant too, not only the
|
||||
// access tokens signed under the old secret. Refresh tokens are opaque and
|
||||
// secret-independent, so they are deleted wholesale — the minimal, exact
|
||||
// global invalidation in this architecture (no token_version column needed).
|
||||
var refreshRevoked int64
|
||||
if s.refresh != nil {
|
||||
n, rerr := s.refresh.RevokeAll(r.Context())
|
||||
if rerr != nil {
|
||||
s.log.Error("rotate-jwt revoke refresh tokens", "err", rerr)
|
||||
s.auditClaims(r, "system.rotate_jwt", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_revoke_failed",
|
||||
"JWT 已轮换,但 refresh token 作废失败,请重试或检查数据库"))
|
||||
return
|
||||
}
|
||||
refreshRevoked = n
|
||||
}
|
||||
s.auditClaims(r, "system.rotate_jwt", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rotated": true,
|
||||
"note": "现有 token 已全部失效,需重新登录",
|
||||
"rotated": true,
|
||||
"refreshTokensRevoked": refreshRevoked,
|
||||
"note": "现有 token 与 refresh token 已全部失效,需重新登录",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
// defaultRefreshTTL is the refresh-token lifetime used when cfg.Auth.RefreshTTL
|
||||
// is unset (an undecoded config in tests), matching the AUTH_REFRESH_TTL default.
|
||||
const defaultRefreshTTL = 720 * time.Hour
|
||||
|
||||
// refreshRequest is the POST /api/v1/auth/refresh body.
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
// refreshResponse is the refresh success payload: a fresh access token plus the
|
||||
// rotated refresh token (omitted when rotation is disabled by config).
|
||||
type refreshResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Role string `json:"role"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
}
|
||||
|
||||
// logoutRequest is the POST /api/v1/auth/logout body.
|
||||
type logoutRequest struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
// refreshTTL returns the configured refresh-token lifetime, falling back to
|
||||
// defaultRefreshTTL when the config was not decoded (zero value).
|
||||
func (s *Server) refreshTTL() time.Duration {
|
||||
if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 {
|
||||
return s.cfg.Auth.RefreshTTL
|
||||
}
|
||||
return defaultRefreshTTL
|
||||
}
|
||||
|
||||
// refreshRotate reports whether single-use rotation is enabled. Rotation is the
|
||||
// default; only an explicit AUTH_REFRESH_ROTATE_DISABLED=true opts out.
|
||||
func (s *Server) refreshRotate() bool {
|
||||
return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled
|
||||
}
|
||||
|
||||
// handleRefresh exchanges a valid opaque refresh token for a new access token,
|
||||
// rotating the refresh token by default. It is public (the refresh token is the
|
||||
// credential) and returns 401 invalid_grant for an unknown, revoked, or expired
|
||||
// token. A successful rotation is audited; the token itself is never logged.
|
||||
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if s.refresh == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured"))
|
||||
return
|
||||
}
|
||||
grant, newRefresh, err := s.refresh.Rotate(r.Context(), req.RefreshToken, auth.RotateOptions{
|
||||
TTL: s.refreshTTL(),
|
||||
Rotate: s.refreshRotate(),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidGrant) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid refresh token"))
|
||||
return
|
||||
}
|
||||
s.log.Error("refresh rotate", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not refresh token"))
|
||||
return
|
||||
}
|
||||
token, err := s.auth.Sign(grant.UserID, grant.Role, tokenTTL)
|
||||
if err != nil {
|
||||
s.log.Error("refresh sign", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not sign token"))
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), grant.UserID+":"+string(grant.Role), "auth.refresh", "ok")
|
||||
writeJSON(w, http.StatusOK, refreshResponse{
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
|
||||
Role: string(grant.Role),
|
||||
RefreshToken: newRefresh,
|
||||
})
|
||||
}
|
||||
|
||||
// handleLogout revokes the refresh token in the body. It is public and
|
||||
// idempotent: an unknown or already revoked token yields {"revoked": false} with
|
||||
// 200 rather than an error, so a client can always clear its local state.
|
||||
//
|
||||
// The body may carry either a refreshToken (revokes that one grant) or nothing,
|
||||
// in which case the Authorization Bearer access token identifies the account and
|
||||
// every refresh grant for it is revoked (logout on all devices). A request with
|
||||
// neither is a 400.
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
var req logoutRequest
|
||||
// An empty body is valid: it means "no refreshToken", and the account is
|
||||
// identified by the bearer access token below.
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if s.refresh == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RefreshToken) != "" {
|
||||
revoked, err := s.refresh.Revoke(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
s.log.Error("logout revoke refresh token", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh token"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"revoked": revoked})
|
||||
return
|
||||
}
|
||||
access := bearerFromRequest(r)
|
||||
if access == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "refreshToken or Authorization bearer token is required"))
|
||||
return
|
||||
}
|
||||
claims, err := s.auth.Verify(access)
|
||||
if err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid access token"))
|
||||
return
|
||||
}
|
||||
n, err := s.refresh.RevokeByUser(r.Context(), claims.Sub)
|
||||
if err != nil {
|
||||
s.log.Error("logout revoke user refresh tokens", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh tokens"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"revoked": n > 0, "revokedCount": n})
|
||||
}
|
||||
|
||||
// bearerFromRequest extracts the Bearer credential from the Authorization
|
||||
// header, returning "" when the header is absent or malformed.
|
||||
func bearerFromRequest(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(prefix):])
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// newRefreshEnv builds a gateway backed by a real JWT service and a real
|
||||
// SQLite-backed refresh store, returning the store and DB so a test can assert
|
||||
// what was persisted. The DB is isolated in memory per test.
|
||||
func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
sqlDB, err := db.Open(ctx, ":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.MigrateUp(ctx, sqlDB, nil); err != nil {
|
||||
t.Fatalf("migrate up: %v", err)
|
||||
}
|
||||
refresh, err := auth.NewRefreshStore(ctx, sqlDB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new refresh store: %v", err)
|
||||
}
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
b := bus.New()
|
||||
cfg := &config.Config{Auth: config.AuthConfig{
|
||||
AdminUser: "admin",
|
||||
AdminPass: "secret",
|
||||
JWTSecret: testSecret,
|
||||
RefreshTTL: time.Hour,
|
||||
}}
|
||||
srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Refresh: refresh})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}, refresh, sqlDB
|
||||
}
|
||||
|
||||
// decodeData unmarshals the {data: ...} envelope payload into v.
|
||||
func decodeData(t *testing.T, body []byte, v any) {
|
||||
t.Helper()
|
||||
env := decodeBody(t, body)
|
||||
raw, ok := env["data"]
|
||||
if !ok {
|
||||
t.Fatalf("response has no data envelope: %s", body)
|
||||
}
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
t.Fatalf("decode data %s: %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
// loginRefresh performs an admin login and returns the access and refresh tokens.
|
||||
func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"username": "admin", "password": "secret"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login status = %d, want 200: %s", resp.StatusCode, body)
|
||||
}
|
||||
var data struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
decodeData(t, body, &data)
|
||||
if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" {
|
||||
t.Fatalf("login payload = %+v, want token/expiresAt/role=owner", data)
|
||||
}
|
||||
if data.RefreshToken == "" {
|
||||
t.Fatalf("login payload has no refreshToken: %s", body)
|
||||
}
|
||||
return data.Token, data.RefreshToken
|
||||
}
|
||||
|
||||
// postRefresh calls /auth/refresh with token and returns the status and body.
|
||||
func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/refresh",
|
||||
map[string]string{"refreshToken": token}, "")
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
// wantInvalidGrant asserts a 401 with the machine-readable invalid_grant code.
|
||||
func wantInvalidGrant(t *testing.T, status int, body []byte) {
|
||||
t.Helper()
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401: %s", status, body)
|
||||
}
|
||||
env := decodeBody(t, body)
|
||||
var eobj struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(env["error"], &eobj); err != nil {
|
||||
t.Fatalf("error envelope: %v (%s)", err, body)
|
||||
}
|
||||
if eobj.Code != "invalid_grant" {
|
||||
t.Fatalf("error code = %q, want invalid_grant: %s", eobj.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginReturnsRefreshTokenHashedAtRest(t *testing.T) {
|
||||
env, _, sqlDB := newRefreshEnv(t)
|
||||
access, refresh := loginRefresh(t, env.ts)
|
||||
|
||||
if _, err := env.auth.Verify(access); err != nil {
|
||||
t.Fatalf("access token does not verify: %v", err)
|
||||
}
|
||||
var hash string
|
||||
if err := sqlDB.QueryRowContext(context.Background(),
|
||||
"SELECT token_hash FROM refresh_tokens WHERE user_id = 'admin'").Scan(&hash); err != nil {
|
||||
t.Fatalf("select token_hash: %v", err)
|
||||
}
|
||||
if hash == refresh {
|
||||
t.Fatal("refresh token stored in plaintext")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(refresh))
|
||||
if want := hex.EncodeToString(sum[:]); hash != want {
|
||||
t.Fatalf("stored hash = %q, want %q", hash, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotatesAccessAndRefreshTokens(t *testing.T) {
|
||||
env, _, _ := newRefreshEnv(t)
|
||||
_, oldRefresh := loginRefresh(t, env.ts)
|
||||
|
||||
status, body := postRefresh(t, env.ts, oldRefresh)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("refresh status = %d, want 200: %s", status, body)
|
||||
}
|
||||
var data struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Role string `json:"role"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
decodeData(t, body, &data)
|
||||
if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" {
|
||||
t.Fatalf("refresh payload = %+v, want token/expiresAt/role", data)
|
||||
}
|
||||
if data.RefreshToken == "" || data.RefreshToken == oldRefresh {
|
||||
t.Fatalf("refresh token not rotated: %q", data.RefreshToken)
|
||||
}
|
||||
claims, err := env.auth.Verify(data.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("refreshed access token does not verify: %v", err)
|
||||
}
|
||||
if claims.Sub != "admin" || claims.Role != auth.RoleOwner {
|
||||
t.Fatalf("refreshed claims = %+v, want admin/owner", claims)
|
||||
}
|
||||
|
||||
// The presented token is single-use: replay must fail.
|
||||
status, body = postRefresh(t, env.ts, oldRefresh)
|
||||
wantInvalidGrant(t, status, body)
|
||||
|
||||
// The rotated token works.
|
||||
if status, body = postRefresh(t, env.ts, data.RefreshToken); status != http.StatusOK {
|
||||
t.Fatalf("rotate replacement status = %d, want 200: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRejectsBadGrants(t *testing.T) {
|
||||
env, refresh, sqlDB := newRefreshEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
expired := "expired-token-value"
|
||||
sum := sha256.Sum256([]byte(expired))
|
||||
if _, err := sqlDB.ExecContext(ctx,
|
||||
`INSERT INTO refresh_tokens (id, user_id, role, token_hash, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
"seed-expired", "admin", "owner", hex.EncodeToString(sum[:]),
|
||||
time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatalf("seed expired token: %v", err)
|
||||
}
|
||||
revoked, _, err := refresh.Issue(ctx, "admin", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue revoked token: %v", err)
|
||||
}
|
||||
if ok, err := refresh.Revoke(ctx, revoked); err != nil || !ok {
|
||||
t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"garbage": "not-a-real-token",
|
||||
"expired": expired,
|
||||
"revoked": revoked,
|
||||
}
|
||||
for name, token := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
status, body := postRefresh(t, env.ts, token)
|
||||
wantInvalidGrant(t, status, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRevokesRefreshToken(t *testing.T) {
|
||||
env, _, _ := newRefreshEnv(t)
|
||||
_, refreshToken := loginRefresh(t, env.ts)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
|
||||
map[string]string{"refreshToken": refreshToken}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body)
|
||||
}
|
||||
var data struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
decodeData(t, body, &data)
|
||||
if !data.Revoked {
|
||||
t.Fatalf("logout revoked = false, want true: %s", body)
|
||||
}
|
||||
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status = %d, want 401: %s", status, body)
|
||||
}
|
||||
|
||||
// Logout is idempotent: a second call reports revoked=false, not an error.
|
||||
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout",
|
||||
map[string]string{"refreshToken": refreshToken}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second logout status = %d, want 200: %s", resp.StatusCode, body)
|
||||
}
|
||||
decodeData(t, body, &data)
|
||||
if data.Revoked {
|
||||
t.Fatalf("second logout revoked = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutWithAccessTokenRevokesUserGrants(t *testing.T) {
|
||||
env, _, _ := newRefreshEnv(t)
|
||||
access, refreshToken := loginRefresh(t, env.ts)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", nil, access)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body)
|
||||
}
|
||||
var data struct {
|
||||
Revoked bool `json:"revoked"`
|
||||
RevokedCount int64 `json:"revokedCount"`
|
||||
}
|
||||
decodeData(t, body, &data)
|
||||
if !data.Revoked || data.RevokedCount != 1 {
|
||||
t.Fatalf("logout payload = %+v, want revoked with 1 grant", data)
|
||||
}
|
||||
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after access-token logout status = %d, want 401: %s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateJWTInvalidatesRefreshTokens(t *testing.T) {
|
||||
env, _, _ := newRefreshEnv(t)
|
||||
_, refreshToken := loginRefresh(t, env.ts)
|
||||
|
||||
owner := env.token(t, auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]string{"newSecret": "a-brand-new-jwt-secret-at-least-32-chars"}, owner)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rotate-jwt status = %d, want 200: %s", resp.StatusCode, body)
|
||||
}
|
||||
var data struct {
|
||||
Rotated bool `json:"rotated"`
|
||||
RefreshTokensRevoked int64 `json:"refreshTokensRevoked"`
|
||||
}
|
||||
decodeData(t, body, &data)
|
||||
if !data.Rotated || data.RefreshTokensRevoked != 1 {
|
||||
t.Fatalf("rotate-jwt payload = %+v, want rotated with 1 refresh token revoked", data)
|
||||
}
|
||||
if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after rotate-jwt status = %d, want 401: %s", status, body)
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,14 @@ type loginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// loginResponse is the login success payload.
|
||||
// loginResponse is the login success payload. RefreshToken is the opaque
|
||||
// long-lived credential (POST /auth/refresh) added by B2; it is omitted when no
|
||||
// refresh store is wired so the pre-B2 contract is unchanged.
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Role string `json:"role"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
Role string `json:"role"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
}
|
||||
|
||||
// chatRequest is the POST /api/v1/chat body. When Stream is true the gateway
|
||||
@@ -114,11 +117,24 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
resp := loginResponse{
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(tokenTTL).Unix(),
|
||||
Role: string(auth.RoleOwner),
|
||||
})
|
||||
}
|
||||
// Issue the long-lived refresh credential alongside the access token. A store
|
||||
// failure fails the login rather than silently handing back a login without
|
||||
// the advertised refresh token; the plaintext is never logged.
|
||||
if s.refresh != nil {
|
||||
refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL())
|
||||
if rerr != nil {
|
||||
s.log.Error("login issue refresh token", "err", rerr)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not issue refresh token"))
|
||||
return
|
||||
}
|
||||
resp.RefreshToken = refreshToken
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleChat runs one synchronous agent turn (or triggers streaming).
|
||||
|
||||
@@ -157,6 +157,32 @@ type UserStore interface {
|
||||
List() []auth.UserSummary
|
||||
}
|
||||
|
||||
// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the
|
||||
// production implementation). It is the credential behind the refreshToken
|
||||
// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only
|
||||
// hashes are persisted; the plaintext token crosses this interface exactly once
|
||||
// per issue/rotate. Optional: when nil login omits refreshToken and the refresh
|
||||
// and logout endpoints report a clear not-configured error.
|
||||
type RefreshGrantStore interface {
|
||||
// Issue mints a refresh token for userID/role and returns its plaintext
|
||||
// (visible once) plus its expiry.
|
||||
Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error)
|
||||
// Rotate validates plaintext and, when opts.Rotate is set, revokes it and
|
||||
// returns a replacement. It returns auth.ErrInvalidGrant for an unknown,
|
||||
// revoked, or expired token.
|
||||
Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error)
|
||||
// Revoke marks the token matching plaintext revoked, returning whether a
|
||||
// live token was found. It is idempotent.
|
||||
Revoke(ctx context.Context, plaintext string) (bool, error)
|
||||
// RevokeAll deletes every refresh token (global invalidation on secret
|
||||
// rotation) and returns how many were removed.
|
||||
RevokeAll(ctx context.Context) (int64, error)
|
||||
// RevokeByUser deletes every refresh token for userID (logout by access
|
||||
// token, where the individual grant is unknown) and returns how many were
|
||||
// removed.
|
||||
RevokeByUser(ctx context.Context, userID string) (int64, error)
|
||||
}
|
||||
|
||||
// BackthinkStore enumerates the persisted background-think records for the
|
||||
// M9/M10 monitor panel. *backthink.Backthink is the production implementation.
|
||||
type BackthinkStore interface {
|
||||
@@ -247,6 +273,9 @@ type Deps struct {
|
||||
// Users lists accounts and API keys. Optional (falls back to the configured
|
||||
// single admin account).
|
||||
Users UserStore
|
||||
// Refresh is the opaque refresh-token store. Optional: when nil login omits
|
||||
// refreshToken and /auth/refresh + /auth/logout answer not-configured.
|
||||
Refresh RefreshGrantStore
|
||||
// Backthink lists background-think records. Optional.
|
||||
Backthink BackthinkStore
|
||||
// RAG backs the admin knowledge-base RAG module. Optional; list/search
|
||||
@@ -314,6 +343,7 @@ type Server struct {
|
||||
taskOutputs TaskOutputStore
|
||||
automations AutomationStore
|
||||
users UserStore
|
||||
refresh RefreshGrantStore
|
||||
backthink BackthinkStore
|
||||
rag RagStore
|
||||
obs *observability
|
||||
@@ -363,6 +393,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
taskOutputs: deps.Tasks,
|
||||
automations: deps.Automations,
|
||||
users: deps.Users,
|
||||
refresh: deps.Refresh,
|
||||
backthink: deps.Backthink,
|
||||
rag: deps.RAG,
|
||||
obs: newObservability(deps),
|
||||
@@ -390,6 +421,11 @@ func (s *Server) Handler() http.Handler {
|
||||
// Public, no auth.
|
||||
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
// Refresh/logout are public by design: the opaque refresh token is itself the
|
||||
// credential, so a client with an expired access token can still rotate or
|
||||
// revoke it. Both fail closed with 401 invalid_grant on a bad token.
|
||||
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
||||
|
||||
// User side, authenticated. Chat is a write (owner/admin); reads accept any
|
||||
// authenticated role so a viewer can still list personas/sessions.
|
||||
|
||||
Reference in new issue
Block a user