package app import ( "context" cryptorand "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "math/big" "net/http" "net/url" "strconv" "strings" "time" "git.5iot.com/iotlcm-public/iotlcm-app/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-app/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-app/internal/library/mcache" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-app/internal/service" "git.5iot.com/iotlcm-public/iotlcm-app/utility" "github.com/gogf/gf/v2/container/gvar" "github.com/gogf/gf/v2/crypto/gmd5" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/wenlng/go-captcha-assets/resources/images" "github.com/wenlng/go-captcha-assets/resources/tiles" "github.com/wenlng/go-captcha/v2/base/option" "github.com/wenlng/go-captcha/v2/slide" "gorm.io/gorm" ) const appSideCaptchaCachePrefix = "app:side:captcha:" const appSmsCachePrefix = "app:sms:" const appSmsCooldownPrefix = "app:sms:cooldown:" const appSmsDailyPhonePrefix = "app:sms:daily:phone:" const appSmsDailyIPPrefix = "app:sms:daily:ip:" const appWechatBindTicketCachePrefix = "app:wechat:bind:" const appWechatMiniAppSecretPlaceholder = "CHANGE_ME_WECHAT_MINI_APP_SECRET" const appWechatBindTicketExpire = 10 * time.Minute const ( appSmsCodeTTL = 5 * time.Minute appSmsCooldownTTL = 60 * time.Second appSmsDailyLimit = 10 appSmsDailyExpire = 24 * time.Hour ) var appSlideCaptcha slide.Captcha type appWechatCode2SessionRes struct { OpenId string `json:"openid"` SessionKey string `json:"session_key"` UnionId string `json:"unionid"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } type appWechatBindTicketCache struct { TenantId string `json:"tenantId"` OpenId string `json:"openId"` UnionId string `json:"unionId"` VerifiedPhone string `json:"verifiedPhone"` } type appCodeHttpSenderConfig struct { Url string `json:"url"` Method string `json:"method"` Headers map[string]string `json:"headers"` BodyTemplate string `json:"bodyTemplate"` SuccessStatus int `json:"successStatus"` } type appSmsHTTPResponse struct { Code int `json:"code"` Message string `json:"message"` } type sAppAuth struct{} func NewAppAuth() *sAppAuth { return &sAppAuth{} } func init() { service.RegisterAppAuth(NewAppAuth()) initAppSlideCaptcha() } func (s *sAppAuth) Captcha(ctx context.Context) (captchaID string, captchaImage string, err error) { return service.SysCaptcha().GenerateCaptcha(ctx) } func initAppSlideCaptcha() { builder := slide.NewBuilder(slide.WithEnableGraphVerticalRandom(true)) imgs, err := images.GetImages() if err != nil { panic(err) } graphs, err := tiles.GetTiles() if err != nil { panic(err) } newGraphs := make([]*slide.GraphImage, 0, len(graphs)) for _, graph := range graphs { newGraphs = append(newGraphs, &slide.GraphImage{ OverlayImage: graph.OverlayImage, MaskImage: graph.MaskImage, ShadowImage: graph.ShadowImage, }) } builder.SetResources( slide.WithGraphImages(newGraphs), slide.WithBackgrounds(imgs), ) builder.SetOptions(slide.WithImageSize(option.Size{Width: 280, Height: 170})) appSlideCaptcha = builder.Make() } func (s *sAppAuth) SideCaptcha(ctx context.Context) (res *model.AppSideCaptchaModel, err error) { captchaData, err := appSlideCaptcha.Generate() if err != nil { return nil, gerror.New("获取滑块验证码出错") } blockData := captchaData.GetData() if blockData == nil { return nil, gerror.New("获取滑块验证码出错") } imageBase64, err := captchaData.GetMasterImage().ToBase64() if err != nil { return nil, gerror.New("获取滑块验证码出错") } tileBase64, err := captchaData.GetTileImage().ToBase64() if err != nil { return nil, gerror.New("获取滑块验证码出错") } dotsByte, err := json.Marshal(blockData) if err != nil { return nil, gerror.New("获取滑块验证码出错") } key, err := gmd5.EncryptBytes(dotsByte) if err != nil { return nil, gerror.New("获取滑块验证码出错") } if err = mcache.Set(ctx, appSideCaptchaCachePrefix+key, hex.EncodeToString(dotsByte), 5*time.Minute); err != nil { return nil, gerror.New("获取滑块验证码出错") } return &model.AppSideCaptchaModel{ CaptchaKey: key, ImageBase64: imageBase64, TileBase64: tileBase64, TileWidth: blockData.Width, TileHeight: blockData.Height, TileX: blockData.DX, TileY: blockData.DY, }, nil } func (s *sAppAuth) CheckSideCaptcha(ctx context.Context, param *model.AppCheckSideCaptchaParam) (err error) { cacheKey := appSideCaptchaCachePrefix + param.CaptchaKey value, err := mcache.Get(ctx, cacheKey) if err != nil || value == nil { return gerror.New("滑块验证已失效") } defer mcache.Remove(ctx, cacheKey) dotsByte, err := hex.DecodeString(fmt.Sprintf("%v", value)) if err != nil { return gerror.New("滑块验证出错") } var blockData *slide.Block if err = json.Unmarshal(dotsByte, &blockData); err != nil { return gerror.New("滑块验证出错") } point := strings.Split(param.Point, ",") if len(point) != 2 { return gerror.New("滑块验证出错") } x, _ := strconv.ParseFloat(fmt.Sprintf("%v", point[0]), 64) y, _ := strconv.ParseFloat(fmt.Sprintf("%v", point[1]), 64) if !slide.Validate(int(x), int(y), int(blockData.X), int(blockData.Y), 4) { return gerror.New("滑块验证失败") } return nil } func (s *sAppAuth) SendSms(ctx context.Context, param *model.AppSendSmsParam) (res *model.AppSendSmsModel, err error) { if param == nil { return nil, gerror.New("短信验证码参数不能为空") } param.TenantId = strings.TrimSpace(param.TenantId) param.Phone = strings.TrimSpace(param.Phone) param.Scene = strings.TrimSpace(param.Scene) if err = s.validateAppSmsScene(ctx, param); err != nil { return nil, err } if err = s.reserveAppSmsCooldown(ctx, param.TenantId, param.Phone); err != nil { return nil, err } if err = s.checkAppSmsRateLimit(ctx, param.TenantId, param.Phone); err != nil { _ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone) return nil, err } code, err := appRandomDigits(6) if err != nil { _ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone) return nil, gerror.Wrap(err, "生成短信验证码失败") } cacheKey := appSmsCacheKey(param.TenantId, param.Phone, param.Scene) if err = setAppSmsCode(ctx, cacheKey, code); err != nil { _ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone) return nil, gerror.New("短信验证码发送失败") } sentByHTTP, err := sendAppPhoneCode(ctx, param.Phone, code, appSmsServiceScene(param.Scene)) if err != nil { _ = deleteAppSmsCode(ctx, cacheKey) _ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone) g.Log().Errorf(ctx, "APP短信验证码发送失败 scene=%s phone=%s err=%+v", param.Scene, param.Phone, err) return nil, gerror.New("短信验证码发送失败,请稍后重试") } res = &model.AppSendSmsModel{Success: true} if !sentByHTTP { res.SmsCode = code } return res, nil } func (s *sAppAuth) validateAppSmsScene(ctx context.Context, param *model.AppSendSmsParam) error { if err := service.AppAuth().CheckSideCaptcha(ctx, &model.AppCheckSideCaptchaParam{ CaptchaKey: strings.TrimSpace(param.CaptchaKey), Point: strings.TrimSpace(param.CaptchaPoint), }); err != nil { return err } users, err := s.findAppLoginUsersByAccount(ctx, param.TenantId, param.Phone) if err != nil { return err } switch param.Scene { case "register": if len(users) > 0 { return gerror.New("手机号已注册") } case "login", "forgotPassword", "wechatBindPhone": if len(users) == 0 { return gerror.New("手机号未注册") } } return nil } func (s *sAppAuth) checkAppSmsRateLimit(ctx context.Context, tenantId string, phone string) error { tenantId = strings.TrimSpace(tenantId) if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } clientIP := strings.TrimSpace(utility.GetClientIp(ctx)) for _, item := range []struct { key string name string skip bool }{ {appSmsDailyPhonePrefix + strings.TrimSpace(tenantId) + ":" + phone, "手机号", false}, {appSmsDailyIPPrefix + clientIP, "当前网络", clientIP == ""}, } { if item.skip { continue } value, err := g.Redis().Do(ctx, "INCR", item.key) if err != nil { return gerror.Wrap(err, "短信服务暂不可用") } if value.Int() == 1 { _, _ = g.Redis().Do(ctx, "EXPIRE", item.key, int(appSmsDailyExpire.Seconds())) } if value.Int() > appSmsDailyLimit { return gerror.Newf("%s今日短信发送次数已达上限", item.name) } } return nil } func (s *sAppAuth) reserveAppSmsCooldown(ctx context.Context, tenantId string, phone string) error { key := appSmsCooldownCacheKey(tenantId, phone) value, err := g.Redis().Do(ctx, "SET", key, "1", "NX", "EX", int(appSmsCooldownTTL.Seconds())) if err != nil { return gerror.Wrap(err, "短信服务暂不可用") } if value.IsNil() || strings.EqualFold(value.String(), "") { return gerror.New("短信验证码发送过于频繁,请稍后再试") } return nil } func releaseAppSmsCooldown(ctx context.Context, tenantId string, phone string) error { _, err := g.Redis().Do(ctx, "DEL", appSmsCooldownCacheKey(tenantId, phone)) return err } func appSmsCooldownCacheKey(tenantId string, phone string) string { if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } return fmt.Sprintf("%s%s:%s", appSmsCooldownPrefix, tenantId, phone) } func appSmsServiceScene(scene string) string { switch scene { case "wechatBindPhone": return "bind_phone" default: return scene } } func sendAppPhoneCode(ctx context.Context, phone string, code string, scene string) (sentByHTTP bool, err error) { var cfg appCodeHttpSenderConfig if v, cfgErr := g.Cfg().Get(ctx, "register.sms"); cfgErr == nil && !v.IsNil() { _ = v.Scan(&cfg) } if strings.TrimSpace(cfg.Url) != "" { return true, callAppCodeHTTP(ctx, cfg, phone, code, scene) } if !strings.EqualFold(strings.TrimSpace(g.Cfg().MustGet(ctx, "system.gfmode", "prod").String()), "dev") { return false, gerror.New("短信服务未配置") } g.Log().Infof(ctx, "APP短信验证码已生成 phone=%s code=%s", phone, code) return false, nil } func callAppCodeHTTP(ctx context.Context, cfg appCodeHttpSenderConfig, contact string, code string, scene string) error { method := strings.ToUpper(strings.TrimSpace(cfg.Method)) if method == "" { method = http.MethodPost } successStatus := cfg.SuccessStatus if successStatus <= 0 { successStatus = http.StatusOK } body := renderAppCodeTemplate(cfg.BodyTemplate, contact, code, scene) if strings.TrimSpace(body) == "" { bodyBytes, _ := json.Marshal(g.Map{ "contact": contact, "phone": contact, "code": code, }) body = string(bodyBytes) } headers := cfg.Headers if headers == nil { headers = map[string]string{} } if _, ok := headers["Content-Type"]; !ok { headers["Content-Type"] = "application/json" } resp, err := g.Client().SetHeaderMap(headers).DoRequest(ctx, method, cfg.Url, body) if err != nil { return gerror.Wrap(err, "请求短信服务失败") } defer resp.Close() respBody := strings.TrimSpace(resp.ReadAllString()) if resp.StatusCode != successStatus { if respBody != "" { return gerror.Newf("短信服务响应异常:%d,%s", resp.StatusCode, respBody) } return gerror.Newf("短信服务响应异常:%d", resp.StatusCode) } if respBody == "" { return nil } var result appSmsHTTPResponse if err = json.Unmarshal([]byte(respBody), &result); err != nil { return gerror.Wrap(err, "解析短信服务响应失败") } if result.Code != 0 { message := strings.TrimSpace(result.Message) if message == "" { message = "短信服务返回失败" } return gerror.New(message) } return nil } func renderAppCodeTemplate(template string, contact string, code string, scene string) string { template = strings.TrimSpace(template) if template == "" { return "" } replacer := strings.NewReplacer( "{contact}", contact, "{{contact}}", contact, "${contact}", contact, "{email}", contact, "{{email}}", contact, "${email}", contact, "{phone}", contact, "{{phone}}", contact, "${phone}", contact, "{scene}", scene, "{{scene}}", scene, "${scene}", scene, "{code}", code, "{{code}}", code, "${code}", code, ) return replacer.Replace(template) } func appSmsCacheKey(tenantId string, phone string, scene string) string { if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } return fmt.Sprintf("%s%s:%s:%s", appSmsCachePrefix, tenantId, scene, phone) } func setAppSmsCode(ctx context.Context, key string, code string) error { _, err := g.Redis().Do(ctx, "SETEX", key, int(appSmsCodeTTL.Seconds()), code) return err } func deleteAppSmsCode(ctx context.Context, key string) error { _, err := g.Redis().Do(ctx, "DEL", key) return err } func verifyAppSmsCode(ctx context.Context, tenantId string, phone string, scene string, code string) error { value, err := g.Redis().Get(ctx, appSmsCacheKey(tenantId, phone, scene)) if err != nil || value == nil { return gerror.NewCode(consts.CodeCaptchaError, "短信验证码已失效") } if strings.TrimSpace(value.String()) != strings.TrimSpace(code) { return gerror.NewCode(consts.CodeCaptchaError, "短信验证码错误") } return nil } func appRandomDigits(length int) (string, error) { if length <= 0 { length = 6 } limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil) value, err := cryptorand.Int(cryptorand.Reader, limit) if err != nil { return "", err } return fmt.Sprintf("%0*d", length, value.Int64()), nil } func (s *sAppAuth) PhoneRegister(ctx context.Context, param *model.AppPhoneRegisterParam) (res *model.AppAuthTokenModel, err error) { if param == nil { return nil, gerror.New("请求参数不能为空") } param.TenantId = strings.TrimSpace(param.TenantId) param.Phone = strings.TrimSpace(param.Phone) param.SmsCode = strings.TrimSpace(param.SmsCode) if err = verifyAppSmsCode(ctx, param.TenantId, param.Phone, "register", param.SmsCode); err != nil { return nil, err } if param.TenantId == "" { param.TenantId = consts.DefaultSystemTenantCode } settings, err := appLoadRegisterSettings(ctx, param.TenantId) if err != nil { return nil, err } salt := utility.RandomString(5) password := appPasswordEncryptForStore(param.Password, salt) nickName := param.NickName if nickName == "" { nickName = "用户" + param.Phone[len(param.Phone)-4:] } now := gtime.Now() err = dao.SysUser.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { if err := s.releaseDeletedPhoneRegisterAccounts(ctx, tx, param.TenantId, param.Phone, now); err != nil { return err } userId, err := dao.SysUser.Ctx(ctx).TX(tx).Data(do.SysUser{ TenantId: param.TenantId, DeptId: settings.DeptId, UserName: param.Phone, NickName: nickName, UserType: consts.SysUserTypeAPP, Phonenumber: param.Phone, Sex: "2", Salt: salt, Password: password, Status: consts.SysUserStatusNormal, CreatedDept: settings.DeptId, CreatedAt: now, UpdatedAt: now, }).InsertAndGetId() if err != nil { return err } if _, err = dao.SysUserRole.Ctx(ctx).TX(tx).Data(do.SysUserRole{ UserId: userId, RoleId: settings.RoleId, }).Insert(); err != nil { return err } _, err = dao.TTerminal.Ctx(ctx).TX(tx).Data(do.TTerminal{ TerminalId: userId, TerminalName: nickName, Phone: param.Phone, Status: consts.SysUserStatusNormal, FullName: nickName, LegalPersonName: nickName, LegalPersonIdCard: "000000000000000000", Balance: 0, TenantId: param.TenantId, CreatedDept: settings.DeptId, CreatedBy: userId, CreatedAt: now, UpdatedBy: userId, UpdatedAt: now, }).Insert() return err }) if err != nil { if isSysUserNameUniqueKeyError(err) { return nil, gerror.New("手机号已注册或账号已被占用") } return nil, err } _ = deleteAppSmsCode(ctx, appSmsCacheKey(param.TenantId, param.Phone, "register")) return s.PhoneLogin(ctx, &model.AppPhoneLoginParam{ TenantId: param.TenantId, Account: param.Phone, Password: param.Password, }) } func (s *sAppAuth) releaseDeletedPhoneRegisterAccounts(ctx context.Context, tx gdb.TX, tenantId string, phone string, now *gtime.Time) error { tenantId = strings.TrimSpace(tenantId) phone = strings.TrimSpace(phone) if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } if phone == "" { return nil } if now == nil { now = gtime.Now() } mod := dao.SysUser.Ctx(ctx).Unscoped() if tx != nil { mod = dao.SysUser.Ctx(ctx).TX(tx).Unscoped() } var users []*entity.SysUser if err := mod. Where(dao.SysUser.Columns().TenantId, tenantId). Where("("+dao.SysUser.Columns().Phonenumber+" = ? OR "+dao.SysUser.Columns().UserName+" = ?)", phone, phone). OrderAsc(dao.SysUser.Columns().UserId). Scan(&users); err != nil { return gerror.Wrap(err, "检查手机号注册状态失败") } for _, user := range users { if user == nil || user.UserId <= 0 { continue } if user.DeletedAt == nil || user.DeletedAt.IsZero() { return gerror.New("手机号已注册") } } for _, user := range users { if user == nil || user.UserId <= 0 { continue } data := do.SysUser{ UpdatedAt: now, } needsUpdate := false if strings.TrimSpace(user.UserName) == phone { data.UserName = deletedRegisterUserName(user.UserId, phone) needsUpdate = true } if strings.TrimSpace(user.Phonenumber) == phone { data.Phonenumber = "" needsUpdate = true } if !needsUpdate { continue } updateMod := dao.SysUser.Ctx(ctx).Unscoped() if tx != nil { updateMod = dao.SysUser.Ctx(ctx).TX(tx).Unscoped() } if _, err := updateMod. WherePri(user.UserId). Data(data). OmitNil(). Update(); err != nil { if isSysUserNameUniqueKeyError(err) { return gerror.New("手机号已注册或账号已被占用") } return gerror.Wrap(err, "释放已删除账号手机号占用失败") } } return nil } func deletedRegisterUserName(userId int64, phone string) string { phone = strings.TrimSpace(phone) if len(phone) > 6 { phone = phone[len(phone)-6:] } return fmt.Sprintf("del%d_%s", userId, phone) } func isSysUserNameUniqueKeyError(err error) bool { if err == nil { return false } errText := err.Error() return strings.Contains(errText, "Duplicate entry") && strings.Contains(errText, "uk_user_name") } func (s *sAppAuth) PhoneLogin(ctx context.Context, param *model.AppPhoneLoginParam) (res *model.AppAuthTokenModel, err error) { if param == nil { param = &model.AppPhoneLoginParam{} } user, candidates, err := s.resolveAppLoginUser(ctx, param) if err != nil { return nil, err } if len(candidates) > 0 { return &model.AppAuthTokenModel{ NeedSelect: true, Candidates: candidates, }, nil } return s.buildAppAuthToken(ctx, user) } func (s *sAppAuth) PhoneSmsLogin(ctx context.Context, param *model.AppPhoneSmsLoginParam) (res *model.AppAuthTokenModel, err error) { if param == nil { return nil, gerror.New("请求参数不能为空") } param.TenantId = strings.TrimSpace(param.TenantId) param.Phone = strings.TrimSpace(param.Phone) param.SmsCode = strings.TrimSpace(param.SmsCode) if err = verifyAppSmsCode(ctx, param.TenantId, param.Phone, "login", param.SmsCode); err != nil { return nil, err } users, err := s.findAppWechatBindUsersByPhone(ctx, param.TenantId, param.Phone) if err != nil { return nil, err } if len(users) == 0 { return nil, gerror.New("手机号未注册") } if len(users) > 1 && param.SelectedUserId <= 0 { return &model.AppAuthTokenModel{NeedSelect: true, Candidates: appLoginCandidateModels(ctx, users)}, nil } user := users[0] if param.SelectedUserId > 0 { user = nil for _, item := range users { if item != nil && item.UserId == param.SelectedUserId { user = item break } } if user == nil { return nil, gerror.New("选择的账号不属于当前手机号") } } _ = deleteAppSmsCode(ctx, appSmsCacheKey(param.TenantId, param.Phone, "login")) return s.buildAppAuthToken(ctx, user) } func (s *sAppAuth) WechatLogin(ctx context.Context, param *model.AppWechatLoginParam) (res *model.AppWechatLoginModel, err error) { if param == nil { return nil, gerror.New("请求参数不能为空") } tenantId := strings.TrimSpace(param.TenantId) if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } session, err := appWechatMiniSessionByLoginCode(ctx, param.WxCode, false) if err != nil { return nil, err } social, err := s.getWechatSocialByOpenId(ctx, tenantId, session.OpenId) if err != nil { return nil, err } if social != nil && social.UserId > 0 { user, err := s.getAppUserByIdForWechat(ctx, tenantId, social.UserId) if err != nil { return nil, err } token, err := s.buildAppAuthToken(ctx, user) if err != nil { return nil, err } return &model.AppWechatLoginModel{AppAuthTokenModel: *token}, nil } ticket, err := appCreateWechatBindTicket(ctx, tenantId, session.OpenId, session.UnionId) if err != nil { return nil, err } return &model.AppWechatLoginModel{ NeedBindPhone: true, BindTicket: ticket, BindExpireIn: int64(appWechatBindTicketExpire / time.Second), }, nil } func (s *sAppAuth) WechatBindPhone(ctx context.Context, param *model.AppWechatBindPhoneParam) (res *model.AppWechatBindPhoneModel, err error) { if param == nil { return nil, gerror.New("请求参数不能为空") } tenantId := strings.TrimSpace(param.TenantId) if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } phone := strings.TrimSpace(param.Phone) if phone == "" { return nil, gerror.New("手机号不能为空") } ticketData, err := appLoadWechatBindTicket(ctx, param.BindTicket) if err != nil { return nil, err } if ticketData.TenantId != tenantId { return nil, gerror.New("微信绑定凭证与当前租户不匹配,请重新发起微信登录") } if err = appEnsureWechatBindPhoneVerified(ctx, param, ticketData, phone); err != nil { return nil, err } users, err := s.findAppWechatBindUsersByPhone(ctx, tenantId, phone) if err != nil { return nil, err } if len(users) == 0 { return nil, gerror.New("该手机号未找到可绑定账号,请先注册或联系管理员分配账号") } if len(users) > 1 && param.SelectedUserId <= 0 { return &model.AppWechatBindPhoneModel{ NeedBindPhone: true, BindTicket: strings.TrimSpace(param.BindTicket), AppAuthTokenModel: model.AppAuthTokenModel{ NeedSelect: true, Candidates: appLoginCandidateModels(ctx, users), }, }, nil } user := users[0] if param.SelectedUserId > 0 { user = nil for _, item := range users { if item != nil && item.UserId == param.SelectedUserId { user = item break } } if user == nil { return nil, gerror.New("选择的账号不属于当前手机号") } } if err = s.saveWechatOpenId(ctx, user, ticketData.OpenId, ticketData.UnionId); err != nil { return nil, err } _ = deleteAppSmsCode(ctx, appSmsCacheKey(tenantId, phone, "wechatBindPhone")) _ = mcache.Remove(ctx, appWechatBindTicketKey(param.BindTicket)) token, err := s.buildAppAuthToken(ctx, user) if err != nil { return nil, err } return &model.AppWechatBindPhoneModel{AppAuthTokenModel: *token}, nil } func (s *sAppAuth) BindWechatOpenIdByLoginCode(ctx context.Context, user *entity.SysUser, wxCode string) error { wxCode = strings.TrimSpace(wxCode) if wxCode == "" { return nil } if user == nil || user.UserId <= 0 { return gerror.New("当前登录账号不存在,无法绑定微信openid") } session, err := appWechatMiniSessionByLoginCode(ctx, wxCode, true) if err != nil { return err } if session == nil { return nil } return s.saveWechatOpenId(ctx, user, session.OpenId, session.UnionId) } func appWechatMiniSessionByLoginCode(ctx context.Context, wxCode string, skipWhenDisabled bool) (*appWechatCode2SessionRes, error) { wxCode = strings.TrimSpace(wxCode) if wxCode == "" { return nil, gerror.New("微信登录凭证不能为空") } cfg, err := appLoadWechatConfig(ctx) if err != nil { return nil, err } if !cfg.Mini.Enabled { if skipWhenDisabled { return nil, nil } return nil, gerror.New("微信小程序登录未启用") } appId := strings.TrimSpace(cfg.Mini.AppId) appSecret := strings.TrimSpace(cfg.Mini.AppSecret) if appId == "" { return nil, gerror.New("微信小程序AppID未配置,无法登录") } if appWechatMiniSecretMissing(appSecret) { return nil, gerror.New("微信小程序AppSecret未配置,无法登录") } return appWechatMiniCode2Session(ctx, appId, appSecret, wxCode) } func appWechatMiniSecretMissing(secret string) bool { secret = strings.TrimSpace(secret) if secret == "" { return true } upper := strings.ToUpper(secret) return upper == appWechatMiniAppSecretPlaceholder || strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "PLACEHOLDER") || strings.Contains(secret, "请填写") } func appWechatMiniCode2Session(ctx context.Context, appId string, appSecret string, wxCode string) (*appWechatCode2SessionRes, error) { params := url.Values{} params.Set("appid", appId) params.Set("secret", appSecret) params.Set("js_code", wxCode) params.Set("grant_type", "authorization_code") req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+params.Encode(), nil) if err != nil { return nil, gerror.Wrap(err, "创建微信openid换取请求失败") } client := &http.Client{Timeout: 8 * time.Second} resp, err := client.Do(req) if err != nil { return nil, gerror.Wrap(err, "请求微信openid失败") } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, gerror.Wrap(err, "读取微信openid响应失败") } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, gerror.Newf("请求微信openid失败,HTTP状态码:%d", resp.StatusCode) } var result appWechatCode2SessionRes if err = json.Unmarshal(body, &result); err != nil { return nil, gerror.Wrap(err, "解析微信openid响应失败") } if result.ErrCode != 0 { msg := strings.TrimSpace(result.ErrMsg) if msg == "" { msg = "微信返回错误" } return nil, gerror.Newf("换取微信openid失败:%s(%d)", msg, result.ErrCode) } if strings.TrimSpace(result.OpenId) == "" { return nil, gerror.New("微信未返回openid") } return &result, nil } func appCreateWechatBindTicket(ctx context.Context, tenantId string, openId string, unionId string) (string, error) { openId = strings.TrimSpace(openId) if openId == "" { return "", gerror.New("微信openid为空,无法生成绑定凭证") } ticket, err := appRandomHex(24) if err != nil { return "", gerror.Wrap(err, "生成微信绑定凭证失败") } data := &appWechatBindTicketCache{ TenantId: strings.TrimSpace(tenantId), OpenId: openId, UnionId: strings.TrimSpace(unionId), } if data.TenantId == "" { data.TenantId = consts.DefaultSystemTenantCode } if err = mcache.Set(ctx, appWechatBindTicketKey(ticket), data, appWechatBindTicketExpire); err != nil { return "", gerror.Wrap(err, "保存微信绑定凭证失败") } return ticket, nil } func appRandomHex(size int) (string, error) { if size <= 0 { size = 16 } buf := make([]byte, size) if _, err := cryptorand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func appWechatBindTicketKey(ticket string) string { return appWechatBindTicketCachePrefix + strings.TrimSpace(ticket) } func appLoadWechatBindTicket(ctx context.Context, ticket string) (*appWechatBindTicketCache, error) { ticket = strings.TrimSpace(ticket) if ticket == "" { return nil, gerror.New("微信绑定凭证不能为空") } value, err := mcache.Get(ctx, appWechatBindTicketKey(ticket)) if err != nil || value == nil { return nil, gerror.New("微信绑定凭证已过期,请重新发起微信登录") } var data appWechatBindTicketCache if err = gvarToStruct(value, &data); err != nil { return nil, gerror.Wrap(err, "读取微信绑定凭证失败") } data.TenantId = strings.TrimSpace(data.TenantId) data.OpenId = strings.TrimSpace(data.OpenId) data.UnionId = strings.TrimSpace(data.UnionId) if data.TenantId == "" { data.TenantId = consts.DefaultSystemTenantCode } if data.OpenId == "" { return nil, gerror.New("微信绑定凭证无效,请重新发起微信登录") } return &data, nil } func gvarToStruct(value interface{}, pointer interface{}) error { switch v := value.(type) { case *gvar.Var: return v.Scan(pointer) case *appWechatBindTicketCache: if data, ok := pointer.(*appWechatBindTicketCache); ok { *data = *v return nil } case appWechatBindTicketCache: if data, ok := pointer.(*appWechatBindTicketCache); ok { *data = v return nil } } bytes, err := json.Marshal(value) if err != nil { return err } return json.Unmarshal(bytes, pointer) } func appEnsureWechatBindPhoneVerified(ctx context.Context, param *model.AppWechatBindPhoneParam, ticketData *appWechatBindTicketCache, phone string) error { if param == nil { return gerror.New("请求参数不能为空") } if ticketData != nil && strings.TrimSpace(ticketData.VerifiedPhone) == strings.TrimSpace(phone) { return nil } if ticketData == nil || strings.TrimSpace(ticketData.OpenId) == "" || strings.TrimSpace(phone) == "" { return gerror.New("微信绑定参数不完整") } if err := verifyAppSmsCode(ctx, ticketData.TenantId, phone, "wechatBindPhone", param.SmsCode); err != nil { return err } ticketData.VerifiedPhone = strings.TrimSpace(phone) if err := mcache.Set(ctx, appWechatBindTicketKey(param.BindTicket), ticketData, appWechatBindTicketExpire); err != nil { return gerror.Wrap(err, "保存手机号验证状态失败") } return nil } func (s *sAppAuth) getWechatSocialByOpenId(ctx context.Context, tenantId string, openId string) (*entity.SysSocial, error) { openId = strings.TrimSpace(openId) if openId == "" { return nil, nil } if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } var socials []*entity.SysSocial err := dao.SysSocial.Ctx(ctx). Where(dao.SysSocial.Columns().TenantId, tenantId). Where(dao.SysSocial.Columns().Source, consts.SourceWechatMp). Where(dao.SysSocial.Columns().OpenId, openId). WhereNull(dao.SysSocial.Columns().DeletedAt). OrderDesc(dao.SysSocial.Columns().Id). Limit(2). Scan(&socials) if err != nil && err != gorm.ErrRecordNotFound { return nil, gerror.Wrap(err, "查询微信绑定账号失败") } if len(socials) == 0 { return nil, nil } if len(socials) > 1 && socials[0] != nil && socials[1] != nil && socials[0].UserId != socials[1].UserId { return nil, gerror.New("当前微信绑定了多个账号,请联系管理员处理") } return socials[0], nil } func (s *sAppAuth) getAppUserByIdForWechat(ctx context.Context, tenantId string, userId int64) (*entity.SysUser, error) { if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } var user *entity.SysUser err := dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where(dao.SysUser.Columns().UserId, userId). WhereNull(dao.SysUser.Columns().DeletedAt). Limit(1). Scan(&user) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if user == nil || user.UserId == 0 { return nil, gerror.New("微信绑定账号不存在") } if err = s.checkAppLoginUserStatus(user); err != nil { return nil, err } return user, nil } func (s *sAppAuth) findAppWechatBindUsersByPhone(ctx context.Context, tenantId string, phone string) ([]*entity.SysUser, error) { tenantId = strings.TrimSpace(tenantId) phone = strings.TrimSpace(phone) if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } if phone == "" { return nil, gerror.New("手机号不能为空") } var users []*entity.SysUser err := dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where(dao.SysUser.Columns().Phonenumber, phone). WhereNull(dao.SysUser.Columns().DeletedAt). OrderAsc(dao.SysUser.Columns().UserId). Scan(&users) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } available := make([]*entity.SysUser, 0, len(users)) var firstStatusErr error for _, user := range users { if user == nil || user.UserId <= 0 { continue } if err = s.checkAppLoginUserStatus(user); err != nil { if firstStatusErr == nil { firstStatusErr = err } continue } available = append(available, user) } if len(available) == 0 && firstStatusErr != nil { return nil, firstStatusErr } return available, nil } func (s *sAppAuth) saveWechatOpenId(ctx context.Context, user *entity.SysUser, openId string, unionId string) error { openId = strings.TrimSpace(openId) if openId == "" { return gerror.New("微信openid为空,无法绑定") } if user == nil || user.UserId <= 0 { return gerror.New("绑定账号不存在") } if user.TenantId == "" { user.TenantId = consts.DefaultSystemTenantCode } now := gtime.Now() if err := s.ensureWechatOpenIdNotBoundToOtherUser(ctx, user, openId); err != nil { return err } var social *entity.SysSocial err := dao.SysSocial.Ctx(ctx). Where(dao.SysSocial.Columns().TenantId, user.TenantId). Where(dao.SysSocial.Columns().UserId, user.UserId). Where(dao.SysSocial.Columns().Source, consts.SourceWechatMp). WhereNull(dao.SysSocial.Columns().DeletedAt). OrderDesc(dao.SysSocial.Columns().Id). Limit(1). Scan(&social) if err != nil && err != gorm.ErrRecordNotFound { return gerror.Wrap(err, "查询微信openid绑定信息失败") } data := do.SysSocial{ UserId: user.UserId, TenantId: user.TenantId, AuthId: consts.SourceWechatMp + ":" + openId, Source: consts.SourceWechatMp, OpenId: openId, UserName: strings.TrimSpace(user.UserName), NickName: strings.TrimSpace(user.NickName), Avatar: strings.TrimSpace(user.Avatar), AccessToken: openId, UnionId: strings.TrimSpace(unionId), UpdatedBy: user.UserId, UpdatedAt: now, CreatedDept: user.DeptId, } if social != nil && social.Id > 0 { _, err = dao.SysSocial.Ctx(ctx). WherePri(social.Id). Data(data). OmitNil(). Update() if err != nil { g.Log().Errorf(ctx, "更新微信openid绑定失败 userId=%d source=%s err=%v", user.UserId, consts.SourceWechatMp, err) return gerror.New("绑定微信openid失败,请稍后重试") } return nil } data.CreatedBy = user.UserId data.CreatedAt = now _, err = dao.SysSocial.Ctx(ctx).Data(data).OmitNil().Insert() if err != nil { g.Log().Errorf(ctx, "创建微信openid绑定失败 userId=%d source=%s err=%v", user.UserId, consts.SourceWechatMp, err) return gerror.New("绑定微信openid失败,请稍后重试") } return nil } func (s *sAppAuth) ensureWechatOpenIdNotBoundToOtherUser(ctx context.Context, user *entity.SysUser, openId string) error { var exists *entity.SysSocial err := dao.SysSocial.Ctx(ctx). Fields(dao.SysSocial.Columns().Id, dao.SysSocial.Columns().UserId). Where(dao.SysSocial.Columns().TenantId, user.TenantId). Where(dao.SysSocial.Columns().Source, consts.SourceWechatMp). Where(dao.SysSocial.Columns().OpenId, strings.TrimSpace(openId)). WhereNot(dao.SysSocial.Columns().UserId, user.UserId). WhereNull(dao.SysSocial.Columns().DeletedAt). Limit(1). Scan(&exists) if err != nil && err != gorm.ErrRecordNotFound { return gerror.Wrap(err, "检查微信绑定冲突失败") } if exists != nil && exists.Id > 0 { return gerror.New("当前微信已绑定其他账号,请先解绑或更换微信") } return nil } func (s *sAppAuth) buildAppAuthToken(ctx context.Context, user *entity.SysUser) (*model.AppAuthTokenModel, error) { if user == nil || user.UserId == 0 { return nil, gerror.New("账号不存在") } ip := g.RequestFromCtx(ctx).GetClientIp() if err := service.SysUser().UpdateLoginInfo(ctx, user.UserId, ip); err != nil { g.Log().Warningf(ctx, "APP账号登录更新登录信息失败 userId=%d err=%v", user.UserId, err) } userInfo, err := getAppUserInfoById(ctx, user.UserId) if err != nil { return nil, err } ctx = contexts.Set(ctx, &model.Context{ User: &model.Identity{ BaseClaims: model.BaseClaims{ ID: user.UserId, Username: user.UserName, NickName: user.NickName, TenantId: user.TenantId, DeptId: user.DeptId, Roles: []model.Role{}, LoginAt: time.Now().Unix(), }, }, }) _, token, err := service.SysAuth().GenerateToken(ctx, &model.LoginUserOut{ ID: user.UserId, Username: user.UserName, NickName: user.NickName, Avatar: user.Avatar, TenantId: user.TenantId, DeptId: user.DeptId, }) if err != nil { return nil, err } expiresText := g.Cfg().MustGet(ctx, "jwt.expiresTime", "7d").String() expiresDuration, _ := utility.ParseDuration(expiresText) return &model.AppAuthTokenModel{ Token: token, ExpiresIn: int64(expiresDuration / time.Second), UserInfo: userInfo, }, nil } func (s *sAppAuth) ForgotPassword(ctx context.Context, param *model.AppForgotPasswordParam) (err error) { if err = verifyAppSmsCode(ctx, param.TenantId, param.Phone, "forgotPassword", param.SmsCode); err != nil { return err } user, err := s.getUserByPhone(ctx, param.TenantId, param.Phone) if err != nil { return err } _ = deleteAppSmsCode(ctx, appSmsCacheKey(param.TenantId, param.Phone, "forgotPassword")) salt := utility.RandomString(5) password := appPasswordEncryptForStore(param.Password, salt) _, err = dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().UserId, user.UserId). Data(do.SysUser{ Password: password, Salt: salt, UpdatedBy: user.UserId, UpdatedAt: gtime.Now(), }). OmitNil(). Update() return err } func appLoginAccount(param *model.AppPhoneLoginParam) string { if param == nil { return "" } if account := strings.TrimSpace(param.Account); account != "" { return account } if username := strings.TrimSpace(param.Username); username != "" { return username } return strings.TrimSpace(param.Phone) } func appPasswordEncryptForStore(password string, salt string) string { return utility.PasswordEncrypt(utility.Md5(password), salt) } func appPasswordMatches(password string, salt string, storedPassword string) bool { return appPasswordEncryptForStore(password, salt) == storedPassword } func (s *sAppAuth) resolveAppLoginUser(ctx context.Context, param *model.AppPhoneLoginParam) (*entity.SysUser, []*model.AppLoginCandidateModel, error) { account := appLoginAccount(param) if account == "" { return nil, nil, gerror.New("请输入手机号或用户名") } tenantId := strings.TrimSpace(param.TenantId) if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } if param.SelectedUserId > 0 { user, err := s.getSelectedAppLoginUser(ctx, tenantId, account, param.SelectedUserId) if err != nil { return nil, nil, err } if !appPasswordMatches(param.Password, user.Salt, user.Password) { return nil, nil, gerror.New("账号或密码错误") } return user, nil, s.checkAppLoginUserStatus(user) } users, err := s.findAppLoginUsersByAccount(ctx, tenantId, account) if err != nil { return nil, nil, err } switch len(users) { case 0: return nil, nil, gerror.New("账号或密码错误") case 1: user := users[0] if !appPasswordMatches(param.Password, user.Salt, user.Password) { return nil, nil, gerror.New("账号或密码错误") } return user, nil, s.checkAppLoginUserStatus(user) default: matchedUsers, err := s.availableAppLoginUsersByPassword(users, param.Password) if err != nil { return nil, nil, err } switch len(matchedUsers) { case 0: return nil, nil, gerror.New("账号或密码错误") case 1: return matchedUsers[0], nil, nil default: return nil, appLoginCandidateModels(ctx, matchedUsers), nil } } } func (s *sAppAuth) getSelectedAppLoginUser(ctx context.Context, tenantId string, account string, userId int64) (*entity.SysUser, error) { var user *entity.SysUser err := dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where(dao.SysUser.Columns().UserId, userId). Where("("+dao.SysUser.Columns().UserName+" = ? OR "+dao.SysUser.Columns().Phonenumber+" = ?)", account, account). Limit(1). Scan(&user) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if user == nil || user.UserId == 0 { return nil, gerror.New("选择的登录账号不存在") } return user, nil } func (s *sAppAuth) findAppLoginUsersByAccount(ctx context.Context, tenantId string, account string) ([]*entity.SysUser, error) { var users []*entity.SysUser account = strings.TrimSpace(account) err := dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where("("+dao.SysUser.Columns().UserName+" = ? OR "+dao.SysUser.Columns().Phonenumber+" = ?)", account, account). OrderAsc(dao.SysUser.Columns().UserId). Scan(&users) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if len(users) <= 1 { return users, nil } unique := make([]*entity.SysUser, 0, len(users)) seen := make(map[int64]struct{}, len(users)) for _, user := range users { if user == nil || user.UserId == 0 { continue } if _, ok := seen[user.UserId]; ok { continue } seen[user.UserId] = struct{}{} unique = append(unique, user) } return unique, nil } func (s *sAppAuth) availableAppLoginUsersByPassword(users []*entity.SysUser, password string) ([]*entity.SysUser, error) { matched := make([]*entity.SysUser, 0, len(users)) var firstStatusErr error for _, user := range users { if user == nil || user.UserId == 0 { continue } if !appPasswordMatches(password, user.Salt, user.Password) { continue } if err := s.checkAppLoginUserStatus(user); err != nil { if firstStatusErr == nil { firstStatusErr = err } continue } matched = append(matched, user) } if len(matched) == 0 && firstStatusErr != nil { return nil, firstStatusErr } return matched, nil } func appLoginCandidateModels(ctx context.Context, users []*entity.SysUser) []*model.AppLoginCandidateModel { deptNames := appLoginDeptNames(ctx, users) candidates := make([]*model.AppLoginCandidateModel, 0, len(users)) for _, user := range users { if user == nil || user.UserId == 0 { continue } role := "企业用户" if appIsTerminalUser(ctx, user) { role = "终端用户" } candidates = append(candidates, &model.AppLoginCandidateModel{ UserId: user.UserId, Username: strings.TrimSpace(user.UserName), NickName: strings.TrimSpace(user.NickName), Phone: appMaskPhone(user.Phonenumber), DeptId: user.DeptId, DeptName: deptNames[user.DeptId], UserType: strings.TrimSpace(user.UserType), Role: role, }) } return candidates } func appLoginDeptNames(ctx context.Context, users []*entity.SysUser) map[int64]string { deptIds := make([]int64, 0, len(users)) seen := make(map[int64]struct{}, len(users)) for _, user := range users { if user == nil || user.DeptId <= 0 { continue } if _, exists := seen[user.DeptId]; exists { continue } seen[user.DeptId] = struct{}{} deptIds = append(deptIds, user.DeptId) } result := make(map[int64]string, len(deptIds)) if len(deptIds) == 0 { return result } var depts []*entity.SysDept if err := dao.SysDept.Ctx(ctx). Fields(dao.SysDept.Columns().DeptId, dao.SysDept.Columns().DeptName). WhereIn(dao.SysDept.Columns().DeptId, deptIds). Scan(&depts); err != nil { return result } for _, dept := range depts { if dept != nil && dept.DeptId > 0 { result[dept.DeptId] = strings.TrimSpace(dept.DeptName) } } return result } func (s *sAppAuth) getUserByAccount(ctx context.Context, tenantId string, account string) (user *entity.SysUser, err error) { account = strings.TrimSpace(account) if account == "" { return nil, gerror.New("请输入手机号或用户名") } if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } err = dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where(dao.SysUser.Columns().UserName, account). Scan(&user) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if user != nil { return user, s.checkAppLoginUserStatus(user) } var users []*entity.SysUser err = dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where(dao.SysUser.Columns().Phonenumber, account). Limit(2). Scan(&users) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if len(users) > 1 { return nil, gerror.New("该手机号绑定多个账号,请使用用户名登录") } if len(users) == 0 || users[0] == nil { return nil, gerror.New("账号不存在") } return users[0], s.checkAppLoginUserStatus(users[0]) } func (s *sAppAuth) getUserByPhone(ctx context.Context, tenantId string, phone string) (user *entity.SysUser, err error) { if tenantId == "" { tenantId = consts.DefaultSystemTenantCode } var users []*entity.SysUser err = dao.SysUser.Ctx(ctx). Where(dao.SysUser.Columns().TenantId, tenantId). Where(dao.SysUser.Columns().Phonenumber, phone). Limit(2). Scan(&users) if err != nil && err != gorm.ErrRecordNotFound { return nil, err } if len(users) > 1 { return nil, gerror.New("该手机号绑定多个账号,请联系管理员处理") } if len(users) == 0 || users[0] == nil { return nil, gerror.New("手机号未注册") } user = users[0] return user, s.checkAppLoginUserStatus(user) } func (s *sAppAuth) checkAppLoginUserStatus(user *entity.SysUser) error { if user == nil { return gerror.New("账号不存在") } switch user.Status { case consts.SysUserStatusDisable: return gerror.New("账号已停用") case consts.SysUserStatusLocked: return gerror.New("账号已锁定") case consts.SysUserStatusExpired: return gerror.New("账号已过期") case consts.SysUserStatusDeleted: return gerror.New("账号已删除") } return nil }