Files
2026-10-08 15:30:37 +08:00

349 lines
11 KiB
Go

package services
import (
"errors"
"fmt"
"math/rand"
"regexp"
"strings"
"sync"
"time"
"server/models"
"server/pkg/passwordutil"
authsvc "server/services/auth"
"github.com/google/uuid"
)
// 统一认证中心自助账户能力:注册、找回密码、验证码。
//
// 与旧版 password_reset.go(按「租户名+账号」定位老表)不同,这里直接操作
// 统一身份层 yz_auth_identity:手机号即全局账号,一处改密全平台生效。
var mobileRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`)
// identityCodeItem 短信验证码条目
type identityCodeItem struct {
Code string
ExpiredAt time.Time
IssuedAt time.Time
}
var identityCodeStore sync.Map // key: purpose|phone
func identityCodeKey(purpose, phone string) string {
return strings.TrimSpace(purpose) + "|" + strings.TrimSpace(phone)
}
// IsMobile 校验手机号格式
func IsMobile(phone string) bool {
return mobileRegexp.MatchString(strings.TrimSpace(phone))
}
// findIdentityByMobile 按手机号查身份(含软删过滤)
func findIdentityByMobile(phone string) *models.AuthIdentity {
if !IsMobile(phone) {
return nil
}
var m models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("mobile", strings.TrimSpace(phone)).
Filter("delete_time__isnull", true).
One(&m); err != nil {
return nil
}
return &m
}
// SendIdentityCode 发送自助账户短信验证码。
// - purpose=register:手机号必须未注册
// - purpose=reset:手机号必须已注册
//
// 60 秒服务端冷却,防短信轰炸;验证码 5 分钟有效。
func SendIdentityCode(purpose, phone string) error {
phone = strings.TrimSpace(phone)
if !IsMobile(phone) {
return errors.New("请输入正确的手机号")
}
if purpose != "register" && purpose != "reset" {
return errors.New("参数错误")
}
existed := findIdentityByMobile(phone)
if purpose == "register" && existed != nil {
return errors.New("该手机号已注册,请直接登录或找回密码")
}
if purpose == "reset" && existed == nil {
// 不暴露「手机号是否存在」会牺牲可用性;注册页已明示手机号唯一,这里直接提示
return errors.New("该手机号尚未注册")
}
// 服务端 60 秒冷却
if v, ok := identityCodeStore.Load(identityCodeKey(purpose, phone)); ok {
if item, ok := v.(identityCodeItem); ok && time.Since(item.IssuedAt) < 60*time.Second {
return errors.New("验证码发送过于频繁,请稍后再试")
}
}
// 短信网关配置校验前置,避免生成验证码后发送失败造成困惑
if _, _, err := getDefaultSystemSMSConfig(); err != nil {
return errors.New("短信服务未配置,请联系平台管理员")
}
code := fmt.Sprintf("%06d", rand.Intn(1000000)) //nolint:gosec // 验证码场景可接受
content := "验证码:" + code + ",5分钟内有效。为保护账号安全,请勿泄露给他人。"
if err := enqueueSMSTaskForLogin(0, phone, content, code); err != nil {
return errors.New("短信发送失败,请稍后重试")
}
now := time.Now()
identityCodeStore.Store(identityCodeKey(purpose, phone), identityCodeItem{
Code: code,
ExpiredAt: now.Add(5 * time.Minute),
IssuedAt: now,
})
return nil
}
// VerifyIdentityCode 校验自助账户短信验证码,验证通过后立即销毁(一次性)
func VerifyIdentityCode(purpose, phone, code string) error {
phone = strings.TrimSpace(phone)
code = strings.TrimSpace(code)
if code == "" {
return errors.New("请输入验证码")
}
key := identityCodeKey(purpose, phone)
v, ok := identityCodeStore.Load(key)
if !ok {
return errors.New("请先获取验证码")
}
item, ok := v.(identityCodeItem)
if !ok {
return errors.New("验证码状态异常,请重新获取")
}
if time.Now().After(item.ExpiredAt) {
identityCodeStore.Delete(key)
return errors.New("验证码已过期,请重新获取")
}
if item.Code != code {
return errors.New("验证码错误")
}
identityCodeStore.Delete(key)
return nil
}
// RegisterIdentityInput 自助注册入参
type RegisterIdentityInput struct {
Phone string
Code string
Password string
Confirm string
Nickname string
TenantCode string // 选填:填了则注册后直接加入该企业(默认「租户用户」角色)
}
// RegisterIdentityResult 注册结果
type RegisterIdentityResult struct {
IdentityID uint64
JoinedTid uint64 // 成功加入的企业;0 表示未加入
Warning string // 加入企业失败的提示(身份已创建成功)
}
// RegisterIdentity 自助注册:创建统一身份,可选加入企业。
//
// 统一认证原则:手机号即全局账号,密码在身份层一份;加入企业只是建绑定,
// 企业内角色默认「租户用户」,后续由企业管理员调整。
func RegisterIdentity(in RegisterIdentityInput) (*RegisterIdentityResult, error) {
phone := strings.TrimSpace(in.Phone)
if !IsMobile(phone) {
return nil, errors.New("请输入正确的手机号")
}
if err := VerifyIdentityCode("register", phone, in.Code); err != nil {
return nil, err
}
if len(strings.TrimSpace(in.Password)) < 6 {
return nil, errors.New("密码长度不能少于6个字符")
}
if in.Password != in.Confirm {
return nil, errors.New("两次输入的密码不一致")
}
// 防并发重复注册
if findIdentityByMobile(phone) != nil {
return nil, errors.New("该手机号已注册,请直接登录")
}
hashed, err := passwordutil.Hash(strings.TrimSpace(in.Password))
if err != nil {
return nil, errors.New("密码处理失败")
}
nickname := strings.TrimSpace(in.Nickname)
if nickname == "" {
nickname = "用户" + phone[len(phone)-4:]
}
// 选填企业编码:先校验企业存在且启用、配额未满,失败直接拒绝(身份尚未创建)
tenantCode := strings.TrimSpace(in.TenantCode)
var tenant *models.SystemTenant
if tenantCode != "" {
t := new(models.SystemTenant)
err := models.Orm.QueryTable(new(models.SystemTenant)).
Filter("tenant_code", tenantCode).
Filter("status", 1).
Filter("delete_time__isnull", true).
One(t)
if err != nil {
return nil, errors.New("企业编码不存在,请确认后重试")
}
if check, err := CheckTenantUserQuota(t.ID, 0); err == nil && !check.Allowed {
return nil, fmt.Errorf("该企业用户数已达上限(%d/%d),请联系企业管理员", check.Used, check.Quota)
}
tenant = t
}
identity := &models.AuthIdentity{
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
PasswordAlgo: passwordutil.AlgoOf(hashed),
Status: models.AuthIdentityStatusEnabled,
}
identity.Mobile = &phone
identity.Nickname = &nickname
identity.PasswordHash = &hashed
id, err := models.Orm.Insert(identity)
if err != nil {
return nil, errors.New("注册失败,请稍后重试")
}
identity.ID = uint64(id)
result := &RegisterIdentityResult{IdentityID: identity.ID}
// 加入企业:建绑定(老表 + 认证中心),角色默认「租户用户」
if tenant != nil {
warn := joinTenantAfterRegister(identity, tenant, nickname, phone, hashed)
if warn != "" {
result.Warning = warn
} else {
result.JoinedTid = tenant.ID
}
}
return result, nil
}
// joinTenantAfterRegister 注册后加入企业;返回警告信息(空串表示成功)。
// 身份已创建成功,加入失败仅降级为警告,不回滚注册。
func joinTenantAfterRegister(identity *models.AuthIdentity, tenant *models.SystemTenant, nickname, phone, passwordHash string) string {
// 企业内账号默认用手机号;若已被本企业其他身份占用则放弃自动加入
var dup models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tenant.ID).
Filter("account", phone).
Filter("delete_time__isnull", true).
One(&dup); err == nil && dup.Uid != identity.ID {
return "注册成功,但企业内账号已被占用,请联系企业管理员邀请你加入"
}
phoneStr := phone
nameStr := nickname
hashStr := passwordHash
if _, err := BindTenantUser(
tenant.ID, identity.ID,
&phoneStr, &nameStr, &phoneStr, nil,
nil, nil, &hashStr,
1, 1, nil,
); err != nil {
return "注册成功,但加入企业失败,请联系企业管理员邀请你加入"
}
// 默认「租户用户」角色;角色 ID 一并写入认证中心绑定
groupID := uint64(0)
if roleID, err := models.GetTenantUserRole(); err == nil && roleID > 0 {
groupID = roleID
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tenant.ID).
Filter("uid", identity.ID).
Update(map[string]interface{}{"group_id": roleID})
}
// 双写认证中心绑定
if err := authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
Tid: tenant.ID,
Account: phoneStr,
Name: nameStr,
Phone: phoneStr,
PasswordHash: passwordHash,
GroupID: groupID,
Status: 1,
IsDefault: 1,
}); err != nil {
return "注册成功,企业绑定已建立,但认证中心同步失败,请联系管理员"
}
return ""
}
// ResetIdentityPasswordByMobile 自助找回密码:验证码通过后重置身份层密码。
// 密码属于身份层,重置后该用户在所有企业的登录密码同步更新。
func ResetIdentityPasswordByMobile(phone, code, newPassword, confirm string) error {
phone = strings.TrimSpace(phone)
if !IsMobile(phone) {
return errors.New("请输入正确的手机号")
}
if err := VerifyIdentityCode("reset", phone, code); err != nil {
return err
}
if len(strings.TrimSpace(newPassword)) < 6 {
return errors.New("密码长度不能少于6个字符")
}
if newPassword != confirm {
return errors.New("两次输入的密码不一致")
}
identity := findIdentityByMobile(phone)
if identity == nil {
return errors.New("该手机号尚未注册")
}
hashed, err := passwordutil.Hash(strings.TrimSpace(newPassword))
if err != nil {
return errors.New("密码处理失败")
}
_, err = models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", identity.ID).
Update(map[string]interface{}{
"password_hash": hashed,
"password_algo": passwordutil.AlgoOf(hashed),
"fail_count": 0,
})
if err != nil {
return errors.New("密码重置失败,请稍后重试")
}
// 老表镜像同步:把新密码写到该身份在各企业的 yz_system_tenant_user 行,
// 保持「老表密码 = 身份层密码」的双写口径(登录以认证中心为准)
mirrorPasswordToTenantRows(identity.ID, hashed)
return nil
}
// mirrorPasswordToTenantRows 把身份层密码镜像到该身份所有企业绑定的老表行
func mirrorPasswordToTenantRows(identityID uint64, passwordHash string) {
var binds []models.AuthTenantUser
if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
Filter("identity_id", identityID).
Filter("delete_time__isnull", true).
All(&binds); err != nil {
return
}
for _, b := range binds {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", b.Tid).
Filter("uid", identityID).
Update(map[string]interface{}{"password": passwordHash})
}
}