feat(server): OIDC 登录支持(F23)
- 引入 openid-client,新增 /api/auth/oidc/login + /api/auth/oidc/callback - 通过 email 绑定既有员工账号,保留密码登录 fallback - OIDC 状态存 cookie(HMAC) + PKCE + SameSite=lax 防 CSRF - 更新 env/README 与 v3 CSV 状态推进
这个提交包含在:
@@ -1,11 +1,15 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { hashPassword, randomRefreshToken, sha256Hex, verifyPassword } = require("../utils/crypto");
|
||||
const { validatePassword } = require("../utils/passwordPolicy");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { generators, getOidcClient } = require("../services/oidc");
|
||||
|
||||
function getHeaderString(request, name) {
|
||||
const value = request.headers[name];
|
||||
@@ -35,12 +39,55 @@ function refreshCookieOptions(config) {
|
||||
};
|
||||
}
|
||||
|
||||
function oidcCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth/oidc",
|
||||
maxAge: 10 * 60,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePrimaryAppOrigin(config, request) {
|
||||
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
|
||||
const first = configured
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)[0];
|
||||
if (first) return first;
|
||||
|
||||
const host = request.headers.host ?? "127.0.0.1:3000";
|
||||
const protocol = request.protocol ?? "http";
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !trimmed.startsWith("/")) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
// Block protocol-relative URLs.
|
||||
if (trimmed.startsWith("//")) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function authRoutes(fastify) {
|
||||
const loginBodySchema = z.object({
|
||||
identifier: z.string().min(3),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const oidcCookieName = "oidc_login";
|
||||
|
||||
const authRateLimit =
|
||||
fastify.config.rateLimitEnabled && typeof fastify.rateLimit === "function"
|
||||
? fastify.rateLimit({
|
||||
@@ -450,6 +497,201 @@ async function authRoutes(fastify) {
|
||||
return sendData(reply, request, { accessToken });
|
||||
});
|
||||
|
||||
fastify.get("/oidc/login", async (request, reply) => {
|
||||
if (!fastify.config.oidcEnabled) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const returnTo = sanitizeReturnTo(request.query?.returnTo);
|
||||
const state = crypto.randomUUID();
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
const codeVerifier = generators.codeVerifier();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
|
||||
const deviceFingerprint =
|
||||
getHeaderString(request, "x-device-fingerprint") ?? `oidc:${crypto.randomUUID()}`;
|
||||
|
||||
const expiresAt = Date.now() + 10 * 60 * 1000;
|
||||
const token = signPayload(
|
||||
{
|
||||
exp: expiresAt,
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
returnTo,
|
||||
deviceFingerprint,
|
||||
},
|
||||
fastify.config.jwtSecret,
|
||||
);
|
||||
|
||||
reply.setCookie(oidcCookieName, token, oidcCookieOptions(fastify.config));
|
||||
|
||||
const { client, config } = await getOidcClient(fastify.config);
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
scope: config.scopes,
|
||||
redirect_uri: config.redirectUri,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
return reply.redirect(authorizationUrl);
|
||||
});
|
||||
|
||||
fastify.get("/oidc/callback", async (request, reply) => {
|
||||
if (!fastify.config.oidcEnabled) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const cookieToken = request.cookies?.[oidcCookieName];
|
||||
const verified = verifyToken(cookieToken, fastify.config.jwtSecret);
|
||||
if (!verified.ok) {
|
||||
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
|
||||
throw new AppError({
|
||||
code: "AUTH_004",
|
||||
message: "Access Token 已过期/无效",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = verified.payload ?? {};
|
||||
const queryState = request.query?.state;
|
||||
if (
|
||||
typeof ctx.exp !== "number" ||
|
||||
ctx.exp <= Date.now() ||
|
||||
typeof ctx.state !== "string" ||
|
||||
typeof ctx.nonce !== "string" ||
|
||||
typeof ctx.codeVerifier !== "string" ||
|
||||
typeof queryState !== "string" ||
|
||||
queryState !== ctx.state
|
||||
) {
|
||||
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
|
||||
throw new AppError({
|
||||
code: "AUTH_004",
|
||||
message: "Access Token 已过期/无效",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
let claims = null;
|
||||
try {
|
||||
const { client, config } = await getOidcClient(fastify.config);
|
||||
const params = client.callbackParams(request.raw);
|
||||
const tokenSet = await client.callback(config.redirectUri, params, {
|
||||
state: ctx.state,
|
||||
nonce: ctx.nonce,
|
||||
code_verifier: ctx.codeVerifier,
|
||||
});
|
||||
claims = tokenSet?.claims?.() ?? null;
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "OIDC callback failed");
|
||||
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
|
||||
throw new AppError({
|
||||
code: "AUTH_004",
|
||||
message: "Access Token 已过期/无效",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const email =
|
||||
typeof claims?.email === "string" && claims.email.trim()
|
||||
? claims.email.trim()
|
||||
: typeof claims?.preferred_username === "string" && claims.preferred_username.trim()
|
||||
? claims.preferred_username.trim()
|
||||
: "";
|
||||
if (!email) {
|
||||
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "账号已离职/禁用",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
select: { id: true, status: true, token_version: true },
|
||||
});
|
||||
if (!employee || employee.status !== "active") {
|
||||
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "账号已离职/禁用",
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshToken = randomRefreshToken();
|
||||
const refreshTokenHash = sha256Hex(refreshToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.refreshTtlSeconds * 1000);
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const activeSessions = await tx.authSession.findMany({
|
||||
where: {
|
||||
employee_id: employee.id,
|
||||
revoked_at: null,
|
||||
expires_at: { gt: now },
|
||||
},
|
||||
orderBy: { created_at: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const maxSessions = 5;
|
||||
const allowedExisting = maxSessions - 1;
|
||||
if (activeSessions.length > allowedExisting) {
|
||||
const revokeCount = activeSessions.length - allowedExisting;
|
||||
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
|
||||
await tx.authSession.updateMany({
|
||||
where: { id: { in: idsToRevoke } },
|
||||
data: { revoked_at: now },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.authSession.create({
|
||||
data: {
|
||||
employee_id: employee.id,
|
||||
refresh_token_hash: refreshTokenHash,
|
||||
device_fingerprint: ctx.deviceFingerprint || `oidc:${crypto.randomUUID()}`,
|
||||
ip_address: request.ip,
|
||||
user_agent: getHeaderString(request, "user-agent") ?? "",
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: "AUTH_OIDC_LOGIN_SUCCESS",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: { email },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
reply.setCookie(
|
||||
fastify.config.refreshCookieName,
|
||||
refreshToken,
|
||||
refreshCookieOptions(fastify.config),
|
||||
);
|
||||
reply.clearCookie(oidcCookieName, oidcCookieOptions(fastify.config));
|
||||
|
||||
const origin = resolvePrimaryAppOrigin(fastify.config, request);
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo);
|
||||
return reply.redirect(`${origin}${returnTo}`);
|
||||
});
|
||||
|
||||
fastify.post("/logout", async (request, reply) => {
|
||||
const refreshToken = request.cookies?.[fastify.config.refreshCookieName];
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户