From 58db7d606306b029c9f26213ba1be2b4b3c9093e Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Mon, 1 Jun 2026 23:53:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=98=BF=E9=87=8C?= =?UTF-8?q?=E4=BA=91=E7=9F=AD=E4=BF=A1=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过阿里云号码认证服务 (DYPNS) 的 SendSmsVerifyCode / CheckSmsVerifyCode 实现手机号验证码两步登录流程,验证码由阿里云生成和管理,无需服务端存储。 新增端点: - POST /v1/auth/phone/send-code (5次/分钟) - POST /v1/auth/phone 核验+登录 (10次/分钟) 新增环境变量:ALIYUN_SMS_SIGN_NAME, ALIYUN_SMS_TEMPLATE_CODE, ALIYUN_SMS_TEMPLATE_PARAM --- .env.example | 3 + bun.lock | 63 +++++++++++++++++++++ docs/api-reference.md | 72 +++++++++++++++++++++--- package.json | 1 + src/routes/auth.ts | 56 ++++++++++++++++--- src/services/auth/aliyun-sms.ts | 99 +++++++++++++++++++++++++++++++++ src/services/auth/phone.ts | 19 ++++++- src/utils/config.ts | 3 + 8 files changed, 299 insertions(+), 17 deletions(-) create mode 100644 src/services/auth/aliyun-sms.ts diff --git a/.env.example b/.env.example index f15d395..0b87e4d 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ OSS_REGION= # Alibaba Cloud (Phase 1e) ALIYUN_ACCESS_KEY_ID= ALIYUN_ACCESS_KEY_SECRET= +ALIYUN_SMS_SIGN_NAME= +ALIYUN_SMS_TEMPLATE_CODE= +# ALIYUN_SMS_TEMPLATE_PARAM={"code":"##code##"} # Huawei IAP (Phase 1c) HUAWEI_IAP_URL= diff --git a/bun.lock b/bun.lock index 6e92ad1..ac8a877 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "duoqi-api", "dependencies": { + "@alicloud/dypnsapi20170525": "^2.0.0", "@fastify/cors": "^11.0.0", "@fastify/helmet": "^13.0.0", "@fastify/jwt": "^9.0.0", @@ -36,6 +37,34 @@ }, }, "packages": { + "@alicloud/credentials": ["@alicloud/credentials@2.4.4", "https://registry.npmmirror.com/@alicloud/credentials/-/credentials-2.4.4.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.8.0", "httpx": "^2.3.3", "ini": "^1.3.5", "kitx": "^2.0.0" } }, "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q=="], + + "@alicloud/darabonba-array": ["@alicloud/darabonba-array@0.1.2", "https://registry.npmmirror.com/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1" } }, "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ=="], + + "@alicloud/darabonba-encode-util": ["@alicloud/darabonba-encode-util@0.0.2", "https://registry.npmmirror.com/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz", { "dependencies": { "moment": "^2.29.1" } }, "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A=="], + + "@alicloud/darabonba-map": ["@alicloud/darabonba-map@0.0.1", "https://registry.npmmirror.com/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1" } }, "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw=="], + + "@alicloud/darabonba-signature-util": ["@alicloud/darabonba-signature-util@0.0.4", "https://registry.npmmirror.com/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz", { "dependencies": { "@alicloud/darabonba-encode-util": "^0.0.1" } }, "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg=="], + + "@alicloud/darabonba-string": ["@alicloud/darabonba-string@1.0.3", "https://registry.npmmirror.com/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1" } }, "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA=="], + + "@alicloud/dypnsapi20170525": ["@alicloud/dypnsapi20170525@2.0.0", "https://registry.npmmirror.com/@alicloud/dypnsapi20170525/-/dypnsapi20170525-2.0.0.tgz", { "dependencies": { "@alicloud/openapi-core": "^1.0.0", "@darabonba/typescript": "^1.0.0" } }, "sha512-eVh1dJ2HA82bBHt+YZFIBzPEYW80FK+TSpcxSR9o0W+FgfTqBaj6eeIHnN7NFhyDAD/3+HtZ146Pmvr51JEEAg=="], + + "@alicloud/endpoint-util": ["@alicloud/endpoint-util@0.0.1", "https://registry.npmmirror.com/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1", "kitx": "^2.0.0" } }, "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg=="], + + "@alicloud/gateway-pop": ["@alicloud/gateway-pop@0.0.6", "https://registry.npmmirror.com/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz", { "dependencies": { "@alicloud/credentials": "^2", "@alicloud/darabonba-array": "^0.1.0", "@alicloud/darabonba-encode-util": "^0.0.2", "@alicloud/darabonba-map": "^0.0.1", "@alicloud/darabonba-signature-util": "^0.0.4", "@alicloud/darabonba-string": "^1.0.2", "@alicloud/endpoint-util": "^0.0.1", "@alicloud/gateway-spi": "^0.0.8", "@alicloud/openapi-util": "^0.3.2", "@alicloud/tea-typescript": "^1.7.1", "@alicloud/tea-util": "^1.4.8" } }, "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA=="], + + "@alicloud/gateway-spi": ["@alicloud/gateway-spi@0.0.8", "https://registry.npmmirror.com/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz", { "dependencies": { "@alicloud/credentials": "^2", "@alicloud/tea-typescript": "^1.7.1" } }, "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g=="], + + "@alicloud/openapi-core": ["@alicloud/openapi-core@1.0.7", "https://registry.npmmirror.com/@alicloud/openapi-core/-/openapi-core-1.0.7.tgz", { "dependencies": { "@alicloud/credentials": "^2.4.2", "@alicloud/gateway-pop": "0.0.6", "@alicloud/gateway-spi": "^0.0.8", "@darabonba/typescript": "^1.0.2" } }, "sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ=="], + + "@alicloud/openapi-util": ["@alicloud/openapi-util@0.3.3", "https://registry.npmmirror.com/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1", "@alicloud/tea-util": "^1.3.0", "kitx": "^2.1.0", "sm3": "^1.0.3" } }, "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A=="], + + "@alicloud/tea-typescript": ["@alicloud/tea-typescript@1.8.0", "https://registry.npmmirror.com/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz", { "dependencies": { "@types/node": "^12.0.2", "httpx": "^2.2.6" } }, "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ=="], + + "@alicloud/tea-util": ["@alicloud/tea-util@1.4.11", "https://registry.npmmirror.com/@alicloud/tea-util/-/tea-util-1.4.11.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1", "@darabonba/typescript": "^1.0.0", "kitx": "^2.0.0" } }, "sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -46,6 +75,8 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@darabonba/typescript": ["@darabonba/typescript@1.0.4", "https://registry.npmmirror.com/@darabonba/typescript/-/typescript-1.0.4.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.5.1", "httpx": "^2.3.2", "lodash": "^4.17.21", "moment": "^2.30.1", "moment-timezone": "^0.5.45", "xml2js": "^0.6.2" } }, "sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "https://registry.npmmirror.com/@drizzle-team/brocli/-/brocli-0.10.2.tgz", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@emnapi/core": ["@emnapi/core@1.9.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], @@ -422,6 +453,8 @@ "html-escaper": ["html-escaper@2.0.2", "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "httpx": ["httpx@2.3.3", "https://registry.npmmirror.com/httpx/-/httpx-2.3.3.tgz", { "dependencies": { "@types/node": "^20", "debug": "^4.1.1" } }, "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw=="], + "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -432,6 +465,8 @@ "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ipaddr.js": ["ipaddr.js@2.3.0", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -466,6 +501,8 @@ "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=="], + "kitx": ["kitx@2.2.0", "https://registry.npmmirror.com/kitx/-/kitx-2.2.0.tgz", { "dependencies": { "@types/node": "^22.5.4" } }, "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg=="], + "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "light-my-request": ["light-my-request@6.6.0", "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], @@ -496,6 +533,8 @@ "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": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], @@ -516,6 +555,10 @@ "mnemonist": ["mnemonist@0.40.3", "https://registry.npmmirror.com/mnemonist/-/mnemonist-0.40.3.tgz", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ=="], + "moment": ["moment@2.30.1", "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + + "moment-timezone": ["moment-timezone@0.5.48", "https://registry.npmmirror.com/moment-timezone/-/moment-timezone-0.5.48.tgz", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="], + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mysql2": ["mysql2@3.20.0", "https://registry.npmmirror.com/mysql2/-/mysql2-3.20.0.tgz", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.2", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.3.3" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg=="], @@ -596,6 +639,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.6.0", "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -608,6 +653,8 @@ "siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "sm3": ["sm3@1.0.3", "https://registry.npmmirror.com/sm3/-/sm3-1.0.3.tgz", {}, "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g=="], + "sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], "source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -672,12 +719,20 @@ "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml2js": ["xml2js@0.6.2", "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xtend": ["xtend@4.0.2", "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@alicloud/darabonba-signature-util/@alicloud/darabonba-encode-util": ["@alicloud/darabonba-encode-util@0.0.1", "https://registry.npmmirror.com/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz", { "dependencies": { "@alicloud/tea-typescript": "^1.7.1", "moment": "^2.29.1" } }, "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw=="], + + "@alicloud/tea-typescript/@types/node": ["@types/node@12.20.55", "https://registry.npmmirror.com/@types/node/-/node-12.20.55.tgz", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "https://registry.npmmirror.com/esbuild/-/esbuild-0.18.20.tgz", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -696,6 +751,10 @@ "fast-json-stringify/ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "httpx/@types/node": ["@types/node@20.19.41", "https://registry.npmmirror.com/@types/node/-/node-20.19.41.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="], + + "kitx/@types/node": ["@types/node@22.19.19", "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], @@ -754,6 +813,10 @@ "fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "httpx/@types/node/undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "kitx/@types/node/undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], diff --git a/docs/api-reference.md b/docs/api-reference.md index 7860fc3..e49b3e9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -251,24 +251,82 @@ > 重复提交相同 `clientMigrationId` 时返回幂等结果,不会触发重复合并。游客账号只能关联一次,已关联后再次调用返回 `CONFLICT`。 +#### POST /auth/phone/send-code + +认证:无 +限流:5 次/分钟 + +用途:向手机号发送短信验证码(通过阿里云号码认证服务)。验证码由阿里云生成并管理,有效期 5 分钟,60 秒内不可重发。 + +请求体: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `phoneNumber` | `string` | 是 | 中国大陆手机号(11 位) | + +成功响应: + +```json +{ + "success": true, + "data": null, + "error": null +} +``` + +错误响应: + +| HTTP | code | 说明 | +|------|------|------| +| 400 | `VALIDATION_ERROR` | 手机号格式错误 | +| 400 | `VALIDATION_ERROR` | 阿里云拒绝该号码 | +| 429 | `RATE_LIMITED` | 发送频率超限(60 秒间隔) | +| 503 | `SERVICE_UNAVAILABLE` | SMS 服务未配置 | +| 503 | `SMS_PROVIDER_ERROR` | 阿里云服务暂时不可用 | + #### POST /auth/phone 认证:无 限流:10 次/分钟 -当前已注册但未实现,固定返回 HTTP 501。 +用途:核验短信验证码。验证通过后自动创建或查找用户,返回 JWT tokens。 + +请求体: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `phoneNumber` | `string` | 是 | 中国大陆手机号(11 位) | +| `verifyCode` | `string` | 是 | 短信验证码(4-8 位数字) | + +成功响应: ```json { - "success": false, - "data": null, - "error": { - "code": "NOT_IMPLEMENTED", - "message": "Phone login not implemented yet" - } + "success": true, + "data": { + "user": { + "id": "uuid", + "nickname": null, + "avatarUrl": null, + "tier": "free" + }, + "tokens": { + "accessToken": "jwt...", + "refreshToken": "jwt..." + } + }, + "error": null } ``` +错误响应: + +| HTTP | code | 说明 | +|------|------|------| +| 400 | `VALIDATION_ERROR` | 手机号或验证码格式错误 | +| 401 | `INVALID_VERIFY_CODE` | 验证码错误或已过期 | +| 503 | `SERVICE_UNAVAILABLE` | SMS 服务未配置 | + #### GET /auth/providers 认证:无 diff --git a/package.json b/package.json index 29a1000..0ec82c0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@alicloud/dypnsapi20170525": "^2.0.0", "@fastify/cors": "^11.0.0", "@fastify/helmet": "^13.0.0", "@fastify/jwt": "^9.0.0", diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b3a237e..fd0ccc4 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -4,6 +4,7 @@ import { db } from '../db/client.js'; import { users, appSettings } from '../db/schema.js'; import { eq, like } from 'drizzle-orm'; import { findOrCreateGuest, findOrCreateHuawei, findOrCreatePhone as _findOrCreatePhone, refreshJwt } from '../services/auth/jwt.js'; +import { sendPhoneCode, loginWithPhone } from '../services/auth/phone.js'; import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js'; import { linkGuestAccount } from '../services/auth/account-link-service.js'; import { NotFoundError } from '../utils/errors.js'; @@ -35,6 +36,15 @@ const providersQuerySchema = z.object({ platform: z.enum(['ios', 'android', 'harmony']), }); +const phoneSendCodeSchema = z.object({ + phoneNumber: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid Chinese mobile phone number'), +}); + +const phoneLoginSchema = z.object({ + phoneNumber: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid Chinese mobile phone number'), + verifyCode: z.string().regex(/^\d{4,8}$/, 'Verification code must be 4-8 digits'), +}); + type Platform = 'ios' | 'android' | 'harmony'; interface ProviderEntry { @@ -67,7 +77,12 @@ const DB_TOGGLE_PROVIDERS = new Set(['wechat', 'qq']); function isCredentialConfigured(providerId: string, _platform?: Platform): boolean { switch (providerId) { case 'phone_sms': - return !!(config.ALIYUN_ACCESS_KEY_ID && config.ALIYUN_ACCESS_KEY_SECRET); + return !!( + config.ALIYUN_ACCESS_KEY_ID && + config.ALIYUN_ACCESS_KEY_SECRET && + config.ALIYUN_SMS_SIGN_NAME && + config.ALIYUN_SMS_TEMPLATE_CODE + ); case 'huawei': return !!(config.HUAWEI_CLIENT_ID && config.HUAWEI_CLIENT_SECRET); case 'apple': @@ -142,13 +157,38 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.send({ success: true, data: result, error: null }); }); - // Phase 2: Phone login - app.post('/auth/phone', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (_request, reply) => { - return reply.status(501).send({ - success: false, - data: null, - error: { code: 'NOT_IMPLEMENTED', message: 'Phone login not implemented yet' }, - }); + // ── Phone Login: Send Verification Code ─────────────────────────── + app.post( + '/auth/phone/send-code', + { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, + async (request, reply) => { + const parsed = phoneSendCodeSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, + data: null, + error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, + }); + } + + await sendPhoneCode(parsed.data.phoneNumber); + return reply.send({ success: true, data: null, error: null }); + }, + ); + + // ── Phone Login: Verify Code ────────────────────────────────────── + app.post('/auth/phone', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { + const parsed = phoneLoginSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, + data: null, + error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, + }); + } + + const result = await loginWithPhone(parsed.data.phoneNumber, parsed.data.verifyCode, app); + return reply.send({ success: true, data: result, error: null }); }); // ── Login Providers ────────────────────────────────────────────── diff --git a/src/services/auth/aliyun-sms.ts b/src/services/auth/aliyun-sms.ts new file mode 100644 index 0000000..177cceb --- /dev/null +++ b/src/services/auth/aliyun-sms.ts @@ -0,0 +1,99 @@ +import DypnsApi from '@alicloud/dypnsapi20170525'; +import { $OpenApiUtil } from '@alicloud/openapi-core'; +import { RuntimeOptions } from '@darabonba/typescript'; +import { config } from '../../utils/config.js'; +import { AppError, ValidationError } from '../../utils/errors.js'; + +const DypnsClient = DypnsApi.default; + +function createClient() { + const openApiConfig = new $OpenApiUtil.Config({ + accessKeyId: config.ALIYUN_ACCESS_KEY_ID, + accessKeySecret: config.ALIYUN_ACCESS_KEY_SECRET, + endpoint: 'dypnsapi.aliyuncs.com', + }); + return new DypnsClient(openApiConfig); +} + +function assertSmsConfigured(): void { + if ( + !config.ALIYUN_ACCESS_KEY_ID || + !config.ALIYUN_ACCESS_KEY_SECRET || + !config.ALIYUN_SMS_SIGN_NAME || + !config.ALIYUN_SMS_TEMPLATE_CODE + ) { + throw new AppError('SMS service is not configured on the server', 503, 'SERVICE_UNAVAILABLE'); + } +} + +function mapAliyunError(code: string, message: string): AppError { + switch (code) { + case 'MOBILE_NUMBER_ILLEGAL': + return new ValidationError('Invalid phone number'); + case 'BUSINESS_LIMIT_CONTROL': + case 'FREQUENCY_FAIL': + return new AppError('Too many SMS requests, please try again later', 429, 'RATE_LIMITED'); + case 'isv.BUSINESS_LIMIT_CONTROL': + return new AppError('SMS daily limit reached for this number', 429, 'RATE_LIMITED'); + case 'biz.FREQUENCY': + return new AppError('Too many SMS requests, please try again later', 429, 'RATE_LIMITED'); + case 'InternalError': + return new AppError('SMS provider temporarily unavailable, please try again', 503, 'SMS_PROVIDER_ERROR'); + case 'INVALID_PARAMETERS': + return new ValidationError('Invalid request parameters'); + default: + return new AppError( + `SMS service error: ${message || code}`, + 502, + 'SMS_SERVICE_ERROR', + ); + } +} + +const RUNTIME_OPTIONS = new RuntimeOptions({ + readTimeout: 10000, + connectTimeout: 5000, +}); + +export async function sendCode(phoneNumber: string): Promise { + assertSmsConfigured(); + + const request = new DypnsApi.SendSmsVerifyCodeRequest({ + phoneNumber, + signName: config.ALIYUN_SMS_SIGN_NAME, + templateCode: config.ALIYUN_SMS_TEMPLATE_CODE, + templateParam: config.ALIYUN_SMS_TEMPLATE_PARAM, + }); + + const response = await createClient().sendSmsVerifyCodeWithOptions(request, RUNTIME_OPTIONS); + + if (!response.body || response.body.code !== 'OK') { + throw mapAliyunError( + response.body?.code ?? 'UNKNOWN', + response.body?.message ?? 'Unknown SMS error', + ); + } +} + +export async function verifyCode(phoneNumber: string, code: string): Promise { + assertSmsConfigured(); + + const request = new DypnsApi.CheckSmsVerifyCodeRequest({ + phoneNumber, + verifyCode: code, + caseAuthPolicy: 1, + }); + + const response = await createClient().checkSmsVerifyCodeWithOptions(request, RUNTIME_OPTIONS); + + if (!response.body || response.body.code !== 'OK') { + throw mapAliyunError( + response.body?.code ?? 'UNKNOWN', + response.body?.message ?? 'Unknown SMS verification error', + ); + } + + if (response.body.model?.verifyResult !== 'PASS') { + throw new AppError('Invalid or expired verification code', 401, 'INVALID_VERIFY_CODE'); + } +} diff --git a/src/services/auth/phone.ts b/src/services/auth/phone.ts index 2097640..c199d81 100644 --- a/src/services/auth/phone.ts +++ b/src/services/auth/phone.ts @@ -1,2 +1,17 @@ -// Phase 2: Phone number login with SMS verification -// Stub for skeleton compilation +import type { FastifyInstance } from 'fastify'; +import { sendCode, verifyCode } from './aliyun-sms.js'; +import { findOrCreatePhone } from './jwt.js'; +import type { LoginResponse } from '../../types/auth.js'; + +export async function sendPhoneCode(phoneNumber: string): Promise { + await sendCode(phoneNumber); +} + +export async function loginWithPhone( + phoneNumber: string, + verifyCodeValue: string, + app: FastifyInstance, +): Promise { + await verifyCode(phoneNumber, verifyCodeValue); + return findOrCreatePhone(phoneNumber, app); +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 1277d25..844661e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -18,6 +18,9 @@ const envSchema = z.object({ APPLE_BUNDLE_ID: z.string().optional(), ALIYUN_ACCESS_KEY_ID: z.string().optional(), ALIYUN_ACCESS_KEY_SECRET: z.string().optional(), + ALIYUN_SMS_SIGN_NAME: z.string().optional(), + ALIYUN_SMS_TEMPLATE_CODE: z.string().optional(), + ALIYUN_SMS_TEMPLATE_PARAM: z.string().default('{"code":"##code##"}'), PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),