做统一认证登录
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user