做统一认证登录

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
+24
View File
@@ -53,6 +53,30 @@ acme_directory =
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
ssl_cert_dir = /www/wwwroot/ssl-certs
# ==================== 统一认证中心(UAC)基础配置 ====================
# 全局 JWT 鉴权模式:off=不启用(等同改造前)/ warn=观察模式只记日志不拦截 / on=真正拦截401
# 上线步骤:先 warn 跑一段时间核对日志,确认白名单无遗漏后再改 on
auth_enforce = warn
# 额外免鉴权路径前缀(在内置白名单之外追加),多个用英文逗号分隔
auth_whitelist =
# JWT 签发者(统一认证中心地址)
jwt_issuer = https://api.yunzer.cn/auth
# HS256 主密钥:留空则沿用内置默认密钥(兼容历史 token),生产环境务必配置
# 注意:修改后所有已签发的旧 token 立即失效,需在业务低峰期操作
jwt_secret = peaceandlove
# HS256 轮换密钥(用于平滑换密钥),格式:kid1:secret1,kid2:secret2
jwt_secrets =
# RS256 密钥对(P1 认证中心启用;未配置时自动回落 HS256,不影响启动)
# 生成命令:
# openssl genpkey -algorithm RSA -out jwt_rsa_private.pem -pkeyopt rsa_keygen_bits:2048
# openssl rsa -pubout -in jwt_rsa_private.pem -out jwt_rsa_public.pem
# 私钥文件不要提交到代码仓库
jwt_rsa_private_key_file = E:\Demo\ssh\jwt_rsa_private.pem
jwt_rsa_public_key_file = E:\Demo\ssh\jwt_rsa_public.pem
jwt_rsa_kid = rsa-1
# ==================== 微信公众号(服务号) ====================
# 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。
+125
View File
@@ -0,0 +1,125 @@
package controllers
import (
"strconv"
"strings"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// ApiPlatformUpgradeController 平台更新内容接口
type ApiPlatformUpgradeController struct {
beego.Controller
}
// List GET /api/platform/upgrade/list 获取平台更新列表(公开接口)
func (c *ApiPlatformUpgradeController) List() {
// 可选参数:limit - 返回数量限制,默认 10
limitStr := c.GetString("limit")
limit := 10
if limitStr != "" {
val, err := strconv.Atoi(limitStr)
if err == nil {
limit = val
}
}
if limit <= 0 || limit > 50 {
limit = 10
}
// 查询显示状态的更新记录,按排序字段和创建时间倒序
var upgradeList []models.PlatformUpgradeNotice
qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
Filter("status", 1).
Filter("delete_time__isnull", true)
_, err := qs.OrderBy("-sort", "-create_time").Limit(limit).All(&upgradeList)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 500,
"msg": "查询失败",
}
_ = c.ServeJSON()
return
}
// 转换为前端需要的格式
data := make([]map[string]interface{}, 0, len(upgradeList))
for _, item := range upgradeList {
record := map[string]interface{}{
"id": item.ID,
"update_date": item.UpdateDate,
"title": item.Title,
"content": item.Content,
"create_time": item.CreateTime.Format("2006-01-02 15:04:05"),
}
data = append(data, record)
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": data,
}
_ = c.ServeJSON()
}
// Detail GET /api/platform/upgrade/detail?id={id} 获取单条更新详情
func (c *ApiPlatformUpgradeController) Detail() {
idStr := strings.TrimSpace(c.GetString("id"))
if idStr == "" {
c.Data["json"] = map[string]interface{}{
"code": 400,
"msg": "缺少参数 id",
}
_ = c.ServeJSON()
return
}
var id uint64
var parseErr error
id, parseErr = strconv.ParseUint(idStr, 10, 64)
if parseErr != nil {
c.Data["json"] = map[string]interface{}{
"code": 400,
"msg": "参数错误",
}
_ = c.ServeJSON()
return
}
var row models.PlatformUpgradeNotice
var queryErr error
queryErr = models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
Filter("id", id).
Filter("status", 1).
Filter("delete_time__isnull", true).
One(&row)
if queryErr != nil {
c.Data["json"] = map[string]interface{}{
"code": 404,
"msg": "更新内容不存在",
}
_ = c.ServeJSON()
return
}
data := map[string]interface{}{
"id": row.ID,
"tenant_id": row.TenantID,
"update_date": row.UpdateDate,
"title": row.Title,
"content": row.Content,
"sort": row.Sort,
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": data,
}
_ = c.ServeJSON()
}
+375
View File
@@ -0,0 +1,375 @@
package auth
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
authsvc "server/services/auth"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// AuthLoginController 认证中心登录相关端点
type AuthLoginController struct {
beego.Controller
}
func (c *AuthLoginController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// LoginPage 统一登录页(beego 模板渲染)
// GET /auth/login?redirect=<base64(回跳URL)>&client_id=xxx&step=tenant
func (c *AuthLoginController) LoginPage() {
clientID := strings.TrimSpace(c.GetString("client_id"))
redirect := c.GetString("redirect")
step := c.GetString("step")
errMsg := c.GetString("error")
// 已登录且已选企业:直接放行到 authorize,无需再输密码
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid != "" && step != "tenant" {
if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID {
if back := decodeRedirect(redirect); back != "" {
c.Redirect(back, 302)
return
}
}
}
// 企业选择步骤:需要展示可进入的企业列表
tenants := make([]authsvc.TenantOption, 0)
if sid != "" {
if session, err := authsvc.GetSession(sid); err == nil {
list, _ := authsvc.ListTenantOptions(session.IdentityID)
tenants = list
// 只有一家企业直接进入,无需展示选择页
if len(tenants) == 1 && step == "tenant" {
if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil {
if back := decodeRedirect(redirect); back != "" {
c.Redirect(back, 302)
return
}
}
}
}
}
c.Data["ClientID"] = clientID
c.Data["Redirect"] = redirect
c.Data["Step"] = step
c.Data["Error"] = errMsg
c.Data["Tenants"] = tenants
c.TplName = "auth/login.tpl"
}
// LoginSubmit 提交登录
// POST /auth/login { account, password, client_id, redirect }
func (c *AuthLoginController) LoginSubmit() {
var req struct {
Account string `json:"account"`
Password string `json:"password"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
DeviceID string `json:"device_id"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
var err error
body, err = io.ReadAll(c.Ctx.Request.Body)
if err != nil || len(body) == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
}
if err := json.Unmarshal(body, &req); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
if strings.TrimSpace(req.Account) == "" || req.Password == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入账号和密码"})
return
}
clientIP := c.Ctx.Input.IP()
userAgent := c.Ctx.Request.UserAgent()
result, err := authsvc.PasswordLogin(req.Account, req.Password, req.ClientID, clientIP, userAgent)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()})
return
}
// 写入认证中心会话 Cookie,供后续 authorize 识别登录态
setSessionCookieForCtx(c.Ctx, result.Tokens.Sid)
if result.NeedChooseTenant {
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "请选择要进入的企业",
"need_choose_tenant": true,
"tenants": result.Tenants,
"sid": result.Tokens.Sid,
})
return
}
back := decodeRedirect(req.Redirect)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "登录成功",
"redirect": back,
"tokens": result.Tokens,
"tenants": result.Tenants,
})
}
// Tenants 当前登录身份可进入的企业列表(Bearer Token)
// GET /auth/tenants
func (c *AuthLoginController) Tenants() {
claims := claimsFromHeaderLogin(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
list, err := authsvc.ListTenantOptions(uint64(claims.UserID))
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
}
// SwitchTenant 免密切换企业
// POST /auth/switch-tenant { tid, client_id, redirect }
func (c *AuthLoginController) SwitchTenant() {
var req struct {
Tid uint64 `json:"tid"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
body, _ = io.ReadAll(c.Ctx.Request.Body)
}
_ = json.Unmarshal(body, &req)
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
if claims := claimsFromHeaderLogin(c); claims != nil {
sid = claims.Sid
}
}
if sid == "" || req.Tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
tokens, profile, err := authsvc.ChooseTenant(sid, req.Tid, req.ClientID)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 403, "msg": err.Error()})
return
}
setSessionCookieForCtx(c.Ctx, tokens.Sid)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "切换成功",
"redirect": decodeRedirect(req.Redirect),
"data": map[string]interface{}{"tokens": tokens, "identity": profile},
})
}
// LogoutPage 单点登出入口(OIDC end_session_endpoint)
// GET /auth/logout?post_logout_redirect_uri=&client_id=
//
// 清除认证中心 Cookie 并吊销会话与令牌;回跳地址必须在应用白名单内。
// 登记了 backchannel_logout_uri 的应用会收到服务端登出通知(P2 阶段接入)。
func (c *AuthLoginController) LogoutPage() {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
access := bearerTokenLogin(c)
if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
if access != "" {
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout)
}
clearSessionCookieForCtx(c.Ctx)
back := strings.TrimSpace(c.GetString("post_logout_redirect_uri"))
cid := strings.TrimSpace(c.GetString("client_id"))
if back != "" && cid != "" {
if client, err := findClient(cid); err == nil && allowRedirect(client, back) {
c.Redirect(back, 302)
return
}
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
}
// Sessions 在线设备列表
// GET /auth/sessions
func (c *AuthLoginController) Sessions() {
claims := claimsFromHeaderLogin(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
list, err := authsvc.ListActiveSessions(uint64(claims.UserID))
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
}
// KickSession 踢下线指定设备
// POST /auth/sessions/kick { sid }
func (c *AuthLoginController) KickSession() {
var req struct {
Sid string `json:"sid"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
body, _ = io.ReadAll(c.Ctx.Request.Body)
}
_ = json.Unmarshal(body, &req)
claims := claimsFromHeaderLogin(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
if req.Sid == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
// 只允许踢自己名下的会话
owned := false
if list, err := authsvc.ListActiveSessions(uint64(claims.UserID)); err == nil {
for _, s := range list {
if s.Sid == req.Sid {
owned = true
break
}
}
}
if !owned {
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权操作"})
return
}
if err := authsvc.RevokeSession(req.Sid, models.RevokeReasonAdmin); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "操作失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已下线"})
}
// LogoutAction 退出登录(清 Cookie + 吊销会话与令牌)
// POST /auth/logout
func (c *AuthLoginController) LogoutAction() {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
access := ""
if claims := claimsFromHeaderLogin(c); claims != nil {
if sid == "" {
sid = claims.Sid
}
access = bearerTokenLogin(c)
}
_ = authsvc.Logout(access, "")
if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
clearSessionCookieForCtx(c.Ctx)
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
}
// VerifyConfig 租户登录验证配置(替代 /backend/login/getOpenVerify)
// GET /auth/verify-config?tid=
func (c *AuthLoginController) VerifyConfig() {
tid, _ := c.GetInt64("tid", 0)
cfg := authsvc.GetTenantSessionPolicy(uint64(tid))
var authCfg models.AuthTenantAuthConfig
verifyType := "captcha"
openVerify := 1
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).One(&authCfg); err == nil {
verifyType = authCfg.VerifyType
openVerify = int(authCfg.OpenVerify)
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"openVerify": openVerify,
"verifyType": verifyType,
"sessionTTL": cfg.SessionTTL,
"maxSession": cfg.MaxSession,
"mfaRequired": 0,
},
})
}
// ---------------------------------------------------------------- 工具
func decodeRedirect(encoded string) string {
if encoded == "" {
return ""
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return ""
}
back := string(raw)
// 只允许站内地址,防开放重定向
if !strings.HasPrefix(back, "/auth/") && !strings.HasPrefix(back, "http") {
return ""
}
return back
}
func claimsFromHeaderLogin(c *AuthLoginController) *jwtutil.Claims {
token := bearerTokenLogin(c)
if token == "" {
return nil
}
claims, err := jwtutil.ParseToken(token)
if err != nil {
return nil
}
return claims
}
func bearerTokenLogin(c *AuthLoginController) string {
header := c.Ctx.Request.Header.Get("Authorization")
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return ""
}
return strings.TrimSpace(parts[1])
}
func setSessionCookieForCtx(ctx *context.Context, sid string) {
ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
sessionCookieName, sid, sessionCookieTTL))
}
func clearSessionCookieForCtx(ctx *context.Context) {
ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
}
+526
View File
@@ -0,0 +1,526 @@
// Package auth 统一认证中心(UAC)控制器:api.yunzer.cn/auth
//
// 实现 OIDC 1.0(基于 OAuth 2.1 + PKCE)标准端点,
// 以后每开发一个新软件,只需在 yz_auth_client 注册一条即可接入。
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
authsvc "server/services/auth"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// 认证中心会话 Cookie(仅作用于认证中心域名,用于 authorize 阶段识别登录态)
const (
sessionCookieName = "yz_sid"
sessionCookieTTL = 7200
)
// grant_type 常量
const (
GrantAuthCode = "authorization_code"
GrantRefresh = "refresh_token"
ResponseTypeCode = "code"
)
// AuthOidcController OIDC 标准端点
type AuthOidcController struct {
beego.Controller
}
func (c *AuthOidcController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
func (c *AuthOidcController) fail(status int, msg string) {
c.Ctx.Output.SetStatus(status)
c.serveJSON(map[string]interface{}{"error": msg})
}
// Discovery OIDC 发现文档
// GET /auth/.well-known/openid-configuration
func (c *AuthOidcController) Discovery() {
issuer := jwtutil.Issuer()
if issuer == "" {
issuer = fmt.Sprintf("https://%s/auth", c.Ctx.Request.Host)
}
c.Data["json"] = map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token",
"userinfo_endpoint": issuer + "/userinfo",
"introspection_endpoint": issuer + "/introspect",
"revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
"jwks_uri": issuer + "/jwks.json",
"response_types_supported": []string{"code"},
"grant_types_supported": []string{GrantAuthCode, GrantRefresh},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{jwtutil.AlgRS256, jwtutil.AlgHS256},
"code_challenge_methods_supported": []string{"S256"},
"scopes_supported": []string{"openid", "profile", "tenant"},
}
_ = c.ServeJSON()
}
// JWKS 公钥集合(各应用本地验签用)
// GET /auth/jwks.json
func (c *AuthOidcController) JWKS() {
keys := jwtutil.JWKS()
if keys == nil {
keys = []jwtutil.JWK{}
}
c.Data["json"] = map[string]interface{}{"keys": keys}
_ = c.ServeJSON()
}
// Authorize 授权端点
// GET /auth/authorize?client_id=&redirect_uri=&response_type=code&scope=&state=&code_challenge=&code_challenge_method=S256
//
// 未登录时重定向到统一登录页,登录后再回到本端点完成授权。
func (c *AuthOidcController) Authorize() {
clientID := strings.TrimSpace(c.GetString("client_id"))
redirectURI := strings.TrimSpace(c.GetString("redirect_uri"))
responseType := strings.TrimSpace(c.GetString("response_type"))
state := c.GetString("state")
scope := c.GetString("scope")
if scope == "" {
scope = "openid"
}
challenge := c.GetString("code_challenge")
challengeMethod := c.GetString("code_challenge_method")
if challengeMethod == "" {
challengeMethod = "S256"
}
nonce := c.GetString("nonce")
if clientID == "" || redirectURI == "" {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("缺少 client_id 或 redirect_uri"))
return
}
if responseType != ResponseTypeCode {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("仅支持 response_type=code"))
return
}
client, err := findClient(clientID)
if err != nil {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("client_id 无效"))
return
}
if !allowRedirect(client, redirectURI) {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("redirect_uri 未登记"))
return
}
// 登录态:Cookie 优先(浏览器跳转),其次 Authorization(服务端调用)
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
if claims := claimsFromHeader(c); claims != nil {
sid = claims.Sid
}
}
if sid == "" {
// 未登录 → 去登录页,登录后带着参数回来
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s",
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
c.Redirect(target, 302)
return
}
session, err := authsvc.GetSession(sid)
if err != nil {
clearSessionCookie(c)
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s",
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
c.Redirect(target, 302)
return
}
// 已登录但未选择企业:跳登录页的企业选择步骤
if session.Tid == authsvc.PendingTenantID {
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
target := fmt.Sprintf("/auth/login?step=tenant&redirect=%s&client_id=%s",
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
c.Redirect(target, 302)
return
}
code, err := issueAuthCode(client.ClientID, session.IdentityID, session.Tid, redirectURI, challenge, challengeMethod, scope, nonce)
if err != nil {
c.Ctx.Output.SetStatus(500)
_, _ = c.Ctx.ResponseWriter.Write([]byte("签发授权码失败"))
return
}
sep := "?"
if strings.Contains(redirectURI, "?") {
sep = "&"
}
c.Redirect(fmt.Sprintf("%s%scode=%s&state=%s", redirectURI, sep, code, state), 302)
}
// Token 令牌端点
// POST /auth/token
// - grant_type=authorization_code:code + code_verifier(PKCE)+ client_id
// - grant_type=refresh_token:refresh_token + client_id
func (c *AuthOidcController) Token() {
grantType := strings.TrimSpace(c.GetString("grant_type"))
clientID := strings.TrimSpace(c.GetString("client_id"))
if clientID == "" {
// 兼容表单/JSON 以外的取参方式
clientID = strings.TrimSpace(c.Ctx.Request.FormValue("client_id"))
}
if grantType == "" {
grantType = strings.TrimSpace(c.Ctx.Request.FormValue("grant_type"))
}
switch grantType {
case GrantAuthCode:
code := strings.TrimSpace(c.GetString("code"))
verifier := strings.TrimSpace(c.GetString("code_verifier"))
if code == "" || verifier == "" || clientID == "" {
c.fail(400, "invalid_request")
return
}
pair, claims, err := exchangeCode(code, verifier, clientID)
if err != nil {
c.fail(400, err.Error())
return
}
idToken, _ := buildIDToken(claims, clientID)
c.serveJSON(map[string]interface{}{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"token_type": pair.TokenType,
"expires_in": pair.ExpiresIn,
"id_token": idToken,
"sid": pair.Sid,
})
case GrantRefresh:
refresh := strings.TrimSpace(c.GetString("refresh_token"))
if refresh == "" || clientID == "" {
c.fail(400, "invalid_request")
return
}
pair, err := authsvc.RefreshTokens(refresh, clientID)
if err != nil {
c.fail(400, err.Error())
return
}
c.serveJSON(map[string]interface{}{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"token_type": pair.TokenType,
"expires_in": pair.ExpiresIn,
"sid": pair.Sid,
})
default:
c.fail(400, "unsupported_grant_type")
}
}
// UserInfo 用户信息端点,需 Bearer Token
// GET /auth/userinfo
func (c *AuthOidcController) UserInfo() {
claims := claimsFromHeader(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"error": "invalid_token"})
return
}
if claims.UserID <= 0 {
c.fail(401, "invalid_token")
return
}
var identity models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", claims.UserID).One(&identity); err != nil {
c.fail(401, "invalid_token")
return
}
profile, err := authsvc.BuildProfile(&identity)
if err != nil {
c.fail(500, "server_error")
return
}
c.serveJSON(map[string]interface{}{
"sub": fmt.Sprintf("%d", identity.ID),
"union_id": identity.UnionID,
"tid": claims.TenantId,
"nickname": profile.Nickname,
"mobile": profile.Mobile,
"email": profile.Email,
"avatar": profile.Avatar,
"tenants": profile.Tenants,
"client_id": claims.ClientID,
})
}
// Introspect 令牌校验,供资源服务(各业务后端)调用
// POST /auth/token 之外的独立端点;比本地 JWKS 验签更实时(可查黑名单)
func (c *AuthOidcController) Introspect() {
token := strings.TrimSpace(c.GetString("token"))
if token == "" {
token = strings.TrimSpace(c.Ctx.Request.FormValue("token"))
}
if token == "" {
c.fail(400, "invalid_request")
return
}
claims, err := jwtutil.ParseToken(token)
if err != nil {
c.serveJSON(map[string]interface{}{"active": false})
return
}
if authsvc.IsBlacklisted(claims.ID) {
c.serveJSON(map[string]interface{}{"active": false})
return
}
c.serveJSON(map[string]interface{}{
"active": true,
"sub": claims.Subject,
"user_id": claims.UserID,
"tid": claims.TenantId,
"client_id": claims.ClientID,
"sid": claims.Sid,
"scope": claims.Scope,
"amr": claims.Amr,
"exp": claims.ExpiresAt.Unix(),
})
}
// Revoke 吊销令牌(登出/踢下线)
// POST /auth/revoke
func (c *AuthOidcController) Revoke() {
token := strings.TrimSpace(c.GetString("token"))
refresh := strings.TrimSpace(c.GetString("refresh_token"))
if token == "" {
token = strings.TrimSpace(c.Ctx.Request.FormValue("token"))
}
if token == "" && refresh == "" {
c.fail(400, "invalid_request")
return
}
_ = authsvc.RevokeTokenPair(refresh, token, models.RevokeReasonLogout)
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已吊销"})
}
// ---------------------------------------------------------------- 内部工具
func authorizePath(c *AuthOidcController) string {
return "/auth/authorize"
}
// clearSessionCookie 清除认证中心会话 Cookie
func clearSessionCookie(c *AuthOidcController) {
c.Ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
}
// setSessionCookie 写入认证中心会话 Cookie
func setSessionCookie(c *AuthOidcController, sid string) {
c.Ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
sessionCookieName, sid, sessionCookieTTL))
}
func bearerToken(c *AuthOidcController) string {
header := c.Ctx.Request.Header.Get("Authorization")
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return ""
}
return strings.TrimSpace(parts[1])
}
func claimsFromHeader(c *AuthOidcController) *jwtutil.Claims {
token := bearerToken(c)
if token == "" {
return nil
}
claims, err := jwtutil.ParseToken(token)
if err != nil {
return nil
}
return claims
}
// findClient 查询启用状态的应用
func findClient(clientID string) (*models.AuthClient, error) {
var client models.AuthClient
err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("client_id", clientID).
Filter("status", 1).
One(&client)
if err != nil {
return nil, err
}
return &client, nil
}
// allowRedirect 校验回跳地址是否在白名单内(精确匹配,防钓鱼)
func allowRedirect(client *models.AuthClient, uri string) bool {
if client.RedirectURIs == nil || *client.RedirectURIs == "" {
return false
}
var list []string
if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil {
return false
}
for _, item := range list {
if strings.TrimSpace(item) == strings.TrimSpace(uri) {
return true
}
}
return false
}
// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
plain, err := randomString(32)
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(plain))
code := &models.AuthCode{
CodeHash: fmt.Sprintf("%x", sum[:]),
ClientID: clientID,
IdentityID: identityID,
Tid: tid,
RedirectURI: redirectURI,
CodeChallenge: challenge,
CodeChallengeMethod: method,
ExpiresAt: time.Now().Add(60 * time.Second),
}
if scope != "" {
code.Scope = &scope
}
if nonce != "" {
code.Nonce = &nonce
}
if _, err := models.Orm.Insert(code); err != nil {
return "", err
}
return plain, nil
}
// exchangeCode 用授权码换令牌(校验 PKCE、一次性、有效期)
func exchangeCode(code, verifier, clientID string) (*authsvc.TokenPair, *jwtutil.Claims, error) {
sum := sha256.Sum256([]byte(code))
var stored models.AuthCode
if err := models.Orm.QueryTable(new(models.AuthCode)).
Filter("code_hash", fmt.Sprintf("%x", sum[:])).One(&stored); err != nil {
return nil, nil, fmt.Errorf("invalid_grant")
}
if stored.Used != 0 || stored.ExpiresAt.Before(time.Now()) {
return nil, nil, fmt.Errorf("invalid_grant")
}
if stored.ClientID != clientID {
return nil, nil, fmt.Errorf("invalid_client")
}
if !verifyPKCE(verifier, stored.CodeChallenge, stored.CodeChallengeMethod) {
return nil, nil, fmt.Errorf("invalid_grant")
}
// 一次性:立即标记已用
_, _ = models.Orm.QueryTable(new(models.AuthCode)).
Filter("code_hash", stored.CodeHash).
Update(map[string]interface{}{"used": 1})
session, err := authsvc.CreateSession(authsvc.SessionInfo{
IdentityID: stored.IdentityID,
Tid: stored.Tid,
ClientID: clientID,
LoginType: authsvc.LoginTypePassword,
Amr: authsvc.AmrPwd,
})
if err != nil {
return nil, nil, err
}
client, err := findClient(clientID)
accessTTL := 1800
refreshTTL := 2592000
if err == nil {
accessTTL = client.AccessTTL
refreshTTL = client.RefreshTTL
}
pair, err := authsvc.IssueTokens(authsvc.TokenIssue{
IdentityID: stored.IdentityID,
Tid: stored.Tid,
ClientID: clientID,
Sid: session.Sid,
UserType: "tenant",
Amr: authsvc.AmrPwd,
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
})
if err != nil {
return nil, nil, err
}
claims, err := jwtutil.ParseToken(pair.AccessToken)
if err != nil {
return nil, nil, err
}
return pair, claims, nil
}
// verifyPKCE 校验 PKCE(S256 或 plain)
func verifyPKCE(verifier, challenge, method string) bool {
if challenge == "" {
return false
}
if method == "S256" {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:]) == challenge
}
return verifier == challenge
}
// buildIDToken 生成 OIDC ID Token
func buildIDToken(claims *jwtutil.Claims, clientID string) (string, error) {
return jwtutil.SignToken(jwtutil.TokenOptions{
Alg: jwtutil.AlgRS256,
UserID: claims.UserID,
TenantID: claims.TenantId,
UserType: claims.UserType,
ClientID: clientID,
Sid: claims.Sid,
Subject: claims.Subject,
Audience: []string{clientID},
Amr: claims.Amr,
TTL: time.Hour,
})
}
// randomString 生成随机串
func randomString(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
File diff suppressed because it is too large Load Diff
+436 -362
View File
@@ -1,362 +1,436 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/passwordutil"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformTenantUserController 平台租户用户绑定管理
type PlatformTenantUserController struct {
beego.Controller
}
type tenantUserPayload struct {
Tid uint64 `json:"tid"`
Uid uint64 `json:"uid"`
Account *string `json:"account"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Email *string `json:"email"`
Password *string `json:"password"`
IsDefault *int8 `json:"is_default"`
Status *int8 `json:"status"`
OrgID *uint64 `json:"org_id"`
Remark *string `json:"remark"`
}
// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤,keyword 对姓名/手机/邮箱/账号模糊匹配)
// GET /platform/tenantUser/list?tid=1&uid=2&keyword=xxx
func (c *PlatformTenantUserController) GetTenantUserList() {
tid, _ := c.GetUint64("tid")
uid, _ := c.GetUint64("uid")
keyword := strings.TrimSpace(c.GetString("keyword"))
qs := models.Orm.QueryTable(new(models.SystemTenantUser))
var cond *orm.Condition
needCond := false
if tid > 0 {
if cond == nil {
cond = orm.NewCondition()
}
cond = cond.And("tid", tid)
needCond = true
}
if uid > 0 {
if cond == nil {
cond = orm.NewCondition()
}
cond = cond.And("uid", uid)
needCond = true
}
if keyword != "" {
kwCond := orm.NewCondition()
kwCond = kwCond.Or("name__icontains", keyword).
Or("phone__icontains", keyword).
Or("email__icontains", keyword).
Or("account__icontains", keyword)
if cond == nil {
cond = kwCond
} else {
cond = cond.AndCond(kwCond)
}
needCond = true
}
if needCond {
qs = qs.SetCond(cond)
}
var rows []models.SystemTenantUser
_, err := qs.OrderBy("-is_default", "-id").All(&rows)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"list": rows,
"total": len(rows),
},
}
_ = c.ServeJSON()
}
// GetTenantUsersByTid 兼容旧路由,根据租户 ID 获取租户用户列表
// GET /platform/getTenantUsers/:tid
func (c *PlatformTenantUserController) GetTenantUsersByTid() {
tidStr := c.Ctx.Input.Param(":tid")
tid, _ := strconv.ParseUint(tidStr, 10, 64)
if tid == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
_ = c.ServeJSON()
return
}
var rows []models.SystemTenantUser
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
OrderBy("-is_default", "-id").
All(&rows)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"list": rows, "total": len(rows)},
}
_ = c.ServeJSON()
}
// GetTenantUserDetail 获取绑定详情
// GET /platform/tenantUser/detail/:id
func (c *PlatformTenantUserController) GetTenantUserDetail() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
_ = c.ServeJSON()
return
}
var row models.SystemTenantUser
err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "记录不存在"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row}
_ = c.ServeJSON()
}
// CreateTenantUser 创建租户用户绑定(写入 yz_system_tenant_user;uid 为空时自动生成)
// POST /platform/tenantUser/create
func (c *PlatformTenantUserController) CreateTenantUser() {
p, ok := c.parsePayload()
if !ok {
return
}
if p.Tid == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
_ = c.ServeJSON()
return
}
if p.Account == nil || strings.TrimSpace(*p.Account) == "" {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"}
_ = c.ServeJSON()
return
}
if p.Password == nil || strings.TrimSpace(*p.Password) == "" {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"}
_ = c.ServeJSON()
return
}
hashed, err := passwordutil.Hash(*p.Password)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
p.Password = &hashed
if p.Uid == 0 {
uid, err := generateTenantUID(p.Tid)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"}
_ = c.ServeJSON()
return
}
p.Uid = uid
}
// 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开)
if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()}
_ = c.ServeJSON()
return
} else if !check.Allowed {
c.Data["json"] = map[string]interface{}{
"code": 400,
"msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota),
}
_ = c.ServeJSON()
return
}
isDefault := int8(0)
status := int8(1)
if p.IsDefault != nil {
isDefault = *p.IsDefault
}
if p.Status != nil {
status = *p.Status
}
id, err := services.BindTenantUser(p.Tid, p.Uid, p.Account, p.Name, p.Phone, p.Email, nil, nil, p.Password, isDefault, status, p.Remark)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()}
_ = c.ServeJSON()
return
}
if p.OrgID != nil && *p.OrgID > 0 {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"org_id": *p.OrgID})
}
if isDefault == 1 {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
// 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。
if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"group_id": adminRoleID})
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
_ = c.ServeJSON()
}
// EditTenantUser 编辑绑定
// POST /platform/tenantUser/edit/:id
func (c *PlatformTenantUserController) EditTenantUser() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
_ = c.ServeJSON()
return
}
p, ok := c.parsePayload()
if !ok {
return
}
update := map[string]interface{}{}
if p.Tid > 0 {
update["tid"] = p.Tid
}
if p.Uid > 0 {
update["uid"] = p.Uid
}
if p.Account != nil {
update["account"] = p.Account
}
if p.Name != nil {
update["name"] = p.Name
}
if p.Phone != nil {
update["phone"] = p.Phone
}
if p.Email != nil {
update["email"] = p.Email
}
if p.Password != nil {
hashed, err := passwordutil.Hash(*p.Password)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
update["password"] = hashed
}
if p.IsDefault != nil {
update["is_default"] = *p.IsDefault
}
if p.Status != nil {
update["status"] = *p.Status
}
if p.OrgID != nil {
update["org_id"] = *p.OrgID
}
if p.Remark != nil {
update["remark"] = p.Remark
}
if len(update) == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"}
_ = c.ServeJSON()
return
}
_, err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Update(update)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()}
_ = c.ServeJSON()
return
}
if p.IsDefault != nil && *p.IsDefault == 1 && p.Uid > 0 && p.Tid > 0 {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON()
}
// DeleteTenantUser 删除绑定
// DELETE /platform/tenantUser/delete/:id
func (c *PlatformTenantUserController) DeleteTenantUser() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
_ = c.ServeJSON()
return
}
if err := services.UnbindTenantUser(id); err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON()
}
func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool) {
var p tenantUserPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
_ = c.ServeJSON()
return tenantUserPayload{}, false
}
return p, true
}
func generateTenantUID(tid uint64) (uint64, error) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < 8; i++ {
uid := uint64(10000000 + rand.Intn(90000000))
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", uid).
Count()
if err != nil {
return 0, err
}
if cnt == 0 {
return uid, nil
}
}
return 0, errors.New("uid collision")
}
package controllers
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/passwordutil"
"server/services"
authsvc "server/services/auth"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// syncTenantUserToAuth 把租户用户同步到统一认证中心(双写)。
// 同步失败只记日志,不影响平台端主流程(认证中心为新增能力)。
func syncTenantUserToAuth(row *models.SystemTenantUser) {
if row == nil {
return
}
in := authsvc.SyncTenantUserInput{
Tid: row.Tid,
GroupID: row.GroupID,
OrgID: row.OrgID,
Status: row.Status,
IsDefault: row.IsDefault,
}
if row.Account != nil {
in.Account = strings.TrimSpace(*row.Account)
}
if row.Name != nil {
in.Name = strings.TrimSpace(*row.Name)
}
if row.Phone != nil {
in.Phone = strings.TrimSpace(*row.Phone)
}
if row.Email != nil {
in.Email = strings.TrimSpace(*row.Email)
}
if row.Password != nil {
in.PasswordHash = *row.Password
}
if err := authsvc.SyncTenantUser(in); err != nil {
log.Printf("[auth] 同步租户用户到认证中心失败 tid=%d account=%s: %v", row.Tid, in.Account, err)
}
}
// removeTenantUserFromAuth 删除认证中心中的企业绑定(保留身份)
func removeTenantUserFromAuth(row *models.SystemTenantUser) {
if row == nil {
return
}
account, phone, email := "", "", ""
if row.Account != nil {
account = strings.TrimSpace(*row.Account)
}
if row.Phone != nil {
phone = strings.TrimSpace(*row.Phone)
}
if row.Email != nil {
email = strings.TrimSpace(*row.Email)
}
if err := authsvc.RemoveTenantUser(row.Tid, account, phone, email); err != nil {
log.Printf("[auth] 删除认证中心企业绑定失败 tid=%d account=%s: %v", row.Tid, account, err)
}
}
// PlatformTenantUserController 平台租户用户绑定管理
type PlatformTenantUserController struct {
beego.Controller
}
type tenantUserPayload struct {
Tid uint64 `json:"tid"`
Uid uint64 `json:"uid"`
Account *string `json:"account"`
Name *string `json:"name"`
Phone *string `json:"phone"`
Email *string `json:"email"`
Password *string `json:"password"`
IsDefault *int8 `json:"is_default"`
Status *int8 `json:"status"`
OrgID *uint64 `json:"org_id"`
Remark *string `json:"remark"`
}
// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤,keyword 对姓名/手机/邮箱/账号模糊匹配)
// GET /platform/tenantUser/list?tid=1&uid=2&keyword=xxx
func (c *PlatformTenantUserController) GetTenantUserList() {
tid, _ := c.GetUint64("tid")
uid, _ := c.GetUint64("uid")
keyword := strings.TrimSpace(c.GetString("keyword"))
qs := models.Orm.QueryTable(new(models.SystemTenantUser))
var cond *orm.Condition
needCond := false
if tid > 0 {
if cond == nil {
cond = orm.NewCondition()
}
cond = cond.And("tid", tid)
needCond = true
}
if uid > 0 {
if cond == nil {
cond = orm.NewCondition()
}
cond = cond.And("uid", uid)
needCond = true
}
if keyword != "" {
kwCond := orm.NewCondition()
kwCond = kwCond.Or("name__icontains", keyword).
Or("phone__icontains", keyword).
Or("email__icontains", keyword).
Or("account__icontains", keyword)
if cond == nil {
cond = kwCond
} else {
cond = cond.AndCond(kwCond)
}
needCond = true
}
if needCond {
qs = qs.SetCond(cond)
}
var rows []models.SystemTenantUser
_, err := qs.OrderBy("-is_default", "-id").All(&rows)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"list": rows,
"total": len(rows),
},
}
_ = c.ServeJSON()
}
// GetTenantUsersByTid 兼容旧路由,根据租户 ID 获取租户用户列表
// GET /platform/getTenantUsers/:tid
func (c *PlatformTenantUserController) GetTenantUsersByTid() {
tidStr := c.Ctx.Input.Param(":tid")
tid, _ := strconv.ParseUint(tidStr, 10, 64)
if tid == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
_ = c.ServeJSON()
return
}
var rows []models.SystemTenantUser
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
OrderBy("-is_default", "-id").
All(&rows)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"list": rows, "total": len(rows)},
}
_ = c.ServeJSON()
}
// GetTenantUserDetail 获取绑定详情
// GET /platform/tenantUser/detail/:id
func (c *PlatformTenantUserController) GetTenantUserDetail() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
_ = c.ServeJSON()
return
}
var row models.SystemTenantUser
err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "记录不存在"}
_ = c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row}
_ = c.ServeJSON()
}
// CreateTenantUser 创建租户用户绑定(写入 yz_system_tenant_user;uid 为空时自动生成)
// POST /platform/tenantUser/create
func (c *PlatformTenantUserController) CreateTenantUser() {
p, ok := c.parsePayload()
if !ok {
return
}
if p.Tid == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
_ = c.ServeJSON()
return
}
if p.Account == nil || strings.TrimSpace(*p.Account) == "" {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"}
_ = c.ServeJSON()
return
}
if p.Password == nil || strings.TrimSpace(*p.Password) == "" {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"}
_ = c.ServeJSON()
return
}
hashed, err := passwordutil.Hash(*p.Password)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
p.Password = &hashed
if p.Uid == 0 {
uid, err := generateTenantUID(p.Tid)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"}
_ = c.ServeJSON()
return
}
p.Uid = uid
}
// 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开)
if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()}
_ = c.ServeJSON()
return
} else if !check.Allowed {
c.Data["json"] = map[string]interface{}{
"code": 400,
"msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota),
}
_ = c.ServeJSON()
return
}
isDefault := int8(0)
status := int8(1)
if p.IsDefault != nil {
isDefault = *p.IsDefault
}
if p.Status != nil {
status = *p.Status
}
id, err := services.BindTenantUser(p.Tid, p.Uid, p.Account, p.Name, p.Phone, p.Email, nil, nil, p.Password, isDefault, status, p.Remark)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()}
_ = c.ServeJSON()
return
}
if p.OrgID != nil && *p.OrgID > 0 {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"org_id": *p.OrgID})
}
if isDefault == 1 {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
// 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。
if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 {
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", id).
Update(map[string]interface{}{"group_id": adminRoleID})
}
}
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库)
var created models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); err == nil {
syncTenantUserToAuth(&created)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
_ = c.ServeJSON()
}
// EditTenantUser 编辑绑定
// POST /platform/tenantUser/edit/:id
func (c *PlatformTenantUserController) EditTenantUser() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
_ = c.ServeJSON()
return
}
p, ok := c.parsePayload()
if !ok {
return
}
update := map[string]interface{}{}
if p.Tid > 0 {
update["tid"] = p.Tid
}
if p.Uid > 0 {
update["uid"] = p.Uid
}
if p.Account != nil {
update["account"] = p.Account
}
if p.Name != nil {
update["name"] = p.Name
}
if p.Phone != nil {
update["phone"] = p.Phone
}
if p.Email != nil {
update["email"] = p.Email
}
if p.Password != nil {
hashed, err := passwordutil.Hash(*p.Password)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
update["password"] = hashed
}
if p.IsDefault != nil {
update["is_default"] = *p.IsDefault
}
if p.Status != nil {
update["status"] = *p.Status
}
if p.OrgID != nil {
update["org_id"] = *p.OrgID
}
if p.Remark != nil {
update["remark"] = p.Remark
}
if len(update) == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"}
_ = c.ServeJSON()
return
}
_, err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Update(update)
if err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()}
_ = c.ServeJSON()
return
}
if p.IsDefault != nil && *p.IsDefault == 1 && p.Uid > 0 && p.Tid > 0 {
_ = services.SetDefaultTenant(p.Uid, p.Tid)
}
// 双写:同步到统一认证中心
var updated models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&updated); err == nil {
syncTenantUserToAuth(&updated)
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON()
}
// DeleteTenantUser 删除绑定
// DELETE /platform/tenantUser/delete/:id
func (c *PlatformTenantUserController) DeleteTenantUser() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
_ = c.ServeJSON()
return
}
// 删除前先读出记录,供认证中心侧定位(删除后就读不到了)
var row models.SystemTenantUser
_ = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
if err := services.UnbindTenantUser(id); err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()}
_ = c.ServeJSON()
return
}
// 双写:删除认证中心中的企业绑定
removeTenantUserFromAuth(&row)
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON()
}
func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool) {
var p tenantUserPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if err := json.Unmarshal(raw, &p); err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
_ = c.ServeJSON()
return tenantUserPayload{}, false
}
return p, true
}
func generateTenantUID(tid uint64) (uint64, error) {
rand.Seed(time.Now().UnixNano())
for i := 0; i < 8; i++ {
uid := uint64(10000000 + rand.Intn(90000000))
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", uid).
Count()
if err != nil {
return 0, err
}
if cnt == 0 {
return uid, nil
}
}
return 0, errors.New("uid collision")
}
+258
View File
@@ -0,0 +1,258 @@
-- =============================================================
-- 统一认证中心(UAC)表结构 数据库:go-platform
-- 路径:api.yunzer.cn/auth
--
-- 设计要点:
-- 1. yz_auth_identity 为「自然人」层,密码上提到这一层;
-- 2. yz_auth_tenant_user 为「身份-企业」绑定,一人可在多家企业任职;
-- 3. 密码列存 PHC 格式($argon2id$...),盐内联,无需独立 salt 列;
-- 历史数据为 salt$sha256(legacy),首次登录时自动升级;
-- 4. 会话/刷新令牌/授权码均存哈希,明文不下发到数据库;
-- 5. 本脚本只建表不写数据,数据迁移由 scripts/migrate_auth.go 完成。
-- =============================================================
SET NAMES utf8mb4;
-- ---------------- 1. 统一身份(自然人) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_identity` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '身份ID',
`union_id` VARCHAR(32) NOT NULL COMMENT '全局唯一标识(32位随机串)',
`mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号,全局唯一',
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱,全局唯一',
`password_algo` VARCHAR(16) NOT NULL DEFAULT 'argon2id' COMMENT 'argon2id/legacy',
`password_hash` VARCHAR(255) DEFAULT NULL COMMENT 'PHC格式,含盐与参数',
`password_ver` INT NOT NULL DEFAULT 1 COMMENT '密码版本,改密时+1用于踢下线',
`nickname` VARCHAR(64) DEFAULT NULL,
`avatar` VARCHAR(500) DEFAULT NULL,
`mfa_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否开启二次验证',
`mfa_secret` VARCHAR(128) DEFAULT NULL COMMENT 'TOTP密钥(加密存储)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用/0禁用/2锁定',
`fail_count` INT NOT NULL DEFAULT 0 COMMENT '连续失败次数',
`locked_until` DATETIME DEFAULT NULL COMMENT '锁定截止时间',
`last_login_at` DATETIME DEFAULT NULL,
`last_login_ip` VARCHAR(45) DEFAULT NULL,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_union_id` (`union_id`),
UNIQUE KEY `uk_mobile` (`mobile`),
UNIQUE KEY `uk_email` (`email`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-身份表';
-- ---------------- 2. 身份-企业绑定(一人多企) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_tenant_user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
`identity_id` BIGINT UNSIGNED NOT NULL COMMENT '身份ID',
`account` VARCHAR(64) DEFAULT NULL COMMENT '企业内账号,可空(用手机号登录)',
`name` VARCHAR(64) DEFAULT NULL COMMENT '在该企业的姓名',
`phone` VARCHAR(20) DEFAULT NULL,
`email` VARCHAR(128) DEFAULT NULL,
`group_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '角色ID(cid=2的租户角色)',
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '部门ID',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认企业',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用/0禁用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_identity` (`tid`, `identity_id`),
KEY `idx_identity` (`identity_id`),
KEY `idx_tid_account` (`tid`, `account`),
KEY `idx_tid_status` (`tid`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-身份企业绑定';
-- ---------------- 3. 接入应用(OIDC Client) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_client` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`client_id` VARCHAR(64) NOT NULL,
`client_secret` VARCHAR(128) DEFAULT NULL COMMENT '机密客户端才有,存哈希',
`app_code` VARCHAR(32) NOT NULL COMMENT 'crm/oa/uniapp/website...',
`name` VARCHAR(128) NOT NULL,
`app_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1Web后端 2SPA 3原生APP 4小程序',
`redirect_uris` TEXT COMMENT 'JSON数组,精确匹配白名单',
`post_logout_uris` TEXT COMMENT '登出后允许回跳的地址',
`backchannel_logout_uri` VARCHAR(500) DEFAULT NULL COMMENT '单点登出回调(服务端通知)',
`grant_types` VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token',
`scope` VARCHAR(255) DEFAULT 'openid,profile,tenant',
`access_ttl` INT NOT NULL DEFAULT 1800 COMMENT '访问令牌有效期(秒)',
`refresh_ttl` INT NOT NULL DEFAULT 2592000 COMMENT '刷新令牌有效期(秒)',
`realm` VARCHAR(16) NOT NULL DEFAULT 'tenant' COMMENT 'tenant/platform',
`status` TINYINT NOT NULL DEFAULT 1,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_client_id` (`client_id`),
UNIQUE KEY `uk_app_code` (`app_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-接入应用';
-- ---------------- 4. 登录会话(1号1机 / 强制下线) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_session` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`sid` VARCHAR(64) NOT NULL COMMENT '会话ID,写入JWT的sid',
`identity_id` BIGINT UNSIGNED NOT NULL,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前所选企业',
`client_id` VARCHAR(64) NOT NULL DEFAULT '',
`device_id` VARCHAR(64) DEFAULT NULL,
`device_name` VARCHAR(128) DEFAULT NULL,
`ip` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(500) DEFAULT NULL,
`login_type` VARCHAR(20) NOT NULL DEFAULT 'password',
`amr` VARCHAR(64) DEFAULT NULL COMMENT 'pwd/sms/otp/wx/dingtalk/feishu/qq/github/google',
`login_at` DATETIME NOT NULL,
`last_access_at` DATETIME NOT NULL,
`expires_at` DATETIME NOT NULL,
`revoked` TINYINT NOT NULL DEFAULT 0,
`revoke_reason` VARCHAR(64) DEFAULT NULL COMMENT 'logout/kicked/admin/expired',
`revoke_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sid` (`sid`),
KEY `idx_identity_status` (`identity_id`, `revoked`),
KEY `idx_tid` (`tid`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-登录会话';
-- ---------------- 5. 刷新令牌(轮换 + 重放检测) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_refresh_token` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`token_hash` VARCHAR(128) NOT NULL COMMENT '不透明串的哈希,明文不下发落库',
`identity_id` BIGINT UNSIGNED NOT NULL,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`client_id` VARCHAR(64) NOT NULL DEFAULT '',
`sid` VARCHAR(64) NOT NULL DEFAULT '',
`family_id` VARCHAR(64) NOT NULL COMMENT '轮换家族,检测到重放则整族吊销',
`rotated_from` VARCHAR(128) DEFAULT NULL,
`used` TINYINT NOT NULL DEFAULT 0,
`revoked` TINYINT NOT NULL DEFAULT 0,
`expires_at` DATETIME NOT NULL,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_token_hash` (`token_hash`),
KEY `idx_family` (`family_id`),
KEY `idx_identity` (`identity_id`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-刷新令牌';
-- ---------------- 6. 授权码(一次性,60秒) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_code` (
`code_hash` VARCHAR(128) NOT NULL COMMENT '授权码哈希',
`client_id` VARCHAR(64) NOT NULL,
`identity_id` BIGINT UNSIGNED NOT NULL,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`redirect_uri` VARCHAR(500) NOT NULL,
`code_challenge` VARCHAR(128) NOT NULL COMMENT 'PKCE',
`code_challenge_method` VARCHAR(10) NOT NULL DEFAULT 'S256',
`scope` VARCHAR(255) DEFAULT NULL,
`nonce` VARCHAR(128) DEFAULT NULL COMMENT 'OIDC nonce,进ID Token',
`used` TINYINT NOT NULL DEFAULT 0,
`expires_at` DATETIME NOT NULL,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`code_hash`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-授权码';
-- ---------------- 7. 令牌吊销表(登出/踢下线) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_token_blacklist` (
`jti` VARCHAR(64) NOT NULL COMMENT 'JWT ID',
`identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`sid` VARCHAR(64) DEFAULT NULL,
`reason` VARCHAR(64) DEFAULT NULL,
`expires_at` DATETIME NOT NULL COMMENT '原token过期时间,到期后可清理',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`jti`),
KEY `idx_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-令牌吊销表';
-- ---------------- 8. 第三方身份绑定 ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_identity_third` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`identity_id` BIGINT UNSIGNED NOT NULL,
`provider` VARCHAR(32) NOT NULL COMMENT 'wechat/dingtalk/feishu/qq/github/google',
`open_id` VARCHAR(128) NOT NULL COMMENT '该平台内的唯一ID',
`union_id` VARCHAR(128) DEFAULT NULL COMMENT '跨平台唯一ID(微信/钉钉有)',
`nickname` VARCHAR(128) DEFAULT NULL,
`avatar` VARCHAR(500) DEFAULT NULL,
`raw` TEXT COMMENT '原始用户信息JSON',
`bind_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_provider_open` (`provider`, `open_id`),
KEY `idx_identity` (`identity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-第三方绑定';
-- ---------------- 9. 租户自带身份源(企业微信/钉钉/飞书/OIDC) ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_tenant_idp` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL,
`provider` VARCHAR(32) NOT NULL COMMENT 'dingtalk/feishu/wework/oidc/saml',
`name` VARCHAR(128) DEFAULT NULL COMMENT '显示名称',
`app_id` VARCHAR(128) DEFAULT NULL,
`app_secret` VARCHAR(512) DEFAULT NULL COMMENT '加密存储',
`issuer` VARCHAR(500) DEFAULT NULL COMMENT 'OIDC/SAML issuer',
`auth_url` VARCHAR(500) DEFAULT NULL,
`token_url` VARCHAR(500) DEFAULT NULL,
`userinfo_url` VARCHAR(500) DEFAULT NULL,
`jwks_url` VARCHAR(500) DEFAULT NULL,
`scopes` VARCHAR(255) DEFAULT NULL,
`attr_map` TEXT COMMENT 'JSON:字段映射(手机/姓名/部门)',
`proxy_url` VARCHAR(500) DEFAULT NULL COMMENT 'GitHub/Google等需要代理时填写',
`status` TINYINT NOT NULL DEFAULT 1,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_provider` (`tid`, `provider`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-租户身份源';
-- ---------------- 10. 租户登录策略 ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_tenant_auth_config` (
`tid` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
`verify_type` VARCHAR(20) NOT NULL DEFAULT 'captcha' COMMENT 'none/captcha/sms/email/geetest',
`open_verify` TINYINT NOT NULL DEFAULT 1 COMMENT '是否开启登录二次验证',
`pwd_min_len` INT NOT NULL DEFAULT 8,
`pwd_complexity` TINYINT NOT NULL DEFAULT 0 COMMENT '0不限制 1字母+数字 2大小写+数字+符号',
`session_ttl` INT NOT NULL DEFAULT 7200 COMMENT '会话有效期(秒)',
`max_session` INT NOT NULL DEFAULT 1 COMMENT '同账号最大同时在线数(默认1号1机)',
`kick_strategy` TINYINT NOT NULL DEFAULT 1 COMMENT '1踢掉旧会话 2拒绝新登录',
`mfa_required` TINYINT NOT NULL DEFAULT 0,
`ip_whitelist` TEXT COMMENT 'JSON数组,为空表示不限制',
`allow_third` VARCHAR(255) DEFAULT NULL COMMENT '允许的第三方登录,逗号分隔,空表示全部',
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`tid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-租户登录策略';
-- ---------------- 11. 统一登录日志 ----------------
CREATE TABLE IF NOT EXISTS `yz_auth_login_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED DEFAULT NULL,
`identity_id` BIGINT UNSIGNED DEFAULT NULL,
`account` VARCHAR(64) NOT NULL DEFAULT '',
`user_name` VARCHAR(64) NOT NULL DEFAULT '',
`tenant_name` VARCHAR(64) NOT NULL DEFAULT '',
`client_id` VARCHAR(64) NOT NULL DEFAULT '',
`login_type` VARCHAR(20) NOT NULL DEFAULT 'password' COMMENT 'password/sms/otp/third',
`amr` VARCHAR(64) DEFAULT NULL,
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1成功/0失败',
`message` VARCHAR(255) NOT NULL DEFAULT '',
`ip` VARCHAR(45) NOT NULL DEFAULT '',
`user_agent` VARCHAR(500) NOT NULL DEFAULT '',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tid` (`tid`),
KEY `idx_identity` (`identity_id`),
KEY `idx_account` (`account`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-登录日志';
-- =============================================================
-- 内置接入应用(后续由 platform 端「应用注册」页面维护)
-- client_secret 为占位值,正式启用前必须在管理端重新生成
-- =============================================================
INSERT INTO `yz_auth_client`
(`client_id`, `app_code`, `name`, `app_type`, `grant_types`, `scope`, `access_ttl`, `refresh_ttl`, `realm`)
VALUES
('yz-backend', 'backend', '租户管理后台', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
('yz-uniapp', 'uniapp', '移动端APP', 3, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
('yz-website', 'website', '企业官网', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
+198 -75
View File
@@ -1,75 +1,198 @@
package middleware
import (
"strings"
"server/pkg/jwtutil"
"github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// JWTAuthMiddleware JWT认证中间件
func JWTAuthMiddleware() web.FilterFunc {
return func(ctx *context.Context) {
// 跳过登录相关的路由
if strings.HasPrefix(ctx.Request.RequestURI, "/api/login") ||
strings.HasPrefix(ctx.Request.RequestURI, "/api/reset-password") {
return
}
// 从请求头中获取Authorization
authHeader := ctx.Request.Header.Get("Authorization")
if authHeader == "" {
ctx.Output.SetStatus(401)
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": "未提供认证信息",
}, false, false)
return
}
// 按空格分割
authParts := strings.SplitN(authHeader, " ", 2)
if !(len(authParts) == 2 && authParts[0] == "Bearer") {
ctx.Output.SetStatus(401)
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": "认证信息格式错误",
}, false, false)
return
}
// 解析token
claims, err := jwtutil.ParseToken(authParts[1])
if err != nil {
// 处理各种错误情况
ctx.Output.SetStatus(401)
switch err.Error() {
case "token is expired":
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": "token已过期",
}, false, false)
default:
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": "无效的token",
}, false, false)
}
return
}
// 将用户信息存储在上下文
ctx.Input.SetData("userId", claims.UserID)
ctx.Input.SetData("username", claims.Username)
ctx.Input.SetData("tenantId", claims.TenantId)
// 从token中获取用户类型(如果token中没有,则默认为"user")
userType := claims.UserType
if userType == "" {
userType = "user"
}
ctx.Input.SetData("userType", userType)
}
}
package middleware
import (
"log"
"net/http"
"strings"
"sync"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// 鉴权开关(app.conf: auth_enforce)
// - off :完全不启用(等同改造前的行为)
// - warn :观察模式,只记录「无 token / token 无效」的接口,不拦截(默认,零风险上线)
// - on :真正拦截并返回 401
//
// 上线建议:先 warn 跑一段时间,用日志核对白名单是否遗漏,确认无误后再切 on。
const (
AuthModeOff = "off"
AuthModeWarn = "warn"
AuthModeOn = "on"
)
// protectedPrefixes 需要鉴权的业务前缀。
// 仅保护业务 API:官网(/ /site/ /index/…)、开放 API(/api/…)、
// 静态资源与 ACME 验证路径均不在其中,避免误伤公开接口。
var protectedPrefixes = []string{"/backend/", "/platform/", "/app/"}
// authWhitelist 受保护前缀内无需鉴权的路径(前缀匹配)
var authWhitelist = []string{
// 统一认证中心(P1 上线)
"/auth/",
// backend 登录/注册/找回密码
"/backend/login",
"/backend/sendLoginCode",
"/backend/register",
"/backend/sendRegisterCode",
"/backend/resetPassword",
"/backend/sendResetCode",
"/backend/verifyAccount",
"/backend/logout",
// platform 登录/找回密码
"/platform/login",
"/platform/sendLoginCode",
"/platform/resetPassword",
"/platform/logout",
// 平台开放接口(客户端更新通知,无需登录)
"/platform/api/",
// app 登录/注册/找回密码
"/app/login",
"/app/sendLoginCode",
"/app/register",
"/app/sendRegisterCode",
"/app/resetPassword",
"/app/sendResetCode",
"/app/verifyAccount",
"/app/logout",
}
var (
modeOnce sync.Once
modeVal = AuthModeWarn
warnSeen sync.Map // 观察模式下按 path 去重,避免日志刷屏
)
func authMode() string {
modeOnce.Do(func() {
if v, err := beego.AppConfig.String("auth_enforce"); err == nil {
switch strings.ToLower(strings.TrimSpace(v)) {
case AuthModeOff, AuthModeWarn, AuthModeOn:
modeVal = strings.ToLower(strings.TrimSpace(v))
}
}
// 额外白名单:auth_whitelist = /backend/xxx,/app/yyy
if extra, err := beego.AppConfig.String("auth_whitelist"); err == nil && strings.TrimSpace(extra) != "" {
for _, p := range strings.Split(extra, ",") {
if p = strings.TrimSpace(p); p != "" {
authWhitelist = append(authWhitelist, p)
}
}
}
log.Printf("[auth] JWT 全局鉴权模式=%s,受保护前缀=%v", modeVal, protectedPrefixes)
})
return modeVal
}
// cleanPath 去掉 query / fragment 后返回纯路径
func cleanPath(uri string) string {
if i := strings.IndexAny(uri, "?#"); i >= 0 {
return uri[:i]
}
return uri
}
func isProtected(path string) bool {
for _, p := range protectedPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
func isWhitelisted(path string) bool {
for _, p := range authWhitelist {
if strings.HasPrefix(path, p) {
return true
}
}
return false
}
// warnLog 观察模式下记录一次「本会被拦截」的请求(按路径去重)
func warnLog(path, reason string) {
if _, loaded := warnSeen.LoadOrStore(path, struct{}{}); loaded {
return
}
log.Printf("[auth][warn] 该接口在 enforce=on 时会被拦截: %s (%s)", path, reason)
}
// JWTAuthMiddleware JWT认证中间件
func JWTAuthMiddleware() beego.FilterFunc {
return func(ctx *context.Context) {
mode := authMode()
if mode == AuthModeOff {
return
}
// 预检请求直接放行
if ctx.Input.Method() == http.MethodOptions {
return
}
path := cleanPath(ctx.Request.RequestURI)
if !isProtected(path) || isWhitelisted(path) {
return
}
// 失败处理:观察模式只记日志,强制模式才真正拦截
reject := func(status int, message string) {
if mode == AuthModeWarn {
warnLog(path, message)
return
}
ctx.Output.SetStatus(status)
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": message,
}, false, false)
}
// 从请求头中获取Authorization
authHeader := ctx.Request.Header.Get("Authorization")
if authHeader == "" {
reject(401, "未提供认证信息")
return
}
// 按空格分割
authParts := strings.SplitN(authHeader, " ", 2)
if !(len(authParts) == 2 && authParts[0] == "Bearer") {
reject(401, "认证信息格式错误")
return
}
// 解析token
claims, err := jwtutil.ParseToken(authParts[1])
if err != nil {
message := "无效的token"
if strings.Contains(err.Error(), "expired") {
message = "token已过期"
}
reject(401, message)
return
}
// 将用户信息存储在上下文
ctx.Input.SetData("userId", claims.UserID)
ctx.Input.SetData("username", claims.Username)
ctx.Input.SetData("tenantId", claims.TenantId)
// 从token中获取用户类型(如果token中没有,则默认为"user")
userType := claims.UserType
if userType == "" {
userType = "user"
}
ctx.Input.SetData("userType", userType)
// 认证中心扩展字段(旧 token 为空值,不影响现有逻辑)
ctx.Input.SetData("clientId", claims.ClientID)
ctx.Input.SetData("sid", claims.Sid)
}
}
@@ -0,0 +1,2 @@
merge_key,row_count,password_variants,tids,representative_id
m:19895983967,2,2,1|2,4
1 merge_key row_count password_variants tids representative_id
2 m:19895983967 2 2 1|2 4
@@ -0,0 +1,2 @@
merge_key,row_count,password_variants,tids,representative_id
m:19895983967,2,2,1|2,4
1 merge_key row_count password_variants tids representative_id
2 m:19895983967 2 2 1|2 4
+4
View File
@@ -0,0 +1,4 @@
old_id,old_uid,tid,identity_id,tenant_user_id,account,merge_key,conflict
2,96085169,2,1,1,test1,m:13357867407,false
1,88888888,1,2,2,hero920103,m:19895983967,true
4,67091493,2,2,3,hero920103,m:19895983967,true
1 old_id old_uid tid identity_id tenant_user_id account merge_key conflict
2 2 96085169 2 1 1 test1 m:13357867407 false
3 1 88888888 1 2 2 hero920103 m:19895983967 true
4 4 67091493 2 2 3 hero920103 m:19895983967 true
+43
View File
@@ -0,0 +1,43 @@
package models
import "time"
// 接入应用类型
const (
AuthAppTypeWeb = 1 // 有后端的 Web 应用(可安全保存 secret)
AuthAppTypeSPA = 2 // 单页应用(只能走 PKCE,不存 secret)
AuthAppTypeNative = 3 // 原生 APP / uniapp
AuthAppTypeMiniProg = 4 // 小程序
)
// 领域隔离:租户域与平台域使用不同 realm,避免平台账号登录租户应用
const (
AuthRealmTenant = "tenant"
AuthRealmPlatform = "platform"
)
// AuthClient 接入应用(OIDC Client):yz_auth_client
// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证。
type AuthClient struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
ClientID string `orm:"column(client_id);size(64)" json:"client_id"`
ClientSecret *string `orm:"column(client_secret);size(128);null" json:"-"`
AppCode string `orm:"column(app_code);size(32)" json:"app_code"`
Name string `orm:"column(name);size(128)" json:"name"`
AppType int8 `orm:"column(app_type);default(1)" json:"app_type"`
RedirectURIs *string `orm:"column(redirect_uris);type(text);null" json:"redirect_uris"`
PostLogoutURIs *string `orm:"column(post_logout_uris);type(text);null" json:"post_logout_uris"`
BackchannelLogoutURI *string `orm:"column(backchannel_logout_uri);size(500);null" json:"backchannel_logout_uri"`
GrantTypes string `orm:"column(grant_types);size(255)" json:"grant_types"`
Scope *string `orm:"column(scope);size(255);null" json:"scope"`
AccessTTL int `orm:"column(access_ttl);default(1800)" json:"access_ttl"`
RefreshTTL int `orm:"column(refresh_ttl);default(2592000)" json:"refresh_ttl"`
Realm string `orm:"column(realm);size(16);default(tenant)" json:"realm"`
Status int8 `orm:"column(status);default(1)" json:"status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
}
func (m *AuthClient) TableName() string {
return "yz_auth_client"
}
+72
View File
@@ -0,0 +1,72 @@
package models
import "time"
// 身份状态
const (
AuthIdentityStatusEnabled = 1 // 启用
AuthIdentityStatusDisabled = 0 // 禁用
AuthIdentityStatusLocked = 2 // 锁定(连续失败过多)
)
// AuthIdentity 统一身份(自然人):yz_auth_identity
//
// 统一认证中心的核心表。密码上提到这一层,一个自然人一份密码,
// 通过 yz_auth_tenant_user 绑定到多家企业(一人可在多家企业任职)。
//
// password_hash 存储 PHC 格式($argon2id$v=19$m=...,t=...,p=...$salt$hash),
// 盐与参数内联,无需独立 salt 列;历史数据为 salt$sha256(legacy),
// 首次登录成功时自动重新哈希为 argon2id。
type AuthIdentity struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
UnionID string `orm:"column(union_id);size(32)" json:"union_id"`
Mobile *string `orm:"column(mobile);size(20);null" json:"mobile"`
Email *string `orm:"column(email);size(128);null" json:"email"`
PasswordAlgo string `orm:"column(password_algo);size(16);default(argon2id)" json:"password_algo"`
PasswordHash *string `orm:"column(password_hash);size(255);null" json:"-"`
PasswordVer int `orm:"column(password_ver);default(1)" json:"password_ver"`
Nickname *string `orm:"column(nickname);size(64);null" json:"nickname"`
Avatar *string `orm:"column(avatar);size(500);null" json:"avatar"`
MfaEnabled int8 `orm:"column(mfa_enabled);default(0)" json:"mfa_enabled"`
MfaSecret *string `orm:"column(mfa_secret);size(128);null" json:"-"`
Status int8 `orm:"column(status);default(1)" json:"status"`
FailCount int `orm:"column(fail_count);default(0)" json:"fail_count"`
LockedUntil *time.Time `orm:"column(locked_until);type(datetime);null" json:"locked_until"`
LastLoginAt *time.Time `orm:"column(last_login_at);type(datetime);null" json:"last_login_at"`
LastLoginIP *string `orm:"column(last_login_ip);size(45);null" json:"last_login_ip"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *AuthIdentity) TableName() string {
return "yz_auth_identity"
}
// 第三方登录 provider 常量
const (
IdPWechat = "wechat"
IdPDingTalk = "dingtalk"
IdPFeishu = "feishu"
IdPQQ = "qq"
IdPGitHub = "github"
IdPGoogle = "google"
)
// AuthIdentityThird 第三方身份绑定:yz_auth_identity_third
// 同一身份可绑定多个第三方账号;同一 provider 的 open_id 全局唯一。
type AuthIdentityThird struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
Provider string `orm:"column(provider);size(32)" json:"provider"`
OpenID string `orm:"column(open_id);size(128)" json:"open_id"`
UnionID *string `orm:"column(union_id);size(128);null" json:"union_id"`
Nickname *string `orm:"column(nickname);size(128);null" json:"nickname"`
Avatar *string `orm:"column(avatar);size(500);null" json:"avatar"`
Raw *string `orm:"column(raw);type(text);null" json:"raw"`
BindTime time.Time `orm:"column(bind_time);auto_now_add;type(datetime)" json:"bind_time"`
}
func (m *AuthIdentityThird) TableName() string {
return "yz_auth_identity_third"
}
+26
View File
@@ -0,0 +1,26 @@
package models
import "time"
// AuthLoginLog 统一登录日志:yz_auth_login_log
// 认证中心上线后逐步替代 yz_system_login_log,补充 client_id / amr 字段。
type AuthLoginLog struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid *uint64 `orm:"column(tid);null" json:"tid"`
IdentityID *uint64 `orm:"column(identity_id);null" json:"identity_id"`
Account string `orm:"column(account);size(64);default('')" json:"account"`
UserName string `orm:"column(user_name);size(64);default('')" json:"user_name"`
TenantName string `orm:"column(tenant_name);size(64);default('')" json:"tenant_name"`
ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
LoginType string `orm:"column(login_type);size(20);default(password)" json:"login_type"`
Amr *string `orm:"column(amr);size(64);null" json:"amr"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Message string `orm:"column(message);size(255);default('')" json:"message"`
IP string `orm:"column(ip);size(45);default('')" json:"ip"`
UserAgent string `orm:"column(user_agent);size(500);default('')" json:"user_agent"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
}
func (m *AuthLoginLog) TableName() string {
return "yz_auth_login_log"
}
+94
View File
@@ -0,0 +1,94 @@
package models
import "time"
// 会话吊销原因
const (
RevokeReasonLogout = "logout" // 用户主动登出
RevokeReasonKicked = "kicked" // 被新登录踢下线(1号1机)
RevokeReasonAdmin = "admin" // 管理员强制下线
RevokeReasonExpired = "expired" // 过期清理
)
// AuthSession 登录会话:yz_auth_session
// 用于「1号1机」并发控制、在线设备列表、强制下线。
type AuthSession struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Sid string `orm:"column(sid);size(64)" json:"sid"`
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
DeviceID *string `orm:"column(device_id);size(64);null" json:"device_id"`
DeviceName *string `orm:"column(device_name);size(128);null" json:"device_name"`
IP *string `orm:"column(ip);size(45);null" json:"ip"`
UserAgent *string `orm:"column(user_agent);size(500);null" json:"user_agent"`
LoginType string `orm:"column(login_type);size(20);default(password)" json:"login_type"`
Amr *string `orm:"column(amr);size(64);null" json:"amr"`
LoginAt time.Time `orm:"column(login_at);type(datetime)" json:"login_at"`
LastAccessAt time.Time `orm:"column(last_access_at);type(datetime)" json:"last_access_at"`
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
Revoked int8 `orm:"column(revoked);default(0)" json:"revoked"`
RevokeReason *string `orm:"column(revoke_reason);size(64);null" json:"revoke_reason"`
RevokeAt *time.Time `orm:"column(revoke_at);type(datetime);null" json:"revoke_at"`
}
func (m *AuthSession) TableName() string {
return "yz_auth_session"
}
// AuthRefreshToken 刷新令牌:yz_auth_refresh_token
// 明文仅在颁发时返回一次,库中只存哈希;轮换时通过 family_id 检测重放。
type AuthRefreshToken struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TokenHash string `orm:"column(token_hash);size(128)" json:"token_hash"`
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
Sid string `orm:"column(sid);size(64);default('')" json:"sid"`
FamilyID string `orm:"column(family_id);size(64)" json:"family_id"`
RotatedFrom *string `orm:"column(rotated_from);size(128);null" json:"rotated_from"`
Used int8 `orm:"column(used);default(0)" json:"used"`
Revoked int8 `orm:"column(revoked);default(0)" json:"revoked"`
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
}
func (m *AuthRefreshToken) TableName() string {
return "yz_auth_refresh_token"
}
// AuthCode 授权码:yz_auth_code
// 一次性、60 秒有效,配合 PKCE(S256)使用。
type AuthCode struct {
CodeHash string `orm:"column(code_hash);size(128);pk" json:"code_hash"`
ClientID string `orm:"column(client_id);size(64)" json:"client_id"`
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
RedirectURI string `orm:"column(redirect_uri);size(500)" json:"redirect_uri"`
CodeChallenge string `orm:"column(code_challenge);size(128)" json:"code_challenge"`
CodeChallengeMethod string `orm:"column(code_challenge_method);size(10);default(S256)" json:"code_challenge_method"`
Scope *string `orm:"column(scope);size(255);null" json:"scope"`
Nonce *string `orm:"column(nonce);size(128);null" json:"nonce"`
Used int8 `orm:"column(used);default(0)" json:"used"`
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
}
func (m *AuthCode) TableName() string {
return "yz_auth_code"
}
// AuthTokenBlacklist 令牌吊销表:yz_auth_token_blacklist
// 记录已登出/被踢下线的 access token 的 jti,过期后可定期清理。
type AuthTokenBlacklist struct {
Jti string `orm:"column(jti);size(64);pk" json:"jti"`
IdentityID uint64 `orm:"column(identity_id);default(0)" json:"identity_id"`
Sid *string `orm:"column(sid);size(64);null" json:"sid"`
Reason *string `orm:"column(reason);size(64);null" json:"reason"`
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
}
func (m *AuthTokenBlacklist) TableName() string {
return "yz_auth_token_blacklist"
}
+66
View File
@@ -0,0 +1,66 @@
package models
import "time"
// 租户自带身份源 provider
const (
TenantIdPDingTalk = "dingtalk"
TenantIdPFeishu = "feishu"
TenantIdPWeWork = "wework"
TenantIdPOIDC = "oidc"
TenantIdPSAML = "saml"
)
// AuthTenantIdp 租户自带身份源:yz_auth_tenant_idp
// 企业客户可配置自己的钉钉/飞书/企业微信或标准 OIDC/SAML 上游,
// 登录时按 tid 路由到对应身份源(第10条需求)。
type AuthTenantIdp struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
Provider string `orm:"column(provider);size(32)" json:"provider"`
Name *string `orm:"column(name);size(128);null" json:"name"`
AppID *string `orm:"column(app_id);size(128);null" json:"app_id"`
AppSecret *string `orm:"column(app_secret);size(512);null" json:"-"`
Issuer *string `orm:"column(issuer);size(500);null" json:"issuer"`
AuthURL *string `orm:"column(auth_url);size(500);null" json:"auth_url"`
TokenURL *string `orm:"column(token_url);size(500);null" json:"token_url"`
UserinfoURL *string `orm:"column(userinfo_url);size(500);null" json:"userinfo_url"`
JwksURL *string `orm:"column(jwks_url);size(500);null" json:"jwks_url"`
Scopes *string `orm:"column(scopes);size(255);null" json:"scopes"`
AttrMap *string `orm:"column(attr_map);type(text);null" json:"attr_map"`
ProxyURL *string `orm:"column(proxy_url);size(500);null" json:"proxy_url"`
Status int8 `orm:"column(status);default(1)" json:"status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
}
func (m *AuthTenantIdp) TableName() string {
return "yz_auth_tenant_idp"
}
// 并发超限策略
const (
KickStrategyKickOld = 1 // 踢掉旧会话(默认)
KickStrategyReject = 2 // 拒绝新登录
)
// AuthTenantAuthConfig 租户登录策略:yz_auth_tenant_auth_config
// 每个租户可自定义验证码方式、密码强度、会话时长与并发设备数。
type AuthTenantAuthConfig struct {
Tid uint64 `orm:"column(tid);pk" json:"tid"`
VerifyType string `orm:"column(verify_type);size(20);default(captcha)" json:"verify_type"`
OpenVerify int8 `orm:"column(open_verify);default(1)" json:"open_verify"`
PwdMinLen int `orm:"column(pwd_min_len);default(8)" json:"pwd_min_len"`
PwdComplexity int8 `orm:"column(pwd_complexity);default(0)" json:"pwd_complexity"`
SessionTTL int `orm:"column(session_ttl);default(7200)" json:"session_ttl"`
MaxSession int `orm:"column(max_session);default(1)" json:"max_session"`
KickStrategy int8 `orm:"column(kick_strategy);default(1)" json:"kick_strategy"`
MfaRequired int8 `orm:"column(mfa_required);default(0)" json:"mfa_required"`
IPWhitelist *string `orm:"column(ip_whitelist);type(text);null" json:"ip_whitelist"`
AllowThird *string `orm:"column(allow_third);size(255);null" json:"allow_third"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
}
func (m *AuthTenantAuthConfig) TableName() string {
return "yz_auth_tenant_auth_config"
}
+28
View File
@@ -0,0 +1,28 @@
package models
import "time"
// AuthTenantUser 身份-企业绑定:yz_auth_tenant_user
//
// 一个 AuthIdentity 可有多条绑定(在多家企业任职),各企业内
// 独立维护姓名、部门、角色与启用状态;密码不属于这一层。
type AuthTenantUser struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
Account *string `orm:"column(account);size(64);null" json:"account"`
Name *string `orm:"column(name);size(64);null" json:"name"`
Phone *string `orm:"column(phone);size(20);null" json:"phone"`
Email *string `orm:"column(email);size(128);null" json:"email"`
GroupID uint64 `orm:"column(group_id);default(0)" json:"group_id"`
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
Status int8 `orm:"column(status);default(1)" json:"status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *AuthTenantUser) TableName() string {
return "yz_auth_tenant_user"
}
+15 -2
View File
@@ -97,6 +97,8 @@ func Init(_ string) {
new(TenantCrmContract),
new(TenantCrmPayback),
new(TenantCrmPaybackRecord),
// 平台更新通知(docs/sql/platform_upgrade.sql)
new(PlatformUpgradeNotice),
new(ErpAccountSet),
new(ErpNormalSetting),
new(ErpCompanyContact),
@@ -176,6 +178,19 @@ func Init(_ string) {
new(WechatMpConfig),
new(WechatMpFollower),
new(WechatMpVerifyCode),
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
new(AuthIdentity),
new(AuthIdentityThird),
new(AuthTenantUser),
new(AuthClient),
new(AuthSession),
new(AuthRefreshToken),
new(AuthCode),
new(AuthTokenBlacklist),
new(AuthTenantIdp),
new(AuthTenantAuthConfig),
new(AuthLoginLog),
)
// 创建全局 Ormer
@@ -192,5 +207,3 @@ func Init(_ string) {
// 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。
Orm = orm.NewOrm()
}
+23
View File
@@ -0,0 +1,23 @@
package models
import "time"
// PlatformUpgradeNotice 平台更新通知记录表:yz_platform_upgrade_notice
// 用于展示平台近期更新内容,支持按时间排序展示
type PlatformUpgradeNotice struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` // 租户 ID,0 表示全局
UpdateDate string `orm:"column(update_date);size(20)" json:"update_date"` // 更新日期 YYYY-MM-DD
Title string `orm:"column(title);size(255)" json:"title"` // 更新标题
Content string `orm:"column(content);type(text)" json:"content"` // 更新内容详情
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序权重,越大越靠前
Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:0-隐藏 1-显示
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformUpgradeNotice) TableName() string {
return "yz_platform_upgrade_notice"
}
+321 -63
View File
@@ -1,63 +1,321 @@
package jwtutil
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// 密钥(后续可从配置中读取)
var secret = []byte("yunzer_jwt_secret_key")
// Claims 定义JWT的claims结构
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
TenantId int `json:"tenant_id"` // 租户ID
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等
jwt.RegisteredClaims
}
// GenerateToken 生成JWT token
func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: userID,
Username: username,
TenantId: tenantId,
UserType: userType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(secret)
return tokenString, err
}
// ParseToken 解析JWT token
func ParseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
package jwtutil
import (
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"math/big"
"os"
"strings"
"sync"
"time"
beego "github.com/beego/beego/v2/server/web"
"github.com/golang-jwt/jwt/v5"
)
// 算法常量
const (
AlgHS256 = "HS256"
AlgRS256 = "RS256"
)
// DefaultKid 默认密钥 ID。历史 token 未携带 kid,统一按此 ID 处理。
const DefaultKid = "default"
// legacySecret 兼容用的内置密钥:仅用于解析历史已签发的 token。
// 生产环境必须在 app.conf 中配置 jwt_secret,否则会一直使用该固定值。
const legacySecret = "yunzer_jwt_secret_key"
// Claims JWT 载荷。旧字段保持不变,新增字段均为 omitempty,
// 历史 token 解析后为零值,不影响现有 78 处解析点。
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
TenantId int `json:"tenant_id"` // 租户ID
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等
// ---- 统一认证中心(OIDC)扩展字段 ----
ClientID string `json:"client_id,omitempty"` // 接入应用 client_id,即 aud 的业务标识
Sid string `json:"sid,omitempty"` // 会话ID,用于单点登出/踢下线
Scope string `json:"scope,omitempty"`
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/...
jwt.RegisteredClaims
}
// ---------------------------------------------------------------- 密钥管理
var (
keyOnce sync.Once
hsKeys map[string][]byte // kid -> HMAC 密钥
rsaPriv *rsa.PrivateKey // RS256 签名
rsaPub *rsa.PublicKey // RS256 验签
rsaKid string // RS256 密钥 ID
issuerVal string
)
func loadKeys() {
hsKeys = map[string][]byte{DefaultKid: []byte(legacySecret)}
// 主密钥(覆盖内置默认值)
if s, _ := beego.AppConfig.String("jwt_secret"); strings.TrimSpace(s) != "" {
hsKeys[DefaultKid] = []byte(strings.TrimSpace(s))
}
// 轮换密钥:jwt_secrets = kid1:secret1,kid2:secret2
if s, _ := beego.AppConfig.String("jwt_secrets"); strings.TrimSpace(s) != "" {
for _, item := range strings.Split(s, ",") {
kv := strings.SplitN(strings.TrimSpace(item), ":", 2)
if len(kv) == 2 && strings.TrimSpace(kv[0]) != "" && strings.TrimSpace(kv[1]) != "" {
hsKeys[strings.TrimSpace(kv[0])] = []byte(strings.TrimSpace(kv[1]))
}
}
}
// RS256 密钥对:优先读文件路径,其次读内联 PEM
privPEM := readKeyConf("jwt_rsa_private_key", "jwt_rsa_private_key_file")
pubPEM := readKeyConf("jwt_rsa_public_key", "jwt_rsa_public_key_file")
if privPEM != "" {
if k, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privPEM)); err == nil {
rsaPriv = k
rsaPub = &k.PublicKey
}
}
if pubPEM != "" {
if k, err := jwt.ParseRSAPublicKeyFromPEM([]byte(pubPEM)); err == nil {
rsaPub = k
}
}
if rsaKid == "" {
rsaKid, _ = beego.AppConfig.String("jwt_rsa_kid")
}
if rsaKid == "" {
rsaKid = "rsa-1"
}
issuerVal, _ = beego.AppConfig.String("jwt_issuer")
}
func readKeyConf(inlineKey, fileKey string) string {
if v, _ := beego.AppConfig.String(inlineKey); strings.TrimSpace(v) != "" {
return strings.ReplaceAll(strings.TrimSpace(v), `\n`, "\n")
}
if p, _ := beego.AppConfig.String(fileKey); strings.TrimSpace(p) != "" {
if b, err := os.ReadFile(strings.TrimSpace(p)); err == nil {
return string(b)
}
}
return ""
}
func ensureKeys() {
keyOnce.Do(loadKeys)
}
// keyFunc 按 token header 的 alg + kid 选择验签密钥。
// 未携带 kid 或 kid 未注册时回落到默认密钥,保证历史 token 仍可解析。
func keyFunc(token *jwt.Token) (interface{}, error) {
ensureKeys()
kid, _ := token.Header["kid"].(string)
switch token.Method.Alg() {
case AlgHS256:
if sec, ok := hsKeys[kid]; ok && kid != "" {
return sec, nil
}
return hsKeys[DefaultKid], nil
case AlgRS256:
if rsaPub == nil {
return nil, errors.New("服务端未配置 RS256 公钥")
}
return rsaPub, nil
}
return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"])
}
// ---------------------------------------------------------------- 签发
// TokenOptions 认证中心签发参数
type TokenOptions struct {
Alg string // HS256 / RS256;留空时优先 RS256,未配置 RSA 则回落 HS256
UserID int // 身份 ID(OIDC sub 用字符串形式)
Username string
TenantID int
UserType string
ClientID string
Sid string
Scope string
Amr string
Subject string
Audience []string
Jti string // JWT ID,用于吊销(登出/踢下线)
TTL time.Duration // 留空默认 30 分钟
Kid string
}
// SignToken 签发 token。RSA 未配置时自动回落 HS256,保证服务可启动。
func SignToken(opt TokenOptions) (string, error) {
ensureKeys()
alg := strings.ToUpper(strings.TrimSpace(opt.Alg))
if alg == "" {
alg = AlgRS256
}
if alg == AlgRS256 && rsaPriv == nil {
alg = AlgHS256
}
ttl := opt.TTL
if ttl <= 0 {
ttl = 30 * time.Minute
}
now := time.Now()
claims := &Claims{
UserID: opt.UserID,
Username: opt.Username,
TenantId: opt.TenantID,
UserType: opt.UserType,
ClientID: opt.ClientID,
Sid: opt.Sid,
Scope: opt.Scope,
Amr: opt.Amr,
RegisteredClaims: jwt.RegisteredClaims{
ID: opt.Jti,
Subject: opt.Subject,
Audience: opt.Audience,
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
if issuerVal != "" {
claims.Issuer = issuerVal
}
if opt.Subject == "" && opt.UserID > 0 {
claims.Subject = fmt.Sprintf("%d", opt.UserID)
}
var method jwt.SigningMethod
switch alg {
case AlgRS256:
method = jwt.SigningMethodRS256
default:
method = jwt.SigningMethodHS256
}
token := jwt.NewWithClaims(method, claims)
kid := strings.TrimSpace(opt.Kid)
if kid == "" && alg == AlgRS256 {
kid = rsaKid
}
if kid != "" {
token.Header["kid"] = kid
}
var key interface{}
if alg == AlgRS256 {
key = rsaPriv
} else {
kidToUse := kid
if kidToUse == "" {
kidToUse = DefaultKid
}
if sec, ok := hsKeys[kidToUse]; ok {
key = sec
} else {
key = hsKeys[DefaultKid]
}
}
return token.SignedString(key)
}
// GenerateToken 生成 JWT token(兼容旧签名与行为)。
// 默认 HS256;若 app.conf 配置了 jwt_issuer 则带上 iss。
func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) {
ttl := 24 * time.Hour
ensureKeys()
now := time.Now()
claims := &Claims{
UserID: userID,
Username: username,
TenantId: tenantId,
UserType: userType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
if issuerVal != "" {
claims.Issuer = issuerVal
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(hsKeys[DefaultKid])
}
// ---------------------------------------------------------------- 解析
// ParseToken 解析并校验 JWT。自动识别 HS256 / RS256,函数签名与旧版一致。
func ParseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
_, err := jwt.ParseWithClaims(tokenString, claims, keyFunc,
jwt.WithValidMethods([]string{AlgHS256, AlgRS256}))
if err != nil {
return nil, err
}
return claims, nil
}
// ParseTokenRaw 解析 token 但不校验有效期,用于登出/吊销场景获取 jti。
func ParseTokenRaw(tokenString string) (*Claims, error) {
claims := &Claims{}
parser := jwt.NewParser(jwt.WithValidMethods([]string{AlgHS256, AlgRS256}), jwt.WithoutClaimsValidation())
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
return nil, err
}
return claims, nil
}
// Issuer 返回配置的签发者(未配置时为空)
func Issuer() string {
ensureKeys()
return issuerVal
}
// JWK JSON Web Key(RS256 公钥)
type JWK struct {
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
Kid string `json:"kid"`
N string `json:"n"`
E string `json:"e"`
}
// JWKS 返回 RSA 公钥集合(供 /auth/jwks.json 暴露)。
// 各应用本地用公钥验签即可,无需每次回调认证中心的 introspect 接口。
func JWKS() []JWK {
ensureKeys()
if rsaPub == nil || rsaPub.N == nil {
return nil
}
return []JWK{{
Kty: "RSA",
Use: "sig",
Alg: AlgRS256,
Kid: rsaKid,
N: base64.RawURLEncoding.EncodeToString(rsaPub.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPub.E)).Bytes()),
}}
}
// HasRSA 是否已配置 RS256 密钥对(决定认证中心能否签发非对称 token)
func HasRSA() bool {
ensureKeys()
return rsaPriv != nil && rsaPub != nil
}
// Kid 返回当前 RS256 密钥 ID
func Kid() string {
ensureKeys()
return rsaKid
}
+70
View File
@@ -0,0 +1,70 @@
package jwtutil
import (
"testing"
"time"
)
// TestGenerateParseBackwardCompat 旧签发方式产生的 token 必须仍能被解析(向后兼容)
func TestGenerateParseBackwardCompat(t *testing.T) {
token, err := GenerateToken(1001, "zhangsan", 7, "backend")
if err != nil {
t.Fatalf("GenerateToken 失败: %v", err)
}
claims, err := ParseToken(token)
if err != nil {
t.Fatalf("ParseToken 失败: %v", err)
}
if claims.UserID != 1001 || claims.Username != "zhangsan" || claims.TenantId != 7 || claims.UserType != "backend" {
t.Fatalf("claims 解析不符: %+v", claims)
}
// 旧 token 无认证中心扩展字段
if claims.Sid != "" || claims.ClientID != "" {
t.Fatal("旧 token 不应携带 sid/client_id")
}
}
// TestSignTokenFallback 未配置 RSA 时自动回落 HS256,且可解析
func TestSignTokenFallback(t *testing.T) {
token, err := SignToken(TokenOptions{
Alg: AlgRS256, // 期望回落到 HS256
UserID: 42,
Username: "u",
TenantID: 3,
UserType: "tenant",
ClientID: "crm",
Sid: "sid-001",
Subject: "42",
TTL: 15 * time.Minute,
})
if err != nil {
t.Fatalf("SignToken 失败: %v", err)
}
claims, err := ParseToken(token)
if err != nil {
t.Fatalf("ParseToken 失败: %v", err)
}
if claims.ClientID != "crm" || claims.Sid != "sid-001" || claims.Subject != "42" {
t.Fatalf("扩展 claims 解析不符: %+v", claims)
}
}
// TestParseInvalid 非法 token 必须报错
func TestParseInvalid(t *testing.T) {
if _, err := ParseToken("not-a-token"); err == nil {
t.Fatal("非法 token 应返回错误")
}
if _, err := ParseToken(""); err == nil {
t.Fatal("空 token 应返回错误")
}
}
// TestHasRSA 无配置时不应 panic
func TestHasRSA(t *testing.T) {
if HasRSA() {
t.Log("已配置 RSA 密钥对")
}
if Kid() == "" {
t.Fatal("kid 不应为空")
}
}
+149 -55
View File
@@ -1,55 +1,149 @@
package passwordutil
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
)
const (
saltBytes = 16
separator = "$"
hashLength = 64 // sha256 hex length
)
// Hash 生成 salt+hash 的存储串,格式:salt$hash(均为 hex)
func Hash(plain string) (string, error) {
plain = strings.TrimSpace(plain)
if plain == "" {
return "", errors.New("password 不能为空")
}
salt := make([]byte, saltBytes)
if _, err := rand.Read(salt); err != nil {
return "", err
}
saltHex := hex.EncodeToString(salt)
hashHex := hashHex(saltHex, plain)
return saltHex + separator + hashHex, nil
}
// Verify 校验存储串(salt$hash)是否匹配输入明文密码。
func Verify(stored, plain string) bool {
stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain)
if stored == "" || plain == "" {
return false
}
parts := strings.Split(stored, separator)
if len(parts) != 2 {
return false
}
saltHex := strings.TrimSpace(parts[0])
hashHexStored := strings.TrimSpace(parts[1])
if saltHex == "" || len(hashHexStored) != hashLength {
return false
}
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
}
func hashHex(saltHex, plain string) string {
sum := sha256.Sum256([]byte(saltHex + plain))
return hex.EncodeToString(sum[:])
}
package passwordutil
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// 算法标识。
// 历史数据用 legacy(sha256 单轮,无迭代拉伸,抗 GPU 爆破能力弱);
// 新密码统一用 argon2id,登录成功检测到 legacy 时应重新哈希升级。
const (
AlgoArgon2id = "argon2id"
AlgoLegacy = "legacy"
)
// argon2id 参数(OWASP 推荐起步配置:64MB / 3 轮 / 并行 2)
const (
argonTime = 3
argonMemory = 64 * 1024 // 单位 KB,即 64MB
argonThreads = 2
argonKeyLen = 32
argonSaltLen = 16
)
// legacy 兼容参数
const (
saltBytes = 16
separator = "$"
hashLength = 64 // sha256 hex length
)
// Hash 使用当前默认算法(argon2id)生成密码存储串。
//
// 返回 PHC 标准格式(盐与参数内联,无需独立 salt 列):
//
// $argon2id$v=19$m=65536,t=3,p=2$<base64(salt)>$<base64(hash)>
//
// 注意:函数签名与旧版一致,全部调用点无需改动即可切换到新算法。
func Hash(plain string) (string, error) {
plain = strings.TrimSpace(plain)
if plain == "" {
return "", errors.New("password 不能为空")
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// Verify 校验明文密码是否匹配存储串,自动识别算法:
// - $argon2id$... → argon2id(新)
// - <salt>$<hash> → legacy sha256(旧,兼容)
func Verify(stored, plain string) bool {
stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain)
if stored == "" || plain == "" {
return false
}
if strings.HasPrefix(stored, "$argon2id$") {
return verifyArgon2id(stored, plain)
}
return verifyLegacy(stored, plain)
}
// NeedsRehash 判断已存储的密码是否需要用当前算法重新哈希。
// 登录成功后调用:返回 true 时应拿当次登录的明文重新 Hash 并落库,
// 实现旧算法用户「首次登录自动升级」,无需强制全员重置密码。
func NeedsRehash(stored string) bool {
return !strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$")
}
// AlgoOf 返回存储串使用的算法标识,用于审计/统计旧算法存量。
func AlgoOf(stored string) string {
if strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$") {
return AlgoArgon2id
}
return AlgoLegacy
}
// verifyArgon2id 解析 PHC 串并用相同参数重算比对(恒定时间比较)。
func verifyArgon2id(stored, plain string) bool {
parts := strings.Split(stored, "$")
// ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash]
if len(parts) != 6 {
return false
}
var memory, iterations, parallelism uint32
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &parallelism); err != nil {
return false
}
if memory == 0 || iterations == 0 || parallelism == 0 || parallelism > 255 {
return false
}
threads := uint8(parallelism)
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil || len(salt) == 0 {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil || len(want) == 0 {
return false
}
got := argon2.IDKey([]byte(plain), salt, iterations, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// verifyLegacy 校验旧格式:salt$hash(均为 hex),sha256(salt+plain)。
// 仅用于兼容历史数据,不再用于新密码。
func verifyLegacy(stored, plain string) bool {
stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain)
if stored == "" || plain == "" {
return false
}
parts := strings.Split(stored, separator)
if len(parts) != 2 {
return false
}
saltHex := strings.TrimSpace(parts[0])
hashHexStored := strings.TrimSpace(parts[1])
if saltHex == "" || len(hashHexStored) != hashLength {
return false
}
if _, err := hex.DecodeString(saltHex); err != nil {
return false
}
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
}
// hashHex 旧算法:sha256(saltHex + plain),供 verifyLegacy 使用。
func hashHex(saltHex, plain string) string {
sum := sha256.Sum256([]byte(saltHex + plain))
return hex.EncodeToString(sum[:])
}
+69
View File
@@ -0,0 +1,69 @@
package passwordutil
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// TestHashVerifyArgon2id 新算法:哈希后可校验,错误密码拒绝
func TestHashVerifyArgon2id(t *testing.T) {
stored, err := Hash("Passw0rd@2026")
if err != nil {
t.Fatalf("Hash 失败: %v", err)
}
if !verifyArgon2id(stored, "Passw0rd@2026") {
t.Fatal("正确密码应校验通过")
}
if verifyArgon2id(stored, "wrong-password") {
t.Fatal("错误密码不应通过")
}
if NeedsRehash(stored) {
t.Fatal("argon2id 不应需要重新哈希")
}
if AlgoOf(stored) != AlgoArgon2id {
t.Fatalf("算法标识应为 argon2id,实际 %s", AlgoOf(stored))
}
}
// TestLegacyCompat 旧算法兼容:历史 salt$sha256 串仍可登录,且被标记为需要升级
func TestLegacyCompat(t *testing.T) {
saltHex := "0123456789abcdef0123456789abcdef"
sum := sha256.Sum256([]byte(saltHex + "oldpass"))
legacy := saltHex + "$" + hex.EncodeToString(sum[:])
if !Verify(legacy, "oldpass") {
t.Fatal("历史密码应校验通过(兼容)")
}
if Verify(legacy, "other") {
t.Fatal("错误密码不应通过")
}
if !NeedsRehash(legacy) {
t.Fatal("历史密码应标记为需要重新哈希")
}
if AlgoOf(legacy) != AlgoLegacy {
t.Fatalf("算法标识应为 legacy,实际 %s", AlgoOf(legacy))
}
}
// TestDifferentSaltSamePassword 相同明文两次哈希结果必须不同(盐随机)
func TestDifferentSaltSamePassword(t *testing.T) {
a, _ := Hash("same-password")
b, _ := Hash("same-password")
if a == b {
t.Fatal("相同明文两次哈希不应相同(盐必须随机)")
}
if !Verify(a, "same-password") || !Verify(b, "same-password") {
t.Fatal("两份哈希都应能通过校验")
}
}
// TestEmptyPassword 空密码边界
func TestEmptyPassword(t *testing.T) {
if _, err := Hash(" "); err == nil {
t.Fatal("空密码应返回错误")
}
if Verify("", "x") || Verify("x", "") {
t.Fatal("空输入不应通过校验")
}
}
+6
View File
@@ -8,6 +8,12 @@ import (
// Register 注册移动端 / 开放 API(api)路由。
func Register() {
// 平台更新通知列表(无需登录)
beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
// 平台更新通知详情(无需登录)
beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
// 客户端检查更新(无需登录)
beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check")
+34
View File
@@ -0,0 +1,34 @@
// Package auth 统一认证中心(UAC)路由:api.yunzer.cn/auth
//
// 重要:该路由组必须在 router.go 的所有运行模式下都注册
// (platform / backend / index / api / app / all),
// 否则对应模式下认证中心不可用。
package auth
import (
authctl "server/controllers/auth"
beego "github.com/beego/beego/v2/server/web"
)
// Register 注册认证中心路由
func Register() {
// ---- OIDC 标准端点 ----
beego.Router("/auth/.well-known/openid-configuration", &authctl.AuthOidcController{}, "get:Discovery")
beego.Router("/auth/jwks.json", &authctl.AuthOidcController{}, "get:JWKS")
beego.Router("/auth/authorize", &authctl.AuthOidcController{}, "get:Authorize")
beego.Router("/auth/token", &authctl.AuthOidcController{}, "post:Token")
beego.Router("/auth/userinfo", &authctl.AuthOidcController{}, "get:UserInfo;post:UserInfo")
beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect")
beego.Router("/auth/revoke", &authctl.AuthOidcController{}, "post:Revoke")
// ---- 登录与会话 ----
// 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
beego.Router("/auth/logout", &authctl.AuthLoginController{}, "get:LogoutPage;post:LogoutAction")
beego.Router("/auth/tenants", &authctl.AuthLoginController{}, "get:Tenants")
beego.Router("/auth/switch-tenant", &authctl.AuthLoginController{}, "post:SwitchTenant")
beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions")
beego.Router("/auth/sessions/kick", &authctl.AuthLoginController{}, "post:KickSession")
beego.Router("/auth/verify-config", &authctl.AuthLoginController{}, "get:VerifyConfig")
}
+90 -80
View File
@@ -1,80 +1,90 @@
package routers
import (
"os"
"server/controllers"
"server/middleware"
"server/routers/api"
"server/routers/app"
"server/routers/backend"
"server/routers/index"
"server/routers/platform"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// 初始化路由(精简版)
func init() {
// 全局 CORS 处理 + 预检请求
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) {
ctx.Output.Header("Access-Control-Allow-Origin", "*")
ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
ctx.Output.Header("Access-Control-Max-Age", "86400")
if ctx.Input.Method() == "OPTIONS" {
ctx.Output.Status = 200
ctx.Output.Body([]byte(""))
return
}
})
// 全局操作日志:请求开始采集并异步写入
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
// Let's Encrypt HTTP-01 验证回访。
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
// 根据运行模式选择要注册的路由组
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
mode := os.Getenv("APP_MODE")
if mode == "" {
mode, _ = beego.AppConfig.String("app_mode")
}
if mode == "" {
mode = "all"
}
switch mode {
case "platform":
platform.Register()
app.Register()
// 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404
backend.RegisterAuthRoutes()
case "backend":
backend.Register()
app.Register()
case "index":
index.Register()
app.Register()
case "api":
api.Register()
case "app":
app.Register()
case "all":
platform.Register()
backend.Register()
index.Register()
api.Register()
app.Register()
default:
// 未知模式时,退回到只启用平台端,避免启动失败
platform.Register()
app.Register()
}
}
package routers
import (
"os"
"server/controllers"
"server/middleware"
"server/routers/api"
"server/routers/app"
authrouter "server/routers/auth"
"server/routers/backend"
"server/routers/index"
"server/routers/platform"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// 初始化路由(精简版)
func init() {
// 全局 CORS 处理 + 预检请求
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) {
ctx.Output.Header("Access-Control-Allow-Origin", "*")
ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
ctx.Output.Header("Access-Control-Max-Age", "86400")
if ctx.Input.Method() == "OPTIONS" {
ctx.Output.Status = 200
ctx.Output.Body([]byte(""))
return
}
})
// 全局 JWT 鉴权:解析 token 并写入上下文(userId/tenantId/userType/...)。
// 模式由 app.conf 的 auth_enforce 控制:
// warn(默认,观察模式,只记录不拦截)→ on(真正拦截)→ off(紧急关闭)
// 放在操作日志之前注册,保证操作日志能取到 userId。
beego.InsertFilter("*", beego.BeforeRouter, middleware.JWTAuthMiddleware())
// 全局操作日志:请求开始采集并异步写入
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
// Let's Encrypt HTTP-01 验证回访。
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
// 统一认证中心(UAC):所有运行模式都必须注册,否则对应模式下无法登录
authrouter.Register()
// 根据运行模式选择要注册的路由组
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
mode := os.Getenv("APP_MODE")
if mode == "" {
mode, _ = beego.AppConfig.String("app_mode")
}
if mode == "" {
mode = "all"
}
switch mode {
case "platform":
platform.Register()
app.Register()
// 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404
backend.RegisterAuthRoutes()
case "backend":
backend.Register()
app.Register()
case "index":
index.Register()
app.Register()
case "api":
api.Register()
case "app":
app.Register()
case "all":
platform.Register()
backend.Register()
index.Register()
api.Register()
app.Register()
default:
// 未知模式时,退回到只启用平台端,避免启动失败
platform.Register()
app.Register()
}
}
+392
View File
@@ -0,0 +1,392 @@
// 统一认证中心一次性数据迁移:
// yz_system_tenant_user → yz_auth_identity(自然人)+ yz_auth_tenant_user(身份-企业绑定)
//
// 前置条件:
// 1. 已人工执行 docs/sql/create_auth_tables.sql 建表;
// 2. 在 go/ 目录下运行(需读取 conf/app.conf)。
//
// 用法:
// go run scripts/migrate_auth.go -check 预检:只输出统计与冲突报告,不写任何数据
// go run scripts/migrate_auth.go -apply 执行迁移(自动备份源表后再写入)
// go run scripts/migrate_auth.go -apply -force 目标表已有数据时强制重跑
//
// 迁移规则:
// - 归并键优先级:手机号 > 邮箱 > 租户内账号;三者皆空时按 tid+原 uid 兜底;
// - 同一归并键命中多条(多企业任职)→ 只建 1 个 identity,再建 N 条企业绑定;
// - 同一归并键下密码不一致 → 记为冲突,取 is_default 优先、其次 id 最大的那条,
// 并写入冲突报告供人工核对;
// - 历史密码为 legacy(salt$sha256),无法离线转 argon2id,原样搬迁,
// 用户登录成功时自动重新哈希升级。
package main
import (
"encoding/csv"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"time"
beego "github.com/beego/beego/v2/server/web"
"github.com/google/uuid"
"server/models"
"server/pkg/passwordutil"
)
// srcRow 源表行(yz_system_tenant_user)
type srcRow struct {
ID uint64
Tid uint64
Uid uint64
Account string
Name string
Phone string
Email string
Password string
GroupID uint64
OrgID uint64
IsDefault int8
Status int8
}
// group 同一归并键下的多条源记录
type group struct {
key string
rows []srcRow
conflict bool // 同键下密码不一致
pwdValues int
}
func trimPtr(p *string) string {
if p == nil {
return ""
}
return strings.TrimSpace(*p)
}
// mergeKey 归并键:手机 > 邮箱 > 账号;都为空时返回空串(由调用方兜底)
func mergeKey(r srcRow) string {
if r.Phone != "" {
return "m:" + r.Phone
}
if r.Email != "" {
return "e:" + r.Email
}
if r.Account != "" {
return "a:" + r.Account
}
return ""
}
func main() {
check := flag.Bool("check", false, "预检模式,只输出报告不写数据")
apply := flag.Bool("apply", false, "执行迁移")
force := flag.Bool("force", false, "目标表已有数据时强制重跑")
flag.Parse()
if !*check && !*apply {
fmt.Println("请指定 -check(预检)或 -apply(执行迁移)")
os.Exit(1)
}
if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil {
log.Printf("加载 conf/app.conf 失败(若已自动加载可忽略): %v", err)
}
models.Init("")
rows, err := loadSourceRows()
if err != nil {
log.Fatalf("读取源表失败: %v", err)
}
if len(rows) == 0 {
log.Fatal("源表 yz_system_tenant_user 无有效数据,无需迁移")
}
log.Printf("源表有效记录: %d 条", len(rows))
groups := buildGroups(rows)
log.Printf("归并后身份数: %d 个(其中冲突 %d 组)", len(groups), countConflict(groups))
// 冲突报告
if err := writeConflictReport(groups); err != nil {
log.Printf("写入冲突报告失败: %v", err)
}
if *check {
printSummary(rows, groups)
log.Println("预检完成(未写入任何数据)。确认无误后执行: go run scripts/migrate_auth.go -apply")
return
}
// ---- 执行迁移 ----
if err := ensureTargetWritable(*force); err != nil {
log.Fatalf("目标表检查失败: %v", err)
}
if err := backupSourceTable(); err != nil {
log.Fatalf("备份源表失败(已中止迁移): %v", err)
}
migratedBindings := 0
conflictHandled := 0
mapFile, err := os.Create(fmt.Sprintf("migrate_auth_map_%s.csv", time.Now().Format("20060102150405")))
if err != nil {
log.Fatalf("创建映射文件失败: %v", err)
}
defer mapFile.Close()
mapWriter := csv.NewWriter(mapFile)
defer mapWriter.Flush()
_ = mapWriter.Write([]string{"old_id", "old_uid", "tid", "identity_id", "tenant_user_id", "account", "merge_key", "conflict"})
for _, g := range groups {
// 代表行:is_default 优先,其次 id 最大
rep := pickRepresentative(g)
identityID, err := insertIdentity(rep, g.key)
if err != nil {
log.Fatalf("写入身份失败(key=%s): %v", g.key, err)
}
if g.conflict {
conflictHandled++
}
for _, r := range g.rows {
tu := models.AuthTenantUser{
Tid: r.Tid,
IdentityID: identityID,
GroupID: r.GroupID,
OrgID: r.OrgID,
IsDefault: r.IsDefault,
Status: r.Status,
}
if r.Account != "" {
tu.Account = &r.Account
}
if r.Name != "" {
tu.Name = &r.Name
}
if r.Phone != "" {
tu.Phone = &r.Phone
}
if r.Email != "" {
tu.Email = &r.Email
}
newID, err := models.Orm.Insert(&tu)
if err != nil {
log.Fatalf("写入企业绑定失败(tid=%d, identity=%d): %v", r.Tid, identityID, err)
}
migratedBindings++
_ = mapWriter.Write([]string{
fmt.Sprintf("%d", r.ID), fmt.Sprintf("%d", r.Uid),
fmt.Sprintf("%d", r.Tid), fmt.Sprintf("%d", identityID),
fmt.Sprintf("%d", newID), r.Account, g.key,
fmt.Sprintf("%v", g.conflict),
})
}
}
log.Printf("迁移完成:身份 %d 个,企业绑定 %d 条,处理冲突 %d 组", len(groups), migratedBindings, conflictHandled)
log.Printf("映射文件已生成:%s(保存好,回滚或排查用)", mapFile.Name())
}
// loadSourceRows 读取未软删的源记录
func loadSourceRows() ([]srcRow, error) {
var list []models.SystemTenantUser
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("delete_time__isnull", true).
All(&list)
if err != nil {
return nil, err
}
rows := make([]srcRow, 0, len(list))
for _, m := range list {
rows = append(rows, srcRow{
ID: m.ID,
Tid: m.Tid,
Uid: m.Uid,
Account: trimPtr(m.Account),
Name: trimPtr(m.Name),
Phone: trimPtr(m.Phone),
Email: trimPtr(m.Email),
Password: trimPtr(m.Password),
GroupID: m.GroupID,
OrgID: m.OrgID,
IsDefault: m.IsDefault,
Status: m.Status,
})
}
return rows, nil
}
// buildGroups 按归并键分组,并标记密码冲突
func buildGroups(rows []srcRow) []*group {
buckets := map[string]*group{}
order := make([]string, 0)
for _, r := range rows {
key := mergeKey(r)
if key == "" {
// 手机/邮箱/账号全空:无法归并,按 tid+uid 兜底为独立身份
key = fmt.Sprintf("u:%d-%d", r.Tid, r.Uid)
}
g, ok := buckets[key]
if !ok {
g = &group{key: key}
buckets[key] = g
order = append(order, key)
}
g.rows = append(g.rows, r)
}
sort.Strings(order)
out := make([]*group, 0, len(order))
for _, k := range order {
g := buckets[k]
pwdSet := map[string]bool{}
for _, r := range g.rows {
pwdSet[r.Password] = true
}
g.pwdValues = len(pwdSet)
g.conflict = len(pwdSet) > 1
out = append(out, g)
}
return out
}
// pickRepresentative 选出代表行:is_default 优先,其次 id 最大
func pickRepresentative(g *group) srcRow {
sorted := make([]srcRow, len(g.rows))
copy(sorted, g.rows)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].IsDefault != sorted[j].IsDefault {
return sorted[i].IsDefault > sorted[j].IsDefault
}
return sorted[i].ID > sorted[j].ID
})
return sorted[0]
}
func countConflict(groups []*group) int {
n := 0
for _, g := range groups {
if g.conflict {
n++
}
}
return n
}
// insertIdentity 写入身份;密码算法按原值识别(legacy 保持原样,首登自动升级)
func insertIdentity(rep srcRow, key string) (uint64, error) {
algo := passwordutil.AlgoLegacy
if passwordutil.AlgoOf(rep.Password) == passwordutil.AlgoArgon2id {
algo = passwordutil.AlgoArgon2id
}
identity := models.AuthIdentity{
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
PasswordAlgo: algo,
Status: rep.Status,
}
if rep.Phone != "" {
identity.Mobile = &rep.Phone
}
if rep.Email != "" {
identity.Email = &rep.Email
}
if rep.Password != "" {
identity.PasswordHash = &rep.Password
}
if rep.Name != "" {
identity.Nickname = &rep.Name
}
id, err := models.Orm.Insert(&identity)
return uint64(id), err
}
// ensureTargetWritable 目标表已有数据时需 -force 才继续,避免重复迁移
func ensureTargetWritable(force bool) error {
cnt, err := models.Orm.QueryTable(new(models.AuthTenantUser)).Count()
if err != nil {
return err
}
if cnt > 0 && !force {
return fmt.Errorf("yz_auth_tenant_user 已存在 %d 条数据,如确认要重跑请加 -force(建议先清空目标表)", cnt)
}
return nil
}
// backupSourceTable 备份源表
func backupSourceTable() error {
name := fmt.Sprintf("yz_system_tenant_user_bak_%s", time.Now().Format("20060102150405"))
_, err := models.Orm.Raw(fmt.Sprintf("CREATE TABLE `%s` AS SELECT * FROM `yz_system_tenant_user`", name)).Exec()
if err != nil {
return err
}
log.Printf("源表已备份为:%s", name)
return nil
}
// writeConflictReport 输出冲突报告 CSV
func writeConflictReport(groups []*group) error {
conflicts := make([]*group, 0)
for _, g := range groups {
if g.conflict {
conflicts = append(conflicts, g)
}
}
// 无法归并(手机/邮箱/账号全空)的记录也提示
unkeyed := 0
for _, g := range groups {
if strings.HasPrefix(g.key, "u:") {
unkeyed += len(g.rows)
}
}
filename := fmt.Sprintf("migrate_auth_conflict_%s.csv", time.Now().Format("20060102150405"))
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
_ = w.Write([]string{"merge_key", "row_count", "password_variants", "tids", "representative_id"})
for _, g := range conflicts {
tids := make([]string, 0, len(g.rows))
for _, r := range g.rows {
tids = append(tids, fmt.Sprintf("%d", r.Tid))
}
rep := pickRepresentative(g)
_ = w.Write([]string{
g.key,
fmt.Sprintf("%d", len(g.rows)),
fmt.Sprintf("%d", g.pwdValues),
strings.Join(tids, "|"),
fmt.Sprintf("%d", rep.ID),
})
}
if len(conflicts) > 0 {
log.Printf("发现密码冲突 %d 组,详见 %s(迁移取 is_default 优先的那条,请人工核对)", len(conflicts), filename)
}
if unkeyed > 0 {
log.Printf("警告:有 %d 条记录手机/邮箱/账号全空,已按 tid+uid 各自独立建身份,建议补齐手机号", unkeyed)
}
return nil
}
// printSummary 预检模式输出摘要
func printSummary(rows []srcRow, groups []*group) {
tenantSet := map[uint64]bool{}
for _, r := range rows {
tenantSet[r.Tid] = true
}
multi := 0
for _, g := range groups {
if len(g.rows) > 1 {
multi++
}
}
log.Println("---------------- 预检摘要 ----------------")
log.Printf("源记录数 : %d", len(rows))
log.Printf("涉及企业数 : %d", len(tenantSet))
log.Printf("归并后身份数 : %d", len(groups))
log.Printf("多企业任职身份 : %d", multi)
log.Printf("密码冲突组数 : %d", countConflict(groups))
log.Println("------------------------------------------")
}
+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
}
+23
View File
@@ -10,6 +10,7 @@ import (
"server/models"
"server/pkg/passwordutil"
authsvc "server/services/auth"
)
// resetCodeItem 存储找回密码的验证码
@@ -250,9 +251,31 @@ func ResetPassword(tenantName, account, phone, smsCode, newPassword, confirmPass
return errors.New("密码更新失败")
}
// 双写:同步新密码到统一认证中心(失败不影响找回密码主流程)
_ = authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
Tid: tenantUser.Tid,
Account: trimStrPtr(tenantUser.Account),
Name: trimStrPtr(tenantUser.Name),
Phone: trimStrPtr(tenantUser.Phone),
Email: trimStrPtr(tenantUser.Email),
PasswordHash: hashedPassword,
GroupID: tenantUser.GroupID,
OrgID: tenantUser.OrgID,
Status: tenantUser.Status,
IsDefault: tenantUser.IsDefault,
})
return nil
}
// trimStrPtr 取指针字符串并去空格;nil 时返回空串
func trimStrPtr(p *string) string {
if p == nil {
return ""
}
return strings.TrimSpace(*p)
}
// enqueueSMSTaskForPasswordReset 发送密码重置短信任务
func enqueueSMSTaskForPasswordReset(tid uint64, phone, content, code string) error {
// 重用已有的短信发送逻辑
+30
View File
@@ -73,6 +73,13 @@ func PlatformAdminLogin(account, password string) (string, *PlatformLoginUser, e
if !passwordutil.Verify(user.Password, password) {
return "", nil, errors.New("用户名或密码错误")
}
// 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录)
upgradePasswordIfNeeded(user.Password, password, func(hashed string) error {
_, err := models.Orm.QueryTable(new(models.AdminUser)).
Filter("id", user.ID).
Update(map[string]interface{}{"password": hashed})
return err
})
const tenantID = 0
const userType = "platform"
@@ -116,6 +123,13 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) {
return "", nil, errors.New("用户名或密码错误")
}
// 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录)
upgradePasswordIfNeeded(*tenantUser.Password, password, func(hashed string) error {
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", tenantUser.ID).
Update(map[string]interface{}{"password": hashed})
return err
})
tenantID := int(tenant.ID)
const userType = "backend"
@@ -144,6 +158,22 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
return token, loginUser, nil
}
// upgradePasswordIfNeeded 旧算法(legacy sha256)密码在登录成功后自动重新哈希为 argon2id。
//
// 旧哈希无法离线转换成新算法(无法反推明文),只能借登录时拿到的明文重新哈希,
// 因此采用「首次登录自动升级」的渐进方式,无需强制全员重置密码。
// 重新哈希失败不影响本次登录,仅下次登录时重试。
func upgradePasswordIfNeeded(stored, plain string, update func(hashed string) error) {
if !passwordutil.NeedsRehash(stored) {
return
}
hashed, err := passwordutil.Hash(plain)
if err != nil {
return
}
_ = update(hashed)
}
// PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称)。
func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) {
u, err := GetAdminUserByID(uid)
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>统一认证中心 - 登录</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; display: flex; align-items: center; justify-content: center;
}
.card {
background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
width: 420px; max-width: 92vw; padding: 40px 36px;
}
.logo { text-align: center; margin-bottom: 28px; }
.logo h1 { font-size: 22px; color: #1a1a2e; font-weight: 600; }
.logo p { font-size: 13px; color: #8a8fa3; margin-top: 6px; }
.field { margin-bottom: 18px; }
.field label { display: block; font-size: 13px; color: #5a6072; margin-bottom: 7px; }
.field input {
width: 100%; height: 44px; padding: 0 14px; font-size: 14px;
border: 1px solid #dcdfe8; border-radius: 8px; outline: none; transition: border-color .2s;
}
.field input:focus { border-color: #667eea; }
.btn {
width: 100%; height: 44px; border: none; border-radius: 8px; cursor: pointer;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; font-size: 15px; font-weight: 500; transition: opacity .2s;
}
.btn:hover { opacity: .9; }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.error {
background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322;
padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; display: none;
}
.tenant-list { list-style: none; }
.tenant-item {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px;
margin-bottom: 12px; cursor: pointer; transition: all .2s;
}
.tenant-item:hover { border-color: #667eea; background: #f7f8ff; }
.tenant-name { font-size: 15px; color: #1a1a2e; font-weight: 500; }
.tenant-meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
.tenant-arrow { color: #c0c4d0; font-size: 18px; }
.tip { text-align: center; font-size: 12px; color: #8a8fa3; margin-top: 20px; }
.back { text-align: center; margin-top: 14px; }
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<h1>云泽统一认证中心</h1>
<p>{{if eq .Step "tenant"}}请选择要进入的企业{{else}}登录后即可使用全部应用{{end}}</p>
</div>
<div class="error" id="error"></div>
{{if eq .Step "tenant"}}
<!-- 企业选择步骤 -->
<ul class="tenant-list">
{{range .Tenants}}
<li class="tenant-item" onclick="chooseTenant({{.Tid}})">
<div>
<div class="tenant-name">{{.TenantName}}</div>
<div class="tenant-meta">{{if .Name}}{{.Name}} · {{end}}企业ID {{.Tid}}</div>
</div>
<span class="tenant-arrow">›</span>
</li>
{{end}}
</ul>
<div class="back"><a onclick="logout()">切换账号</a></div>
{{else}}
<!-- 账号密码登录 -->
<form id="loginForm" onsubmit="return submitLogin(event)">
<div class="field">
<label>账号</label>
<input id="account" type="text" placeholder="手机号 / 邮箱 / 企业账号" autocomplete="username" autofocus>
</div>
<div class="field">
<label>密码</label>
<input id="password" type="password" placeholder="请输入密码" autocomplete="current-password">
</div>
<button class="btn" id="submitBtn" type="submit">登 录</button>
</form>
{{end}}
<div class="tip">统一身份认证 · 一次登录,全平台通行</div>
</div>
<script>
var CLIENT_ID = "{{.ClientID}}";
var REDIRECT = "{{.Redirect}}";
function showError(msg) {
var el = document.getElementById('error');
el.textContent = msg;
el.style.display = 'block';
}
function submitLogin(e) {
e.preventDefault();
var account = document.getElementById('account').value.trim();
var password = document.getElementById('password').value;
if (!account) { showError('请输入账号'); return false; }
if (!password) { showError('请输入密码'); return false; }
var btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '登录中...';
fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: account, password: password, client_id: CLIENT_ID, redirect: REDIRECT })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.code !== 200) {
showError(res.msg || '登录失败');
btn.disabled = false;
btn.textContent = '登 录';
return;
}
if (res.need_choose_tenant) {
window.location.href = '/auth/login?step=tenant&client_id=' + encodeURIComponent(CLIENT_ID) + '&redirect=' + encodeURIComponent(REDIRECT);
return;
}
if (res.redirect) { window.location.href = res.redirect; return; }
window.location.href = '/auth/login';
})
.catch(function () {
showError('网络异常,请稍后重试');
btn.disabled = false;
btn.textContent = '登 录';
});
return false;
}
function chooseTenant(tid) {
fetch('/auth/switch-tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tid: tid, client_id: CLIENT_ID, redirect: REDIRECT })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.code !== 200) { showError(res.msg || '切换失败'); return; }
if (res.redirect) { window.location.href = res.redirect; return; }
window.location.href = '/auth/login';
});
}
function logout() {
fetch('/auth/logout', { method: 'POST' }).then(function () {
window.location.href = '/auth/login?client_id=' + encodeURIComponent(CLIENT_ID) + '&redirect=' + encodeURIComponent(REDIRECT);
});
}
</script>
</body>
</html>