feat: jwt cache, redis health, help-ticket admin, and write rate limits
Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
3bb96eb6f3
commit
968ca15c27
23 files changed
+1344
-205
No files matched your search
@@ -97,13 +97,18 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
titleKey: "tickets",
|
||||
subtitleKey: "ticketsSubtitle",
|
||||
icon: Ticket,
|
||||
prefixes: ["/admin/tickets"],
|
||||
prefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "tickets",
|
||||
match: ["/admin/tickets"],
|
||||
},
|
||||
{
|
||||
href: "/admin/help-tickets",
|
||||
labelKey: "helpTickets",
|
||||
match: ["/admin/help-tickets"],
|
||||
},
|
||||
{ href: "/admin/tickets/templates", labelKey: "templates" },
|
||||
],
|
||||
},
|
||||
@@ -285,7 +290,7 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
href: "/admin/tickets",
|
||||
labelKey: "tickets",
|
||||
icon: Ticket,
|
||||
matchPrefixes: ["/admin/tickets"],
|
||||
matchPrefixes: ["/admin/tickets", "/admin/help-tickets"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+76
-71
@@ -4,6 +4,7 @@ import Discord from "next-auth/providers/discord";
|
||||
import Google from "next-auth/providers/google";
|
||||
import { env } from "@/env";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -210,6 +211,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
(user as { jwtVersion?: number }).jwtVersion ??
|
||||
token.jwtVersion ??
|
||||
0;
|
||||
token.jwtCheckedAt = Date.now();
|
||||
}
|
||||
|
||||
if (user && account?.provider === "credentials") {
|
||||
@@ -217,29 +219,58 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
return token;
|
||||
}
|
||||
|
||||
const requireLink = await siteSettings.getBool(
|
||||
"oauth_require_link",
|
||||
false,
|
||||
);
|
||||
// OAuth account linking only when establishing a session — not on
|
||||
// every subsequent request (avoids siteSettings + DB on each hit).
|
||||
if (user || account) {
|
||||
const requireLink = await siteSettings.getBool(
|
||||
"oauth_require_link",
|
||||
false,
|
||||
);
|
||||
|
||||
// Try Discord ID via SocialAccounts (always allowed, even when requireLink is true).
|
||||
if (
|
||||
!token.sub &&
|
||||
account?.provider === "discord" &&
|
||||
account.providerAccountId
|
||||
) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: account.providerAccountId,
|
||||
// Try Discord ID via SocialAccounts (always allowed, even when requireLink is true).
|
||||
if (
|
||||
!token.sub &&
|
||||
account?.provider === "discord" &&
|
||||
account.providerAccountId
|
||||
) {
|
||||
try {
|
||||
const linked = await prisma.socialAccounts.findUnique({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: account.providerAccountId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (linked) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(linked.userId) },
|
||||
});
|
||||
if (linked) {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(linked.userId) },
|
||||
select: {
|
||||
id: true,
|
||||
rank: true,
|
||||
username: true,
|
||||
websiteJwtVersion: true,
|
||||
},
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
token.jwtVersion = dbUser.websiteJwtVersion;
|
||||
token.jwtCheckedAt = Date.now();
|
||||
return token;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
// Email-based binding: only when requireLink is off AND account has no 2FA.
|
||||
if (!requireLink && user?.email && !token.sub) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
rank: true,
|
||||
@@ -252,59 +283,33 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
token.jwtVersion = dbUser.websiteJwtVersion;
|
||||
token.jwtCheckedAt = Date.now();
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check jwt version at most once per minute (memory/Redis cached).
|
||||
if (token.sub && !token.invalid) {
|
||||
const lastCheck =
|
||||
typeof token.jwtCheckedAt === "number" ? token.jwtCheckedAt : 0;
|
||||
if (Date.now() - lastCheck >= 60_000) {
|
||||
try {
|
||||
const version = await getCachedJwtVersion(Number(token.sub));
|
||||
if (
|
||||
version === null ||
|
||||
(token.jwtVersion ?? 0) !== version
|
||||
) {
|
||||
token.invalid = true;
|
||||
delete token.sub;
|
||||
return token;
|
||||
}
|
||||
token.jwtCheckedAt = Date.now();
|
||||
} catch {
|
||||
/* keep session on transient DB/cache errors */
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
// Email-based binding: only when requireLink is off AND account has no 2FA.
|
||||
if (!requireLink && user?.email && !token.sub) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findFirst({
|
||||
where: { mail: user.email, twoFactorConfirmedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
rank: true,
|
||||
username: true,
|
||||
websiteJwtVersion: true,
|
||||
},
|
||||
});
|
||||
if (dbUser) {
|
||||
token.sub = String(dbUser.id);
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
token.jwtVersion = dbUser.websiteJwtVersion;
|
||||
}
|
||||
} catch {
|
||||
// leave token as-is on lookup failure
|
||||
}
|
||||
}
|
||||
|
||||
if (token.sub && !token.invalid) {
|
||||
try {
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: Number(token.sub) },
|
||||
select: {
|
||||
websiteJwtVersion: true,
|
||||
rank: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!dbUser ||
|
||||
(token.jwtVersion ?? 0) !== dbUser.websiteJwtVersion
|
||||
) {
|
||||
token.invalid = true;
|
||||
delete token.sub;
|
||||
return token;
|
||||
}
|
||||
token.rank = dbUser.rank;
|
||||
token.name = dbUser.username;
|
||||
} catch {
|
||||
/* keep session on transient DB errors */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFindUnique = vi.hoisted(() => vi.fn());
|
||||
const mockRedisGet = vi.hoisted(() => vi.fn());
|
||||
const mockRedisSetex = vi.hoisted(() => vi.fn());
|
||||
const mockRedisDel = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: mockFindUnique },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: mockRedisGet,
|
||||
setex: mockRedisSetex,
|
||||
del: mockRedisDel,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("jwt-version-cache", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mockRedisGet.mockResolvedValue(null);
|
||||
mockRedisSetex.mockResolvedValue("OK");
|
||||
mockRedisDel.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it("returns DB version and caches it", async () => {
|
||||
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 3 });
|
||||
const { getCachedJwtVersion } = await import("./jwt-version-cache");
|
||||
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
|
||||
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||
await expect(getCachedJwtVersion(42)).resolves.toBe(3);
|
||||
expect(mockFindUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invalidates memory and redis entries", async () => {
|
||||
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 1 });
|
||||
const { getCachedJwtVersion, invalidateJwtVersionCache } = await import(
|
||||
"./jwt-version-cache"
|
||||
);
|
||||
await getCachedJwtVersion(7);
|
||||
await invalidateJwtVersionCache(7);
|
||||
expect(mockRedisDel).toHaveBeenCalled();
|
||||
mockFindUnique.mockResolvedValue({ websiteJwtVersion: 2 });
|
||||
await expect(getCachedJwtVersion(7)).resolves.toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const MEMORY_TTL_MS = 60_000;
|
||||
const REDIS_TTL_SEC = 60;
|
||||
const memory = new Map<number, { version: number; expiresAt: number }>();
|
||||
|
||||
function redisKey(userId: number): string {
|
||||
return `jwtver:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached `users.website_jwt_version` for Auth.js JWT validation.
|
||||
* Avoids a DB round-trip on every authenticated request.
|
||||
*/
|
||||
export async function getCachedJwtVersion(
|
||||
userId: number,
|
||||
): Promise<number | null> {
|
||||
if (!Number.isInteger(userId) || userId <= 0) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const hit = memory.get(userId);
|
||||
if (hit && hit.expiresAt > now) return hit.version;
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const raw = await redis.get(redisKey(userId));
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const version = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(version)) {
|
||||
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
|
||||
return version;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to DB */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { websiteJwtVersion: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
const version = row.websiteJwtVersion;
|
||||
memory.set(userId, { version, expiresAt: now + MEMORY_TTL_MS });
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.setex(redisKey(userId), REDIS_TTL_SEC, String(version));
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
return version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call after bumping website_jwt_version so other instances drop sessions ASAP. */
|
||||
export async function invalidateJwtVersionCache(userId: number): Promise<void> {
|
||||
memory.delete(userId);
|
||||
if (redis) {
|
||||
try {
|
||||
await redis.del(redisKey(userId));
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ describe("production deploy workflow", () => {
|
||||
expect(deployJob).toContain('ln -sfn "${LIVE}/.env"');
|
||||
expect(deployJob).toContain("-e storage");
|
||||
expect(deployJob).toContain("DATABASE_POOL_SIZE=");
|
||||
expect(deployJob).toContain("REDIS_URL is unset");
|
||||
expect(deployJob).not.toContain("SKIP_ENV_VALIDATION=1");
|
||||
expect(deployJob).toContain("pnpm install --frozen-lockfile");
|
||||
});
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ function createRedis(): Redis | null {
|
||||
) {
|
||||
globalForRedis.redisMissingWarned = true;
|
||||
console.error(
|
||||
"[redis] REDIS_URL is unset in production. Rate limits and shared caches fall back to in-process memory and will not work correctly across multiple instances.",
|
||||
"[redis] REDIS_URL is unset in production. Rate limits, site-settings cache, and JWT session invalidation fall back to in-process memory and will not work correctly across multiple instances or restarts. Set REDIS_URL in .env.",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user