269 lines
7.5 KiB
Go
269 lines
7.5 KiB
Go
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)
|
||
}
|