做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+268
View File
@@ -0,0 +1,268 @@
package auth
import (
"errors"
"strings"
"time"
"server/models"
"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,
UserType: "tenant",
Amr: AmrPwd,
AccessTTL: accessTTL,
})
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,
UserType: "tenant",
})
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 {
return RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
}