做统一认证登录
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// Package auth 统一认证中心(UAC)业务层。
|
||||
//
|
||||
// 认证中心只负责「你是谁、你能进哪些企业、你能用哪些应用」,
|
||||
// 菜单/按钮/数据权限仍由各业务端自治。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// TenantOption 登录后可选择进入的企业(一人可在多家企业任职)
|
||||
type TenantOption struct {
|
||||
Tid uint64 `json:"tid"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Name string `json:"name"`
|
||||
GroupID uint64 `json:"group_id"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// IdentityProfile 认证中心视角的身份概要
|
||||
type IdentityProfile struct {
|
||||
ID uint64 `json:"id"`
|
||||
UnionID string `json:"union_id"`
|
||||
Mobile string `json:"mobile"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Status int8 `json:"status"`
|
||||
MfaOn bool `json:"mfa_enabled"`
|
||||
Tenants []TenantOption `json:"tenants"`
|
||||
}
|
||||
|
||||
// FindIdentityByAccount 按登录账号查找身份。
|
||||
// 匹配顺序:手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。
|
||||
func FindIdentityByAccount(account string) (*models.AuthIdentity, error) {
|
||||
account = strings.TrimSpace(account)
|
||||
if account == "" {
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true)
|
||||
|
||||
for _, field := range []string{"mobile", "email"} {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := base.Filter(field, account).One(m); err == nil {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 企业内账号:yz_auth_tenant_user.account → identity_id
|
||||
var bind models.AuthTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("account", account).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-is_default", "id").
|
||||
One(&bind); err == nil {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", bind.IdentityID).One(m); err == nil {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
|
||||
// ListTenantOptions 列出该身份可登录的企业(过滤企业停用与绑定停用)。
|
||||
func ListTenantOptions(identityID uint64) ([]TenantOption, error) {
|
||||
var binds []models.AuthTenantUser
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-is_default", "id").
|
||||
All(&binds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]TenantOption, 0, len(binds))
|
||||
for _, b := range binds {
|
||||
var tenant models.SystemTenant
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("id", b.Tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&tenant); err != nil {
|
||||
continue // 租户不存在或已删除
|
||||
}
|
||||
if tenant.Status != 1 {
|
||||
continue // 租户停用
|
||||
}
|
||||
opt := TenantOption{
|
||||
Tid: b.Tid,
|
||||
TenantName: strings.TrimSpace(tenant.TenantName),
|
||||
GroupID: b.GroupID,
|
||||
OrgID: b.OrgID,
|
||||
IsDefault: b.IsDefault == 1,
|
||||
}
|
||||
if b.Name != nil {
|
||||
opt.Name = strings.TrimSpace(*b.Name)
|
||||
}
|
||||
out = append(out, opt)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTenantUser 获取身份在指定企业的绑定关系
|
||||
func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) {
|
||||
var bind models.AuthTenantUser
|
||||
err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&bind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &bind, nil
|
||||
}
|
||||
|
||||
// BuildProfile 组装身份概要(含可选企业列表)
|
||||
func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) {
|
||||
tenants, err := ListTenantOptions(m.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &IdentityProfile{
|
||||
ID: m.ID,
|
||||
UnionID: m.UnionID,
|
||||
Status: m.Status,
|
||||
MfaOn: m.MfaEnabled == 1,
|
||||
Tenants: tenants,
|
||||
}
|
||||
if m.Mobile != nil {
|
||||
p.Mobile = *m.Mobile
|
||||
}
|
||||
if m.Email != nil {
|
||||
p.Email = *m.Email
|
||||
}
|
||||
if m.Nickname != nil {
|
||||
p.Nickname = *m.Nickname
|
||||
}
|
||||
if m.Avatar != nil {
|
||||
p.Avatar = *m.Avatar
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// 默认会话配置(租户未配置 yz_auth_tenant_auth_config 时使用)
|
||||
const (
|
||||
DefaultSessionTTL = 7200 // 会话有效期(秒)
|
||||
DefaultMaxSession = 1 // 默认 1 号 1 机
|
||||
)
|
||||
|
||||
// TenantSessionPolicy 租户会话策略
|
||||
type TenantSessionPolicy struct {
|
||||
SessionTTL int
|
||||
MaxSession int
|
||||
KickStrategy int8
|
||||
}
|
||||
|
||||
// GetTenantSessionPolicy 读取租户登录策略,未配置时返回默认值
|
||||
func GetTenantSessionPolicy(tid uint64) TenantSessionPolicy {
|
||||
policy := TenantSessionPolicy{
|
||||
SessionTTL: DefaultSessionTTL,
|
||||
MaxSession: DefaultMaxSession,
|
||||
KickStrategy: models.KickStrategyKickOld,
|
||||
}
|
||||
var cfg models.AuthTenantAuthConfig
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||
Filter("tid", tid).One(&cfg); err != nil {
|
||||
return policy
|
||||
}
|
||||
if cfg.SessionTTL > 0 {
|
||||
policy.SessionTTL = cfg.SessionTTL
|
||||
}
|
||||
if cfg.MaxSession > 0 {
|
||||
policy.MaxSession = cfg.MaxSession
|
||||
}
|
||||
if cfg.KickStrategy == models.KickStrategyReject {
|
||||
policy.KickStrategy = models.KickStrategyReject
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
// SessionInfo 创建会话的入参
|
||||
type SessionInfo struct {
|
||||
IdentityID uint64
|
||||
Tid uint64
|
||||
ClientID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
IP string
|
||||
UserAgent string
|
||||
LoginType string
|
||||
Amr string
|
||||
}
|
||||
|
||||
// CreateSession 创建会话并执行并发控制。
|
||||
//
|
||||
// 并发策略(租户可配):
|
||||
// - KickStrategyKickOld(默认):超出上限时踢掉最旧的会话(1号1机)
|
||||
// - KickStrategyReject:超出上限时拒绝新登录
|
||||
func CreateSession(info SessionInfo) (*models.AuthSession, error) {
|
||||
policy := GetTenantSessionPolicy(info.Tid)
|
||||
|
||||
// 已占用的活跃会话
|
||||
var actives []models.AuthSession
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", info.IdentityID).
|
||||
Filter("revoked", 0).
|
||||
OrderBy("login_at").
|
||||
All(&actives); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 过期会话先作废,不计入占用
|
||||
now := time.Now()
|
||||
valid := make([]models.AuthSession, 0, len(actives))
|
||||
for _, s := range actives {
|
||||
if s.ExpiresAt.Before(now) {
|
||||
_ = RevokeSession(s.Sid, models.RevokeReasonExpired)
|
||||
continue
|
||||
}
|
||||
valid = append(valid, s)
|
||||
}
|
||||
|
||||
if len(valid) >= policy.MaxSession {
|
||||
if policy.KickStrategy == models.KickStrategyReject {
|
||||
return nil, ErrSessionLimitExceeded
|
||||
}
|
||||
// 踢掉最旧的,直到腾出名额
|
||||
kick := len(valid) - policy.MaxSession + 1
|
||||
for i := 0; i < kick && i < len(valid); i++ {
|
||||
_ = RevokeSession(valid[i].Sid, models.RevokeReasonKicked)
|
||||
}
|
||||
}
|
||||
|
||||
sid, err := randomToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ttl := time.Duration(policy.SessionTTL) * time.Second
|
||||
s := &models.AuthSession{
|
||||
Sid: sid,
|
||||
IdentityID: info.IdentityID,
|
||||
Tid: info.Tid,
|
||||
ClientID: info.ClientID,
|
||||
LoginType: orDefault(info.LoginType, "password"),
|
||||
LoginAt: now,
|
||||
LastAccessAt: now,
|
||||
ExpiresAt: now.Add(ttl),
|
||||
}
|
||||
if info.DeviceID != "" {
|
||||
s.DeviceID = &info.DeviceID
|
||||
}
|
||||
if info.DeviceName != "" {
|
||||
s.DeviceName = &info.DeviceName
|
||||
}
|
||||
if info.IP != "" {
|
||||
s.IP = &info.IP
|
||||
}
|
||||
if info.UserAgent != "" {
|
||||
s.UserAgent = &info.UserAgent
|
||||
}
|
||||
if info.Amr != "" {
|
||||
s.Amr = &info.Amr
|
||||
}
|
||||
if _, err := models.Orm.Insert(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// GetSession 查询有效会话(已吊销或已过期返回错误)
|
||||
func GetSession(sid string) (*models.AuthSession, error) {
|
||||
var s models.AuthSession
|
||||
if err := models.Orm.QueryTable(new(models.AuthSession)).Filter("sid", sid).One(&s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Revoked != 0 {
|
||||
return nil, ErrSessionRevoked
|
||||
}
|
||||
if s.ExpiresAt.Before(time.Now()) {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// TouchSession 更新会话最近访问时间(建议每 5~10 分钟一次,避免高频写库)
|
||||
func TouchSession(sid string) error {
|
||||
last := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("sid", sid).
|
||||
Update(map[string]interface{}{"last_access_at": last})
|
||||
return err
|
||||
}
|
||||
|
||||
// SwitchSessionTenant 切换当前会话所属企业(免密切换)
|
||||
func SwitchSessionTenant(sid string, tid uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("sid", sid).
|
||||
Update(map[string]interface{}{"tid": tid})
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeSession 吊销单个会话
|
||||
func RevokeSession(sid, reason string) error {
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("sid", sid).
|
||||
Update(map[string]interface{}{
|
||||
"revoked": 1,
|
||||
"revoke_reason": reason,
|
||||
"revoke_at": now,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RevokeAllSessions 吊销该身份的全部会话(改密、管理员下线等场景)
|
||||
func RevokeAllSessions(identityID uint64, reason string) error {
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("revoked", 0).
|
||||
Update(map[string]interface{}{
|
||||
"revoked": 1,
|
||||
"revoke_reason": reason,
|
||||
"revoke_at": now,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ListActiveSessions 在线设备列表
|
||||
func ListActiveSessions(identityID uint64) ([]models.AuthSession, error) {
|
||||
var list []models.AuthSession
|
||||
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("revoked", 0).
|
||||
OrderBy("-login_at").
|
||||
All(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// randomToken 生成 URL 安全的随机串(用于 sid / refresh token 明文)
|
||||
func randomToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
)
|
||||
|
||||
// SyncTenantUserInput 老表 → 认证中心同步入参
|
||||
type SyncTenantUserInput struct {
|
||||
Tid uint64
|
||||
Account string // 企业内账号
|
||||
Name string
|
||||
Phone string
|
||||
Email string
|
||||
PasswordHash string // 已哈希的密码;为空表示不修改密码
|
||||
GroupID uint64
|
||||
OrgID uint64
|
||||
Status int8
|
||||
IsDefault int8
|
||||
}
|
||||
|
||||
// SyncTenantUser 把租户用户同步到统一认证中心(幂等,可重复调用)。
|
||||
//
|
||||
// 归并规则与迁移脚本一致:手机号 > 邮箱 > 企业内账号。
|
||||
// - 身份不存在 → 创建;已存在 → 更新手机/邮箱/昵称/状态
|
||||
// - 绑定不存在 → 创建;已存在 → 更新账号/姓名/部门/角色/状态
|
||||
//
|
||||
// 注意:密码属于身份层(一人一份),因此在任一企业修改密码,
|
||||
// 该用户在其他企业的登录密码会同步变化——这是统一认证的预期行为。
|
||||
func SyncTenantUser(in SyncTenantUserInput) error {
|
||||
identity, err := findOrCreateIdentity(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upsertTenantBinding(identity, in)
|
||||
}
|
||||
|
||||
// RemoveTenantUser 删除该用户在指定企业的绑定(保留身份本身,
|
||||
// 因为该身份可能还绑定着其他企业)。
|
||||
func RemoveTenantUser(tid uint64, account, phone, email string) error {
|
||||
identity := findIdentityByKey(account, phone, email)
|
||||
if identity == nil {
|
||||
return nil // 认证中心无此身份,无需处理
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("identity_id", identity.ID).
|
||||
Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// findOrCreateIdentity 按归并键查找身份,不存在则创建
|
||||
func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error) {
|
||||
if m := findIdentityByKey(in.Account, in.Phone, in.Email); m != nil {
|
||||
// 补全手机/邮箱/昵称;密码仅在显式传入时更新
|
||||
update := map[string]interface{}{}
|
||||
if in.Phone != "" {
|
||||
update["mobile"] = in.Phone
|
||||
}
|
||||
if in.Email != "" {
|
||||
update["email"] = in.Email
|
||||
}
|
||||
if in.Name != "" {
|
||||
update["nickname"] = in.Name
|
||||
}
|
||||
if in.Status > 0 {
|
||||
update["status"] = in.Status
|
||||
}
|
||||
if in.PasswordHash != "" {
|
||||
update["password_hash"] = in.PasswordHash
|
||||
update["password_algo"] = passwordutil.AlgoOf(in.PasswordHash)
|
||||
}
|
||||
if len(update) > 0 {
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", m.ID).
|
||||
Update(update)
|
||||
}
|
||||
// 重新读取,返回最新值
|
||||
_ = models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", m.ID).One(m)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
item := &models.AuthIdentity{
|
||||
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
PasswordAlgo: passwordutil.AlgoLegacy,
|
||||
Status: in.Status,
|
||||
}
|
||||
if in.Status == 0 {
|
||||
item.Status = models.AuthIdentityStatusEnabled
|
||||
}
|
||||
if in.Phone != "" {
|
||||
item.Mobile = &in.Phone
|
||||
}
|
||||
if in.Email != "" {
|
||||
item.Email = &in.Email
|
||||
}
|
||||
if in.Name != "" {
|
||||
item.Nickname = &in.Name
|
||||
}
|
||||
if in.PasswordHash != "" {
|
||||
item.PasswordHash = &in.PasswordHash
|
||||
item.PasswordAlgo = passwordutil.AlgoOf(in.PasswordHash)
|
||||
}
|
||||
id, err := models.Orm.Insert(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.ID = uint64(id)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// findIdentityByKey 按 手机 > 邮箱 > 账号 查找身份
|
||||
func findIdentityByKey(account, phone, email string) *models.AuthIdentity {
|
||||
base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true)
|
||||
|
||||
if v := strings.TrimSpace(phone); v != "" {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := base.Filter("mobile", v).One(m); err == nil {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(email); v != "" {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := base.Filter("email", v).One(m); err == nil {
|
||||
return m
|
||||
}
|
||||
}
|
||||
// 企业内账号:先找绑定关系再回查身份
|
||||
if v := strings.TrimSpace(account); v != "" {
|
||||
var bind models.AuthTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("account", v).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-is_default", "id").
|
||||
One(&bind); err == nil {
|
||||
m := &models.AuthIdentity{}
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", bind.IdentityID).One(m); err == nil {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertTenantBinding 创建或更新身份-企业绑定
|
||||
func upsertTenantBinding(identity *models.AuthIdentity, in SyncTenantUserInput) error {
|
||||
var existed models.AuthTenantUser
|
||||
err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", in.Tid).
|
||||
Filter("identity_id", identity.ID).
|
||||
One(&existed)
|
||||
|
||||
if err != nil {
|
||||
// 新建绑定
|
||||
item := &models.AuthTenantUser{
|
||||
Tid: in.Tid,
|
||||
IdentityID: identity.ID,
|
||||
GroupID: in.GroupID,
|
||||
OrgID: in.OrgID,
|
||||
Status: in.Status,
|
||||
IsDefault: in.IsDefault,
|
||||
}
|
||||
if in.Status == 0 {
|
||||
item.Status = 1
|
||||
}
|
||||
if in.Account != "" {
|
||||
item.Account = &in.Account
|
||||
}
|
||||
if in.Name != "" {
|
||||
item.Name = &in.Name
|
||||
}
|
||||
if in.Phone != "" {
|
||||
item.Phone = &in.Phone
|
||||
}
|
||||
if in.Email != "" {
|
||||
item.Email = &in.Email
|
||||
}
|
||||
_, err := models.Orm.Insert(item)
|
||||
return err
|
||||
}
|
||||
|
||||
update := map[string]interface{}{}
|
||||
if in.Account != "" {
|
||||
update["account"] = in.Account
|
||||
}
|
||||
if in.Name != "" {
|
||||
update["name"] = in.Name
|
||||
}
|
||||
if in.Phone != "" {
|
||||
update["phone"] = in.Phone
|
||||
}
|
||||
if in.Email != "" {
|
||||
update["email"] = in.Email
|
||||
}
|
||||
if in.GroupID > 0 {
|
||||
update["group_id"] = in.GroupID
|
||||
}
|
||||
if in.OrgID > 0 {
|
||||
update["org_id"] = in.OrgID
|
||||
}
|
||||
if in.Status > 0 {
|
||||
update["status"] = in.Status
|
||||
}
|
||||
if in.IsDefault >= 0 {
|
||||
update["is_default"] = in.IsDefault
|
||||
}
|
||||
if len(update) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("id", existed.ID).
|
||||
Update(update)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
)
|
||||
|
||||
// 认证中心错误定义
|
||||
var (
|
||||
ErrSessionLimitExceeded = errors.New("同时在线设备数已达上限")
|
||||
ErrSessionRevoked = errors.New("会话已失效,请重新登录")
|
||||
ErrSessionExpired = errors.New("会话已过期,请重新登录")
|
||||
ErrRefreshTokenInvalid = errors.New("刷新令牌无效或已过期")
|
||||
ErrRefreshTokenReused = errors.New("刷新令牌已被使用,疑似重放攻击,已吊销该登录")
|
||||
)
|
||||
|
||||
// TokenPair 签发的令牌对
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Sid string `json:"sid"`
|
||||
}
|
||||
|
||||
// TokenIssue 签发令牌的入参
|
||||
type TokenIssue struct {
|
||||
IdentityID uint64
|
||||
Tid uint64
|
||||
ClientID string
|
||||
Sid string
|
||||
Username string
|
||||
UserType string
|
||||
Amr string
|
||||
AccessTTL int // 秒
|
||||
RefreshTTL int // 秒
|
||||
}
|
||||
|
||||
// IssueTokens 签发访问令牌与刷新令牌。
|
||||
// 刷新令牌明文只在本次返回,库中仅存哈希。
|
||||
func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
accessTTL := opt.AccessTTL
|
||||
if accessTTL <= 0 {
|
||||
accessTTL = 1800
|
||||
}
|
||||
refreshTTL := opt.RefreshTTL
|
||||
if refreshTTL <= 0 {
|
||||
refreshTTL = 2592000
|
||||
}
|
||||
|
||||
jti := uuid.NewString()
|
||||
access, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(opt.IdentityID),
|
||||
Username: opt.Username,
|
||||
TenantID: int(opt.Tid),
|
||||
UserType: opt.UserType,
|
||||
ClientID: opt.ClientID,
|
||||
Sid: opt.Sid,
|
||||
Amr: opt.Amr,
|
||||
Subject: strconv.FormatUint(opt.IdentityID, 10),
|
||||
Audience: []string{opt.ClientID},
|
||||
Jti: jti,
|
||||
TTL: time.Duration(accessTTL) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plain, err := randomToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
family := uuid.NewString()
|
||||
now := time.Now()
|
||||
rt := &models.AuthRefreshToken{
|
||||
TokenHash: hashToken(plain),
|
||||
IdentityID: opt.IdentityID,
|
||||
Tid: opt.Tid,
|
||||
ClientID: opt.ClientID,
|
||||
Sid: opt.Sid,
|
||||
FamilyID: family,
|
||||
ExpiresAt: now.Add(time.Duration(refreshTTL) * time.Second),
|
||||
}
|
||||
if _, err := models.Orm.Insert(rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: plain,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: accessTTL,
|
||||
Sid: opt.Sid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshTokens 用刷新令牌换取新的令牌对(轮换 + 重放检测)。
|
||||
//
|
||||
// 安全要点:检测到已使用的刷新令牌再次出现时,判定为重放,
|
||||
// 吊销整个 family(该次登录的全部令牌)。
|
||||
func RefreshTokens(plain, clientID string) (*TokenPair, error) {
|
||||
h := hashToken(plain)
|
||||
var rt models.AuthRefreshToken
|
||||
if err := models.Orm.QueryTable(new(models.AuthRefreshToken)).Filter("token_hash", h).One(&rt); err != nil {
|
||||
return nil, ErrRefreshTokenInvalid
|
||||
}
|
||||
if rt.ClientID != "" && clientID != "" && rt.ClientID != clientID {
|
||||
return nil, ErrRefreshTokenInvalid
|
||||
}
|
||||
if rt.Revoked != 0 || rt.ExpiresAt.Before(time.Now()) {
|
||||
return nil, ErrRefreshTokenInvalid
|
||||
}
|
||||
if rt.Used != 0 {
|
||||
// 重放:吊销同族全部令牌与该会话
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("family_id", rt.FamilyID).
|
||||
Update(map[string]interface{}{"revoked": 1})
|
||||
_ = RevokeSession(rt.Sid, models.RevokeReasonAdmin)
|
||||
return nil, ErrRefreshTokenReused
|
||||
}
|
||||
|
||||
// 标记已用(保留 Row 以便审计)
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("id", rt.ID).
|
||||
Update(map[string]interface{}{"used": 1})
|
||||
|
||||
// 会话校验:已吊销/过期则拒绝续期
|
||||
s, err := GetSession(rt.Sid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = TouchSession(rt.Sid)
|
||||
|
||||
pair, err := IssueTokens(TokenIssue{
|
||||
IdentityID: rt.IdentityID,
|
||||
Tid: s.Tid, // 以会话当前企业为准(支持切换企业后刷新)
|
||||
ClientID: rt.ClientID,
|
||||
Sid: rt.Sid,
|
||||
Amr: derefStr(s.Amr),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 新令牌继承同一 family,便于后续溯源与整族吊销
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("token_hash", hashToken(pair.RefreshToken)).
|
||||
Update(map[string]interface{}{"family_id": rt.FamilyID, "rotated_from": h}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。
|
||||
func RevokeTokenPair(plain, accessToken, reason string) error {
|
||||
if plain != "" {
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("token_hash", hashToken(plain)).
|
||||
Update(map[string]interface{}{"revoked": 1})
|
||||
}
|
||||
if accessToken != "" {
|
||||
if claims, err := jwtutil.ParseTokenRaw(accessToken); err == nil {
|
||||
_ = Blacklist(claims.ID, claims.Sid, reason, time.Unix(claims.ExpiresAt.Unix(), 0))
|
||||
if claims.Sid != "" {
|
||||
_ = RevokeSession(claims.Sid, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Blacklist 把 jti 加入吊销表
|
||||
func Blacklist(jti, sid, reason string, expiresAt time.Time) error {
|
||||
if jti == "" {
|
||||
return nil
|
||||
}
|
||||
item := &models.AuthTokenBlacklist{
|
||||
Jti: jti,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if sid != "" {
|
||||
item.Sid = &sid
|
||||
}
|
||||
if reason != "" {
|
||||
item.Reason = &reason
|
||||
}
|
||||
_, err := models.Orm.InsertOrUpdate(item, "jti")
|
||||
return err
|
||||
}
|
||||
|
||||
// IsBlacklisted 判断 jti 是否已被吊销
|
||||
func IsBlacklisted(jti string) bool {
|
||||
if jti == "" {
|
||||
return false
|
||||
}
|
||||
return models.Orm.QueryTable(new(models.AuthTokenBlacklist)).Filter("jti", jti).Exist()
|
||||
}
|
||||
|
||||
func hashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func derefStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
Reference in New Issue
Block a user