349 lines
11 KiB
Go
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})
|
|
}
|
|
}
|