package auth import ( "errors" "strings" "time" "server/models" "server/pkg/jwtutil" "server/pkg/passwordutil" ) // 登录方式与认证手段(amr) const ( LoginTypePassword = "password" LoginTypeSMS = "sms" LoginTypeThird = "third" AmrPwd = "pwd" AmrSms = "sms" AmrOtp = "otp" ) // 失败锁定策略 const ( MaxFailCount = 5 // 连续失败次数阈值 LockDuration = 15 * time.Minute // 锁定时长 PendingTenantID = 0 // 未选择企业时的 tid ) // 登录失败错误 var ( ErrAccountDisabled = errors.New("账号已禁用") ErrAccountLocked = errors.New("账号已被锁定,请稍后再试") ErrPasswordWrong = errors.New("账号或密码错误") ErrNoTenantBound = errors.New("该账号未绑定任何企业") ErrTenantNotAllowed = errors.New("无权访问该企业") ErrTenantUserDisabled = errors.New("在该企业的账号已被停用") ) // LoginResult 登录结果。 // 当 NeedChooseTenant 为 true 时表示一人多企业,前端需展示企业列表让用户选择, // 选择后调用 ChooseTenant 换取正式令牌。 type LoginResult struct { NeedChooseTenant bool `json:"need_choose_tenant"` Identity *IdentityProfile `json:"identity"` Tenants []TenantOption `json:"tenants"` Tokens *TokenPair `json:"tokens,omitempty"` } // PasswordLogin 账号密码登录 // // 流程:查身份 → 校验状态/锁定 → 校验密码 → 失败计数 → 列出可登录企业 // - 只绑定 1 家企业:直接建会话并签发令牌 // - 绑定多家企业:建待选会话(tid=0),签发临时令牌,等用户选择企业 func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginResult, error) { identity, err := FindIdentityByAccount(account) if err != nil { return nil, ErrPasswordWrong // 不暴露账号是否存在 } if err := checkIdentityStatus(identity); err != nil { return nil, err } if identity.PasswordHash == nil || !passwordutil.Verify(*identity.PasswordHash, password) { _ = recordFail(identity.ID) return nil, ErrPasswordWrong } // 登录成功:重置失败计数,旧算法密码自动升级 _ = resetFail(identity.ID) if passwordutil.NeedsRehash(*identity.PasswordHash) { if hashed, hErr := passwordutil.Hash(password); hErr == nil { _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identity.ID). Update(map[string]interface{}{ "password_hash": hashed, "password_algo": passwordutil.AlgoArgon2id, }) } } tenants, err := ListTenantOptions(identity.ID) if err != nil { return nil, err } if len(tenants) == 0 { _ = writeLoginLog(0, identity.ID, account, "", clientID, LoginTypePassword, AmrPwd, 0, ErrNoTenantBound.Error(), ip, userAgent) return nil, ErrNoTenantBound } profile, err := BuildProfile(identity) if err != nil { return nil, err } result := &LoginResult{Tenants: tenants, Identity: profile} // 唯一企业直接进入;多家企业先建待选会话 targetTid := uint64(PendingTenantID) if len(tenants) == 1 { targetTid = tenants[0].Tid } sess, err := CreateSession(SessionInfo{ IdentityID: identity.ID, Tid: targetTid, ClientID: clientID, IP: ip, UserAgent: userAgent, LoginType: LoginTypePassword, Amr: AmrPwd, }) if err != nil { return nil, err } // 待选状态下令牌有效期缩短为 10 分钟(仅够用户做完选择) accessTTL := 0 if targetTid == PendingTenantID { accessTTL = 600 result.NeedChooseTenant = true } tokens, err := IssueTokens(TokenIssue{ IdentityID: identity.ID, Tid: targetTid, ClientID: clientID, Sid: sess.Sid, Username: profile.Nickname, Amr: AmrPwd, AccessTTL: accessTTL, // 不指定 UserType:由 IssueTokens 按应用编码给出 backend / app }) if err != nil { return nil, err } result.Tokens = tokens tenantName := "" if targetTid != PendingTenantID { for _, t := range tenants { if t.Tid == targetTid { tenantName = t.TenantName } } } _ = writeLoginLog(targetTid, identity.ID, account, profile.Nickname, clientID, LoginTypePassword, AmrPwd, 1, "登录成功", ip, userAgent) _ = tenantName return result, nil } // ChooseTenant 选择进入的企业:校验绑定后更新会话 tid 并重新签发令牌 func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *IdentityProfile, error) { sess, err := GetSession(sid) if err != nil { return nil, nil, err } bind, err := GetTenantUser(sess.IdentityID, tid) if err != nil { return nil, nil, ErrTenantNotAllowed } if bind.Status != 1 { return nil, nil, ErrTenantUserDisabled } var identity models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", sess.IdentityID).One(&identity); err != nil { return nil, nil, ErrPasswordWrong } if err := SwitchSessionTenant(sid, tid); err != nil { return nil, nil, err } profile, err := BuildProfile(&identity) if err != nil { return nil, nil, err } tokens, err := IssueTokens(TokenIssue{ IdentityID: identity.ID, Tid: tid, ClientID: clientID, Sid: sid, Username: profile.Nickname, }) if err != nil { return nil, nil, err } return tokens, profile, nil } // checkIdentityStatus 校验身份状态与锁定时间 func checkIdentityStatus(m *models.AuthIdentity) error { if m.Status == models.AuthIdentityStatusDisabled { return ErrAccountDisabled } if m.Status == models.AuthIdentityStatusLocked { if m.LockedUntil != nil && m.LockedUntil.After(time.Now()) { return ErrAccountLocked } // 锁定已到期,自动解锁 _, _ = models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", m.ID). Update(map[string]interface{}{"status": models.AuthIdentityStatusEnabled, "fail_count": 0, "locked_until": nil}) } return nil } // recordFail 记录一次失败,达到阈值则锁定 func recordFail(identityID uint64) error { var m models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", identityID).One(&m); err != nil { return err } fail := m.FailCount + 1 update := map[string]interface{}{"fail_count": fail} if fail >= MaxFailCount { lockUntil := time.Now().Add(LockDuration) update["status"] = models.AuthIdentityStatusLocked update["locked_until"] = lockUntil } _, err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", identityID).Update(update) return err } // resetFail 登录成功后重置失败计数与锁定 func resetFail(identityID uint64) error { _, err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", identityID). Update(map[string]interface{}{ "fail_count": 0, "locked_until": nil, "status": models.AuthIdentityStatusEnabled, "last_login_at": time.Now(), }) return err } // writeLoginLog 写入统一登录日志(失败不影响主流程) func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, loginType, amr string, status int8, message, ip, userAgent string) error { log := &models.AuthLoginLog{ IdentityID: &identityID, Account: strings.TrimSpace(account), UserName: userName, ClientID: clientID, LoginType: loginType, Status: status, Message: message, IP: ip, UserAgent: userAgent, } if tid > 0 { log.Tid = &tid } if amr != "" { log.Amr = &amr } _, err := models.Orm.Insert(log) return err } // Logout 登出:吊销刷新令牌、会话与访问令牌,并通知其他应用(单点登出) func Logout(accessToken, refreshToken string) error { err := RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout) // 单点登出:通知该用户在其他应用的会话(如后台登出后 APP 同步下线) identityID := uint64(0) if claims, e := jwtutil.ParseTokenRaw(accessToken); e == nil && claims.UserID > 0 { identityID = uint64(claims.UserID) } if identityID == 0 && refreshToken != "" { if rt, e := findRefreshToken(refreshToken); e == nil { identityID = rt.IdentityID } } if identityID > 0 { NotifyBackchannelLogout(identityID) } return err } // findRefreshToken 按明文查找刷新令牌记录 func findRefreshToken(plain string) (*models.AuthRefreshToken, error) { var rt models.AuthRefreshToken if err := models.Orm.QueryTable(new(models.AuthRefreshToken)). Filter("token_hash", hashToken(plain)).One(&rt); err != nil { return nil, err } return &rt, nil }