Files
yunzerwebsiteallinone/go/services/auth/login.go
T
2026-09-20 00:19:08 +08:00

294 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}