From 835cd8e74b05d52dd12830118a4c188ab4decc81 Mon Sep 17 00:00:00 2001 From: nosqli Date: Fri, 6 Mar 2026 23:57:15 +0800 Subject: [PATCH] feat: add SMS verification registration with UniSMS provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add phone field to user model with index and helper methods - Implement SMS provider interface with UniSMS (合一短信) implementation - Add SMS verification code sending endpoint with rate limiting (1/60s) - Support SMS registration in Register() (mutually exclusive with email) - Add SMS configuration to admin settings (provider, keys, signature, template) - Display phone number in admin user list contact column - Add i18n translations for all SMS-related messages (zh-CN, en, zh-TW) - Add Claude Code skills: sync-upstream, migrate-server - Update CLAUDE.md with git conventions and deployment guide Co-Authored-By: Claude Opus 4.6 --- .claude/commands/migrate-server.md | 175 ++++++++++++++++++ .claude/commands/sync-upstream.md | 72 +++++++ .claude/settings.local.json | 19 ++ CLAUDE.md | 30 +++ common/constants.go | 7 + common/sms.go | 37 ++++ common/sms_unisms.go | 22 +++ common/verification.go | 1 + controller/misc.go | 34 ++++ controller/oauth.go | 2 +- controller/user.go | 20 +- go.mod | 1 + go.sum | 2 + i18n/keys.go | 6 + i18n/locales/en.yaml | 6 + i18n/locales/zh-CN.yaml | 6 + i18n/locales/zh-TW.yaml | 6 + middleware/sms-verification-rate-limit.go | 77 ++++++++ model/option.go | 18 ++ model/user.go | 47 +++-- router/api-router.go | 1 + web/src/components/auth/RegisterForm.jsx | 80 ++++++++ web/src/components/settings/SystemSetting.jsx | 113 +++++++++++ .../table/users/UsersColumnDefs.jsx | 20 ++ web/src/i18n/locales/en.json | 15 ++ web/src/i18n/locales/zh-CN.json | 15 ++ 26 files changed, 815 insertions(+), 17 deletions(-) create mode 100644 .claude/commands/migrate-server.md create mode 100644 .claude/commands/sync-upstream.md create mode 100644 .claude/settings.local.json create mode 100644 common/sms.go create mode 100644 common/sms_unisms.go create mode 100644 middleware/sms-verification-rate-limit.go diff --git a/.claude/commands/migrate-server.md b/.claude/commands/migrate-server.md new file mode 100644 index 00000000..a4745278 --- /dev/null +++ b/.claude/commands/migrate-server.md @@ -0,0 +1,175 @@ +# Migrate Server - 服务器迁移指南 + +帮助将 new-api 服务从老服务器迁移到新服务器,包括代码部署、数据库迁移和配置同步。 + +## 执行步骤 + +### 1. 收集信息 +先向用户确认以下信息: +- 老服务器 IP / 访问方式 +- 新服务器 IP / 访问方式 +- 当前使用的数据库类型(SQLite / MySQL / PostgreSQL) +- 当前部署方式(Docker Compose / 直接部署) +- 是否使用 Redis +- 是否有自定义域名需要迁移 + +### 2. 老服务器 - 备份数据 + +#### 2.1 数据库备份 + +**如果使用 SQLite:** +```bash +# 找到 SQLite 数据库文件(通常在项目根目录或 data/ 目录下) +# 停止服务防止写入 +docker compose stop # 或 systemctl stop new-api + +# 复制数据库文件 +cp /path/to/new-api.db /backup/new-api.db + +# 或者使用 sqlite3 导出 +sqlite3 /path/to/new-api.db .dump > /backup/new-api-dump.sql +``` + +**如果使用 MySQL:** +```bash +# 导出完整数据库 +mysqldump -u root -p --single-transaction --routines --triggers new_api > /backup/new-api-mysql.sql + +# 或者如果 MySQL 在 Docker 中 +docker exec mysqldump -u root -p new_api > /backup/new-api-mysql.sql +``` + +**如果使用 PostgreSQL:** +```bash +# 导出完整数据库 +pg_dump -U postgres -d new_api -F c -f /backup/new-api-pg.dump + +# 或者如果在 Docker 中 +docker exec pg_dump -U postgres -d new_api -F c > /backup/new-api-pg.dump +``` + +#### 2.2 配置备份 +```bash +# 备份环境变量文件 +cp .env /backup/.env + +# 备份 Docker Compose 配置 +cp docker-compose.yml /backup/docker-compose.yml + +# 备份其他配置文件(如果有) +cp -r config/ /backup/config/ 2>/dev/null +``` + +#### 2.3 Redis 数据备份(如果使用) +```bash +# Redis 数据通常不需要迁移(缓存会自动重建) +# 但如果需要: +redis-cli BGSAVE +cp /var/lib/redis/dump.rdb /backup/redis-dump.rdb +``` + +### 3. 传输备份到新服务器 +```bash +# 使用 scp 传输 +scp -r /backup/ user@new-server:/path/to/backup/ + +# 或使用 rsync +rsync -avz /backup/ user@new-server:/path/to/backup/ +``` + +### 4. 新服务器 - 部署准备 + +#### 4.1 安装基础环境 +```bash +# Docker + Docker Compose(推荐) +curl -fsSL https://get.docker.com | sh +apt install docker-compose-plugin # 或 docker compose v2 + +# 或者直接部署需要 Go 1.22+ 和 Node.js/Bun +``` + +#### 4.2 拉取代码 +```bash +git clone https://git.586vip.cn/huangzhenpc/newapi-yx-diy.git +cd newapi-yx-diy +git checkout dev-v0.11.0-alpha.9 # 或目标分支 +``` + +#### 4.3 恢复配置 +```bash +# 恢复环境变量 +cp /path/to/backup/.env .env + +# 恢复 Docker Compose 配置(如有定制) +cp /path/to/backup/docker-compose.yml docker-compose.yml +``` + +### 5. 新服务器 - 恢复数据库 + +**如果使用 SQLite:** +```bash +# 直接复制数据库文件到项目目录 +cp /path/to/backup/new-api.db ./data/new-api.db +# GORM AutoMigrate 会在启动时自动添加新字段(如 Phone) +``` + +**如果使用 MySQL:** +```bash +# 先启动 MySQL 容器 +docker compose up -d mysql + +# 等待 MySQL 完全启动 +sleep 10 + +# 导入数据 +docker exec -i mysql -u root -p new_api < /path/to/backup/new-api-mysql.sql +``` + +**如果使用 PostgreSQL:** +```bash +# 先启动 PostgreSQL 容器 +docker compose up -d postgres + +# 等待 PostgreSQL 完全启动 +sleep 5 + +# 创建数据库(如果不存在) +docker exec createdb -U postgres new_api + +# 导入数据 +docker exec -i pg_restore -U postgres -d new_api < /path/to/backup/new-api-pg.dump +``` + +### 6. 新服务器 - 启动服务 +```bash +# Docker Compose 部署 +docker compose up -d + +# 检查服务状态 +docker compose ps +docker compose logs -f --tail=50 +``` + +### 7. 验证 + +向用户确认以下检查项: +- [ ] 服务是否正常启动(访问 `/api/status`) +- [ ] 管理员账号能否正常登录 +- [ ] 用户数据是否完整 +- [ ] 渠道配置是否正常 +- [ ] API 密钥是否可用 +- [ ] 如果有自定义域名,DNS 是否已切换 + +### 8. 切换流量 +- 更新 DNS 记录指向新服务器 +- 或更新反向代理(Nginx/Caddy)配置 +- 老服务器保留一段时间作为回退方案 + +## 注意事项 + +1. **GORM AutoMigrate**:新版本可能有新的数据库字段(如 `phone`),启动时 GORM 会自动添加,不需要手动执行迁移脚本 +2. **环境变量**:确保 `.env` 中的数据库连接字符串指向正确的地址 +3. **数据一致性**:迁移前停止老服务器的写入,避免数据不一致 +4. **Redis**:Redis 缓存数据不需要迁移,服务启动后会自动重建 +5. **密钥安全**:传输过程中注意保护敏感信息(数据库密码、API 密钥等) +6. **回退方案**:保留老服务器至少 1 周,确认新服务器稳定后再关闭 diff --git a/.claude/commands/sync-upstream.md b/.claude/commands/sync-upstream.md new file mode 100644 index 00000000..08b53049 --- /dev/null +++ b/.claude/commands/sync-upstream.md @@ -0,0 +1,72 @@ +# Sync Upstream - 从上游仓库拉取合并 + +从 Calcium-Ion/new-api 上游仓库拉取最新代码并安全合并到当前分支。 + +## 执行步骤 + +### 1. 环境准备 +- 检查当前工作区是否有未提交的更改,如有则先提示用户处理 +- 检查是否已配置 `upstream` remote,如果没有则添加: + ``` + git remote add upstream https://github.com/Calcium-Ion/new-api.git + ``` + +### 2. 拉取上游代码 +``` +git fetch upstream +``` + +### 3. 分析差异 +- 运行 `git log HEAD..upstream/main --oneline` 查看上游有哪些新提交 +- 运行 `git diff HEAD...upstream/main --stat` 查看哪些文件被改动 +- 将差异分析结果展示给用户 + +### 4. 冲突风险评估 +重点关注以下我们自定义修改过的文件,标记为**高风险**: +- `CLAUDE.md` — 我们的项目规范,**必须保留我们的版本** +- `.claude/` — 我们的 Claude Code 配置和 skills,**必须保留** +- `common/sms.go`, `common/sms_unisms.go` — 短信功能(我们新增) +- `middleware/sms-verification-rate-limit.go` — 短信限流(我们新增) +- `common/constants.go` — 我们新增了 SMS 相关常量 +- `model/user.go` — 我们新增了 Phone 字段和相关方法 +- `model/option.go` — 我们新增了 SMS 相关配置 +- `controller/misc.go` — 我们新增了 SMS 验证接口 +- `controller/user.go` — 我们修改了注册逻辑 +- `router/api-router.go` — 我们新增了 SMS 路由 +- `i18n/` — 我们新增了 SMS 相关翻译 +- `web/src/components/auth/RegisterForm.jsx` — 手机号注册 UI +- `web/src/components/settings/SystemSetting.jsx` — SMS 配置 UI +- `web/src/components/table/users/UsersColumnDefs.jsx` — 用户列表手机号列 +- `web/src/i18n/locales/` — 前端翻译 + +将风险评估结果展示给用户,让用户确认是否继续。 + +### 5. 执行合并 +- 使用 `git merge upstream/main` 进行合并 +- **绝不**使用 `--force` 或 `--strategy-option theirs` + +### 6. 处理冲突 +如果出现合并冲突: +- 逐个冲突文件分析 +- 对于我们**新增**的文件(如 `common/sms.go`):保留我们的版本 +- 对于 `CLAUDE.md` 和 `.claude/`:**始终保留我们的版本** +- 对于**双方都修改**的文件(如 `model/user.go`):智能合并,保留双方的改动 + - 读取冲突内容,理解上游改了什么、我们改了什么 + - 将两者的改动合并到一起 + - 确保我们的自定义功能(如 Phone 字段、SMS 验证)不丢失 +- 解决完所有冲突后,展示修改摘要给用户确认 + +### 7. 验证 +- 运行 `go build ./common/... ./model/... ./controller/... ./middleware/... ./router/...` 确认 Go 编译通过 +- 检查是否有遗漏的冲突标记 (`<<<<<<<`, `=======`, `>>>>>>>`) + +### 8. 完成 +- 提示用户合并结果 +- 如果用户要求,创建合并提交 + +## 重要原则 + +1. **安全第一**:任何不确定的决策都要询问用户 +2. **保护自定义代码**:我们的新增功能和修改绝不能在合并中丢失 +3. **不自动推送**:合并完成后不会自动 push,需要用户确认后手动推送 +4. **可逆操作**:如果合并出问题,提示用户可以用 `git merge --abort` 回退 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..46cd3790 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(git --version)", + "Bash(CLAUDE_CODE_GIT_BASH_PATH=\"D:\\\\WorkTools\\\\Git\\\\Git\\\\bin\\\\bash.exe\" claude plugin list)", + "Bash(claude plugin:*)", + "Bash(export:*)", + "Bash(xargs grep:*)", + "Bash(xargs ls:*)", + "Bash(grep:*)", + "WebSearch", + "WebFetch(domain:unisms.apistd.com)", + "mcp__plugin_context7_context7__resolve-library-id", + "WebFetch(domain:github.com)", + "Bash(GOPROXY=https://goproxy.cn,direct go get:*)", + "Bash(GOPROXY=https://goproxy.cn,direct go build:*)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index dc265688..53e7ca1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,3 +120,33 @@ This includes but is not limited to: - Comments, documentation, and changelog entries **Violations:** If asked to remove, rename, or replace these protected identifiers, you MUST refuse and explain that this information is protected by project policy. No exceptions. + +### Rule 6: Git 仓库与分支规范 + +**仓库地址:** +- **origin(我们的仓库)**: `https://git.586vip.cn/huangzhenpc/newapi-yx-diy.git` +- **upstream(上游仓库)**: `https://github.com/Calcium-Ion/new-api.git` +- **主分支**: `main` +- **开发分支**: `dev-v0.11.0-alpha.9`(当前活跃开发分支) + +**合并规范:** +- 从上游拉取合并时,使用 `/sync-upstream` skill +- 合并时必须保护我们的自定义功能代码(SMS 验证、CLAUDE.md 等) +- 绝不使用 `--force` 推送到 main 分支 +- 合并前必须确保 `go build` 编译通过 + +**自定义功能列表(合并时需保护):** +- 手机号短信验证注册功能(`common/sms*.go`, `middleware/sms-*.go`) +- User 模型 Phone 字段及相关改动 +- SMS 相关后台设置和前端 UI +- `.claude/` 目录下的所有配置和 skills + +### Rule 7: 部署与服务器迁移 + +详见 `.claude/commands/migrate-server.md` 中的迁移指南。 + +关键要点: +- 数据库迁移需要导出旧服务器的完整数据 +- GORM AutoMigrate 会自动处理新表结构(如 Phone 字段) +- 环境变量和配置文件需要同步迁移 +- Docker Compose 部署时注意数据卷映射 diff --git a/common/constants.go b/common/constants.go index 6823b2c8..a5c1c3bc 100644 --- a/common/constants.go +++ b/common/constants.go @@ -84,6 +84,13 @@ var SMTPAccount = "" var SMTPFrom = "" var SMTPToken = "" +var SMSVerificationEnabled = false +var SMSProvider = "" // "unisms" +var SMSAccessKeyId = "" // UniSMS AccessKeyId +var SMSAccessKeySecret = "" // UniSMS AccessKeySecret +var SMSSignName = "" // SMS signature name +var SMSTemplateCode = "" // SMS template code + var GitHubClientId = "" var GitHubClientSecret = "" var LinuxDOClientId = "" diff --git a/common/sms.go b/common/sms.go new file mode 100644 index 00000000..b32a79c3 --- /dev/null +++ b/common/sms.go @@ -0,0 +1,37 @@ +package common + +import ( + "errors" + "regexp" +) + +// SmsProvider defines the interface for SMS providers +type SmsProvider interface { + SendCode(phone string, code string) error +} + +var smsProviders = map[string]SmsProvider{} + +// RegisterSmsProvider registers an SMS provider by name +func RegisterSmsProvider(name string, provider SmsProvider) { + smsProviders[name] = provider +} + +// SendSMS sends a verification code via the configured SMS provider +func SendSMS(phone string, code string) error { + if SMSProvider == "" { + return errors.New("SMS provider not configured") + } + provider, ok := smsProviders[SMSProvider] + if !ok { + return errors.New("unknown SMS provider: " + SMSProvider) + } + return provider.SendCode(phone, code) +} + +var chinesePhoneRegex = regexp.MustCompile(`^1[3-9]\d{9}$`) + +// IsValidPhone checks if the phone number is a valid Chinese mobile number +func IsValidPhone(phone string) bool { + return chinesePhoneRegex.MatchString(phone) +} diff --git a/common/sms_unisms.go b/common/sms_unisms.go new file mode 100644 index 00000000..ca8b05cc --- /dev/null +++ b/common/sms_unisms.go @@ -0,0 +1,22 @@ +package common + +import ( + unisms "github.com/apistd/uni-go-sdk/sms" +) + +type UniSmsProvider struct{} + +func (p *UniSmsProvider) SendCode(phone string, code string) error { + client := unisms.NewClient(SMSAccessKeyId, SMSAccessKeySecret) + message := unisms.BuildMessage() + message.SetTo(phone) + message.SetSignature(SMSSignName) + message.SetTemplateId(SMSTemplateCode) + message.SetTemplateData(map[string]string{"code": code}) + _, err := client.Send(message) + return err +} + +func init() { + RegisterSmsProvider("unisms", &UniSmsProvider{}) +} diff --git a/common/verification.go b/common/verification.go index 41fd3c94..a5bc797a 100644 --- a/common/verification.go +++ b/common/verification.go @@ -16,6 +16,7 @@ type verificationValue struct { const ( EmailVerificationPurpose = "v" PasswordResetPurpose = "r" + SmsVerificationPurpose = "s" ) var verificationMutex sync.Mutex diff --git a/controller/misc.go b/controller/misc.go index b24a74ad..27deab60 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" @@ -51,6 +52,7 @@ func GetStatus(c *gin.Context) { "version": common.Version, "start_time": common.StartTime, "email_verification": common.EmailVerificationEnabled, + "sms_verification": common.SMSVerificationEnabled, "github_oauth": common.GitHubOAuthEnabled, "github_client_id": common.GitHubClientId, "discord_oauth": system_setting.GetDiscordSettings().Enabled, @@ -299,6 +301,38 @@ func SendEmailVerification(c *gin.Context) { return } +func SendSmsVerification(c *gin.Context) { + phone := c.Query("phone") + if phone == "" { + common.ApiErrorI18n(c, i18n.MsgUserPhoneEmpty) + return + } + if !common.IsValidPhone(phone) { + common.ApiErrorI18n(c, i18n.MsgUserPhoneFormatInvalid) + return + } + if !common.SMSVerificationEnabled { + common.ApiErrorI18n(c, i18n.MsgUserSmsNotConfigured) + return + } + if model.IsPhoneAlreadyTaken(phone) { + common.ApiErrorI18n(c, i18n.MsgUserPhoneAlreadyTaken) + return + } + code := common.GenerateVerificationCode(6) + common.RegisterVerificationCodeWithKey(phone, code, common.SmsVerificationPurpose) + err := common.SendSMS(phone, code) + if err != nil { + common.SysLog("failed to send SMS: " + err.Error()) + common.ApiErrorI18n(c, i18n.MsgUserSendSmsFailed) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) +} + func SendPasswordResetEmail(c *gin.Context) { email := c.Query("email") if err := common.Validate.Var(email, "required,email"); err != nil { diff --git a/controller/oauth.go b/controller/oauth.go index 818a28f8..57bc7b62 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -239,7 +239,7 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o user.Username = provider.GetProviderPrefix() + strconv.Itoa(model.GetMaxUserId()+1) if oauthUser.Username != "" { - if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, ""); err == nil && !exists { + if exists, err := model.CheckUserExistOrDeleted(oauthUser.Username, "", ""); err == nil && !exists { // 防止索引退化 if len(oauthUser.Username) <= model.UserNameMaxLength { user.Username = oauthUser.Username diff --git a/controller/user.go b/controller/user.go index b58eab88..c20f40c4 100644 --- a/controller/user.go +++ b/controller/user.go @@ -157,7 +157,21 @@ func Register(c *gin.Context) { return } } - exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email) + if common.SMSVerificationEnabled { + if user.Phone == "" || user.SmsVerificationCode == "" { + common.ApiErrorI18n(c, i18n.MsgUserSmsVerificationRequired) + return + } + if !common.IsValidPhone(user.Phone) { + common.ApiErrorI18n(c, i18n.MsgUserPhoneFormatInvalid) + return + } + if !common.VerifyCodeWithKey(user.Phone, user.SmsVerificationCode, common.SmsVerificationPurpose) { + common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) + return + } + } + exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email, user.Phone) if err != nil { common.ApiErrorI18n(c, i18n.MsgDatabaseError) common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err)) @@ -179,6 +193,9 @@ func Register(c *gin.Context) { if common.EmailVerificationEnabled { cleanUser.Email = user.Email } + if common.SMSVerificationEnabled { + cleanUser.Phone = user.Phone + } if err := cleanUser.Insert(inviterId); err != nil { common.ApiError(c, err) return @@ -390,6 +407,7 @@ func GetSelf(c *gin.Context) { "role": user.Role, "status": user.Status, "email": user.Email, + "phone": user.Phone, "github_id": user.GitHubId, "discord_id": user.DiscordId, "oidc_id": user.OidcId, diff --git a/go.mod b/go.mod index cf5fbfd3..1f0bb30e 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( require ( github.com/DmitriyVTitov/size v1.5.0 // indirect github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect + github.com/apistd/uni-go-sdk v0.0.2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect diff --git a/go.sum b/go.sum index 23fe7948..742bc116 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= +github.com/apistd/uni-go-sdk v0.0.2 h1:7kqETCOz/rz8AQU55XGzxDFGoFeMgeZL5fGwvxKBZrc= +github.com/apistd/uni-go-sdk v0.0.2/go.mod h1:eIqYos4IbHgE/rB75r05ypNLahooEMJCrbjXq322b74= github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= diff --git a/i18n/keys.go b/i18n/keys.go index 4d98540a..9c747028 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -100,6 +100,12 @@ const ( MsgUserTelegramIdEmpty = "user.telegram_id_empty" MsgUserTelegramNotBound = "user.telegram_not_bound" MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty" + MsgUserSmsVerificationRequired = "user.sms_verification_required" + MsgUserPhoneFormatInvalid = "user.phone_format_invalid" + MsgUserPhoneAlreadyTaken = "user.phone_already_taken" + MsgUserSendSmsFailed = "user.send_sms_failed" + MsgUserSmsNotConfigured = "user.sms_not_configured" + MsgUserPhoneEmpty = "user.phone_empty" ) // Quota related messages diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 54dbf918..095473a1 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -90,6 +90,12 @@ user.wechat_id_empty: "WeChat ID is empty!" user.telegram_id_empty: "Telegram ID is empty!" user.telegram_not_bound: "This Telegram account is not bound" user.linux_do_id_empty: "Linux DO ID is empty!" +user.sms_verification_required: "SMS verification is enabled, please enter your phone number and verification code" +user.phone_format_invalid: "Invalid phone number format" +user.phone_already_taken: "This phone number is already registered" +user.send_sms_failed: "Failed to send SMS, please try again later" +user.sms_not_configured: "SMS service is not configured" +user.phone_empty: "Phone number cannot be empty" # Quota messages quota.negative: "Quota cannot be negative!" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 4e0b5cd1..774b870b 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -91,6 +91,12 @@ user.wechat_id_empty: "WeChat id 为空!" user.telegram_id_empty: "Telegram id 为空!" user.telegram_not_bound: "该 Telegram 账户未绑定" user.linux_do_id_empty: "Linux DO id 为空!" +user.sms_verification_required: "管理员开启了短信验证,请输入手机号和验证码" +user.phone_format_invalid: "手机号格式不正确" +user.phone_already_taken: "该手机号已被注册" +user.send_sms_failed: "短信发送失败,请稍后重试" +user.sms_not_configured: "短信服务未配置" +user.phone_empty: "手机号不能为空" # Quota messages quota.negative: "额度不能为负数!" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index dcdd331b..cfbebed8 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -91,6 +91,12 @@ user.wechat_id_empty: "WeChat id 為空!" user.telegram_id_empty: "Telegram id 為空!" user.telegram_not_bound: "該 Telegram 帳號未綁定" user.linux_do_id_empty: "Linux DO id 為空!" +user.sms_verification_required: "管理員開啟了簡訊驗證,請輸入手機號和驗證碼" +user.phone_format_invalid: "手機號格式不正確" +user.phone_already_taken: "該手機號已被註冊" +user.send_sms_failed: "簡訊發送失敗,請稍後重試" +user.sms_not_configured: "簡訊服務未配置" +user.phone_empty: "手機號不能為空" # Quota messages quota.negative: "額度不能為負數!" diff --git a/middleware/sms-verification-rate-limit.go b/middleware/sms-verification-rate-limit.go new file mode 100644 index 00000000..60497f07 --- /dev/null +++ b/middleware/sms-verification-rate-limit.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + + "github.com/gin-gonic/gin" +) + +const ( + SmsVerificationRateLimitMark = "SMS" + SmsVerificationMaxRequests = 1 // 60 seconds max 1 request + SmsVerificationDuration = 60 // 60 second window +) + +func redisSmsVerificationRateLimiter(c *gin.Context) { + ctx := context.Background() + rdb := common.RDB + key := "smsVerification:" + SmsVerificationRateLimitMark + ":" + c.ClientIP() + + count, err := rdb.Incr(ctx, key).Result() + if err != nil { + memorySmsVerificationRateLimiter(c) + return + } + + if count == 1 { + _ = rdb.Expire(ctx, key, time.Duration(SmsVerificationDuration)*time.Second).Err() + } + + if count <= int64(SmsVerificationMaxRequests) { + c.Next() + return + } + + ttl, err := rdb.TTL(ctx, key).Result() + waitSeconds := int64(SmsVerificationDuration) + if err == nil && ttl > 0 { + waitSeconds = int64(ttl.Seconds()) + } + + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds), + }) + c.Abort() +} + +func memorySmsVerificationRateLimiter(c *gin.Context) { + key := SmsVerificationRateLimitMark + ":" + c.ClientIP() + + if !inMemoryRateLimiter.Request(key, SmsVerificationMaxRequests, SmsVerificationDuration) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": "发送过于频繁,请稍后再试", + }) + c.Abort() + return + } + + c.Next() +} + +func SmsVerificationRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + if common.RedisEnabled { + redisSmsVerificationRateLimiter(c) + } else { + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + memorySmsVerificationRateLimiter(c) + } + } +} diff --git a/model/option.go b/model/option.go index 697e77df..637f2084 100644 --- a/model/option.go +++ b/model/option.go @@ -62,6 +62,12 @@ func InitOptionMap() { common.OptionMap["SMTPAccount"] = "" common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) + common.OptionMap["SMSVerificationEnabled"] = strconv.FormatBool(common.SMSVerificationEnabled) + common.OptionMap["SMSProvider"] = common.SMSProvider + common.OptionMap["SMSAccessKeyId"] = common.SMSAccessKeyId + common.OptionMap["SMSAccessKeySecret"] = common.SMSAccessKeySecret + common.OptionMap["SMSSignName"] = common.SMSSignName + common.OptionMap["SMSTemplateCode"] = common.SMSTemplateCode common.OptionMap["Notice"] = "" common.OptionMap["About"] = "" common.OptionMap["HomePageContent"] = "" @@ -292,6 +298,8 @@ func updateOptionMap(key string, value string) (err error) { setting.StopOnSensitiveEnabled = boolValue case "SMTPSSLEnabled": common.SMTPSSLEnabled = boolValue + case "SMSVerificationEnabled": + common.SMSVerificationEnabled = boolValue case "WorkerAllowHttpImageRequestEnabled": system_setting.WorkerAllowHttpImageRequestEnabled = boolValue case "DefaultUseAutoGroup": @@ -314,6 +322,16 @@ func updateOptionMap(key string, value string) (err error) { common.SMTPFrom = value case "SMTPToken": common.SMTPToken = value + case "SMSProvider": + common.SMSProvider = value + case "SMSAccessKeyId": + common.SMSAccessKeyId = value + case "SMSAccessKeySecret": + common.SMSAccessKeySecret = value + case "SMSSignName": + common.SMSSignName = value + case "SMSTemplateCode": + common.SMSTemplateCode = value case "ServerAddress": system_setting.ServerAddress = value case "WorkerUrl": diff --git a/model/user.go b/model/user.go index 1210b543..76dcd444 100644 --- a/model/user.go +++ b/model/user.go @@ -29,6 +29,8 @@ type User struct { Role int `json:"role" gorm:"type:int;default:1"` // admin, common Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled Email string `json:"email" gorm:"index" validate:"max=50"` + Phone string `json:"phone" gorm:"index" validate:"max=20"` + SmsVerificationCode string `json:"sms_verification_code" gorm:"-:all"` GitHubId string `json:"github_id" gorm:"column:github_id;index"` DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` @@ -159,26 +161,28 @@ func generateDefaultSidebarConfigForRole(userRole int) string { } // CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil -func CheckUserExistOrDeleted(username string, email string) (bool, error) { +func CheckUserExistOrDeleted(username string, email string, phone string) (bool, error) { var user User - // err := DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error - // check email if empty var err error - if email == "" { - err = DB.Unscoped().First(&user, "username = ?", username).Error - } else { - err = DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error + conditions := []string{"username = ?"} + args := []interface{}{username} + if email != "" { + conditions = append(conditions, "email = ?") + args = append(args, email) } + if phone != "" { + conditions = append(conditions, "phone = ?") + args = append(args, phone) + } + query := strings.Join(conditions, " or ") + err = DB.Unscoped().Where(query, args...).First(&user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - // not exist, return false, nil return false, nil } - // other error, return false, err return false, err } - // exist, return true, nil return true, nil } @@ -242,7 +246,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, query := tx.Unscoped().Model(&User{}) // 构建搜索条件 - likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ? OR phone LIKE ?" // 尝试将关键字转换为整数ID keywordInt, err := strconv.Atoi(keyword) @@ -251,19 +255,19 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, likeCondition = "id = ? OR " + likeCondition if group != "" { query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", - keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) + keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) } else { query = query.Where(likeCondition, - keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") } } else { // 非数字关键字,只搜索字符串字段 if group != "" { query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) } else { query = query.Where(likeCondition, - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") } } @@ -546,6 +550,7 @@ func (user *User) ClearBinding(bindingType string) error { bindingColumnMap := map[string]string{ "email": "email", + "phone": "phone", "github": "github_id", "discord": "discord_id", "oidc": "oidc_id", @@ -680,6 +685,18 @@ func IsEmailAlreadyTaken(email string) bool { return DB.Unscoped().Where("email = ?", email).Find(&User{}).RowsAffected == 1 } +func IsPhoneAlreadyTaken(phone string) bool { + return DB.Unscoped().Where("phone = ?", phone).Find(&User{}).RowsAffected == 1 +} + +func (user *User) FillUserByPhone() error { + if user.Phone == "" { + return errors.New("phone 为空!") + } + DB.Where(User{Phone: user.Phone}).First(user) + return nil +} + func IsWeChatIdAlreadyTaken(wechatId string) bool { return DB.Unscoped().Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1 } diff --git a/router/api-router.go b/router/api-router.go index d4893400..bd6f0fc3 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) + apiRouter.GET("/sms_verification", middleware.SmsVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendSmsVerification) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) // OAuth routes - specific routes must come before :provider wildcard diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 0a755b19..fc775ee8 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -50,6 +50,7 @@ import { IconUser, IconLock, IconKey, + IconPhone, } from '@douyinfe/semi-icons'; import { onGitHubOAuthClicked, @@ -79,6 +80,8 @@ const RegisterForm = () => { password2: '', email: '', verification_code: '', + phone: '', + sms_verification_code: '', wechat_verification_code: '', }); const { username, password, password2 } = inputs; @@ -142,9 +145,14 @@ const RegisterForm = () => { ); const [showEmailVerification, setShowEmailVerification] = useState(false); + const [showSmsVerification, setShowSmsVerification] = useState(false); + const [smsCodeLoading, setSmsCodeLoading] = useState(false); + const [smsDisableButton, setSmsDisableButton] = useState(false); + const [smsCountdown, setSmsCountdown] = useState(60); useEffect(() => { setShowEmailVerification(!!status?.email_verification); + setShowSmsVerification(!!status?.sms_verification); if (status?.turnstile_check) { setTurnstileEnabled(true); setTurnstileSiteKey(status.turnstile_site_key); @@ -168,6 +176,19 @@ const RegisterForm = () => { return () => clearInterval(countdownInterval); // Clean up on unmount }, [disableButton, countdown]); + useEffect(() => { + let smsInterval = null; + if (smsDisableButton && smsCountdown > 0) { + smsInterval = setInterval(() => { + setSmsCountdown(smsCountdown - 1); + }, 1000); + } else if (smsCountdown === 0) { + setSmsDisableButton(false); + setSmsCountdown(60); + } + return () => clearInterval(smsInterval); + }, [smsDisableButton, smsCountdown]); + useEffect(() => { return () => { if (githubTimeoutRef.current) { @@ -279,6 +300,31 @@ const RegisterForm = () => { } }; + const sendSmsVerificationCode = async () => { + if (inputs.phone === '') return; + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setSmsCodeLoading(true); + try { + const res = await API.get( + `/api/sms_verification?phone=${encodeURIComponent(inputs.phone)}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('验证码发送成功,请查看手机短信!')); + setSmsDisableButton(true); + } else { + showError(message); + } + } catch (error) { + showError(t('发送验证码失败,请重试')); + } finally { + setSmsCodeLoading(false); + } + }; + const handleGitHubClick = () => { if (githubButtonDisabled) { return; @@ -637,6 +683,40 @@ const RegisterForm = () => { )} + {showSmsVerification && ( + <> + handleChange('phone', value)} + prefix={} + suffix={ + + } + /> + + handleChange('sms_verification_code', value) + } + prefix={} + /> + + )} + {(hasUserAgreement || hasPrivacyPolicy) && (
{ PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', + SMSVerificationEnabled: '', + SMSProvider: '', + SMSAccessKeyId: '', + SMSAccessKeySecret: '', + SMSSignName: '', + SMSTemplateCode: '', GitHubOAuthEnabled: '', GitHubClientId: '', GitHubClientSecret: '', @@ -174,6 +180,7 @@ const SystemSetting = () => { case 'PasswordLoginEnabled': case 'PasswordRegisterEnabled': case 'EmailVerificationEnabled': + case 'SMSVerificationEnabled': case 'GitHubOAuthEnabled': case 'WeChatAuthEnabled': case 'TelegramOAuthEnabled': @@ -347,6 +354,34 @@ const SystemSetting = () => { } }; + const submitSMS = async () => { + const options = []; + if (originInputs['SMSProvider'] !== inputs.SMSProvider) { + options.push({ key: 'SMSProvider', value: inputs.SMSProvider }); + } + if (originInputs['SMSAccessKeyId'] !== inputs.SMSAccessKeyId) { + options.push({ key: 'SMSAccessKeyId', value: inputs.SMSAccessKeyId }); + } + if ( + originInputs['SMSAccessKeySecret'] !== inputs.SMSAccessKeySecret && + inputs.SMSAccessKeySecret !== '' + ) { + options.push({ + key: 'SMSAccessKeySecret', + value: inputs.SMSAccessKeySecret, + }); + } + if (originInputs['SMSSignName'] !== inputs.SMSSignName) { + options.push({ key: 'SMSSignName', value: inputs.SMSSignName }); + } + if (originInputs['SMSTemplateCode'] !== inputs.SMSTemplateCode) { + options.push({ key: 'SMSTemplateCode', value: inputs.SMSTemplateCode }); + } + if (options.length > 0) { + await updateOptions(options); + } + }; + const submitEmailDomainWhitelist = async () => { if (Array.isArray(emailDomainWhitelist)) { await updateOptions([ @@ -681,6 +716,24 @@ const SystemSetting = () => { if (optionKey === 'PasswordLoginEnabled' && !value) { setShowPasswordLoginConfirmModal(true); + } else if (optionKey === 'SMSVerificationEnabled' && value) { + // When enabling SMS verification, disable email verification (mutual exclusion) + await updateOptions([ + { key: 'SMSVerificationEnabled', value: true }, + { key: 'EmailVerificationEnabled', value: false }, + ]); + if (formApiRef.current) { + formApiRef.current.setValue('EmailVerificationEnabled', false); + } + } else if (optionKey === 'EmailVerificationEnabled' && value) { + // When enabling email verification, disable SMS verification (mutual exclusion) + await updateOptions([ + { key: 'EmailVerificationEnabled', value: true }, + { key: 'SMSVerificationEnabled', value: false }, + ]); + if (formApiRef.current) { + formApiRef.current.setValue('SMSVerificationEnabled', false); + } } else { await updateOptions([{ key: optionKey, value }]); } @@ -1015,6 +1068,15 @@ const SystemSetting = () => { > {t('通过密码注册时需要进行邮箱验证')} + + handleCheckboxChange('SMSVerificationEnabled', e) + } + > + {t('通过密码注册时需要进行短信验证(与邮箱验证互斥)')} + { + + + {t('用以支持短信验证码注册(与邮箱验证互斥)')} + + + + UniSMS (合一短信) + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/components/table/users/UsersColumnDefs.jsx b/web/src/components/table/users/UsersColumnDefs.jsx index dc3e6f34..c07d5320 100644 --- a/web/src/components/table/users/UsersColumnDefs.jsx +++ b/web/src/components/table/users/UsersColumnDefs.jsx @@ -331,6 +331,26 @@ export const getUsersColumns = ({ key: 'quota_usage', render: (text, record) => renderQuotaUsage(text, record, t), }, + { + title: t('联系方式'), + key: 'contact', + render: (text, record) => { + const parts = []; + if (record.email) parts.push(record.email); + if (record.phone) parts.push(record.phone); + return parts.length > 0 ? ( + + {parts.map((p, i) => ( + + {p} + + ))} + + ) : ( + '-' + ); + }, + }, { title: t('分组'), dataIndex: 'group', diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index f6c13e7d..a80dbc5c 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2603,6 +2603,21 @@ "验证数据库连接状态": "Verify database connection status", "验证码": "Verification Code", "验证码发送成功,请检查邮箱!": "The verification code was sent successfully, please check your email!", + "验证码发送成功,请查看手机短信!": "Verification code sent successfully, please check your SMS!", + "发送验证码失败,请重试": "Failed to send verification code, please try again", + "手机号": "Phone Number", + "输入手机号": "Enter phone number", + "短信验证码": "SMS Verification Code", + "输入短信验证码": "Enter SMS verification code", + "通过密码注册时需要进行短信验证(与邮箱验证互斥)": "SMS verification required for password registration (mutually exclusive with email verification)", + "配置短信服务": "Configure SMS Service", + "用以支持短信验证码注册(与邮箱验证互斥)": "To support SMS verification code registration (mutually exclusive with email verification)", + "短信服务商": "SMS Provider", + "选择短信服务商": "Select SMS provider", + "短信签名": "SMS Signature", + "短信模板 ID": "SMS Template ID", + "保存短信设置": "Save SMS Settings", + "联系方式": "Contact", "验证设置": "Verify setup", "验证身份": "Verify identity", "验证配置错误": "Verification configuration error", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index fb135f6f..eee3ebba 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2622,6 +2622,21 @@ "验证数据库连接状态": "验证数据库连接状态", "验证码": "验证码", "验证码发送成功,请检查邮箱!": "验证码发送成功,请检查邮箱!", + "验证码发送成功,请查看手机短信!": "验证码发送成功,请查看手机短信!", + "发送验证码失败,请重试": "发送验证码失败,请重试", + "手机号": "手机号", + "输入手机号": "输入手机号", + "短信验证码": "短信验证码", + "输入短信验证码": "输入短信验证码", + "通过密码注册时需要进行短信验证(与邮箱验证互斥)": "通过密码注册时需要进行短信验证(与邮箱验证互斥)", + "配置短信服务": "配置短信服务", + "用以支持短信验证码注册(与邮箱验证互斥)": "用以支持短信验证码注册(与邮箱验证互斥)", + "短信服务商": "短信服务商", + "选择短信服务商": "选择短信服务商", + "短信签名": "短信签名", + "短信模板 ID": "短信模板 ID", + "保存短信设置": "保存短信设置", + "联系方式": "联系方式", "验证设置": "验证设置", "验证身份": "验证身份", "验证配置错误": "验证配置错误",