做统一认证登录

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
+148
View File
@@ -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
}
+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)
}
+220
View File
@@ -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
}
+219
View File
@@ -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
}
+217
View File
@@ -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
}