- 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 同步
256 lines
7.9 KiB
Go
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)
|
|
}
|
|
}
|