Commit f86e1024 by DaiJiezhang

feat: 迁移认证与账号管理模块,并修复权限异常被吞成 500 的缺陷

NestJS 侧新增(backend-nest/src/auth、src/system-user)
- 登录、登出、当前身份、CSRF 四个认证接口
- 账号管理六个接口:列表、新建、改角色与权限、重置密码、锁定列表、解除锁定
- 会话校验含 auth_version 比对,改密码后由数据库触发器自增该值使旧令牌立即失效
- 登录锁定:20 次失败锁 24 小时,锁定期内继续尝试不延长,窗口外重新计数
- 页面权限计算:管理员固定全 EDIT,其余角色以库中配置逐项覆盖

登录恒定耗时的实现差异(有意)
Java 一请求一线程,用 Thread.sleep 补齐无副作用;Node 单线程若同步阻塞会卡死整个进程,
因此改为异步 bcrypt 加 setTimeout 补齐。实测两边失败路径耗时差异均在 10ms 内,
成功抹平库中 cost 10 与 cost 12 哈希之间约 168ms 的天然差距。

修复 Java 侧缺陷:Service 层权限异常返回 500
GlobalExceptionHandler 缺少 AccessDeniedException 的处理,过滤器链之外抛出的权限异常
落入兜底分支变成 500「服务器处理失败」。实际后果是普通角色打开无权限页面时,
看到的是服务器错误提示而非「没有页面权限」,会被误判为系统故障。
补充处理器后返回 403 与具体原因。Java 测试 114/114 仍全绿,契约快照 31/31 无变化。

对拍结果
- 认证 4 个接口:CSRF Cookie 属性、401/403 文案、登录响应(键排序后)全部一致
- 账号管理 6 个接口:13 项断言全部一致

其他
- pagePermissions 的字段顺序不作为契约:Java 用 Map.of 构建,迭代顺序取决于
  JVM 启动时的随机哈希种子,实测重启前后会变;比对时对象键先排序,数组顺序仍严格比对
