批量更新

This commit is contained in:
2026-09-20 00:19:08 +08:00
parent fa281363fb
commit c5ed596008
47 changed files with 5385 additions and 1199 deletions
+19
View File
@@ -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)
+413
View File
@@ -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]
}
+29 -4
View File
@@ -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
}
+114
View File
@@ -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
}
+61 -2
View File
@@ -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 != "" {