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