- 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 同步
330 lines
12 KiB
Go
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)
|
|
}
|