Files
edgeKey/server/authjs-handler.ts
2026-04-21 11:56:35 +08:00

223 lines
7.1 KiB
TypeScript

import { Auth, type AuthConfig, createActionURL, setEnvDefaults } from "@auth/core";
import CredentialsProvider from "@auth/core/providers/credentials";
import type { Session } from "@auth/core/types";
import { enhance, type UniversalHandler, type UniversalMiddleware } from "@universal-middleware/core";
import { PrismaClient } from "../generated/prisma/client";
import { internalServerError, rateLimitError } from "../lib/app-error";
import { logger } from "../lib/logger";
import { hashAdminPassword } from "../modules/auth/crypto";
const ADMIN_ROLE = "admin" as const;
const loginAttemptStore = new Map<string, { count: number; expiresAt: number }>();
interface AuthContext {
prisma: PrismaClient;
session?: Session | null;
}
function getAuthSecret() {
const secret = process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET;
if (!secret) {
throw internalServerError("认证配置缺失", "AUTH_SECRET_MISSING", {
details: {
envKeys: ["AUTH_SECRET", "NEXTAUTH_SECRET"],
},
});
}
return secret;
}
function getLoginRateLimitConfig() {
// 登录尝试次数限制配置,默认 10 次/10 分钟
const maxAttempts = Number(process.env.ADMIN_LOGIN_MAX_ATTEMPTS || 10);
const windowMs = Number(process.env.ADMIN_LOGIN_WINDOW_MS || 10 * 60 * 1000);
return {
maxAttempts: Number.isFinite(maxAttempts) ? maxAttempts : 10,
windowMs: Number.isFinite(windowMs) ? windowMs : 10 * 60 * 1000,
};
}
function getClientIp(request: Request) {
const forwarded = request.headers.get("x-forwarded-for");
return request.headers.get("cf-connecting-ip") || forwarded?.split(",")[0]?.trim() || "unknown";
}
function isCredentialsCallbackRequest(request: Request) {
const url = new URL(request.url);
return request.method === "POST" && url.pathname.endsWith("/api/auth/callback/credentials");
}
function isRateLimited(request: Request) {
const { maxAttempts, windowMs } = getLoginRateLimitConfig();
const now = Date.now();
const key = getClientIp(request);
const current = loginAttemptStore.get(key);
if (!current || current.expiresAt <= now) {
loginAttemptStore.set(key, { count: 1, expiresAt: now + windowMs });
return false;
}
current.count += 1;
loginAttemptStore.set(key, current);
return current.count > maxAttempts;
}
async function findAdminByCredentials(prisma: PrismaClient, username: string, password: string) {
const admin = await prisma.admin.findUnique({
where: { username },
});
if (!admin || admin.status !== "ACTIVE") {
return null;
}
const passwordHash = hashAdminPassword(password);
if (admin.passwordHash !== passwordHash) {
return null;
}
return {
id: String(admin.id),
name: admin.nickname || admin.username,
username: admin.username,
role: ADMIN_ROLE,
};
}
function createAuthjsConfig(prisma: PrismaClient) {
return {
basePath: "/api/auth",
trustHost: true,
secret: getAuthSecret(),
session: {
strategy: "jwt",
},
pages: {
signIn: "/admin/login",
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text", placeholder: "admin" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const usernameRaw = credentials?.username;
const passwordRaw = credentials?.password;
const username = typeof usernameRaw === "string" ? usernameRaw.trim() : "";
const password = typeof passwordRaw === "string" ? passwordRaw : "";
if (!username || !password) {
return null;
}
return findAdminByCredentials(prisma, username, password);
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.username = user.username;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = typeof token.id === "string" ? token.id : undefined;
session.user.username = typeof token.username === "string" ? token.username : undefined;
session.user.role = token.role === ADMIN_ROLE ? ADMIN_ROLE : undefined;
}
return session;
},
},
} satisfies Omit<AuthConfig, "raw">;
}
/**
* Retrieve Auth.js session from Request
*/
export async function getSession(req: Request, config: Omit<AuthConfig, "raw">): Promise<Session | null> {
setEnvDefaults(process.env, config);
const requestURL = new URL(req.url);
const url = createActionURL("session", requestURL.protocol, req.headers, process.env, config);
const response = await Auth(new Request(url, { headers: { cookie: req.headers.get("cookie") ?? "" } }), config);
const { status = 200 } = response;
const data = await response.json();
if (!data || !Object.keys(data).length) return null;
if (status === 200) return data as Session;
throw internalServerError(typeof data === "object" && "message" in data ? (data.message as string) : undefined, "AUTH_SESSION_REQUEST_FAILED", {
details: {
status,
},
});
}
// Note: You can directly define a server middleware instead of defining a Universal Middleware. (You can remove @universal-middleware/* — Vike's scaffolder uses it only to simplify its internal logic, see https://github.com/vikejs/vike/discussions/3116)
/**
* Add Auth.js session to the context.
* @link {@see https://authjs.dev/getting-started/session-management/get-session}
*/
export const authjsSessionMiddleware: UniversalMiddleware = enhance(
// The context we add here is automatically merged into pageContext
async (request, context) => {
try {
const authContext = context as unknown as AuthContext;
const config = createAuthjsConfig(authContext.prisma);
return {
...authContext,
// Sets pageContext.session
session: await getSession(request, config),
};
} catch (error) {
logger.warn(error instanceof Error ? error : new Error(String(error)), {
event: "auth.session.middleware_failed",
});
return {
...context,
session: null,
};
}
},
{
name: "my-app:authjs-middleware",
immutable: false,
},
);
// Note: You can directly define a server middleware instead of defining a Universal Middleware. (You can remove @universal-middleware/* — Vike's scaffolder uses it only to simplify its internal logic, see https://github.com/vikejs/vike/discussions/3116)
/**
* Auth.js route
* @link {@see https://authjs.dev/getting-started/installation}
**/
export const authjsHandler = enhance(
async (request, context) => {
if (isCredentialsCallbackRequest(request) && isRateLimited(request)) {
const error = rateLimitError("Too Many Requests", "AUTH_RATE_LIMITED");
return new Response(error.message, {
status: error.statusCode,
});
}
const authContext = context as unknown as AuthContext;
return Auth(request, createAuthjsConfig(authContext.prisma));
},
{
name: "my-app:authjs-handler",
path: "/api/auth/**",
method: ["GET", "POST"],
immutable: false,
},
) satisfies UniversalHandler;