From 541dd50ea33d91c01457cc44df63bd383534d1e4 Mon Sep 17 00:00:00 2001 From: expressgy Date: Sun, 6 Jul 2025 03:45:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95=20?= =?UTF-8?q?1.=20=E5=90=8C=E6=84=8FHTTP=E8=BF=94=E5=9B=9E=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=EF=BC=8C=E5=86=99=E6=96=B9=E6=B3=95=202.=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8B=A6=E6=88=AA=EF=BC=8C=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E7=9A=84=E9=94=99=E8=AF=AF=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=203.=20=E4=BC=98=E5=8C=96auth=E4=B8=AD=E7=9A=84=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 35 +- package.json | 1 + src/config/jwt.config.ts | 71 +--- src/constants/error-codes.ts | 8 + src/demo/jwt.ts | 18 + src/modules/auth/auth.controller.ts | 94 ++--- src/modules/auth/auth.response.ts | 189 +++++++++- src/modules/auth/auth.schema.ts | 44 ++- src/modules/auth/auth.service.ts | 209 ++++++++++- src/plugins/jwt/jwt.plugins.ts | 14 +- src/plugins/jwt/jwt.service.ts | 489 ++++---------------------- tasks/M2-基础用户系统-开发任务计划.md | 35 +- 12 files changed, 606 insertions(+), 601 deletions(-) create mode 100644 src/demo/jwt.ts diff --git a/bun.lock b/bun.lock index cc03ff1..e3d67da 100644 --- a/bun.lock +++ b/bun.lock @@ -4,13 +4,13 @@ "": { "name": "cursor-init", "dependencies": { - "@elysiajs/jwt": "^1.3.1", "@elysiajs/swagger": "^1.3.0", "@types/ua-parser-js": "^0.7.39", "bcrypt": "^6.0.0", "canvas": "^3.1.2", "chalk": "^5.4.1", "drizzle-orm": "^0.44.2", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.1", "nanoid": "^5.1.5", "nodemailer": "^7.0.4", @@ -24,6 +24,7 @@ "devDependencies": { "@types/bcrypt": "^5.0.2", "@types/bun": "^1.0.25", + "@types/jsonwebtoken": "^9.0.10", "@types/nodemailer": "^6.4.17", "@types/redis": "^4.0.11", "@types/winston": "^2.4.4", @@ -45,8 +46,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "https://registry.npmmirror.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@elysiajs/jwt": ["@elysiajs/jwt@1.3.1", "https://registry.npmmirror.com/@elysiajs/jwt/-/jwt-1.3.1.tgz", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-BVLAp0ER4839bR82ElgTsI7OoPxvFWP5u02KMgqpNoAM6xirJBYTKqANzY9ghuMfQoVEuD4B1/8lZwdPKAVg9Q=="], - "@elysiajs/swagger": ["@elysiajs/swagger@1.3.0", "https://registry.npmmirror.com/@elysiajs/swagger/-/swagger-1.3.0.tgz", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-0fo3FWkDRPNYpowJvLz3jBHe9bFe6gruZUyf+feKvUEEMG9ZHptO1jolSoPE0ffFw1BgN1/wMsP19p4GRXKdfg=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "https://registry.npmmirror.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -211,6 +210,10 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@24.0.4", "https://registry.npmmirror.com/@types/node/-/node-24.0.4.tgz", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="], "@types/node-fetch": ["@types/node-fetch@2.6.12", "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.12.tgz", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], @@ -293,6 +296,8 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.2.17", "https://registry.npmmirror.com/bun-types/-/bun-types-1.2.17.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], @@ -357,6 +362,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "elysia": ["elysia@1.3.5", "https://registry.npmmirror.com/elysia/-/elysia-1.3.5.tgz", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-XVIKXlKFwUT7Sta8GY+wO5reD9I0rqAEtaz1Z71UgJb61csYt8Q3W9al8rtL5RgumuRR8e3DNdzlUN9GkC4KDw=="], "enabled": ["enabled@2.0.0", "https://registry.npmmirror.com/enabled/-/enabled-2.0.0.tgz", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], @@ -505,8 +512,6 @@ "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jose": ["jose@6.0.11", "https://registry.npmmirror.com/jose/-/jose-6.0.11.tgz", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], - "js-tokens": ["js-tokens@9.0.1", "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], "js-yaml": ["js-yaml@4.1.0", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], @@ -517,6 +522,12 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "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" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], + + "jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="], + + "jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="], + "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kuler": ["kuler@2.0.0", "https://registry.npmmirror.com/kuler/-/kuler-2.0.0.tgz", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], @@ -525,8 +536,22 @@ "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "logform": ["logform@2.7.0", "https://registry.npmmirror.com/logform/-/logform-2.7.0.tgz", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], "long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], diff --git a/package.json b/package.json index ce80159..d602f1b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "canvas": "^3.1.2", "chalk": "^5.4.1", "drizzle-orm": "^0.44.2", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.1", "nanoid": "^5.1.5", "nodemailer": "^7.0.4", diff --git a/src/config/jwt.config.ts b/src/config/jwt.config.ts index 63b2239..1d576d4 100644 --- a/src/config/jwt.config.ts +++ b/src/config/jwt.config.ts @@ -7,7 +7,6 @@ * @description 统一导出JWT密钥和过期时间,支持不同类型的token配置 */ -import { TOKEN_TYPES, type TokenType } from '@/type/jwt.type'; /** * JWT基础配置 @@ -22,72 +21,4 @@ export const jwtConfig = { issuer: process.env.JWT_ISSUER || 'elysia-api', /** JWT受众 */ audience: process.env.JWT_AUDIENCE || 'web-client', - /** Token有效期(向后兼容) */ - exp: '7d', -}; - -/** - * 不同类型Token的配置 - * @description 区分不同用途的token配置,包括过期时间和盐值 - */ -export const tokenConfig = { - /** 访问令牌配置 */ - accessToken: { - /** 过期时间 */ - expiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '2h', - /** 盐值(用于增强安全性) */ - salt: process.env.JWT_ACCESS_SALT || 'access_token_salt_2024', - /** 令牌类型标识 */ - type: 'access' as const, - }, - - /** 刷新令牌配置 */ - refreshToken: { - /** 过期时间 */ - expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d', - /** 盐值 */ - salt: process.env.JWT_REFRESH_SALT || 'refresh_token_salt_2024', - /** 令牌类型标识 */ - type: 'refresh' as const, - }, - - /** 邮箱激活令牌配置 */ - activationToken: { - /** 过期时间 */ - expiresIn: process.env.JWT_ACTIVATION_EXPIRES_IN || '24h', - /** 盐值 */ - salt: process.env.JWT_ACTIVATION_SALT || 'activation_token_salt_2024', - /** 令牌类型标识 */ - type: 'activation' as const, - }, - - /** 密码重置令牌配置 */ - passwordResetToken: { - /** 过期时间 */ - expiresIn: process.env.JWT_PASSWORD_RESET_EXPIRES_IN || '1h', - /** 盐值 */ - salt: process.env.JWT_PASSWORD_RESET_SALT || 'password_reset_salt_2024', - /** 令牌类型标识 */ - type: 'password_reset' as const, - }, -}; - -/** - * 获取指定类型token的配置 - * @param type Token类型 - * @returns Token配置 - */ -export function getTokenConfig(type: TokenType) { - switch (type) { - case TOKEN_TYPES.ACCESS: - return tokenConfig.accessToken; - case TOKEN_TYPES.REFRESH: - return tokenConfig.refreshToken; - case TOKEN_TYPES.ACTIVATION: - return tokenConfig.activationToken; - case TOKEN_TYPES.PASSWORD_RESET: - return tokenConfig.passwordResetToken; - default: - throw new Error(`未知的Token类型: ${type}`); - } -} +}; \ No newline at end of file diff --git a/src/constants/error-codes.ts b/src/constants/error-codes.ts index e2f3f47..3909003 100644 --- a/src/constants/error-codes.ts +++ b/src/constants/error-codes.ts @@ -2,6 +2,8 @@ * @file 统一错误码定义 * @author AI助手 * @date 2025-06-29 + * @lastEditor AI Assistant + * @lastEditTime 2025-01-07 * @description 定义整个应用的统一错误码,提供类型安全的错误处理 */ @@ -47,6 +49,8 @@ export const ERROR_CODES = { ACCOUNT_NOT_ACTIVATED: 'ACCOUNT_NOT_ACTIVATED', // 账号未激活 ACCOUNT_LOCKED: 'ACCOUNT_LOCKED', // 账号被锁定 TOO_MANY_FAILED_ATTEMPTS: 'TOO_MANY_FAILED_ATTEMPTS', // 失败次数过多 + TOO_MANY_ATTEMPTS: 'TOO_MANY_ATTEMPTS', // 登录次数过多 + CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED', // 需要验证码 // 密码重置相关错误 INVALID_RESET_TOKEN: 'INVALID_RESET_TOKEN', // 重置令牌无效 @@ -108,6 +112,8 @@ export const ERROR_CODE_TO_HTTP_STATUS: Record = { [ERROR_CODES.ACCOUNT_NOT_ACTIVATED]: 403, [ERROR_CODES.ACCOUNT_LOCKED]: 403, [ERROR_CODES.TOO_MANY_FAILED_ATTEMPTS]: 429, + [ERROR_CODES.TOO_MANY_ATTEMPTS]: 429, + [ERROR_CODES.CAPTCHA_REQUIRED]: 400, // 密码重置相关错误 [ERROR_CODES.INVALID_RESET_TOKEN]: 400, @@ -164,6 +170,8 @@ export const ERROR_CODE_MESSAGES: Record = { [ERROR_CODES.ACCOUNT_NOT_ACTIVATED]: '账号未激活', [ERROR_CODES.ACCOUNT_LOCKED]: '账号已被锁定', [ERROR_CODES.TOO_MANY_FAILED_ATTEMPTS]: '登录失败次数过多', + [ERROR_CODES.TOO_MANY_ATTEMPTS]: '登录次数过多,请稍后再试', + [ERROR_CODES.CAPTCHA_REQUIRED]: '请输入验证码', // 密码重置相关错误 [ERROR_CODES.INVALID_RESET_TOKEN]: '重置令牌无效或已过期', diff --git a/src/demo/jwt.ts b/src/demo/jwt.ts new file mode 100644 index 0000000..ec8df94 --- /dev/null +++ b/src/demo/jwt.ts @@ -0,0 +1,18 @@ +import { getTokenConfig } from '@/config'; +import { TOKEN_TYPES } from '@/type/jwt.type'; +import { jwt } from '@elysiajs/jwt'; + + + + +const config = getTokenConfig(TOKEN_TYPES.ACTIVATION) + +const token = jwt().sign() + + + + + + + + diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 7f7385b..44d4ecc 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,17 +2,16 @@ * @file 认证模块Controller层实现 * @author AI Assistant * @date 2024-12-19 - * @description 认证模块的路由控制器,处理用户注册和邮箱激活相关请求 + * @lastEditor AI Assistant + * @lastEditTime 2025-01-07 + * @description 认证模块的路由控制器,处理用户注册、邮箱激活、用户登录等请求 */ import { Elysia } from 'elysia'; -import { RegisterSchema, ActivateSchema } from './auth.schema'; -import { RegisterResponses, ActivateResponses } from './auth.response'; +import { RegisterSchema, ActivateSchema, LoginSchema } from './auth.schema'; +import { RegisterResponses, ActivateResponses, LoginResponses } from './auth.response'; import { authService } from './auth.service'; import { tags } from '@/modules/tags'; -import { Logger } from '@/plugins/logger/logger.service'; -import { ERROR_CODES } from '@/constants/error-codes'; -import { BusinessError } from '@/utils/response.helper'; /** * 认证控制器 @@ -28,28 +27,7 @@ export const authController = new Elysia() */ .post( '/register', - async ({ body, set }) => { - try { - return await authService.register(body); - } catch (error) { - if (error instanceof BusinessError) { - set.status = 400; - return { - code: error.code, - message: error.message, - data: null, - }; - } - - Logger.error(error as Error); - set.status = 500; - return { - code: ERROR_CODES.INTERNAL_ERROR, - message: '服务器内部错误', - data: null, - }; - } - }, + ({ body, set }) => authService.register(body), { body: RegisterSchema, detail: { @@ -71,43 +49,7 @@ export const authController = new Elysia() */ .post( '/activate', - async ({ body, set }) => { - try { - return await authService.activate(body); - } catch (error) { - if (error instanceof BusinessError) { - // 根据错误码设置适当的HTTP状态码 - switch (error.code) { - case ERROR_CODES.INVALID_ACTIVATION_TOKEN: - case ERROR_CODES.TOKEN_EXPIRED: - set.status = 400; - break; - case ERROR_CODES.USER_NOT_FOUND: - set.status = 404; - break; - case ERROR_CODES.ALREADY_ACTIVATED: - set.status = 409; - break; - default: - set.status = 400; - } - - return { - code: error.code, - message: error.message, - data: null, - }; - } - - Logger.error(error as Error); - set.status = 500; - return { - code: ERROR_CODES.INTERNAL_ERROR, - message: '服务器内部错误', - data: null, - }; - } - }, + ({ body, set }) => authService.activate(body), { body: ActivateSchema, detail: { @@ -118,4 +60,26 @@ export const authController = new Elysia() }, response: ActivateResponses, } + ) + + /** + * 用户登录接口 + * @route POST /api/auth/login + * @description 用户登录,支持用户名或邮箱登录,返回JWT令牌 + * @param body LoginRequest 登录请求参数 + * @returns LoginSuccessResponse | LoginErrorResponse + */ + .post( + '/login', + ({ body, set }) => authService.login(body), + { + body: LoginSchema, + detail: { + summary: '用户登录', + description: '用户登录接口,支持用户名或邮箱登录,登录成功返回JWT访问令牌和刷新令牌', + tags: [tags.auth], + operationId: 'loginUser', + }, + response: LoginResponses, + } ); \ No newline at end of file diff --git a/src/modules/auth/auth.response.ts b/src/modules/auth/auth.response.ts index 27fc917..0a52fa5 100644 --- a/src/modules/auth/auth.response.ts +++ b/src/modules/auth/auth.response.ts @@ -2,7 +2,9 @@ * @file 认证模块响应格式定义 * @author AI Assistant * @date 2024-12-19 - * @description 定义认证模块的用户注册响应格式 + * @lastEditor AI Assistant + * @lastEditTime 2025-01-07 + * @description 定义认证模块的响应格式,包括用户注册、邮箱激活、用户登录等 */ import { t, type Static } from 'elysia'; @@ -223,4 +225,187 @@ export type ActivateSuccessData = Static; export type ActivateSuccessResponse = Static; /** 邮箱激活失败响应类型 */ -export type ActivateErrorResponse = Static; \ No newline at end of file +export type ActivateErrorResponse = Static; + +// ========== 用户登录相关响应格式 ========== + +/** + * 用户登录成功响应数据Schema + * @description 用户登录成功后返回的用户信息和认证令牌 + */ +export const LoginSuccessDataSchema = t.Object({ + /** 用户基本信息 */ + user: t.Object({ + /** 用户ID */ + id: t.String({ + description: '用户ID(bigint类型以字符串形式返回防止精度丢失)', + examples: ['1', '2', '3'] + }), + /** 用户名 */ + username: t.String({ + description: '用户名', + examples: ['admin', 'testuser'] + }), + /** 邮箱地址 */ + email: t.String({ + description: '邮箱地址', + examples: ['user@example.com'] + }), + /** 账号状态 */ + status: t.String({ + description: '账号状态', + examples: ['active'] + }), + /** 最后登录时间 */ + lastLoginAt: t.Union([t.String(), t.Null()], { + description: '最后登录时间', + examples: ['2024-12-19T10:30:00Z', null] + }) + }), + /** 认证令牌信息 */ + tokens: t.Object({ + /** 访问令牌 */ + accessToken: t.String({ + description: 'JWT访问令牌', + examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] + }), + /** 刷新令牌 */ + refreshToken: t.String({ + description: 'JWT刷新令牌', + examples: ['eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'] + }), + /** 令牌类型 */ + tokenType: t.String({ + description: '令牌类型', + examples: ['Bearer'] + }), + /** 过期时间(秒) */ + expiresIn: t.String({ + description: '访问令牌过期时间(秒)', + examples: [7200, 86400] + }), + /** 刷新令牌过期时间(秒) */ + refreshExpiresIn: t.String({ + description: '刷新令牌过期时间(秒)', + examples: [2592000] + }) + }) +}); + +/** + * 用户登录成功响应Schema + * @description 用户登录成功的完整响应格式 + */ +export const LoginSuccessResponseSchema = globalResponseWrapperSchema(LoginSuccessDataSchema); + +/** + * 用户登录失败响应Schema + * @description 用户登录失败的错误响应格式 + */ +export const LoginErrorResponseSchema = t.Object({ + /** 错误代码 */ + code: t.Union([ + t.Literal('VALIDATION_ERROR'), + t.Literal('USER_NOT_FOUND'), + t.Literal('INVALID_PASSWORD'), + t.Literal('ACCOUNT_NOT_ACTIVATED'), + t.Literal('ACCOUNT_LOCKED'), + t.Literal('TOO_MANY_ATTEMPTS'), + t.Literal('CAPTCHA_REQUIRED'), + t.Literal('CAPTCHA_ERROR'), + t.Literal('INTERNAL_ERROR') + ], { + description: '错误代码', + examples: ['USER_NOT_FOUND', 'INVALID_PASSWORD', 'ACCOUNT_NOT_ACTIVATED'] + }), + /** 错误信息 */ + message: t.String({ + description: '错误信息', + examples: ['用户不存在', '密码错误', '账号未激活'] + }), + /** 错误数据 */ + data: t.Union([ + t.Null(), + t.Object({ + /** 登录失败次数 */ + attempts: t.Optional(t.Number({ + description: '登录失败次数', + examples: [3, 5] + })), + /** 账号锁定时间 */ + lockUntil: t.Optional(t.String({ + description: '账号锁定到期时间', + examples: ['2024-12-19T11:30:00Z'] + })), + /** 是否需要验证码 */ + captchaRequired: t.Optional(t.Boolean({ + description: '是否需要验证码', + examples: [true] + })) + }) + ], { + description: '错误时的附加数据' + }) +}); + +/** + * 用户登录接口响应组合 + * @description 用于Controller中定义所有可能的响应格式 + */ +export const LoginResponses = { + 200: LoginSuccessResponseSchema, + 400: LoginErrorResponseSchema, + 401: t.Object({ + code: t.Literal('UNAUTHORIZED'), + message: t.String({ + description: '认证失败', + examples: ['用户名或密码错误', '账号未激活'] + }), + data: t.Null() + }), + 423: t.Object({ + code: t.Literal('ACCOUNT_LOCKED'), + message: t.String({ + description: '账号被锁定', + examples: ['账号已被锁定,请稍后再试'] + }), + data: t.Object({ + lockUntil: t.String({ + description: '锁定到期时间', + examples: ['2024-12-19T11:30:00Z'] + }) + }) + }), + 429: t.Object({ + code: t.Literal('TOO_MANY_ATTEMPTS'), + message: t.String({ + description: '登录次数过多', + examples: ['登录次数过多,请稍后再试'] + }), + data: t.Object({ + retryAfter: t.Number({ + description: '重试间隔(秒)', + examples: [300, 600] + }) + }) + }), + 500: t.Object({ + code: t.Literal('INTERNAL_ERROR'), + message: t.String({ + description: '内部服务器错误', + examples: ['服务器内部错误'] + }), + data: t.Null() + }) +}; + +// ========== TypeScript类型导出 ========== + +/** 用户登录成功响应数据类型 */ +export type LoginSuccessData = Static; + +/** 用户登录成功响应类型 */ +export type LoginSuccessResponse = Static; + +/** 用户登录失败响应类型 */ +export type LoginErrorResponse = Static; \ No newline at end of file diff --git a/src/modules/auth/auth.schema.ts b/src/modules/auth/auth.schema.ts index 7b108bb..f328501 100644 --- a/src/modules/auth/auth.schema.ts +++ b/src/modules/auth/auth.schema.ts @@ -63,8 +63,50 @@ export const ActivateSchema = t.Object({ }) }); +/** + * 用户登录Schema + * @description 用户登录请求参数验证规则 + */ +export const LoginSchema = t.Object({ + /** 登录标识符,支持用户名或邮箱 */ + identifier: t.String({ + minLength: 2, + maxLength: 100, + description: '登录标识符,支持用户名或邮箱', + examples: ['admin', 'user@example.com'] + }), + /** 密码 */ + password: t.String({ + minLength: 6, + maxLength: 50, + description: '用户密码', + examples: ['password123'] + }), + /** 图形验证码(可选) */ + captcha: t.Optional(t.String({ + minLength: 4, + maxLength: 6, + description: '图形验证码,登录失败次数过多时需要', + examples: ['a1b2'] + })), + /** 验证码会话ID(可选) */ + captchaId: t.Optional(t.String({ + description: '验证码会话ID,与captcha配对使用', + examples: ['uuid-string-here'] + })), + /** 是否记住登录状态 */ + rememberMe: t.Optional(t.Boolean({ + description: '是否记住登录状态,影响token过期时间', + examples: [true, false], + default: false + })) +}); + /** 用户注册请求类型 */ export type RegisterRequest = Static; /** 邮箱激活请求类型 */ -export type ActivateRequest = Static; \ No newline at end of file +export type ActivateRequest = Static; + +/** 用户登录请求类型 */ +export type LoginRequest = Static; \ No newline at end of file diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 5eb6749..45c3bc1 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -2,7 +2,9 @@ * @file 认证模块Service层实现 * @author AI Assistant * @date 2024-12-19 - * @description 认证模块的用户注册业务逻辑实现 + * @lastEditor AI Assistant + * @lastEditTime 2025-01-07 + * @description 认证模块的业务逻辑实现,包括用户注册、邮箱激活、用户登录等 */ import bcrypt from 'bcrypt'; @@ -16,12 +18,14 @@ import { successResponse, errorResponse, BusinessError } from '@/utils/response. import { nextId } from '@/utils/snowflake'; import { jwtService } from '@/plugins/jwt/jwt.service'; import { emailService } from '@/plugins/email/email.service'; -import type { RegisterRequest, ActivateRequest } from './auth.schema'; +import type { RegisterRequest, ActivateRequest, LoginRequest } from './auth.schema'; import type { RegisterSuccessResponse, RegisterErrorResponse, ActivateSuccessResponse, - ActivateErrorResponse + ActivateErrorResponse, + LoginSuccessResponse, + LoginErrorResponse } from './auth.response'; /** @@ -429,6 +433,205 @@ export class AuthService { } } + /** + * 用户登录 + * @param request 用户登录请求参数 + * @returns Promise + */ + async login(request: LoginRequest): Promise { + try { + Logger.info(`用户登录请求:${JSON.stringify({ ...request, password: '***', captcha: '***' })}`); + + const { identifier, password, captcha, captchaId, rememberMe = false } = request; + + // 1. 验证验证码(如果提供) + if (captcha && captchaId) { + // await this.validateCaptcha(captcha, captchaId); + } + + // 2. 查找用户(支持用户名或邮箱) + const user = await this.findUserByIdentifier(identifier); + + // todo 判断帐号状态,是否锁定 + + // 3. 验证密码 + await this.verifyPassword(password, user.passwordHash); + + // 4. 检查账号状态 + await this.checkAccountStatus(user); + + // 5. 生成JWT令牌 + const tokens = jwtService.generateTokens(user, rememberMe); + + // 6. 更新最后登录时间 + await this.updateLastLoginTime(user.id); + + // 7. 记录登录日志 + await this.recordLoginLog(user.id, identifier); + + Logger.info(`用户登录成功:${user.id} - ${user.username}`); + + console.log(tokens); + + return successResponse({ + user: { + id: user.id, + username: user.username, + email: user.email, + status: user.status, + lastLoginAt: user.lastLoginAt + }, + tokens + }, '登录成功'); + + } catch (error) { + Logger.error(new Error(`用户登录失败:${error}`)); + + if (error instanceof BusinessError) { + throw error; + } + + throw new BusinessError('登录失败,请稍后重试', ERROR_CODES.INTERNAL_ERROR); + } + } + + /** + * 根据标识符查找用户(支持用户名或邮箱) + * @param identifier 用户标识符(用户名或邮箱) + * @returns Promise 用户信息 + */ + private async findUserByIdentifier(identifier: string): Promise<{ + id: string; + username: string; + email: string; + status: string; + passwordHash: string; + lastLoginAt: string | null; + }> { + try { + // 判断是否为邮箱格式 + const isEmail = identifier.includes('@'); + + // 构建查询条件 + const whereCondition = isEmail + ? eq(sysUsers.email, identifier) + : eq(sysUsers.username, identifier); + + const [user] = await db().select({ + id: sysUsers.id, + username: sysUsers.username, + email: sysUsers.email, + status: sysUsers.status, + passwordHash: sysUsers.passwordHash, + lastLoginAt: sysUsers.lastLoginAt + }) + .from(sysUsers) + .where(whereCondition) + .limit(1); + + if (!user) { + throw new BusinessError('用户不存在', ERROR_CODES.USER_NOT_FOUND); + } + + return { + id: user.id.toString(), // 转换为字符串避免精度丢失 + username: user.username, + email: user.email, + status: user.status, + passwordHash: user.passwordHash, + lastLoginAt: user.lastLoginAt + }; + + } catch (error) { + if (error instanceof BusinessError) { + throw error; + } + Logger.error(new Error(`查找用户失败:${error}`)); + throw new BusinessError('查找用户失败', ERROR_CODES.INTERNAL_ERROR); + } + } + + /** + * 验证密码 + * @param password 原始密码 + * @param passwordHash 密码哈希 + */ + private async verifyPassword(password: string, passwordHash: string): Promise { + try { + const isValid = await bcrypt.compare(password, passwordHash); + + if (!isValid) { + // todo 记录错误登录次数,如果超过5次,则锁定账号 + throw new BusinessError('密码错误', ERROR_CODES.INVALID_PASSWORD); + } + + } catch (error) { + if (error instanceof BusinessError) { + throw error; + } + Logger.error(new Error(`密码验证失败:${error}`)); + throw new BusinessError('密码验证失败', ERROR_CODES.INTERNAL_ERROR); + } + } + + /** + * 检查账号状态 + * @param user 用户信息 + */ + private async checkAccountStatus(user: { status: string }): Promise { + if (user.status === 'pending') { + throw new BusinessError('账号未激活,请先激活账号', ERROR_CODES.ACCOUNT_NOT_ACTIVATED); + } + + if (user.status === 'locked') { + throw new BusinessError('账号已被锁定,请联系管理员', ERROR_CODES.ACCOUNT_LOCKED); + } + + if (user.status !== 'active') { + throw new BusinessError('账号状态异常,请联系管理员', ERROR_CODES.ACCOUNT_LOCKED); + } + } + + /** + * 更新最后登录时间 + * @param userId 用户ID + */ + private async updateLastLoginTime(userId: string): Promise { + try { + await db().update(sysUsers) + .set({ lastLoginAt: new Date().toISOString() }) + .where(eq(sysUsers.id, BigInt(userId))); + + } catch (error) { + // 记录错误但不影响登录流程 + Logger.error(new Error(`更新最后登录时间失败:${error}`)); + } + } + + /** + * 记录登录日志 + * @param userId 用户ID + * @param identifier 登录标识符 + */ + private async recordLoginLog(userId: string, identifier: string): Promise { + try { + Logger.info(`用户登录日志:用户ID=${userId}, 标识符=${identifier}, 时间=${new Date().toISOString()}`); + + // TODO: 如果有登录日志表,可以在这里记录到数据库 + // await db().insert(loginLogs).values({ + // userId: BigInt(userId), + // identifier, + // loginTime: new Date().toISOString(), + // ip: '0.0.0.0', // 从请求中获取 + // userAgent: 'unknown' // 从请求中获取 + // }); + + } catch (error) { + // 记录错误但不影响登录流程 + Logger.error(new Error(`记录登录日志失败:${error}`)); + } + } + /** * 发送激活成功邮件 * @param email 邮箱地址 diff --git a/src/plugins/jwt/jwt.plugins.ts b/src/plugins/jwt/jwt.plugins.ts index 83996fc..949d604 100644 --- a/src/plugins/jwt/jwt.plugins.ts +++ b/src/plugins/jwt/jwt.plugins.ts @@ -8,26 +8,18 @@ */ import { Elysia } from 'elysia'; import { jwt } from '@elysiajs/jwt'; -import { jwtConfig } from '@/config/jwt.config'; import type { JwtUserType, JwtPayloadType } from '@/type/jwt.type'; - -export const jwtPlugin = jwt({ - name: 'jwt', - secret: jwtConfig.secret, - exp: jwtConfig.exp, -}); - +import { jwtService } from './jwt.service'; export const jwtAuthPlugin = (app: Elysia) => app - .use(jwtPlugin) - .derive(async ({ jwt, headers, status }) => { + .derive(async ({ headers, status }) => { const authHeader = headers['authorization']; if (!authHeader?.startsWith('Bearer ')) { return status(401, '未携带Token'); } const token = authHeader.replace('Bearer ', ''); try { - const payload = await jwt.verify(token) as JwtPayloadType | false; + const payload = jwtService.verifyToken(token) as JwtPayloadType | false; if (!payload) return status(401, 'Token无效'); // 提取用户信息 diff --git a/src/plugins/jwt/jwt.service.ts b/src/plugins/jwt/jwt.service.ts index 0486b22..7f83cca 100644 --- a/src/plugins/jwt/jwt.service.ts +++ b/src/plugins/jwt/jwt.service.ts @@ -1,455 +1,88 @@ /** - * @file JWT服务类 - * @author hotok - * @date 2025-06-29 - * @lastEditor hotok - * @lastEditTime 2025-07-06 - * @description 提供类型安全的JWT生成、验证和管理功能,支持不同类型的token + * @file JWT服务类 - 原生版 + * @author AI Assistant + * @date 2025-01-07 + * @description 使用原生jsonwebtoken库提供JWT功能 */ -import crypto from 'crypto'; -import { jwtConfig, tokenConfig, getTokenConfig } from '@/config/jwt.config'; -import { Logger } from '@/plugins/logger/logger.service'; +import jwt from 'jsonwebtoken'; +import { jwtConfig } from '@/config'; import { TOKEN_TYPES } from '@/type/jwt.type'; -import type { - JwtUserType, - JwtPayloadType, - JwtSignOptionsType, - TokenType, - BaseJwtPayload, - ActivationTokenPayload, - AccessTokenPayload, - RefreshTokenPayload, - PasswordResetTokenPayload, -} from '@/type/jwt.type'; -import type { UserInfoType } from '@/modules/example/example.schema'; /** - * JWT服务类 - * @description 提供JWT Token的生成、验证、刷新等功能,支持不同类型的token + * JWT服务类 - 原生版 */ export class JwtService { /** - * 生成JWT Token - * @param userInfo 完整的用户信息 - * @param options 可选的JWT配置 - * @returns Promise JWT Token字符串 - * @modification hotok 2025-06-29 初始实现JWT生成功能 + * 生成激活Token */ - async generateToken(userInfo: UserInfoType, options?: Partial): Promise { - try { - // 从完整用户信息提取JWT载荷所需的字段 - const jwtUser: JwtUserType = { - userId: String(userInfo.id), - username: userInfo.username, - email: userInfo.email, - nickname: userInfo.nickname, - status: userInfo.status, - role: options?.user?.role, // 如果有传入角色信息 - }; - - // 构建JWT载荷 - const payload: Omit = { - ...jwtUser, - sub: String(userInfo.id), - iss: options?.issuer || 'elysia-api', - aud: options?.audience || 'web-client', - }; - - // 注意:实际的token生成需要使用Elysia的jwt实例 - // 这里提供接口定义,具体实现需要在controller中使用jwt.sign - const token = 'generated-token-placeholder'; - - Logger.info(`JWT Token生成成功,用户ID: ${userInfo.id}, 用户名: ${userInfo.username}`); - return token; - - } catch (error) { - Logger.error(new Error(`JWT Token生成失败: ${error}`)); - throw new Error('Token生成失败'); - } - } - - /** - * 验证JWT Token - * @param token JWT Token字符串 - * @returns Promise 验证成功返回载荷,失败返回null - * @modification hotok 2025-06-29 初始实现JWT验证功能 - */ - async verifyToken(token: string): Promise { - try { - // 注意:实际的token验证需要使用Elysia的jwt实例 - // 这里提供接口定义,具体实现需要在controller中使用jwt.verify - const payload = null as any as JwtPayloadType; - - if (!payload || !payload.userId) { - Logger.warn(`JWT Token验证失败:载荷无效`); - return null; - } - - // 检查Token是否过期 - const now = Math.floor(Date.now() / 1000); - if (payload.exp && payload.exp < now) { - Logger.warn(`JWT Token已过期,用户ID: ${payload.userId}`); - return null; - } - - Logger.info(`JWT Token验证成功,用户ID: ${payload.userId}`); - return payload; - - } catch (error) { - Logger.warn(`JWT Token验证失败: ${error}`); - return null; - } - } - - /** - * 刷新JWT Token - * @param oldToken 旧的JWT Token - * @param userInfo 最新的用户信息 - * @returns Promise 新的JWT Token,失败返回null - * @modification hotok 2025-06-29 初始实现JWT刷新功能 - */ - async refreshToken(oldToken: string, userInfo: UserInfoType): Promise { - try { - // 验证旧Token - const oldPayload = await this.verifyToken(oldToken); - if (!oldPayload) { - Logger.warn('Token刷新失败:旧Token无效'); - return null; - } - - // 生成新Token - const newToken = await this.generateToken(userInfo); - - Logger.info(`JWT Token刷新成功,用户ID: ${userInfo.id}`); - return newToken; - - } catch (error) { - Logger.error(new Error(`JWT Token刷新失败: ${error}`)); - return null; - } - } - - /** - * 提取JWT载荷中的用户信息 - * @param payload JWT载荷 - * @returns JwtUserType 用户信息 - * @modification hotok 2025-06-29 添加用户信息提取功能 - */ - extractUserFromPayload(payload: JwtPayloadType): JwtUserType { - return { - userId: payload.userId, - username: payload.username, - email: payload.email, - nickname: payload.nickname, - status: payload.status, - role: payload.role, - }; - } - - /** - * 检查用户Token是否即将过期 - * @param payload JWT载荷 - * @param thresholdMinutes 阈值分钟数(默认30分钟) - * @returns boolean 是否即将过期 - */ - isTokenExpiringSoon(payload: JwtPayloadType, thresholdMinutes: number = 30): boolean { - if (!payload.exp) return false; - - const now = Math.floor(Date.now() / 1000); - const threshold = thresholdMinutes * 60; // 转换为秒 - - return (payload.exp - now) <= threshold; - } - - /** - * 获取Token剩余有效时间 - * @param payload JWT载荷 - * @returns number 剩余秒数,-1表示已过期 - */ - getTokenRemainingTime(payload: JwtPayloadType): number { - if (!payload.exp) return -1; - - const now = Math.floor(Date.now() / 1000); - const remaining = payload.exp - now; - - return remaining > 0 ? remaining : -1; - } - - /** - * 生成盐值哈希 - * @param tokenType Token类型 - * @param userId 用户ID(字符串形式) - * @param email 用户邮箱 - * @returns 盐值哈希 - */ - private generateSaltHash(tokenType: TokenType, userId: string, email: string): string { - const config = getTokenConfig(tokenType); - const data = `${config.salt}:${userId}:${email}:${Date.now()}`; - return crypto.createHash('sha256').update(data).digest('hex'); - } - - /** - * 验证盐值哈希 - * @param saltHash 盐值哈希 - * @param tokenType Token类型 - * @param userId 用户ID(字符串形式) - * @param email 用户邮箱 - * @returns 是否有效 - */ - private verifySaltHash(saltHash: string, tokenType: TokenType, userId: string, email: string): boolean { - try { - // 简单验证,实际应用中可以实现更复杂的验证逻辑 - return Boolean(saltHash) && saltHash.length === 64; // SHA256哈希长度 - } catch (error) { - Logger.warn(`盐值哈希验证失败: ${error}`); - return false; - } - } - - /** - * 生成邮箱激活Token - * @param userId 用户ID(字符串形式) - * @param email 用户邮箱 - * @param username 用户名 - * @returns Promise 激活Token - */ - async generateActivationToken(userId: string, email: string, username: string): Promise { - try { - const config = getTokenConfig(TOKEN_TYPES.ACTIVATION); - const saltHash = this.generateSaltHash(TOKEN_TYPES.ACTIVATION, userId, email); - - // 注意:这里返回的是载荷对象,实际的token生成需要在controller中使用jwt.sign - const payload: Omit = { + generateActivationToken(userId: string, email: string, username: string){ + return jwt.sign( + { userId, - username, email, - tokenType: TOKEN_TYPES.ACTIVATION, - saltHash, - purpose: 'email_activation', - iss: jwtConfig.issuer, - aud: jwtConfig.audience, - sub: userId, - }; - - Logger.info(`邮箱激活Token载荷生成成功,用户ID: ${userId}, 邮箱: ${email}`); - - // 返回JSON字符串,controller中需要使用jwt.sign进行实际签名 - return JSON.stringify(payload); - - } catch (error) { - Logger.error(new Error(`邮箱激活Token生成失败: ${error}`)); - throw new Error('激活Token生成失败'); - } + username, + type: TOKEN_TYPES.ACTIVATION, + }, + jwtConfig.secret, + { expiresIn: '1D' } + ); } /** - * 生成访问Token - * @param userInfo 用户信息 - * @param role 用户角色 - * @returns Promise 访问Token载荷JSON + * 生成登录Token对 */ - async generateAccessToken(userInfo: { + generateTokens(userInfo: { id: string; username: string; email: string; nickname?: string; status: string; - }, role?: string): Promise { + }, rememberMe = false) { + const userPayload = { + userId: userInfo.id, + username: userInfo.username, + email: userInfo.email, + nickname: userInfo.nickname, + status: userInfo.status, + }; + const accessToken = jwt.sign( + { + ...userInfo, + type: TOKEN_TYPES.ACCESS, + }, + jwtConfig.secret, + { expiresIn: '20M' } + ) + + const refreshToken = jwt.sign( + { + ...userInfo, + type: TOKEN_TYPES.REFRESH, + }, + jwtConfig.secret, + { expiresIn: '14D' } + ) + + return { + accessToken, + refreshToken, + tokenType: 'Bearer', + expiresIn: '20M', + refreshExpiresIn: '14D', + }; + } + + /** + * 验证激活Token + */ + verifyToken(token: string) { try { - const config = getTokenConfig(TOKEN_TYPES.ACCESS); - const saltHash = this.generateSaltHash(TOKEN_TYPES.ACCESS, userInfo.id, userInfo.email); - - const payload: Omit = { - userId: userInfo.id, - username: userInfo.username, - email: userInfo.email, - nickname: userInfo.nickname, - status: userInfo.status, - role, - tokenType: TOKEN_TYPES.ACCESS, - saltHash, - iss: jwtConfig.issuer, - aud: jwtConfig.audience, - sub: userInfo.id, - }; - - Logger.info(`访问Token载荷生成成功,用户ID: ${userInfo.id}, 用户名: ${userInfo.username}`); - return JSON.stringify(payload); - - } catch (error) { - Logger.error(new Error(`访问Token生成失败: ${error}`)); - throw new Error('访问Token生成失败'); + return jwt.verify(token, jwtConfig.secret) + } catch { + return { valid: false }; } } - - /** - * 生成刷新Token - * @param userId 用户ID(字符串形式) - * @param username 用户名 - * @param email 用户邮箱 - * @param accessTokenId 关联的访问token ID(可选) - * @returns Promise 刷新Token载荷JSON - */ - async generateRefreshToken(userId: string, username: string, email: string, accessTokenId?: string): Promise { - try { - const config = getTokenConfig(TOKEN_TYPES.REFRESH); - const saltHash = this.generateSaltHash(TOKEN_TYPES.REFRESH, userId, email); - - const payload: Omit = { - userId, - username, - email, - tokenType: TOKEN_TYPES.REFRESH, - saltHash, - accessTokenId, - iss: jwtConfig.issuer, - aud: jwtConfig.audience, - sub: userId, - }; - - Logger.info(`刷新Token载荷生成成功,用户ID: ${userId}`); - return JSON.stringify(payload); - - } catch (error) { - Logger.error(new Error(`刷新Token生成失败: ${error}`)); - throw new Error('刷新Token生成失败'); - } - } - - /** - * 生成密码重置Token - * @param userId 用户ID(字符串形式) - * @param email 用户邮箱 - * @param username 用户名 - * @returns Promise 密码重置Token载荷JSON - */ - async generatePasswordResetToken(userId: string, email: string, username: string): Promise { - try { - const config = getTokenConfig(TOKEN_TYPES.PASSWORD_RESET); - const saltHash = this.generateSaltHash(TOKEN_TYPES.PASSWORD_RESET, userId, email); - - const payload: Omit = { - userId, - username, - email, - tokenType: TOKEN_TYPES.PASSWORD_RESET, - saltHash, - purpose: 'password_reset', - iss: jwtConfig.issuer, - aud: jwtConfig.audience, - sub: userId, - }; - - Logger.info(`密码重置Token载荷生成成功,用户ID: ${userId}, 邮箱: ${email}`); - return JSON.stringify(payload); - - } catch (error) { - Logger.error(new Error(`密码重置Token生成失败: ${error}`)); - throw new Error('密码重置Token生成失败'); - } - } - - /** - * 验证激活Token载荷 - * @param payload JWT载荷 - * @returns 是否有效的激活Token - */ - verifyActivationTokenPayload(payload: any): payload is ActivationTokenPayload { - try { - // 检查基础字段 - if (!payload || typeof payload !== 'object') { - return false; - } - - // 检查token类型 - if (payload.tokenType !== TOKEN_TYPES.ACTIVATION) { - Logger.warn(`Token类型不匹配,期望: ${TOKEN_TYPES.ACTIVATION}, 实际: ${payload.tokenType}`); - return false; - } - - // 检查必需字段 - const requiredFields = ['userId', 'username', 'email', 'saltHash', 'purpose']; - for (const field of requiredFields) { - if (!payload[field]) { - Logger.warn(`激活Token载荷缺少字段: ${field}`); - return false; - } - } - - // 检查purpose - if (payload.purpose !== 'email_activation') { - Logger.warn(`激活Token用途不正确: ${payload.purpose}`); - return false; - } - - // 验证盐值哈希 - if (!this.verifySaltHash(payload.saltHash, TOKEN_TYPES.ACTIVATION, payload.userId, payload.email)) { - Logger.warn(`激活Token盐值哈希验证失败`); - return false; - } - - Logger.info(`激活Token载荷验证成功,用户ID: ${payload.userId}`); - return true; - - } catch (error) { - Logger.error(new Error(`激活Token载荷验证失败: ${error}`)); - return false; - } - } - - /** - * 验证访问Token载荷 - * @param payload JWT载荷 - * @returns 是否有效的访问Token - */ - verifyAccessTokenPayload(payload: any): payload is AccessTokenPayload { - try { - if (!payload || typeof payload !== 'object') { - return false; - } - - if (payload.tokenType !== TOKEN_TYPES.ACCESS) { - return false; - } - - const requiredFields = ['userId', 'username', 'email', 'saltHash', 'status']; - for (const field of requiredFields) { - if (!payload[field]) { - return false; - } - } - - if (!this.verifySaltHash(payload.saltHash, TOKEN_TYPES.ACCESS, payload.userId, payload.email)) { - return false; - } - - return true; - - } catch (error) { - Logger.error(new Error(`访问Token载荷验证失败: ${error}`)); - return false; - } - } - - /** - * 获取Token配置(用于Controller) - * @param tokenType Token类型 - * @returns Token配置 - */ - getTokenConfig(tokenType: TokenType) { - return getTokenConfig(tokenType); - } - - /** - * 获取JWT基础配置(用于Controller) - */ - getJwtConfig() { - return jwtConfig; - } } -/** - * 导出JWT服务实例 - */ export const jwtService = new JwtService(); \ No newline at end of file diff --git a/tasks/M2-基础用户系统-开发任务计划.md b/tasks/M2-基础用户系统-开发任务计划.md index 9b9f13e..dd2efbc 100644 --- a/tasks/M2-基础用户系统-开发任务计划.md +++ b/tasks/M2-基础用户系统-开发任务计划.md @@ -70,18 +70,18 @@ - [x] 1.4 创建auth.controller.ts - 实现注册路由 - [x] 1.5 创建auth.test.ts - 编写注册测试用例 -- [ ] 2.0 POST /auth/activate - 邮箱激活接口 - - [ ] 2.1 扩展auth.schema.ts - 定义激活Schema - - [ ] 2.2 扩展auth.response.ts - 定义激活响应格式 - - [ ] 2.3 扩展auth.service.ts - 实现激活业务逻辑 - - [ ] 2.4 扩展auth.controller.ts - 实现激活路由 - - [ ] 2.5 扩展auth.test.ts - 编写激活测试用例 +- [x] 2.0 POST /auth/activate - 邮箱激活接口 + - [x] 2.1 扩展auth.schema.ts - 定义激活Schema + - [x] 2.2 扩展auth.response.ts - 定义激活响应格式 + - [x] 2.3 扩展auth.service.ts - 实现激活业务逻辑 + - [x] 2.4 扩展auth.controller.ts - 实现激活路由 + - [x] 2.5 扩展auth.test.ts - 编写激活测试用例 - [ ] 3.0 POST /auth/login - 用户登录接口 - - [ ] 3.1 扩展auth.schema.ts - 定义登录Schema - - [ ] 3.2 扩展auth.response.ts - 定义登录响应格式 - - [ ] 3.3 扩展auth.service.ts - 实现登录业务逻辑 - - [ ] 3.4 扩展auth.controller.ts - 实现登录路由 + - [x] 3.1 扩展auth.schema.ts - 定义登录Schema + - [x] 3.2 扩展auth.response.ts - 定义登录响应格式 + - [x] 3.3 扩展auth.service.ts - 实现登录业务逻辑 + - [x] 3.4 扩展auth.controller.ts - 实现登录路由 - [ ] 3.5 扩展auth.test.ts - 编写登录测试用例 - [ ] 4.0 POST /auth/refresh - Token刷新接口 @@ -112,12 +112,15 @@ - [ ] 7.4 扩展auth.controller.ts - 实现重置密码路由 - [ ] 7.5 扩展auth.test.ts - 编写重置密码测试用例 -- [ ] 8.0 GET /auth/captcha - 图形验证码接口 - - [ ] 8.1 扩展auth.schema.ts - 定义验证码Schema - - [ ] 8.2 扩展auth.response.ts - 定义验证码响应格式 - - [ ] 8.3 扩展auth.service.ts - 集成验证码服务 - - [ ] 8.4 扩展auth.controller.ts - 实现验证码路由 - - [ ] 8.5 扩展auth.test.ts - 编写验证码测试用例 +- [x] 8.0 GET /auth/captcha - 图形验证码接口 + - [x] 8.1 扩展auth.schema.ts - 定义验证码Schema + - [x] 8.2 扩展auth.response.ts - 定义验证码响应格式 + - [x] 8.3 扩展auth.service.ts - 集成验证码服务 + - [x] 8.4 扩展auth.controller.ts - 实现验证码路由 + - [x] 8.5 扩展auth.test.ts - 编写验证码测试用例 + +- [ ] 9.0 检查认证模块 (Auth Module)那些接口需要加分布式锁,并加锁 + ### 👤 用户管理模块 (User Module) - P0优先级