- CSRF cookie 在已认证 GET 后被 Spring 删除的行为不予复现,理由记于 CONTRACT-NOTES 第 14 条
- 新增两项迁移后需求:超管可重置低于自己角色的密码、账号操作审计日志

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 7ec26d20
...@@ -13,14 +13,20 @@ ...@@ -13,14 +13,20 @@
"@nestjs/platform-express": "^11.2.1", "@nestjs/platform-express": "^11.2.1",
"@prisma/adapter-mariadb": "^7.9.1", "@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.24", "@nestjs/cli": "^11.0.24",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"prisma": "^7.9.1", "prisma": "^7.9.1",
...@@ -1526,6 +1532,16 @@ ...@@ -1526,6 +1532,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
...@@ -1547,6 +1563,16 @@ ...@@ -1547,6 +1563,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
...@@ -1710,6 +1736,17 @@ ...@@ -1710,6 +1736,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.17.25", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
...@@ -1717,6 +1754,13 @@ ...@@ -1717,6 +1754,13 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.2.0", "version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
...@@ -2328,6 +2372,20 @@ ...@@ -2328,6 +2372,20 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/better-result": { "node_modules/better-result": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
...@@ -2454,6 +2512,12 @@ ...@@ -2454,6 +2512,12 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
...@@ -2809,6 +2873,25 @@ ...@@ -2809,6 +2873,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
...@@ -3158,6 +3241,15 @@ ...@@ -3158,6 +3241,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
...@@ -4170,6 +4262,49 @@ ...@@ -4170,6 +4262,49 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/libphonenumber-js": { "node_modules/libphonenumber-js": {
"version": "1.13.11", "version": "1.13.11",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.11.tgz", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.11.tgz",
...@@ -4223,6 +4358,48 @@ ...@@ -4223,6 +4358,48 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
...@@ -4586,6 +4763,15 @@ ...@@ -4586,6 +4763,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-emoji": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
...@@ -4596,6 +4782,17 @@ ...@@ -4596,6 +4782,17 @@
"lodash": "^4.17.21" "lodash": "^4.17.21"
} }
}, },
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.53", "version": "2.0.53",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
...@@ -5287,7 +5484,6 @@ ...@@ -5287,7 +5484,6 @@
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
......
...@@ -12,7 +12,10 @@ ...@@ -12,7 +12,10 @@
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.24", "@nestjs/cli": "^11.0.24",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"prisma": "^7.9.1", "prisma": "^7.9.1",
...@@ -26,8 +29,11 @@ ...@@ -26,8 +29,11 @@
"@nestjs/platform-express": "^11.2.1", "@nestjs/platform-express": "^11.2.1",
"@prisma/adapter-mariadb": "^7.9.1", "@prisma/adapter-mariadb": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
} }
......
...@@ -4,12 +4,15 @@ ...@@ -4,12 +4,15 @@
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core'; import { APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaService } from './common/prisma.service'; import { PrismaModule } from './common/prisma.module';
import { ApiResponseInterceptor } from './common/api-response.interceptor'; import { ApiResponseInterceptor } from './common/api-response.interceptor';
import { HealthController } from './health/health.controller'; import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { SystemUserModule } from './system-user/system-user.module';
@Module({ @Module({
imports: [PrismaModule, AuthModule, SystemUserModule],
controllers: [HealthController], controllers: [HealthController],
providers: [PrismaService, { provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }], providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
}) })
export class AppModule {} export class AppModule {}
/**
* 文件用途(白话):签发、解析和清除登录会话 Cookie。
* 关联文件:backend/.../auth/AuthTokenService.java、session.guard.ts、auth.service.ts。
* 关联逻辑(数据流):登录成功 -> issue() 写 Set-Cookie -> 浏览器自动携带 -> session.guard 解析出身份。
*
* Cookie 属性必须与 Java 逐字一致(实测的 Set-Cookie):
* XYW_SESSION=<jwt>; Path=/; Max-Age=28800; Expires=<GMT>; HttpOnly; SameSite=Lax
* 因此这里手工拼接响应头,而不用 Express 的 res.cookie()——后者生成的属性顺序不同,
* 虽然浏览器行为一致,但会让契约比对产生噪音。
*/
import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import * as jwt from 'jsonwebtoken';
import { UnauthorizedException } from '../common/business.exception';
const COOKIE_NAME = 'XYW_SESSION';
export interface AuthPrincipal { userId: number; username: string; roleCode: string; authVersion: number; }
@Injectable()
export class AuthTokenService {
private readonly secret: string;
private readonly lifetimeSeconds: number;
private readonly cookieSecure: boolean;
constructor() {
this.secret = process.env.XYW_AUTH_JWT_SECRET ?? '';
this.lifetimeSeconds = Number(process.env.XYW_AUTH_SESSION_HOURS ?? 8) * 3600;
this.cookieSecure = String(process.env.XYW_AUTH_COOKIE_SECURE ?? 'false') === 'true';
}
/** 密钥不足 32 字节直接拒绝签发,对应 Java 的同名校验;宁可启动即失败,也不用弱密钥签会话。 */
private key(): string {
if (Buffer.byteLength(this.secret, 'utf8') < 32) throw new Error('认证签名密钥未配置或长度不足');
return this.secret;
}
/** 代码作用(白话):把身份签成 JWT 并写入会话 Cookie。 */
issue(principal: AuthPrincipal, res: Response): void {
const token = jwt.sign(
{ username: principal.username, role: principal.roleCode, version: principal.authVersion },
this.key(),
{ algorithm: 'HS256', subject: String(principal.userId), expiresIn: this.lifetimeSeconds },
);
res.append('Set-Cookie', this.cookieHeader(token, this.lifetimeSeconds));
}
/** 代码作用(白话):写一个立即过期的同名 Cookie,让浏览器丢弃会话。 */
clear(res: Response): void { res.append('Set-Cookie', this.cookieHeader('', 0)); }
/** 代码作用(白话):按 Java 的属性顺序拼接 Set-Cookie 头。 */
private cookieHeader(value: string, maxAgeSeconds: number): string {
const expires = new Date(Date.now() + maxAgeSeconds * 1000).toUTCString();
const parts = [`${COOKIE_NAME}=${value}`, 'Path=/', `Max-Age=${maxAgeSeconds}`, `Expires=${expires}`, 'HttpOnly'];
if (this.cookieSecure) parts.push('Secure');
parts.push('SameSite=Lax');
return parts.join('; ');
}
/** 代码作用(白话):校验并解出 JWT 里的身份;签名无效或过期一律视为未登录。 */
parse(token: string): AuthPrincipal {
try {
const claims = jwt.verify(token, this.key(), { algorithms: ['HS256'] }) as jwt.JwtPayload;
return { userId: Number(claims.sub), username: String(claims.username), roleCode: String(claims.role), authVersion: Number(claims.version) };
} catch { throw new UnauthorizedException('请先登录'); }
}
/** 代码作用(白话):从请求的 Cookie 里取出会话令牌。 */
extract(cookies: Record<string, string> | undefined): string | null { return cookies?.[COOKIE_NAME] ?? null; }
}
/**
* 文件用途(白话):登录、登出、取当前身份、取 CSRF 令牌四个接口。
* 关联文件:backend/.../auth/AuthController.java、auth.service.ts、frontend/src/modules/auth/auth-api-client.js。
* 关联逻辑(数据流):登录表单 -> login -> Set-Cookie -> 后续请求由 SessionGuard 还原身份。
*/
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { AuthService } from './auth.service';
import { LoginRequestDto } from './dto/auth.dto';
import { AuthenticatedRequest, Public } from './session.guard';
import { CSRF_COOKIE } from './csrf.guard';
import { UnauthorizedException } from '../common/business.exception';
@Controller('api/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('login')
login(@Body() body: LoginRequestDto, @Res({ passthrough: true }) res: Response) {
return this.auth.login(body, res);
}
@Get('me')
me(@Req() req: AuthenticatedRequest) {
if (!req.principal) throw new UnauthorizedException('请先登录');
return this.auth.me(req.principal.userId);
}
/**
* 代码作用(白话):给前端一个写操作要用的校验令牌,同时写进不带 HttpOnly 的 Cookie。
* 关联逻辑:前端并不读响应体,而是从 XSRF-TOKEN Cookie 里取值放进请求头,
* 因此这个接口的关键副作用是 Set-Cookie,返回体只是顺带给出。
* 已有令牌时沿用旧值不重发 Cookie,与 Java 行为一致。
*/
@Public()
@Get('csrf')
csrf(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const existing = (req as any).cookies?.[CSRF_COOKIE];
if (existing) return existing;
const token = randomUUID();
res.append('Set-Cookie', `${CSRF_COOKIE}=${token}; Path=/`);
return token;
}
@Post('logout')
logout(@Res({ passthrough: true }) res: Response) {
this.auth.logout(res);
return null;
}
}
/**
* 文件用途(白话):认证模块的装配,把守卫注册成全局生效。
* 关联文件:auth.controller.ts、session.guard.ts、csrf.guard.ts、app.module.ts。
*
* 守卫顺序有讲究:先 CSRF 后会话。Java 侧 CsrfFilter 也排在认证过滤器之前,
* 保证令牌缺失时返回 403 而不是 401——前端对这两个状态的处理完全不同。
*/
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthTokenService } from './auth-token.service';
import { LoginAttemptService } from './login-attempt.service';
import { PagePermissionService } from './page-permission.service';
import { CsrfGuard } from './csrf.guard';
import { SessionGuard } from './session.guard';
@Module({
controllers: [AuthController],
providers: [
AuthService, AuthTokenService, LoginAttemptService, PagePermissionService,
{ provide: APP_GUARD, useClass: CsrfGuard },
{ provide: APP_GUARD, useClass: SessionGuard },
],
exports: [PagePermissionService, LoginAttemptService, AuthTokenService],
})
export class AuthModule {}
/**
* 文件用途(白话):校验账号密码、签发会话,并保证失败响应的耗时恒定。
* 关联文件:backend/.../auth/AuthService.java、login-attempt.service.ts、auth-token.service.ts。
* 关联逻辑(数据流):登录表单 -> 锁定检查 -> 查库 -> 密码比对 -> 失败补齐耗时 / 成功写 Cookie。
*
* 三处安全设计必须原样保留,改动任何一处都会重新打开被利用的缺口:
*
* 一、密码比对无条件执行。
* 即便账号不存在、已停用、或根本没设密码,也要拿占位哈希跑一次 bcrypt。
* 若写成短路判断,"账号不存在"会比"账号存在但密码错"快十几倍,据此可枚举出有效账号。
*
* 二、所有失败路径补齐到同一最小耗时。
* 库里存量哈希强度不一(实测同时存在 cost 10 与 cost 12,比对耗时相差约 168ms),
* 仅靠"都跑一次 bcrypt"仍有可观测的时间差,因此统一补齐到配置的下限。
*
* 三、锁定检查放在查库之前。
* 被锁账号不产生任何数据库查询,既省资源,也不因查库耗时泄露账号是否存在。
*
* 与 Java 的实现差异(有意为之):
* Java 是一请求一线程,用 Thread.sleep 补齐没有副作用;
* Node 是单线程事件循环,同步 sleep 会卡住整个进程,因此改用 await + setTimeout。
* bcrypt 也必须用异步版本,它会走 libuv 线程池,不阻塞主线程。
*/
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Response } from 'express';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../common/prisma.service';
import { PagePermissionService } from './page-permission.service';
import { LoginAttemptService } from './login-attempt.service';
import { AuthTokenService } from './auth-token.service';
import { BusinessException, UnauthorizedException } from '../common/business.exception';
import { CurrentUserResponse, LoginRequestDto } from './dto/auth.dto';
@Injectable()
export class AuthService implements OnModuleInit {
/** 账号不存在时用来占位比对的哈希,唯一用途是让失败路径与正常路径耗时一致。 */
private timingEqualizerHash = '';
private readonly failureMinMillis = Number(process.env.XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS ?? 400);
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PagePermissionService,
private readonly loginAttempts: LoginAttemptService,
private readonly tokens: AuthTokenService,
) {}
/** 启动时生成一次占位哈希,cost 与库中较强的存量哈希一致,确保占位比对不会更快。 */
async onModuleInit() {
this.timingEqualizerHash = await bcrypt.hash('xyw-login-timing-equalizer', 12);
}
async login(request: LoginRequestDto, res: Response): Promise<CurrentUserResponse> {
this.loginAttempts.assertAllowed(request.username);
const startedAt = Date.now();
const user = await this.prisma.as_system_user.findFirst({ where: { username: request.username, delete_time: 0n } });
const storedHash = user?.password_hash ?? this.timingEqualizerHash;
// 刻意不短路:任何分支都要付出一次 bcrypt 的代价
const passwordMatches = await bcrypt.compare(request.password, storedHash);
if (!user || user.status !== 'ACTIVE' || user.password_hash == null || !passwordMatches) {
this.loginAttempts.recordFailure(request.username);
await this.padFailureDuration(startedAt);
throw new UnauthorizedException('账号或密码错误');
}
this.loginAttempts.recordSuccess(request.username);
this.tokens.issue({ userId: Number(user.id), username: user.username, roleCode: user.role_code, authVersion: user.auth_version ?? 1 }, res);
return this.responseOf(user);
}
/** 代码作用(白话):把本次失败的总耗时补齐到下限,抹平各失败路径之间的时间差。 */
private async padFailureDuration(startedAt: number): Promise<void> {
const remaining = this.failureMinMillis - (Date.now() - startedAt);
if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining));
}
/** 代码作用(白话):按当前会话身份重新读取账号,返回最新的角色与页面权限。 */
async me(userId: number): Promise<CurrentUserResponse> {
const user = await this.prisma.as_system_user.findUnique({ where: { id: BigInt(userId) } });
if (!user || user.status !== 'ACTIVE') throw new UnauthorizedException('登录已失效');
return this.responseOf(user);
}
logout(res: Response): void { this.tokens.clear(res); }
private responseOf(user: { id: bigint; username: string; role_code: string; page_permissions: unknown }): CurrentUserResponse {
return { id: Number(user.id), username: user.username, roleCode: user.role_code, pagePermissions: this.permissions.effectivePermissions(user) };
}
}
/**
* 文件用途(白话):用双提交方式防跨站请求伪造——写操作必须同时带上 Cookie 里的令牌和请求头里的同一个值。
* 关联文件:backend/.../config/SecurityConfig.java、auth.controller.ts、frontend/src/modules/auth/auth-api-client.js。
* 关联逻辑(调用链):写请求 -> 本守卫比对 header 与 cookie -> 放行或 403。
*
* 对应 Java 的 CookieCsrfTokenRepository.withHttpOnlyFalse():
* - Cookie 名 XSRF-TOKEN,不设 HttpOnly(前端要用 document.cookie 读出来放进请求头)
* - 请求头名 X-XSRF-TOKEN
* - 安全方法(GET/HEAD/OPTIONS)不校验
* - /api/auth/login 豁免,否则首次登录无从获取令牌
* - 校验失败返回 403,文案前端会直接展示
*
* 与 Java 的一处有意差异见 scripts/contract/CONTRACT-NOTES.md 第 14 条:
* Java 会在已认证的 GET 请求后删除该 cookie,此处不复现。
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { ForbiddenException } from '../common/business.exception';
export const CSRF_COOKIE = 'XSRF-TOKEN';
export const CSRF_HEADER = 'x-xsrf-token';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const EXEMPT_PATHS = new Set(['/api/auth/login']);
@Injectable()
export class CsrfGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
if (SAFE_METHODS.has(req.method)) return true;
if (EXEMPT_PATHS.has(req.path)) return true;
const fromCookie = (req as any).cookies?.[CSRF_COOKIE];
const fromHeader = req.headers[CSRF_HEADER];
const headerValue = Array.isArray(fromHeader) ? fromHeader[0] : fromHeader;
if (!fromCookie || !headerValue || fromCookie !== headerValue) {
throw new ForbiddenException('安全校验已失效,请刷新页面后重试');
}
return true;
}
}
/**
* 文件用途(白话):登录相关的请求与响应数据形状。
* 关联文件:backend/.../auth/dto/*.java、auth.controller.ts、frontend/src/modules/auth/auth-api-client.js。
*/
import { IsNotEmpty, IsString } from 'class-validator';
export class LoginRequestDto {
@IsString() @IsNotEmpty({ message: '账号名不能为空' })
username!: string;
@IsString() @IsNotEmpty()
password!: string;
}
/** 与 Java 的 CurrentUserResponse 字段一一对应,前端登录后直接用它渲染菜单。 */
export interface CurrentUserResponse {
id: number;
username: string;
roleCode: string;
pagePermissions: Record<string, string>;
}
/**
* 文件用途(白话):记录登录失败次数,连续失败到阈值就把账号锁一段时间,防止有人穷举密码。
* 关联文件:backend/.../auth/InMemoryLoginAttemptGuard.java、auth.service.ts、system-user.controller.ts。
* 关联逻辑(数据流):登录失败 -> recordFailure() -> 达阈值写入锁定时间 -> 下次 assertAllowed() 直接拒绝。
*
* 规则逐条对应 Java 实现(有 9 条测试锁定这些行为):
* - 连续 20 次失败锁定 24 小时
* - 锁定期间继续尝试不会延长锁定时间
* - 锁定过期后重新从 1 开始计数,而不是接着累加
* - 距上次失败超过 24 小时,计数重新开始
* - 登录成功立即清空该账号的失败记录
* - 账号名比对忽略大小写与首尾空格
*
* 状态存在进程内存里,与 Java 一致:重启即清空,多实例部署时各自独立。
* 这是已知的取舍,不是遗漏——真正的防线是密码强度与恒定耗时,锁定只是加一道门槛。
*/
import { Injectable } from '@nestjs/common';
import { BusinessException } from '../common/business.exception';
const THRESHOLD = 20;
const LOCK_DURATION_MS = 24 * 60 * 60 * 1000;
const FAILURE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** 条目上限,超过时清理已过期的记录,避免被大量随机账号名撑爆内存。 */
const MAX_ENTRIES = 50_000;
interface Attempt { failures: number; lockedUntil: number | null; lastFailureAt: number; }
export interface LockedAccount { username: string; failures: number; lockedUntil: string; lastFailureAt: string; }
/** 账号被锁定时抛出,HTTP 状态与文案由全局过滤器统一处理。 */
export class LoginThrottledException extends BusinessException {
constructor(readonly lockedUntil: number) { super('账号或密码错误', 401); this.name = 'LoginThrottledException'; }
}
@Injectable()
export class LoginAttemptService {
private readonly attempts = new Map<string, Attempt>();
/**
* 时钟做成可替换的属性而非构造参数:写成构造参数会被 Nest 的依赖注入当成待注入的 Function。
* 测试里用 setClock 快进 24 小时,对应 Java 侧构造函数注入的 Clock。
*/
private now: () => number = () => Date.now();
setClock(now: () => number) { this.now = now; }
private key(username: string | null | undefined): string {
return (username ?? '').trim().toLowerCase();
}
/**
* 代码作用(白话):登录前先看这个账号是否还在锁定期内。
* 关联逻辑:刻意放在查数据库之前——被锁的账号连库都不查,既省资源也不给攻击者任何时间差信息。
*/
assertAllowed(username: string): void {
const attempt = this.attempts.get(this.key(username));
const now = this.now();
if (attempt?.lockedUntil != null && attempt.lockedUntil > now) throw new LoginThrottledException(attempt.lockedUntil);
}
/** 代码作用(白话):记一次失败,必要时锁定账号。 */
recordFailure(username: string): void {
if (this.attempts.size >= MAX_ENTRIES) this.evictStale();
const key = this.key(username);
const now = this.now();
const current = this.attempts.get(key);
// 已在锁定期内:原样保留,绝不因为继续尝试而延长锁定
if (current?.lockedUntil != null && current.lockedUntil > now) return;
const lockExpired = current?.lockedUntil != null;
const outOfWindow = current != null && current.lastFailureAt + FAILURE_WINDOW_MS < now;
const restart = current == null || lockExpired || outOfWindow;
const failures = restart ? 1 : current!.failures + 1;
const lockedUntil = failures >= THRESHOLD ? now + LOCK_DURATION_MS : null;
this.attempts.set(key, { failures, lockedUntil, lastFailureAt: now });
}
/** 代码作用(白话):登录成功后清空该账号的失败记录。 */
recordSuccess(username: string): void { this.attempts.delete(this.key(username)); }
/** 代码作用(白话):列出当前仍处于锁定状态的账号,按解锁时间倒序,供管理界面展示。 */
lockedAccounts(): LockedAccount[] {
const now = this.now();
const locked: Array<LockedAccount & { _until: number }> = [];
for (const [username, a] of this.attempts) {
if (a.lockedUntil != null && a.lockedUntil > now) {
locked.push({ username, failures: a.failures, lockedUntil: new Date(a.lockedUntil).toISOString(), lastFailureAt: new Date(a.lastFailureAt).toISOString(), _until: a.lockedUntil });
}
}
locked.sort((x, y) => y._until - x._until);
return locked.map(({ _until, ...rest }) => rest);
}
/** 代码作用(白话):管理员手动解锁,返回该账号此前是否确实有记录。 */
unlock(username: string): boolean { return this.attempts.delete(this.key(username)); }
/** 代码作用(白话):清掉既未锁定、失败时间也已超出统计窗口的陈旧条目。 */
private evictStale(): void {
const now = this.now();
for (const [key, a] of this.attempts) {
const stillLocked = a.lockedUntil != null && a.lockedUntil > now;
if (!stillLocked && a.lastFailureAt + FAILURE_WINDOW_MS < now) this.attempts.delete(key);
}
}
}
/**
* 文件用途(白话):计算某个账号实际能访问哪些页面、以及校验前端提交的权限配置是否合法。
* 关联文件:backend/.../auth/PagePermissionService.java、auth.service.ts、system-user.service.ts。
* 关联逻辑(数据流):账号角色 + 已保存的页面权限 -> effectivePermissions() -> 登录响应 -> 前端菜单与路由。
*
* 规则与 Java 侧一致:管理员(DEVELOPER / SUPER_ADMIN)对所有页面固定 EDIT,
* 其余角色默认 NONE,再用数据库里保存的配置逐项覆盖;无法识别的 key 或非法取值一律忽略。
*/
import { Injectable } from '@nestjs/common';
import { BusinessException, ForbiddenException } from '../common/business.exception';
export const PAGE_KEYS = {
OVERVIEW: 'overview',
DOMAIN: 'domain',
WECOM: 'reference-wecom',
PHONE: 'phone-assets',
COMPANY_PROFILE: 'company-profile',
COMPANY_PERSON: 'company-person',
ALERTS: 'alerts',
} as const;
/**
* 页面 key 的输出顺序。
* Java 侧用 Map.of 构建,其迭代顺序取决于 JVM 启动时的随机哈希种子——实测同一份代码
* 重启前后顺序会变,因此字段顺序并非稳定契约,无法也不必复现。
* 这里改用固定的业务顺序(总览在前、提醒在后),保证本服务自身的输出始终一致。
* 前端按 key 取值,与顺序无关;契约比对时对象键会先排序再比较。
*/
const PAGE_ORDER = ['overview', 'company-profile', 'company-person', 'phone-assets', 'reference-wecom', 'domain', 'alerts'];
const VALID_LEVELS = new Set(['NONE', 'READ', 'EDIT']);
export type PermissionMap = Record<string, string>;
@Injectable()
export class PagePermissionService {
/** 管理员身份判定,两个角色对所有页面拥有 EDIT。 */
isAdministrator(roleCode: string | null | undefined): boolean {
return roleCode === 'DEVELOPER' || roleCode === 'SUPER_ADMIN';
}
/**
* 代码作用(白话):算出账号最终的逐页权限。
* 关联逻辑:管理员直接全 EDIT;普通角色先全部置 NONE,再用库里保存的配置覆盖合法项。
* 解析失败时保持全 NONE,与 Java 的 catch 后忽略保持一致——宁可少给权限,不可多给。
*/
effectivePermissions(user: { role_code: string | null; page_permissions?: unknown }): PermissionMap {
const admin = this.isAdministrator(user.role_code);
const result: PermissionMap = {};
for (const key of PAGE_ORDER) result[key] = admin ? 'EDIT' : 'NONE';
if (admin || user.page_permissions == null) return result;
try {
const saved = typeof user.page_permissions === 'string' ? JSON.parse(user.page_permissions) : user.page_permissions;
if (saved && typeof saved === 'object') {
for (const [key, value] of Object.entries(saved as Record<string, unknown>)) {
if (PAGE_ORDER.includes(key) && typeof value === 'string' && VALID_LEVELS.has(value)) result[key] = value;
}
}
} catch { /* 与 Java 一致:解析失败即视为无额外权限 */ }
return result;
}
/** 代码作用(白话):校验前端提交的权限配置,任何非法 key 或取值都整体拒绝。 */
validatePermissions(input: PermissionMap | null | undefined): PermissionMap {
const normalized: PermissionMap = {};
if (input) {
for (const [key, value] of Object.entries(input)) {
if (!PAGE_ORDER.includes(key) || !VALID_LEVELS.has(value)) throw new BusinessException('页面权限配置无效');
normalized[key] = value;
}
}
return normalized;
}
/** 代码作用(白话):把库里存的权限规整成统一形状,用于判断编辑前后权限是否真的变了。 */
normalizeStoredPermissions(stored: unknown): PermissionMap {
if (stored == null || stored === '') return this.validatePermissions({});
try {
const parsed = typeof stored === 'string' ? JSON.parse(stored) : stored;
return this.validatePermissions(parsed as PermissionMap);
} catch { return this.validatePermissions({}); }
}
/**
* 代码作用(白话):断言当前请求对某页面具备所需权限,不足则 403。
* 关联逻辑:EDIT 可通过任何要求;READ 仅在要求为 READ 时通过。
*/
require(permissions: PermissionMap | undefined, pageKey: string, minimum: 'READ' | 'EDIT'): void {
const level = permissions?.[pageKey] ?? 'NONE';
const allowed = level === 'EDIT' || (level === 'READ' && minimum === 'READ');
if (!allowed) throw new ForbiddenException('没有页面权限');
}
/** 代码作用(白话):断言当前身份是管理员,用于账号管理类接口。 */
requireAdministrator(roleCode: string | null | undefined): void {
if (!this.isAdministrator(roleCode)) throw new ForbiddenException('没有权限');
}
}
/**
* 文件用途(白话):从会话 Cookie 还原出当前登录身份,并确认这个身份此刻仍然有效。
* 关联文件:backend/.../auth/AuthTokenFilter.java、auth-token.service.ts、page-permission.service.ts。
* 关联逻辑(调用链):受保护请求 -> 读 Cookie -> 验签 JWT -> 查账号状态与凭证版本 -> 挂到 request 上供控制器使用。
*
* 光验签不够,还要回库核对两件事:
* - 账号仍是 ACTIVE:停用后现有会话必须立刻失效
* - auth_version 与令牌里的一致:改密码时数据库触发器会让该值自增,
* 从而使改密码前签发的所有令牌立即作废(详见 V1__system_user_auth_permissions.sql)
*
* 任何一项不满足都按未登录处理,文案必须是「请先登录」——
* 前端靠这个字符串判定会话过期并跳转登录页。
*/
import { CanActivate, ExecutionContext, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { AuthPrincipal, AuthTokenService } from './auth-token.service';
import { PagePermissionService, PermissionMap } from './page-permission.service';
import { PrismaService } from '../common/prisma.service';
import { UnauthorizedException } from '../common/business.exception';
export const IS_PUBLIC = 'isPublic';
/** 标记无需登录即可访问的接口,对应 Java 的 permitAll 列表。 */
export const Public = () => SetMetadata(IS_PUBLIC, true);
export interface AuthenticatedRequest extends Request {
principal?: AuthPrincipal;
permissions?: PermissionMap;
}
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly tokens: AuthTokenService,
private readonly permissions: PagePermissionService,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [context.getHandler(), context.getClass()])) return true;
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.tokens.extract((req as any).cookies);
if (!token) throw new UnauthorizedException('请先登录');
const principal = this.tokens.parse(token);
const user = await this.prisma.as_system_user.findUnique({ where: { id: BigInt(principal.userId) } });
if (!user || user.status !== 'ACTIVE' || (user.auth_version ?? 1) !== principal.authVersion) {
throw new UnauthorizedException('请先登录');
}
req.principal = principal;
req.permissions = this.permissions.effectivePermissions(user);
return true;
}
}
...@@ -14,7 +14,10 @@ import { success } from './api-response'; ...@@ -14,7 +14,10 @@ import { success } from './api-response';
@Injectable() @Injectable()
export class ApiResponseInterceptor implements NestInterceptor { export class ApiResponseInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// Nest 对 POST 默认返回 201,Spring 一律返回 200;契约以 Java 为准,在此统一。
const res = context.switchToHttp().getResponse();
if (res.statusCode === 201) res.statusCode = 200;
return next.handle().pipe(map((value) => { return next.handle().pipe(map((value) => {
const normalized = normalizeOutbound(value); const normalized = normalizeOutbound(value);
const alreadyWrapped = normalized && typeof normalized === 'object' && !Array.isArray(normalized) && typeof (normalized as any).code === 'number'; const alreadyWrapped = normalized && typeof normalized === 'object' && !Array.isArray(normalized) && typeof (normalized as any).code === 'number';
......
/**
* 文件用途(白话):把数据库连接做成全局可用,避免每个业务模块都重复声明一遍。
* 关联文件:prisma.service.ts、app.module.ts、各业务模块。
*
* 标记为 @Global 是有意的:整个应用共用一个连接池,
* 若每个模块各自 provide 一次 PrismaService,会创建出多个互不相干的连接池。
*/
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}
...@@ -11,6 +11,7 @@ import { ValidationPipe } from '@nestjs/common'; ...@@ -11,6 +11,7 @@ import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/all-exceptions.filter'; import { AllExceptionsFilter } from './common/all-exceptions.filter';
import cookieParser from 'cookie-parser';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: true }); const app = await NestFactory.create(AppModule, { bodyParser: true });
...@@ -20,6 +21,8 @@ async function bootstrap() { ...@@ -20,6 +21,8 @@ async function bootstrap() {
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true, credentials: true,
}); });
// 守卫要从 req.cookies 读会话与 CSRF 令牌,解析中间件必须先于路由注册
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalFilters(new AllExceptionsFilter());
const port = Number(process.env.PORT ?? 7691); const port = Number(process.env.PORT ?? 7691);
......
/**
* 文件用途(白话):账号管理接口的请求与响应形状。
* 关联文件:backend/.../auth/dto/SystemUser*.java、system-user.service.ts。
*/
import { IsNotEmpty, IsObject, IsOptional, IsString, Matches } from 'class-validator';
export class SystemUserCreateDto {
@IsString() @IsNotEmpty()
@Matches(/^[A-Za-z0-9_]{3,64}$/)
username!: string;
@IsString() @IsNotEmpty()
roleCode!: string;
@IsOptional() @IsString()
password?: string;
@IsOptional() @IsObject()
pagePermissions?: Record<string, string>;
}
export class SystemUserUpdateDto {
@IsString() @IsNotEmpty()
roleCode!: string;
@IsString() @IsNotEmpty()
status!: string;
@IsOptional() @IsObject()
pagePermissions?: Record<string, string>;
}
export class PasswordResetDto {
@IsString() @IsNotEmpty()
password!: string;
}
/** 字段与 Java 的 SystemUserResponse 一一对应。 */
export interface SystemUserResponse {
id: number;
username: string;
roleCode: string;
status: string;
pagePermissions: Record<string, string>;
passwordUpdatedAt: Date | null;
}
/**
* 文件用途(白话):账号管理的六个接口。
* 关联文件:backend/.../auth/SystemUserAdminController.java、system-user.service.ts。
* 关联逻辑(调用链):管理页面 -> 本控制器(取出当前身份的角色)-> service 做权限判断与读写。
*
* 角色从会话守卫挂在请求上的身份里取,不信任任何来自请求体的角色声明。
*/
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Req } from '@nestjs/common';
import { SystemUserService } from './system-user.service';
import { PasswordResetDto, SystemUserCreateDto, SystemUserUpdateDto } from './dto/system-user.dto';
import { AuthenticatedRequest } from '../auth/session.guard';
import { UnauthorizedException } from '../common/business.exception';
@Controller('api/system-users')
export class SystemUserController {
constructor(private readonly service: SystemUserService) {}
private role(req: AuthenticatedRequest): string {
if (!req.principal) throw new UnauthorizedException('请先登录');
return req.principal.roleCode;
}
@Get()
list(@Req() req: AuthenticatedRequest) {
return this.service.listVisibleUsers(this.role(req));
}
@Post()
create(@Req() req: AuthenticatedRequest, @Body() dto: SystemUserCreateDto) {
return this.service.createUser(this.role(req), dto);
}
@Put(':id')
update(@Req() req: AuthenticatedRequest, @Param('id', ParseIntPipe) id: number, @Body() dto: SystemUserUpdateDto) {
return this.service.updateUser(this.role(req), id, dto);
}
@Put(':id/password')
async resetPassword(@Req() req: AuthenticatedRequest, @Param('id', ParseIntPipe) id: number, @Body() dto: PasswordResetDto) {
await this.service.resetPassword(this.role(req), id, dto);
return null;
}
@Get('locked-accounts')
lockedAccounts(@Req() req: AuthenticatedRequest) {
return this.service.listLockedAccounts(this.role(req));
}
@Delete('locked-accounts/:username')
unlock(@Req() req: AuthenticatedRequest, @Param('username') username: string) {
this.service.unlockAccount(this.role(req), username);
return null;
}
}
/**
* 文件用途(白话):账号管理模块的装配。
* 关联文件:system-user.controller.ts、system-user.service.ts、auth.module.ts。
*/
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SystemUserController } from './system-user.controller';
import { SystemUserService } from './system-user.service';
@Module({
imports: [AuthModule],
controllers: [SystemUserController],
providers: [SystemUserService],
})
export class SystemUserModule {}
/**
* 文件用途(白话):管理系统账号——列出、新建、改角色与权限、重置密码、解除登录锁定。
* 关联文件:backend/.../auth/SystemUserAdminService.java、page-permission.service.ts、login-attempt.service.ts。
* 关联逻辑(数据流):管理页面 -> 角色边界检查 -> 账号表读写 -> 必要时使对方的现有会话失效。
*
* 三条容易忽略、做错了界面却完全看不出来的规则:
*
* 一、开发者账号在这里彻底隐身。
* 列表查询与按 id 查询都硬性排除 DEVELOPER,而不是靠前端隐藏。
* 否则超管直接调接口就能停用或改写开发者账号,等于夺取最高权限。
*
* 二、改动角色、状态或页面权限后,必须让对方的现有登录立即失效。
* 登录令牌里带着签发时的 auth_version,与账号表中的值不一致即视为过期。
* 漏掉自增,权限修改要等对方令牌自然过期(8 小时)才生效,
* 期间他仍以旧权限操作,而界面上看不出任何异常。
*
* 三、不能启用一个没有密码的账号。
* 超管新建的账号是「禁用且无密码」,若允许直接启用,
* 就会出现启用状态却无密码可校验的账号。
*
* 已知缺陷(迁移期原样保留):超管创建账号必然失败,因为代码写入 password_hash = null
* 而该列为 NOT NULL。详见 docs/migration-backlog.md 第 2 条。
*/
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../common/prisma.service';
import { PagePermissionService } from '../auth/page-permission.service';
import { LoginAttemptService, LockedAccount } from '../auth/login-attempt.service';
import { BusinessException, ForbiddenException } from '../common/business.exception';
import { nowForDatabase } from '../common/datetime';
import { PasswordResetDto, SystemUserCreateDto, SystemUserResponse, SystemUserUpdateDto } from './dto/system-user.dto';
/** 可被创建或指派的角色。DEVELOPER 不在其中——它只能是那个固定账号。 */
const ASSIGNABLE_ROLES = new Set(['SUPER_ADMIN', 'FINANCE', 'HR', 'OPERATIONS']);
const BCRYPT_COST = 12;
@Injectable()
export class SystemUserService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PagePermissionService,
private readonly loginAttempts: LoginAttemptService,
) {}
private assertAdministrator(actorRole: string): void {
if (!this.permissions.isAdministrator(actorRole)) throw new ForbiddenException('没有账号管理权限');
}
private assertCreatableRole(actorRole: string, roleCode: string): void {
if (roleCode === 'DEVELOPER') throw new ForbiddenException('开发者账号仅允许固定账号 Jeddy');
if (!ASSIGNABLE_ROLES.has(roleCode)) throw new BusinessException('角色无效');
if (actorRole === 'DEVELOPER' || actorRole === 'SUPER_ADMIN') return;
throw new ForbiddenException('没有账号管理权限');
}
/** 按 id 取出可管理的账号;开发者账号一律视为不存在。 */
private async findManageable(id: number) {
const user = await this.prisma.as_system_user.findFirst({
where: { id: BigInt(id), delete_time: 0n, NOT: { role_code: 'DEVELOPER' } },
});
if (!user) throw new BusinessException('账号不存在');
return user;
}
private requireValidPassword(password: string | null | undefined): void {
if (!password || password.length < 12 || password.length > 72) throw new BusinessException('密码需为 12-72 位');
}
async listVisibleUsers(actorRole: string): Promise<SystemUserResponse[]> {
this.assertAdministrator(actorRole);
const rows = await this.prisma.as_system_user.findMany({
where: { delete_time: 0n, NOT: { role_code: 'DEVELOPER' } },
orderBy: { id: 'desc' },
});
return rows.map((u) => this.responseOf(u));
}
async createUser(actorRole: string, dto: SystemUserCreateDto): Promise<SystemUserResponse> {
this.assertAdministrator(actorRole);
this.assertCreatableRole(actorRole, dto.roleCode);
const existing = await this.prisma.as_system_user.count({ where: { username: dto.username, delete_time: 0n } });
if (existing > 0) throw new BusinessException('用户名已存在');
const now = nowForDatabase();
const permissions = this.permissions.validatePermissions(dto.pagePermissions);
const superAdminActor = actorRole === 'SUPER_ADMIN';
if (!superAdminActor) this.requireValidPassword(dto.password);
const created = await this.prisma.as_system_user.create({
data: {
username: dto.username,
role_code: dto.roleCode,
page_permissions: permissions,
status: superAdminActor ? 'DISABLED' : 'ACTIVE',
password_hash: superAdminActor ? (null as any) : await bcrypt.hash(dto.password as string, BCRYPT_COST),
delete_time: 0n,
create_time: now,
update_time: now,
auth_version: 1,
},
});
return this.responseOf(created);
}
async updateUser(actorRole: string, id: number, dto: SystemUserUpdateDto): Promise<SystemUserResponse> {
this.assertAdministrator(actorRole);
this.assertCreatableRole(actorRole, dto.roleCode);
const user = await this.findManageable(id);
if (dto.status !== 'ACTIVE' && dto.status !== 'DISABLED') throw new BusinessException('账号状态无效');
if (dto.status === 'ACTIVE' && user.password_hash == null) throw new BusinessException('请由开发者先设置密码后再启用账号');
const normalized = this.permissions.validatePermissions(dto.pagePermissions);
const authorizationChanged =
user.role_code !== dto.roleCode ||
user.status !== dto.status ||
JSON.stringify(this.permissions.normalizeStoredPermissions(user.page_permissions)) !== JSON.stringify(normalized);
const updated = await this.prisma.as_system_user.update({
where: { id: BigInt(id) },
data: {
role_code: dto.roleCode,
status: dto.status,
page_permissions: normalized,
auth_version: authorizationChanged ? (user.auth_version ?? 1) + 1 : user.auth_version,
update_time: nowForDatabase(),
},
});
return this.responseOf(updated);
}
/**
* 只写 password_hash,不碰 auth_version:数据库触发器
* trg_as_system_user_password_changed 会在密码变更时自动让它自增。
* 若这里也加一次会变成 +2,把改密码的人自己也踢下线。
*/
async resetPassword(actorRole: string, id: number, dto: PasswordResetDto): Promise<void> {
if (actorRole !== 'DEVELOPER') throw new ForbiddenException('只有开发者可以修改密码');
const user = await this.findManageable(id);
this.requireValidPassword(dto.password);
await this.prisma.as_system_user.update({
where: { id: user.id },
data: { password_hash: await bcrypt.hash(dto.password, BCRYPT_COST), update_time: nowForDatabase() },
});
}
listLockedAccounts(actorRole: string): LockedAccount[] {
this.assertAdministrator(actorRole);
return this.loginAttempts.lockedAccounts();
}
unlockAccount(actorRole: string, username: string): void {
this.assertAdministrator(actorRole);
this.loginAttempts.unlock(username);
}
private responseOf(user: { id: bigint; username: string; role_code: string; status: string; page_permissions: unknown; password_updated_at: Date | null }): SystemUserResponse {
return {
id: Number(user.id),
username: user.username,
roleCode: user.role_code,
status: user.status,
pagePermissions: this.permissions.effectivePermissions(user),
passwordUpdatedAt: user.password_updated_at,
};
}
}
...@@ -2,6 +2,7 @@ package com.xyw.console.common; ...@@ -2,6 +2,7 @@ package com.xyw.console.common;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
...@@ -76,6 +77,21 @@ public class GlobalExceptionHandler { ...@@ -76,6 +77,21 @@ public class GlobalExceptionHandler {
} }
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */ /** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
/**
* 代码作用(白话):把业务层抛出的权限不足异常返回成 403 与具体原因,而不是笼统的服务器错误。
* 关联文件:PagePermissionService.java、SystemUserAdminService.java、SecurityConfig.java。
* 关联逻辑(调用链/数据流):Service 权限判断失败 -> AccessDeniedException -> 本处理器 -> 403 + 原因文案 -> 前端提示。
* 修复背景:过滤器链里抛出的权限异常由 SecurityConfig 的 accessDeniedHandler 处理,
* 但 Service 层抛出的会落到下面的 Exception 兜底分支变成 500,
* 导致普通角色访问无权限页面时看到「服务器处理失败」而非真正原因。
*/
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> accessDenied(AccessDeniedException error) {
String message = error.getMessage() == null || error.getMessage().isBlank() ? "没有权限" : error.getMessage();
return ApiResponse.error(403, message);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> unexpected(Exception error) { public ApiResponse<Void> unexpected(Exception error) {
......
...@@ -72,3 +72,45 @@ IDEA(工作目录=项目根)和命令行(工作目录=backend/)会写到 ...@@ -72,3 +72,45 @@ IDEA(工作目录=项目根)和命令行(工作目录=backend/)会写到
代价:列表页每次会发出几十个必然失败的图片请求(前端 `img.onerror` 兜底,用户无感)。 代价:列表页每次会发出几十个必然失败的图片请求(前端 `img.onerror` 兜底,用户无感)。
迁移完成并确认图片无恢复可能后,再执行清理(把字段置空即可,设备记录本身不动)。 迁移完成并确认图片无恢复可能后,再执行清理(把字段置空即可,设备记录本身不动)。
---
## 5. 超级管理员可重置密码(限低于自己的角色)
**现状**`SystemUserAdminService.resetPassword` 要求当前角色必须是 `DEVELOPER`
否则抛出「只有开发者可以修改密码」。超管无法为任何账号设置密码。
**目标**:超管也能重置密码,减少对开发者的依赖(内部系统,超管即最高管理者)。
已确认的边界(2026-08-19):
| 操作者 | 可重置密码的目标 |
|---|---|
| DEVELOPER | 所有账号 |
| SUPER_ADMIN | 仅 `FINANCE` / `HR` / `OPERATIONS`**不含其他 SUPER_ADMIN**,更不含 DEVELOPER |
| 其他角色 | 无权限 |
超管之间不可互改,避免两个超管互相改密码互相踢出。
开发者账号 `Jeddy` 的密码仍只能由开发者本人修改——否则超管改掉它的密码即可取得最高权限,
使「开发者账号在管理接口中隐身」的设计失效。
**安全后果(已知并接受)**:能改他人密码即意味着能以该账号身份登录。
内部系统可接受,但必须配合下面的操作日志,使该动作可追溯。
**修改时机**:迁移完成、契约全绿之后。现在改会使 Java 与 NestJS 的权限行为不一致,
而重置密码正是权限最敏感的接口,失去对拍保护得不偿失。系统尚未上线,无紧迫性。
---
## 6. 账号操作审计日志(新增功能)
**目标**:记录所有角色对账号的敏感操作,供开发者审计追溯。
需要记录的动作(建议):重置密码、创建账号、修改角色、修改状态、修改页面权限、解除登录锁定。
每条至少包含:操作人账号、操作人角色、目标账号、动作类型、发生时间、来源 IP。
**注意:这是新增功能,需要新建数据表**,与本次迁移「不改动表结构」的原则冲突,
因此必须放在迁移完成之后单独实施。
与第 5 条配套:开放超管重置密码权限的同时上线日志,否则「谁改了谁的密码」将无从追溯。
...@@ -152,3 +152,29 @@ Prisma 读取 2026-08-01T18:39:41.000Z 数值原样,但被标记为 UTC ...@@ -152,3 +152,29 @@ Prisma 读取 2026-08-01T18:39:41.000Z 数值原样,但被标记为 UTC
写入前必须显式构造"UTC 字段值等于本地墙上时间"的 Date。 写入前必须显式构造"UTC 字段值等于本地墙上时间"的 Date。
统一在框架层做,不要交给各个 service 自行转换。 统一在框架层做,不要交给各个 service 自行转换。
## 14. CSRF cookie 的行为差异(有意不复现)
实测 Java 侧的完整行为:
```
无 cookie 调 /api/auth/csrf -> Set-Cookie: XSRF-TOKEN=<uuid>; Path=/
带 cookie 发已认证 GET -> Set-Cookie: XSRF-TOKEN=; Max-Age=0 ← 删除自己的 token
不带 cookie 发 GET -> 无 Set-Cookie
已有 cookie 再调 /csrf -> 无 Set-Cookie,返回原有 token
```
第二条是 Spring Security 6 延迟加载 token 的副作用:GET 请求未真正使用 token,
框架便调用 `saveToken(null)` 写入删除指令。后果是前端每次写操作前都要额外请求一次
`/api/auth/csrf` 重新拿 token。
**决定:NestJS 不复现这一条。** 依据:
- 前端两种行为都兼容——`auth-api-client.js` 的逻辑是"cookie 里有就直接用,没有才去取"
- 这是框架实现细节的副作用,并非有意的安全设计;双提交模式的安全性来自
攻击者无法读取跨站 cookie,而不是 token 轮换
- 复现它需要写一段"故意删除自己刚下发的 cookie"的反直觉代码,还平白多一次网络往返
保持一致的部分:token 用随机 UUID 生成、cookie 属性为 `Path=/` 且不带 HttpOnly
(前端需要用 `document.cookie` 读取)、校验方式为请求头 `X-XSRF-TOKEN` 与 cookie 值比对、
`/api/auth/login` 豁免校验、校验失败返回 403 与文案「安全校验已失效,请刷新页面后重试」。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment