Files
Cyrene_v2/internal/auth/refresh.go
T
AskaEth 026b27b310
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 同步
2026-09-08 21:45:00 +08:00

330 lines
12 KiB
Go

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)
}