Files
Cyrene_v2/internal/auth/refresh_test.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

256 lines
7.9 KiB
Go

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