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 状态推进
这个提交包含在:
@@ -15,6 +15,14 @@ JWT_SECRET="change_me_in_trialrun"
|
||||
# 生成方式示例:openssl rand -base64 32
|
||||
FIELD_ENCRYPTION_KEY=""
|
||||
|
||||
# ===== 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"
|
||||
|
||||
# ===== 数据库(Compose 内部连接)=====
|
||||
POSTGRES_DB="hyb_oa"
|
||||
POSTGRES_USER="hyboa_user"
|
||||
|
||||
@@ -64,6 +64,12 @@ services:
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
@@ -120,6 +126,12 @@ services:
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
@@ -164,6 +176,12 @@ services:
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
OIDC_ENABLED: ${OIDC_ENABLED}
|
||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||
OIDC_SCOPES: ${OIDC_SCOPES}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
|
||||
@@ -24,7 +24,7 @@ F19d,作业迁移(超期归档 -> pg-boss),"将 90 天游离归档迁移到
|
||||
F20,Worker进程拆分(可选),"提供 API 进程与 Worker 进程拆分的部署方式(compose 开关),保持单机也可跑","开启后:API 不跑作业;Worker 只跑作业;两者共享 DATABASE_URL;功能不回归","manual: docker compose 起两个进程并跑回归脚本",manual,DONE,DONE,DONE,"deploy/trialrun/docker-compose.yml | server/src/index.js | server/README.md","[""F19""]","仍保持单机;为未来横向扩展铺路"
|
||||
F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层","上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚","manual: trialrun 环境启用 MinIO 并完成一次上传预览",manual,DONE,DONE,DONE,"server/src/services/storage/* | deploy/trialrun/* | server/README.md","[""F20""]","优先用 @aws-sdk/client-s3;单机优先 MinIO"
|
||||
F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,DONE,DONE,DONE,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后"
|
||||
F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,TODO,TODO,TODO,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略"
|
||||
F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,DONE,DONE,DONE,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略"
|
||||
F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单"
|
||||
F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略"
|
||||
F26,部门API(departments),"补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs","部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/<ID>' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments/<ID>/archive';可选 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq",manual,DONE,DONE,DONE,"server/src/routes/departments.js | server/src/app.js | server/README.md","[""F1""]","单机试运行优先;树构造按 parent_id 组装"
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户