fix: 完善刷新令牌与联合限流
This commit is contained in:
@@ -20,6 +20,7 @@ import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js
|
||||
import { AppModule } from "../app.module.js";
|
||||
import { configureApp } from "../main.js";
|
||||
import { RedisService } from "../redis/redis.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||
@@ -59,8 +60,10 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
for (const key of await redis.keys("drift:auth:e2e:*"))
|
||||
await redis.del(key);
|
||||
delete process.env.SMS_SEND_LIMIT;
|
||||
delete process.env.SMS_SEND_DEVICE_LIMIT;
|
||||
delete process.env.SMS_SEND_IP_LIMIT;
|
||||
delete process.env.SMS_VERIFY_LIMIT;
|
||||
delete process.env.SMS_VERIFY_COMBINED_LIMIT;
|
||||
delete process.env.SMS_CODE_TTL_SECONDS;
|
||||
delete process.env.ACCESS_TOKEN_TTL_SECONDS;
|
||||
delete process.env.TRUST_PROXY;
|
||||
@@ -81,6 +84,8 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
.post("/api/v1/auth/sms/login")
|
||||
.send({ phone, deviceId, code: sent.body.data.debugCode });
|
||||
}
|
||||
const refreshValue = (cookie: string) =>
|
||||
cookie.slice("refresh_token=".length);
|
||||
it("sends, logs in with one-time code, stores encrypted identity, sets strict cookie, and guards me", async () => {
|
||||
const sent = await send();
|
||||
expect(sent.status).toBe(201);
|
||||
@@ -126,6 +131,38 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
const keys = await redis.keys("drift:auth:e2e:limit:send-phone:*");
|
||||
expect(await redis.ttl(keys[0]!)).toBeGreaterThan(0);
|
||||
});
|
||||
it("does not consume other send dimensions when one atomic limit is exceeded", async () => {
|
||||
process.env.SMS_SEND_LIMIT = "1";
|
||||
process.env.SMS_SEND_DEVICE_LIMIT = "10";
|
||||
process.env.SMS_SEND_IP_LIMIT = "10";
|
||||
await send();
|
||||
const deviceKey = (
|
||||
await redis.keys("drift:auth:e2e:limit:send-device:*")
|
||||
)[0]!;
|
||||
const ipKey = (await redis.keys("drift:auth:e2e:limit:send-ip:*"))[0]!;
|
||||
expect(await redis.mGet([deviceKey, ipKey])).toEqual(["1", "1"]);
|
||||
await send().then((response) => expect(response.status).toBe(429));
|
||||
expect(await redis.mGet([deviceKey, ipKey])).toEqual(["1", "1"]);
|
||||
});
|
||||
|
||||
it("does not consume combined verify dimension when phone verification is exceeded", async () => {
|
||||
process.env.SMS_VERIFY_LIMIT = "1";
|
||||
process.env.SMS_VERIFY_COMBINED_LIMIT = "10";
|
||||
const sent = await send();
|
||||
const wrong = sent.body.data.debugCode === "000000" ? "999999" : "000000";
|
||||
const attempt = () =>
|
||||
request(app.getHttpServer())
|
||||
.post("/api/v1/auth/sms/login")
|
||||
.send({ phone, deviceId, code: wrong });
|
||||
await attempt().then((response) => expect(response.status).toBe(401));
|
||||
const key = (
|
||||
await redis.keys("drift:auth:e2e:limit:verify-combined:*")
|
||||
)[0]!;
|
||||
expect(await redis.get(key)).toBe("1");
|
||||
await attempt().then((response) => expect(response.status).toBe(429));
|
||||
expect(await redis.get(key)).toBe("1");
|
||||
});
|
||||
|
||||
it("omits debugCode in production", async () => {
|
||||
process.env.NODE_ENV = "production";
|
||||
try {
|
||||
@@ -162,6 +199,45 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
.expect(401);
|
||||
expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull();
|
||||
});
|
||||
it("issues a 32-byte opaque refresh token that does not expose the session id", async () => {
|
||||
const logged = await login();
|
||||
const token = refreshValue(
|
||||
cookieValue(logged.headers["set-cookie"] as unknown as string[]),
|
||||
);
|
||||
const session = await prisma.session.findFirstOrThrow();
|
||||
expect(Buffer.from(token, "base64url")).toHaveLength(32);
|
||||
expect(token).not.toContain(session.id);
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
});
|
||||
|
||||
it("detects first-generation replay after three rotations and revokes current token", async () => {
|
||||
const logged = await login();
|
||||
const cookies = [
|
||||
cookieValue(logged.headers["set-cookie"] as unknown as string[]),
|
||||
];
|
||||
for (let generation = 0; generation < 3; generation += 1) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/token/refresh")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", cookies.at(-1)!)
|
||||
.expect(200);
|
||||
cookies.push(
|
||||
cookieValue(response.headers["set-cookie"] as unknown as string[]),
|
||||
);
|
||||
}
|
||||
const replay = await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/token/refresh")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", cookies[0]!)
|
||||
.expect(401);
|
||||
expect(replay.body.code).toBe("AUTH_REFRESH_REUSED");
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/token/refresh")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", cookies.at(-1)!)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("uses the first refresh cookie rather than allowing a duplicate to overwrite it", async () => {
|
||||
const logged = await login();
|
||||
const valid = cookieValue(
|
||||
@@ -352,6 +428,51 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing session", { session_id: randomUUID() }],
|
||||
["account claim mismatch", { sub: randomUUID() }],
|
||||
["device claim mismatch", { device_id: "another-device" }],
|
||||
])(
|
||||
"guard independently rejects %s with a uniform error",
|
||||
async (_name, change) => {
|
||||
await login();
|
||||
const session = await prisma.session.findFirstOrThrow({
|
||||
include: { account: true },
|
||||
});
|
||||
const token = app.get(TokenService).issueAccess({
|
||||
sub: session.accountId,
|
||||
session_id: session.id,
|
||||
device_id: session.deviceId ?? "",
|
||||
scopes: ["user"],
|
||||
token_version: session.account.tokenVersion,
|
||||
...change,
|
||||
});
|
||||
const denied = await request(app.getHttpServer())
|
||||
.get("/api/v1/me")
|
||||
.set("Authorization", `Bearer ${token}`)
|
||||
.expect(401);
|
||||
expect(denied.body).toMatchObject({
|
||||
code: "AUTH_UNAUTHORIZED",
|
||||
message: "Unauthorized",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("guard independently rejects a database-expired session with a uniform error", async () => {
|
||||
const logged = await login();
|
||||
await prisma.session.updateMany({
|
||||
data: { expiresAt: new Date(Date.now() - 1) },
|
||||
});
|
||||
const denied = await request(app.getHttpServer())
|
||||
.get("/api/v1/me")
|
||||
.set("Authorization", `Bearer ${logged.body.data.accessToken}`)
|
||||
.expect(401);
|
||||
expect(denied.body).toMatchObject({
|
||||
code: "AUTH_UNAUTHORIZED",
|
||||
message: "Unauthorized",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a suspended account", async () => {
|
||||
const logged = await login();
|
||||
const session = await prisma.session.findFirstOrThrow();
|
||||
@@ -458,6 +579,54 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"not-a-token%ZZ",
|
||||
"x".repeat(4096),
|
||||
])(
|
||||
"returns stable 401 for malformed refresh token %# without revoking",
|
||||
async (token) => {
|
||||
await login();
|
||||
const session = await prisma.session.findFirstOrThrow();
|
||||
const denied = await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/token/refresh")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", `refresh_token=${token}`)
|
||||
.expect(401);
|
||||
expect(denied.body.code).toBe("AUTH_INVALID_CREDENTIALS");
|
||||
expect(
|
||||
(await prisma.session.findUniqueOrThrow({ where: { id: session.id } }))
|
||||
.revokedAt,
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("logs out a family through any used refresh token idempotently", async () => {
|
||||
const logged = await login();
|
||||
const first = cookieValue(
|
||||
logged.headers["set-cookie"] as unknown as string[],
|
||||
);
|
||||
const rotated = await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/token/refresh")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", first)
|
||||
.expect(200);
|
||||
const current = cookieValue(
|
||||
rotated.headers["set-cookie"] as unknown as string[],
|
||||
);
|
||||
for (let n = 0; n < 2; n += 1)
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/logout")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", first)
|
||||
.expect(200);
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v1/auth/token/refresh")
|
||||
.set("Origin", origin)
|
||||
.set("Cookie", current)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("recovers Auth Redis after a connection deadline and closes its sockets", async () => {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((socket) => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
envInt,
|
||||
phoneHmac,
|
||||
refreshHmac,
|
||||
safeEqual,
|
||||
} from "./auth.config.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
|
||||
@@ -45,19 +44,31 @@ export class AuthService {
|
||||
private prefix() {
|
||||
return process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
|
||||
}
|
||||
private async limit(
|
||||
kind: string,
|
||||
parts: string[],
|
||||
max: number,
|
||||
ttl: number,
|
||||
private async limitAll(
|
||||
dimensions: Array<{
|
||||
kind: string;
|
||||
parts: string[];
|
||||
max: number;
|
||||
ttl: number;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
await this.redis.ensureConnected();
|
||||
const key = `${this.prefix()}limit:${kind}:${parts.join(":")}`;
|
||||
const result = (await this.redis.client.eval(
|
||||
`local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n`,
|
||||
{ keys: [key], arguments: [String(ttl)] },
|
||||
)) as number;
|
||||
if (result > max)
|
||||
const keys = dimensions.map(
|
||||
({ kind, parts }) => `${this.prefix()}limit:${kind}:${parts.join(":")}`,
|
||||
);
|
||||
const args = dimensions.flatMap(({ max, ttl }) => [
|
||||
String(max),
|
||||
String(ttl),
|
||||
]);
|
||||
// Atomic across all dimensions on the MVP's single Redis node. Redis Cluster
|
||||
// would require a colocated hash tag or a different distributed limiter.
|
||||
const result = Number(
|
||||
await this.redis.client.eval(
|
||||
`for i,key in ipairs(KEYS) do local n=tonumber(redis.call('GET',key) or '0'); local max=tonumber(ARGV[(i-1)*2+1]); if n+1>max then return 0 end end; for i,key in ipairs(KEYS) do local n=redis.call('INCR',key); if n==1 then redis.call('EXPIRE',key,ARGV[(i-1)*2+2]) end end; return 1`,
|
||||
{ keys, arguments: args },
|
||||
),
|
||||
);
|
||||
if (result !== 1)
|
||||
throw new DomainException(
|
||||
ErrorCode.RATE_LIMITED,
|
||||
"Too many requests",
|
||||
@@ -71,25 +82,26 @@ export class AuthService {
|
||||
): Promise<{ sent: true; debugCode?: string }> {
|
||||
const phone = this.normalizePhone(rawPhone);
|
||||
const digest = phoneHmac(phone);
|
||||
await Promise.all([
|
||||
this.limit(
|
||||
"send-phone",
|
||||
[digest],
|
||||
envInt("SMS_SEND_LIMIT", 3),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
this.limit(
|
||||
"send-device",
|
||||
[deviceId],
|
||||
envInt("SMS_SEND_DEVICE_LIMIT", 5),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
this.limit(
|
||||
"send-ip",
|
||||
[phoneHmac(ip)],
|
||||
envInt("SMS_SEND_IP_LIMIT", 10),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
const window = envInt("SMS_RATE_WINDOW_SECONDS", 300);
|
||||
await this.limitAll([
|
||||
{
|
||||
kind: "send-phone",
|
||||
parts: [digest],
|
||||
max: envInt("SMS_SEND_LIMIT", 3),
|
||||
ttl: window,
|
||||
},
|
||||
{
|
||||
kind: "send-device",
|
||||
parts: [deviceId],
|
||||
max: envInt("SMS_SEND_DEVICE_LIMIT", 5),
|
||||
ttl: window,
|
||||
},
|
||||
{
|
||||
kind: "send-ip",
|
||||
parts: [phoneHmac(ip)],
|
||||
max: envInt("SMS_SEND_IP_LIMIT", 10),
|
||||
ttl: window,
|
||||
},
|
||||
]);
|
||||
const code = randomInt(0, 1000000).toString().padStart(6, "0");
|
||||
await this.redis.client.set(
|
||||
@@ -109,19 +121,20 @@ export class AuthService {
|
||||
): Promise<TokenPair> {
|
||||
const phone = this.normalizePhone(rawPhone);
|
||||
const digest = phoneHmac(phone);
|
||||
await Promise.all([
|
||||
this.limit(
|
||||
"verify-phone",
|
||||
[digest],
|
||||
envInt("SMS_VERIFY_LIMIT", 5),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
this.limit(
|
||||
"verify-combined",
|
||||
[digest, phoneHmac(ip), deviceId],
|
||||
envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
|
||||
envInt("SMS_RATE_WINDOW_SECONDS", 300),
|
||||
),
|
||||
const window = envInt("SMS_RATE_WINDOW_SECONDS", 300);
|
||||
await this.limitAll([
|
||||
{
|
||||
kind: "verify-phone",
|
||||
parts: [digest],
|
||||
max: envInt("SMS_VERIFY_LIMIT", 5),
|
||||
ttl: window,
|
||||
},
|
||||
{
|
||||
kind: "verify-combined",
|
||||
parts: [digest, phoneHmac(ip), deviceId],
|
||||
max: envInt("SMS_VERIFY_COMBINED_LIMIT", 5),
|
||||
ttl: window,
|
||||
},
|
||||
]);
|
||||
const key = `${this.prefix()}code:${digest}`;
|
||||
const consumed = await this.redis.client.eval(
|
||||
@@ -162,7 +175,7 @@ export class AuthService {
|
||||
deviceId: string,
|
||||
): Promise<TokenPair> {
|
||||
const id = randomUUID();
|
||||
const refresh = this.tokens.newRefresh(id);
|
||||
const refresh = this.tokens.newRefresh();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000,
|
||||
);
|
||||
@@ -174,6 +187,9 @@ export class AuthService {
|
||||
refreshTokenHash: refreshHmac(refresh),
|
||||
tokenFamily: id,
|
||||
expiresAt,
|
||||
refreshTokens: {
|
||||
create: { tokenHash: refreshHmac(refresh), generation: 0, expiresAt },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.pair(account, id, deviceId, refresh);
|
||||
@@ -198,48 +214,59 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
async refresh(token: string): Promise<TokenPair> {
|
||||
const parsed = this.parseRefresh(token);
|
||||
if (!this.validRefreshShape(token)) throw this.invalid();
|
||||
const hash = refreshHmac(token);
|
||||
const observed = await this.prisma.session.findUnique({
|
||||
where: { id: parsed },
|
||||
const observed = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash: hash },
|
||||
include: { session: true },
|
||||
});
|
||||
if (
|
||||
observed?.previousTokenHash &&
|
||||
safeEqual(observed.previousTokenHash, hash)
|
||||
)
|
||||
return this.revokeReusedFamily(observed.tokenFamily);
|
||||
if (!observed) throw this.invalid();
|
||||
if (observed.status === "USED")
|
||||
return this.revokeReusedFamily(observed.session.tokenFamily);
|
||||
|
||||
try {
|
||||
const result = await this.prisma.$transaction(
|
||||
async (tx): Promise<TokenPair | { reusedFamily: string }> => {
|
||||
const session = await tx.session.findUnique({
|
||||
where: { id: parsed },
|
||||
include: { account: true },
|
||||
const stored = await tx.refreshToken.findUnique({
|
||||
where: { tokenHash: hash },
|
||||
include: { session: { include: { account: true } } },
|
||||
});
|
||||
if (!session) throw this.invalid();
|
||||
if (!stored) throw this.invalid();
|
||||
if (stored.status === "USED")
|
||||
return { reusedFamily: stored.session.tokenFamily };
|
||||
const session = stored.session;
|
||||
if (
|
||||
session.previousTokenHash &&
|
||||
safeEqual(session.previousTokenHash, hash)
|
||||
)
|
||||
return { reusedFamily: session.tokenFamily };
|
||||
if (
|
||||
!safeEqual(session.refreshTokenHash, hash) ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= new Date() ||
|
||||
stored.expiresAt <= new Date() ||
|
||||
session.account.status !== "ACTIVE"
|
||||
)
|
||||
throw this.invalid();
|
||||
const next = this.tokens.newRefresh(session.id);
|
||||
const changed = await tx.session.updateMany({
|
||||
where: { id: session.id, refreshTokenHash: hash, revokedAt: null },
|
||||
data: {
|
||||
previousTokenHash: hash,
|
||||
refreshTokenHash: refreshHmac(next),
|
||||
generation: { increment: 1 },
|
||||
rotatedAt: new Date(),
|
||||
},
|
||||
const next = this.tokens.newRefresh();
|
||||
const nextHash = refreshHmac(next);
|
||||
const usedAt = new Date();
|
||||
const changed = await tx.refreshToken.updateMany({
|
||||
where: { id: stored.id, status: "ACTIVE" },
|
||||
data: { status: "USED", usedAt, replacedByHash: nextHash },
|
||||
});
|
||||
if (changed.count !== 1) return { reusedFamily: session.tokenFamily };
|
||||
await tx.refreshToken.create({
|
||||
data: {
|
||||
tokenHash: nextHash,
|
||||
sessionId: session.id,
|
||||
generation: stored.generation + 1,
|
||||
expiresAt: session.expiresAt,
|
||||
},
|
||||
});
|
||||
await tx.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
previousTokenHash: hash,
|
||||
refreshTokenHash: nextHash,
|
||||
generation: stored.generation + 1,
|
||||
rotatedAt: usedAt,
|
||||
},
|
||||
});
|
||||
return this.pair(
|
||||
session.account,
|
||||
session.id,
|
||||
@@ -256,16 +283,21 @@ export class AuthService {
|
||||
if (error instanceof DomainException) throw error;
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2034"
|
||||
(error.code === "P2034" || error.code === "P2002")
|
||||
) {
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: parsed },
|
||||
});
|
||||
if (
|
||||
session?.previousTokenHash &&
|
||||
safeEqual(session.previousTokenHash, hash)
|
||||
)
|
||||
return this.revokeReusedFamily(session.tokenFamily);
|
||||
// A serialization/uniqueness loser can observe the winner only after its
|
||||
// transaction commits. Poll briefly, then revoke outside the failed tx.
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const raced = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash: hash },
|
||||
include: { session: true },
|
||||
});
|
||||
if (raced?.status === "USED")
|
||||
return this.revokeReusedFamily(raced.session.tokenFamily);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 10 * (attempt + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -282,26 +314,19 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
async logout(token: string | undefined): Promise<void> {
|
||||
if (!token) return;
|
||||
let id: string;
|
||||
try {
|
||||
id = this.parseRefresh(token);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const hash = refreshHmac(token);
|
||||
if (!token || !this.validRefreshShape(token)) return;
|
||||
const stored = await this.prisma.refreshToken.findUnique({
|
||||
where: { tokenHash: refreshHmac(token) },
|
||||
include: { session: true },
|
||||
});
|
||||
if (!stored) return;
|
||||
await this.prisma.session.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [{ refreshTokenHash: hash }, { previousTokenHash: hash }],
|
||||
},
|
||||
where: { tokenFamily: stored.session.tokenFamily },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
private parseRefresh(token: string): string {
|
||||
const id = token.split(".")[0];
|
||||
if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw this.invalid();
|
||||
return id;
|
||||
private validRefreshShape(token: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{43}$/.test(token);
|
||||
}
|
||||
private invalid() {
|
||||
return new DomainException(
|
||||
|
||||
@@ -76,7 +76,7 @@ export class TokenService {
|
||||
);
|
||||
}
|
||||
}
|
||||
newRefresh(sessionId: string): string {
|
||||
return `${sessionId}.${randomBytes(32).toString("base64url")}`;
|
||||
newRefresh(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TYPE "RefreshTokenStatus" AS ENUM ('ACTIVE', 'USED');
|
||||
|
||||
CREATE TABLE "refresh_tokens" (
|
||||
"id" UUID NOT NULL,
|
||||
"token_hash" VARCHAR(255) NOT NULL,
|
||||
"session_id" UUID NOT NULL,
|
||||
"generation" INTEGER NOT NULL,
|
||||
"status" "RefreshTokenStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"used_at" TIMESTAMPTZ(3),
|
||||
"replaced_by_hash" VARCHAR(255),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "refresh_tokens_generation_nonnegative" CHECK ("generation" >= 0),
|
||||
CONSTRAINT "refresh_tokens_used_state_consistent" CHECK (
|
||||
("status" = 'ACTIVE' AND "used_at" IS NULL AND "replaced_by_hash" IS NULL)
|
||||
OR ("status" = 'USED' AND "used_at" IS NOT NULL AND "replaced_by_hash" IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Legacy refresh values exposed the session id and cannot be upgraded client-side.
|
||||
-- Revoke those families; subsequent logins create opaque token history rows.
|
||||
UPDATE "sessions" SET "revoked_at" = COALESCE("revoked_at", CURRENT_TIMESTAMP);
|
||||
|
||||
CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
|
||||
CREATE UNIQUE INDEX "refresh_tokens_replaced_by_hash_key" ON "refresh_tokens"("replaced_by_hash");
|
||||
CREATE UNIQUE INDEX "refresh_tokens_session_id_generation_key" ON "refresh_tokens"("session_id", "generation");
|
||||
CREATE INDEX "refresh_tokens_session_id_status_idx" ON "refresh_tokens"("session_id", "status");
|
||||
CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
|
||||
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_session_id_fkey"
|
||||
FOREIGN KEY ("session_id") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+37
-13
@@ -19,6 +19,11 @@ enum AuthProvider {
|
||||
WECHAT
|
||||
}
|
||||
|
||||
enum RefreshTokenStatus {
|
||||
ACTIVE
|
||||
USED
|
||||
}
|
||||
|
||||
enum ReviewStatus {
|
||||
DRAFT
|
||||
REVIEWING
|
||||
@@ -148,25 +153,44 @@ model AuthIdentity {
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
accountId String @map("account_id") @db.Uuid
|
||||
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255)
|
||||
deviceId String? @map("device_id") @db.VarChar(255)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
||||
tokenFamily String @map("token_family") @db.Uuid
|
||||
generation Int @default(0)
|
||||
previousTokenHash String? @unique @map("previous_token_hash") @db.VarChar(255)
|
||||
rotatedAt DateTime? @map("rotated_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
accountId String @map("account_id") @db.Uuid
|
||||
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255)
|
||||
deviceId String? @map("device_id") @db.VarChar(255)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
||||
tokenFamily String @map("token_family") @db.Uuid
|
||||
generation Int @default(0)
|
||||
previousTokenHash String? @unique @map("previous_token_hash") @db.VarChar(255)
|
||||
rotatedAt DateTime? @map("rotated_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
@@index([accountId, expiresAt])
|
||||
@@index([tokenFamily])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tokenHash String @unique @map("token_hash") @db.VarChar(255)
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
generation Int
|
||||
status RefreshTokenStatus @default(ACTIVE)
|
||||
usedAt DateTime? @map("used_at") @db.Timestamptz(3)
|
||||
replacedByHash String? @unique @map("replaced_by_hash") @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, generation])
|
||||
@@index([sessionId, status])
|
||||
@@index([expiresAt])
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
model Bottle {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
authorId String @map("author_id") @db.Uuid
|
||||
|
||||
@@ -61,6 +61,38 @@ describe("database authority constraints", () => {
|
||||
});
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
|
||||
it("defines indexed refresh-token history", async () => {
|
||||
const columns = await prisma.$queryRaw<Array<{ column_name: string }>>`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'refresh_tokens'`;
|
||||
expect(columns.map(({ column_name }) => column_name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"token_hash",
|
||||
"session_id",
|
||||
"generation",
|
||||
"status",
|
||||
"used_at",
|
||||
"replaced_by_hash",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
]),
|
||||
);
|
||||
const indexes = await prisma.$queryRaw<Array<{ indexdef: string }>>`
|
||||
SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'refresh_tokens'`;
|
||||
expect(
|
||||
indexes.some(
|
||||
({ indexdef }) =>
|
||||
indexdef.includes("UNIQUE") && indexdef.includes("token_hash"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
indexes.some(
|
||||
({ indexdef }) =>
|
||||
indexdef.includes("session_id") && indexdef.includes("generation"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("expresses independent moderation and pool lifecycle state", async () => {
|
||||
const author = await createAccount("states");
|
||||
const bottle = await prisma.bottle.create({
|
||||
|
||||
Reference in New Issue
Block a user