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 状态推进
这个提交包含在:
2026-02-09 21:01:36 +08:00
父节点 1855a82011
当前提交 3cc3013d5a
共修改 10 个文件,包含 386 行新增和 1 行删除
+8
查看文件
@@ -38,6 +38,14 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
# WEAK_PASSWORD_CHECK_ENABLED="true"
# WEAK_PASSWORD_MIN_SCORE="2" # zxcvbn score < 2 -> reject
# ===== OIDC/SSO(F23,可选)=====
# OIDC_ENABLED="true"
# OIDC_ISSUER_URL=""
# OIDC_CLIENT_ID=""
# OIDC_CLIENT_SECRET=""
# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback"
# OIDC_SCOPES="openid profile email"
DELEGATION_REVOKE_CRON="0 * * * *"
OCR_PROVIDER="off"
+5
查看文件
@@ -394,6 +394,11 @@ npm run job:ocr-once
- `RATE_LIMIT_LOGIN_MAX`: `/api/auth/login` 等接口限流次数(默认 `10`)
- `RATE_LIMIT_LOGIN_WINDOW`: 限流时间窗(默认 `1 minute`,支持 ms 格式如 `30s`/`1m`)
- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`)
- `OIDC_ENABLED`: 是否启用 OIDC 登录(可选;默认 `false`)
- `OIDC_ISSUER_URL`: OIDC issuer URL(发现地址)
- `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET`: OIDC client 配置
- `OIDC_REDIRECT_URI`: 回调地址(例如 `http://127.0.0.1:3000/api/auth/oidc/callback`)
- `OIDC_SCOPES`: 默认 `openid profile email`
- `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`)
- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`)
- `OCR_LOCK_TTL_SECONDS`: OCR 锁超时秒数(默认 `300`)
+32
查看文件
@@ -26,6 +26,7 @@
"fastify-plugin": "^5.1.0",
"node-cron": "^4.2.1",
"nodemailer": "^6.10.1",
"openid-client": "^6.8.2",
"pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.3.0",
@@ -4389,6 +4390,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
@@ -4908,6 +4918,15 @@
"integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "3.8.4",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz",
"integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@@ -4938,6 +4957,19 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
"integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
"license": "MIT",
"dependencies": {
"jose": "^6.1.3",
"oauth4webapi": "^3.8.4"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+1
查看文件
@@ -42,6 +42,7 @@
"fastify-plugin": "^5.1.0",
"nodemailer": "^6.10.1",
"node-cron": "^4.2.1",
"openid-client": "^6.8.2",
"prom-client": "^15.1.0",
"pg-boss": "^10.3.0",
"pg": "^8.18.0",
+7
查看文件
@@ -49,6 +49,13 @@ function getConfig() {
loginFailMaxAttempts: Number(process.env.AUTH_LOGIN_FAIL_MAX_ATTEMPTS ?? 5),
loginLockSeconds: Number(process.env.AUTH_LOGIN_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS),
oidcEnabled: process.env.OIDC_ENABLED === "true",
oidcIssuerUrl: process.env.OIDC_ISSUER_URL ?? "",
oidcClientId: process.env.OIDC_CLIENT_ID ?? "",
oidcClientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
oidcRedirectUri: process.env.OIDC_REDIRECT_URI ?? "",
oidcScopes: process.env.OIDC_SCOPES ?? "openid profile email",
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
storageDir: path.resolve(serverRoot, storageDir),
storageDriver: process.env.STORAGE_DRIVER ?? "local",
+242
查看文件
@@ -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) {
+64
查看文件
@@ -0,0 +1,64 @@
const { Issuer, generators } = require("openid-client");
function validateOidcConfig(config) {
if (!config?.oidcEnabled) {
throw new Error("OIDC is disabled");
}
if (!config.oidcIssuerUrl) {
throw new Error("Missing OIDC_ISSUER_URL");
}
if (!config.oidcClientId) {
throw new Error("Missing OIDC_CLIENT_ID");
}
if (!config.oidcRedirectUri) {
throw new Error("Missing OIDC_REDIRECT_URI");
}
return {
issuerUrl: config.oidcIssuerUrl,
clientId: config.oidcClientId,
clientSecret: config.oidcClientSecret || undefined,
redirectUri: config.oidcRedirectUri,
scopes: config.oidcScopes || "openid profile email",
};
}
let cachedClientPromise = null;
let cachedClientKey = null;
async function getOidcClient(config) {
const oidc = validateOidcConfig(config);
const key = JSON.stringify({
issuerUrl: oidc.issuerUrl,
clientId: oidc.clientId,
redirectUri: oidc.redirectUri,
});
if (cachedClientPromise && cachedClientKey === key) {
return cachedClientPromise;
}
cachedClientKey = key;
cachedClientPromise = Issuer.discover(oidc.issuerUrl).then((issuer) => {
const client = new issuer.Client({
client_id: oidc.clientId,
client_secret: oidc.clientSecret,
redirect_uris: [oidc.redirectUri],
response_types: ["code"],
});
return { issuer, client, config: oidc };
});
return cachedClientPromise;
}
module.exports = {
generators,
getOidcClient,
validateOidcConfig,
};