批量更新
This commit is contained in:
@@ -119,6 +119,25 @@ func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) {
|
||||
return &bind, nil
|
||||
}
|
||||
|
||||
// FindIdentityIDByAccount 按「企业 + 账号」查找认证中心身份ID。
|
||||
// 账号可以是企业内账号、手机号或邮箱(老登录入口按账号登录时使用)。
|
||||
func FindIdentityIDByAccount(tid uint64, account string) uint64 {
|
||||
account = strings.TrimSpace(account)
|
||||
if tid == 0 || account == "" {
|
||||
return 0
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true)
|
||||
var bind models.AuthTenantUser
|
||||
for _, field := range []string{"account", "phone", "email"} {
|
||||
if err := base.Filter(field, account).One(&bind); err == nil {
|
||||
return bind.IdentityID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// BuildProfile 组装身份概要(含可选企业列表)
|
||||
func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) {
|
||||
tenants, err := ListTenantOptions(m.ID)
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
// Package idp 第三方身份源(微信/钉钉/飞书/QQ/GitHub/Google)统一适配层。
|
||||
//
|
||||
// 各平台 OAuth2 流程基本一致,差异只在端点地址、参数名与用户字段,
|
||||
// 因此用一份通用实现 + 预设配置覆盖,新增平台只需加一条 preset。
|
||||
package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// UserInfo 第三方返回的标准化用户信息
|
||||
type UserInfo struct {
|
||||
OpenID string // 该平台内的唯一 ID(必填)
|
||||
UnionID string // 跨平台唯一 ID(微信/钉钉有,GitHub/Google 用 OpenID 代替)
|
||||
Nickname string
|
||||
Avatar string
|
||||
Raw string // 原始 JSON,便于排查
|
||||
}
|
||||
|
||||
// Config 一个第三方身份源的配置
|
||||
type Config struct {
|
||||
Provider string
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserinfoURL string
|
||||
Scopes []string
|
||||
AppID string
|
||||
AppSecret string
|
||||
ProxyURL string // GitHub/Google 在国内服务器需要代理时填写
|
||||
|
||||
// 参数风格:微信/QQ 用 appid+secret,其余用 client_id+client_secret
|
||||
UseAppIDStyle bool
|
||||
// 用户信息接口风格:query=拼在 URL 上(微信/QQ),bearer=放 Authorization 头
|
||||
UserinfoStyle string
|
||||
|
||||
// 用户信息字段映射(一级 JSON 字段)
|
||||
OpenIDField string
|
||||
UnionIDField string
|
||||
NicknameField string
|
||||
AvatarField string
|
||||
}
|
||||
|
||||
// presets 各平台预设(端点与字段映射)
|
||||
var presets = map[string]Config{
|
||||
models.IdPWechat: {
|
||||
Provider: models.IdPWechat,
|
||||
AuthURL: "https://open.weixin.qq.com/connect/oauth2/authorize",
|
||||
TokenURL: "https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
UserinfoURL: "https://api.weixin.qq.com/sns/userinfo",
|
||||
Scopes: []string{"snsapi_userinfo"},
|
||||
UseAppIDStyle: true,
|
||||
UserinfoStyle: "query",
|
||||
OpenIDField: "openid",
|
||||
UnionIDField: "unionid",
|
||||
NicknameField: "nickname",
|
||||
AvatarField: "headimgurl",
|
||||
},
|
||||
models.IdPQQ: {
|
||||
Provider: models.IdPQQ,
|
||||
AuthURL: "https://graph.qq.com/oauth2.0/authorize",
|
||||
TokenURL: "https://graph.qq.com/oauth2.0/token",
|
||||
UserinfoURL: "https://graph.qq.com/user/get_user_info",
|
||||
Scopes: []string{"get_user_info"},
|
||||
UseAppIDStyle: true,
|
||||
UserinfoStyle: "query",
|
||||
OpenIDField: "openid",
|
||||
NicknameField: "nickname",
|
||||
AvatarField: "figureurl_qq_2",
|
||||
},
|
||||
models.IdPGitHub: {
|
||||
Provider: models.IdPGitHub,
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserinfoURL: "https://api.github.com/user",
|
||||
Scopes: []string{"read:user"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "id",
|
||||
NicknameField: "login",
|
||||
AvatarField: "avatar_url",
|
||||
},
|
||||
models.IdPGoogle: {
|
||||
Provider: models.IdPGoogle,
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserinfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "sub",
|
||||
NicknameField: "name",
|
||||
AvatarField: "picture",
|
||||
},
|
||||
models.IdPDingTalk: {
|
||||
Provider: models.IdPDingTalk,
|
||||
AuthURL: "https://login.dingtalk.com/oauth2/auth",
|
||||
TokenURL: "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
|
||||
UserinfoURL: "https://api.dingtalk.com/v1.0/contact/users/me",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "unionId",
|
||||
UnionIDField: "unionId",
|
||||
NicknameField: "nick",
|
||||
AvatarField: "avatarUrl",
|
||||
},
|
||||
models.IdPFeishu: {
|
||||
Provider: models.IdPFeishu,
|
||||
AuthURL: "https://open.feishu.cn/open-apis/authen/v1/authorize",
|
||||
TokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
UserinfoURL: "https://open.feishu.cn/open-apis/authen/v1/user_info",
|
||||
Scopes: []string{"contact:user.base:readonly"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "open_id",
|
||||
UnionIDField: "union_id",
|
||||
NicknameField: "name",
|
||||
AvatarField: "avatar_url",
|
||||
},
|
||||
}
|
||||
|
||||
// Supported 返回支持的第三方平台列表
|
||||
func Supported() []string {
|
||||
return []string{
|
||||
models.IdPWechat, models.IdPDingTalk, models.IdPFeishu,
|
||||
models.IdPQQ, models.IdPGitHub, models.IdPGoogle,
|
||||
}
|
||||
}
|
||||
|
||||
// IsSupported 是否为已知平台
|
||||
func IsSupported(provider string) bool {
|
||||
_, ok := presets[provider]
|
||||
return ok
|
||||
}
|
||||
|
||||
// LoadConfig 读取身份源配置:
|
||||
// - tid > 0 时优先取租户自带身份源(yz_auth_tenant_idp)
|
||||
// - 取不到或 tid=0 时取平台全局配置(tid=0 的记录)
|
||||
//
|
||||
// 未配置任何记录时返回 false,调用方应提示「该登录方式未开通」。
|
||||
func LoadConfig(provider string, tid uint64) (Config, bool) {
|
||||
base, ok := presets[provider]
|
||||
if !ok {
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
var row models.AuthTenantIdp
|
||||
qs := models.Orm.QueryTable(new(models.AuthTenantIdp)).
|
||||
Filter("provider", provider).
|
||||
Filter("status", 1)
|
||||
|
||||
if tid > 0 {
|
||||
if err := qs.Filter("tid", tid).One(&row); err == nil {
|
||||
return applyDBConfig(base, &row), true
|
||||
}
|
||||
}
|
||||
// 回落到平台全局配置(tid=0)
|
||||
if err := qs.Filter("tid", 0).One(&row); err == nil {
|
||||
return applyDBConfig(base, &row), true
|
||||
}
|
||||
return base, false
|
||||
}
|
||||
|
||||
func applyDBConfig(base Config, row *models.AuthTenantIdp) Config {
|
||||
if row.AppID != nil && strings.TrimSpace(*row.AppID) != "" {
|
||||
base.AppID = strings.TrimSpace(*row.AppID)
|
||||
}
|
||||
if row.AppSecret != nil && strings.TrimSpace(*row.AppSecret) != "" {
|
||||
base.AppSecret = strings.TrimSpace(*row.AppSecret)
|
||||
}
|
||||
if row.ProxyURL != nil && strings.TrimSpace(*row.ProxyURL) != "" {
|
||||
base.ProxyURL = strings.TrimSpace(*row.ProxyURL)
|
||||
}
|
||||
if row.AuthURL != nil && strings.TrimSpace(*row.AuthURL) != "" {
|
||||
base.AuthURL = strings.TrimSpace(*row.AuthURL)
|
||||
}
|
||||
if row.TokenURL != nil && strings.TrimSpace(*row.TokenURL) != "" {
|
||||
base.TokenURL = strings.TrimSpace(*row.TokenURL)
|
||||
}
|
||||
if row.UserinfoURL != nil && strings.TrimSpace(*row.UserinfoURL) != "" {
|
||||
base.UserinfoURL = strings.TrimSpace(*row.UserinfoURL)
|
||||
}
|
||||
if row.Scopes != nil && strings.TrimSpace(*row.Scopes) != "" {
|
||||
base.Scopes = splitList(*row.Scopes)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func splitList(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildAuthURL 生成跳转到第三方授权页的地址
|
||||
func BuildAuthURL(cfg Config, redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
if cfg.UseAppIDStyle {
|
||||
v.Set("appid", cfg.AppID)
|
||||
} else {
|
||||
v.Set("client_id", cfg.AppID)
|
||||
}
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", strings.Join(cfg.Scopes, ","))
|
||||
v.Set("state", state)
|
||||
|
||||
addr := cfg.AuthURL + "?" + v.Encode()
|
||||
if cfg.Provider == models.IdPWechat {
|
||||
// 微信要求在 hash 后带 #wechat_redirect
|
||||
return addr + "#wechat_redirect"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// Exchange 用授权码换取用户信息
|
||||
func Exchange(cfg Config, code string) (*UserInfo, error) {
|
||||
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return nil, errors.New("该登录方式尚未配置,请联系管理员")
|
||||
}
|
||||
|
||||
// 1. code 换 access_token
|
||||
v := url.Values{}
|
||||
if cfg.UseAppIDStyle {
|
||||
v.Set("appid", cfg.AppID)
|
||||
v.Set("secret", cfg.AppSecret)
|
||||
} else {
|
||||
v.Set("client_id", cfg.AppID)
|
||||
v.Set("client_secret", cfg.AppSecret)
|
||||
}
|
||||
v.Set("code", code)
|
||||
v.Set("grant_type", "authorization_code")
|
||||
|
||||
tokenBody, err := post(cfg, cfg.TokenURL, v, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenMap := parseMap(tokenBody)
|
||||
accessToken := firstStr(tokenMap, "access_token")
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("换取令牌失败: %s", truncate(tokenBody, 200))
|
||||
}
|
||||
openID := firstStr(tokenMap, "openid", "unionId", "open_id", "sub", "id")
|
||||
|
||||
// 2. 取用户信息
|
||||
var infoMap map[string]interface{}
|
||||
if cfg.UserinfoStyle == "query" {
|
||||
q := url.Values{}
|
||||
q.Set("access_token", accessToken)
|
||||
if openID != "" {
|
||||
q.Set("openid", openID)
|
||||
}
|
||||
// QQ 需要额外带 oauth_consumer_key
|
||||
if cfg.Provider == models.IdPQQ {
|
||||
q.Set("oauth_consumer_key", cfg.AppID)
|
||||
}
|
||||
body, err := get(cfg, cfg.UserinfoURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoMap = parseMap(body)
|
||||
} else {
|
||||
body, err := get(cfg, cfg.UserinfoURL, map[string]string{
|
||||
"Authorization": "Bearer " + accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoMap = parseMap(body)
|
||||
// 飞书把数据包在 data 里
|
||||
if d, ok := infoMap["data"].(map[string]interface{}); ok {
|
||||
infoMap = d
|
||||
}
|
||||
}
|
||||
|
||||
info := &UserInfo{Raw: truncate(mustJSON(infoMap), 2000)}
|
||||
info.OpenID = strField(infoMap, cfg.OpenIDField)
|
||||
if cfg.UnionIDField != "" {
|
||||
info.UnionID = strField(infoMap, cfg.UnionIDField)
|
||||
}
|
||||
info.Nickname = strField(infoMap, cfg.NicknameField)
|
||||
info.Avatar = strField(infoMap, cfg.AvatarField)
|
||||
|
||||
if info.OpenID == "" && openID != "" {
|
||||
info.OpenID = openID
|
||||
}
|
||||
if info.OpenID == "" {
|
||||
return nil, errors.New("未能获取第三方账号标识")
|
||||
}
|
||||
if info.UnionID == "" {
|
||||
info.UnionID = info.OpenID
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP
|
||||
|
||||
func client(cfg Config) *http.Client {
|
||||
c := &http.Client{Timeout: 15 * time.Second}
|
||||
if cfg.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.ProxyURL); err == nil {
|
||||
c.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func post(cfg Config, target string, form url.Values, headers map[string]string) (string, error) {
|
||||
req, err := http.NewRequest("POST", target, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cfg.Provider == models.IdPGitHub {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client(cfg).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求第三方失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func get(cfg Config, target string, headers map[string]string) (string, error) {
|
||||
req, err := http.NewRequest("GET", target, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client(cfg).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求第三方失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 解析工具
|
||||
|
||||
// parseMap 兼容 JSON 与 querystring 两种响应(微信/QQ 早期接口返回 form 格式)
|
||||
func parseMap(body string) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
if err := json.Unmarshal([]byte(body), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
if values, err := url.ParseQuery(body); err == nil {
|
||||
for k, v := range values {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstStr(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t != "" {
|
||||
return t
|
||||
}
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strField(m map[string]interface{}, field string) string {
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
return firstStr(m, field)
|
||||
}
|
||||
|
||||
func mustJSON(m map[string]interface{}) string {
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
)
|
||||
|
||||
@@ -127,9 +128,9 @@ func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginRes
|
||||
ClientID: clientID,
|
||||
Sid: sess.Sid,
|
||||
Username: profile.Nickname,
|
||||
UserType: "tenant",
|
||||
Amr: AmrPwd,
|
||||
AccessTTL: accessTTL,
|
||||
// 不指定 UserType:由 IssueTokens 按应用编码给出 backend / app
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -184,7 +185,6 @@ func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *Identit
|
||||
ClientID: clientID,
|
||||
Sid: sid,
|
||||
Username: profile.Nickname,
|
||||
UserType: "tenant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -262,7 +262,32 @@ func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, l
|
||||
return err
|
||||
}
|
||||
|
||||
// Logout 登出:吊销刷新令牌、会话与访问令牌
|
||||
// Logout 登出:吊销刷新令牌、会话与访问令牌,并通知其他应用(单点登出)
|
||||
func Logout(accessToken, refreshToken string) error {
|
||||
return RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
)
|
||||
|
||||
// BackchannelLogoutEvent OIDC 标准登出事件声明
|
||||
const BackchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
|
||||
|
||||
// logoutTarget 需要收到登出通知的应用
|
||||
type logoutTarget struct {
|
||||
ClientID string
|
||||
Endpoint string
|
||||
Sid string
|
||||
Identity uint64
|
||||
}
|
||||
|
||||
// NotifyBackchannelLogout 单点登出:通知该用户已登录的所有应用。
|
||||
//
|
||||
// 流程:查库收集目标应用(同步)→ 异步逐个 POST logout_token。
|
||||
// 各应用收到后应清除本地会话,否则用户在这边登出了,其他应用仍显示已登录。
|
||||
//
|
||||
// 注意:查库必须在请求上下文内同步完成(beego 全局 Ormer 不适合跨 goroutine 使用),
|
||||
// 异步部分只做 HTTP 通知,不再触碰数据库。
|
||||
func NotifyBackchannelLogout(identityID uint64) {
|
||||
targets, err := collectLogoutTargets(identityID)
|
||||
if err != nil || len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, t := range targets {
|
||||
if err := sendLogoutToken(t); err != nil {
|
||||
log.Printf("[auth] 单点登出通知失败 client=%s: %v", t.ClientID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// collectLogoutTargets 收集该用户当前活跃会话涉及的应用(按 client_id 去重)
|
||||
func collectLogoutTargets(identityID uint64) ([]logoutTarget, error) {
|
||||
var sessions []models.AuthSession
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("revoked", 0).
|
||||
All(&sessions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
targets := make([]logoutTarget, 0)
|
||||
for _, s := range sessions {
|
||||
if s.ClientID == "" || seen[s.ClientID] {
|
||||
continue
|
||||
}
|
||||
var client models.AuthClient
|
||||
if err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||
Filter("client_id", s.ClientID).One(&client); err != nil {
|
||||
continue
|
||||
}
|
||||
if client.BackchannelLogoutURI == nil || strings.TrimSpace(*client.BackchannelLogoutURI) == "" {
|
||||
continue
|
||||
}
|
||||
seen[s.ClientID] = true
|
||||
targets = append(targets, logoutTarget{
|
||||
ClientID: s.ClientID,
|
||||
Endpoint: strings.TrimSpace(*client.BackchannelLogoutURI),
|
||||
Sid: s.Sid,
|
||||
Identity: identityID,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// sendLogoutToken 按 OIDC Back-Channel Logout 规范发送 logout_token
|
||||
func sendLogoutToken(t logoutTarget) error {
|
||||
token, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(t.Identity),
|
||||
Subject: fmt.Sprintf("%d", t.Identity),
|
||||
Audience: []string{t.ClientID},
|
||||
ClientID: t.ClientID,
|
||||
Sid: t.Sid,
|
||||
Jti: uuid.NewString(),
|
||||
Events: map[string]interface{}{BackchannelLogoutEvent: map[string]interface{}{}},
|
||||
TTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("logout_token", token)
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.PostForm(t.Endpoint, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("应用返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -46,6 +47,10 @@ type TokenIssue struct {
|
||||
|
||||
// IssueTokens 签发访问令牌与刷新令牌。
|
||||
// 刷新令牌明文只在本次返回,库中仅存哈希。
|
||||
//
|
||||
// 令牌中的 user_id 为认证中心 identity_id(yz_auth_identity.id)。
|
||||
// 业务表(文件/客户/合同/日程等)中的 uid 已由 scripts/uidmigrate 全量迁移为
|
||||
// 同一套 ID,因此无需任何兼容换算。
|
||||
func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
accessTTL := opt.AccessTTL
|
||||
if accessTTL <= 0 {
|
||||
@@ -56,13 +61,27 @@ func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
refreshTTL = 2592000
|
||||
}
|
||||
|
||||
// 全量迁移后,令牌中的 user_id 统一为认证中心的 identity_id
|
||||
userID := int(opt.IdentityID)
|
||||
|
||||
// userType 需与现有业务接口的判定保持一致:
|
||||
// 后端接口普遍要求 user_type 为 "backend"(租户后台)或 "app"(移动端),
|
||||
// 若签发 "tenant" 会被判为无权访问。这里按应用编码给出默认值。
|
||||
userType := opt.UserType
|
||||
if userType == "" {
|
||||
userType = "backend"
|
||||
if opt.ClientID == "yz-uniapp" {
|
||||
userType = "app"
|
||||
}
|
||||
}
|
||||
|
||||
jti := uuid.NewString()
|
||||
access, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(opt.IdentityID),
|
||||
UserID: userID,
|
||||
Username: opt.Username,
|
||||
TenantID: int(opt.Tid),
|
||||
UserType: opt.UserType,
|
||||
UserType: userType,
|
||||
ClientID: opt.ClientID,
|
||||
Sid: opt.Sid,
|
||||
Amr: opt.Amr,
|
||||
@@ -159,6 +178,46 @@ func RefreshTokens(plain, clientID string) (*TokenPair, error) {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// FindLegacyUID 通过「身份 + 企业」在老表 yz_system_tenant_user 中找到对应 uid。
|
||||
//
|
||||
// 双轨期(老登录与统一认证并行)下业务接口仍以老 uid 识别用户,
|
||||
// 因此签发令牌、返回用户信息时都要换算回老 uid。
|
||||
// 匹配顺序:账号 → 手机号 → 邮箱;都匹配不到返回 0。
|
||||
func FindLegacyUID(tid, identityID uint64) uint64 {
|
||||
if tid == 0 || identityID == 0 {
|
||||
return 0
|
||||
}
|
||||
var bind models.AuthTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&bind); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("tid", tid)
|
||||
match := func(field string, value *string) uint64 {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return 0
|
||||
}
|
||||
var row models.SystemTenantUser
|
||||
if err := qs.Filter(field, strings.TrimSpace(*value)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row); err == nil {
|
||||
return row.Uid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if uid := match("account", bind.Account); uid > 0 {
|
||||
return uid
|
||||
}
|
||||
if uid := match("phone", bind.Phone); uid > 0 {
|
||||
return uid
|
||||
}
|
||||
return match("email", bind.Email)
|
||||
}
|
||||
|
||||
// RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。
|
||||
func RevokeTokenPair(plain, accessToken, reason string) error {
|
||||
if plain != "" {
|
||||
|
||||
Reference in New Issue
Block a user