Files
yunzerwebsiteallinone/go/controllers/platform_auth_client.go
T
2026-09-20 00:19:08 +08:00

392 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthClientController 统一认证中心 —— 接入应用(OIDC Client)管理。
//
// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证,
// 无需改代码、无需手写 SQL。
type PlatformAuthClientController struct {
beego.Controller
}
func (c *PlatformAuthClientController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:接入应用属于平台级敏感配置,仅平台管理员可操作。
// 不依赖全局中间件(其处于 warn 观察模式时不拦截),这里主动校验。
func (c *PlatformAuthClientController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
// 平台端应用注册只允许平台管理员操作;租户管理员无权访问
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
type authClientPayload struct {
ClientID string `json:"client_id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
AppType int8 `json:"app_type"`
RedirectURIs *string `json:"redirect_uris"`
PostLogoutURIs *string `json:"post_logout_uris"`
BackchannelLogoutURI *string `json:"backchannel_logout_uri"`
GrantTypes string `json:"grant_types"`
Scope *string `json:"scope"`
AccessTTL int `json:"access_ttl"`
RefreshTTL int `json:"refresh_ttl"`
Realm string `json:"realm"`
Status *int8 `json:"status"`
}
func (c *PlatformAuthClientController) parsePayload() (authClientPayload, bool) {
var p authClientPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return p, false
}
}
return p, true
}
// List 接入应用列表(不返回 client_secret)
// GET /platform/authClient/list
func (c *PlatformAuthClientController) List() {
var rows []models.AuthClient
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
OrderBy("-id").All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
return
}
// 脱敏:只告知是否配置了密钥,不下发明文或哈希
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"id": r.ID,
"client_id": r.ClientID,
"app_code": r.AppCode,
"name": r.Name,
"app_type": r.AppType,
"redirect_uris": derefStrAuth(r.RedirectURIs),
"post_logout_uris": derefStrAuth(r.PostLogoutURIs),
"backchannel_logout_uri": derefStrAuth(r.BackchannelLogoutURI),
"grant_types": r.GrantTypes,
"scope": derefStrAuth(r.Scope),
"access_ttl": r.AccessTTL,
"refresh_ttl": r.RefreshTTL,
"realm": r.Realm,
"status": r.Status,
"has_secret": r.ClientSecret != nil,
"create_time": r.CreateTime,
})
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "success",
"data": map[string]interface{}{"list": list, "total": len(list)},
})
}
// Detail 应用详情
// GET /platform/authClient/detail/:id
func (c *PlatformAuthClientController) Detail() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
var row models.AuthClient
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
row.ClientSecret = nil
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": row})
}
// Create 新增应用
// POST /platform/authClient/create
func (c *PlatformAuthClientController) Create() {
p, ok := c.parsePayload()
if !ok {
return
}
p.ClientID = strings.TrimSpace(p.ClientID)
p.AppCode = strings.TrimSpace(p.AppCode)
p.Name = strings.TrimSpace(p.Name)
if p.ClientID == "" || p.AppCode == "" || p.Name == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id、应用编码、名称均不能为空"})
return
}
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
return
}
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
return
}
if exist := existsAuthClient("client_id", p.ClientID); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id 已存在"})
return
}
if exist := existsAuthClient("app_code", p.AppCode); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "应用编码已存在"})
return
}
item := &models.AuthClient{
ClientID: p.ClientID,
AppCode: p.AppCode,
Name: p.Name,
AppType: p.AppType,
RedirectURIs: p.RedirectURIs,
PostLogoutURIs: p.PostLogoutURIs,
BackchannelLogoutURI: p.BackchannelLogoutURI,
GrantTypes: defaultStrAuth(p.GrantTypes, "authorization_code,refresh_token"),
Scope: p.Scope,
AccessTTL: defaultIntAuth(p.AccessTTL, 1800),
RefreshTTL: defaultIntAuth(p.RefreshTTL, 2592000),
Realm: defaultStrAuth(p.Realm, models.AuthRealmTenant),
Status: 1,
}
if p.Status != nil {
item.Status = *p.Status
}
// 机密客户端(有后端的 Web 应用)才下发密钥;SPA/APP 走 PKCE,不持有密钥
secretPlain := ""
if p.AppType == models.AuthAppTypeWeb {
plain, hashed, err := generateClientSecret()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
return
}
secretPlain = plain
item.ClientSecret = &hashed
}
id, err := models.Orm.Insert(item)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "创建成功" + map[bool]string{true: "(请妥善保存密钥,仅此一次显示)", false: ""}[secretPlain != ""],
"data": map[string]interface{}{"id": id, "client_secret": secretPlain},
})
}
// Edit 编辑应用
// POST /platform/authClient/edit/:id
func (c *PlatformAuthClientController) Edit() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
p, ok := c.parsePayload()
if !ok {
return
}
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
return
}
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
return
}
update := map[string]interface{}{}
if v := strings.TrimSpace(p.Name); v != "" {
update["name"] = v
}
if p.AppType > 0 {
update["app_type"] = p.AppType
}
if p.RedirectURIs != nil {
update["redirect_uris"] = *p.RedirectURIs
}
if p.PostLogoutURIs != nil {
update["post_logout_uris"] = *p.PostLogoutURIs
}
if p.BackchannelLogoutURI != nil {
update["backchannel_logout_uri"] = *p.BackchannelLogoutURI
}
if v := strings.TrimSpace(p.GrantTypes); v != "" {
update["grant_types"] = v
}
if p.Scope != nil {
update["scope"] = *p.Scope
}
if p.AccessTTL > 0 {
update["access_ttl"] = p.AccessTTL
}
if p.RefreshTTL > 0 {
update["refresh_ttl"] = p.RefreshTTL
}
if v := strings.TrimSpace(p.Realm); v != "" {
update["realm"] = v
}
if p.Status != nil {
update["status"] = *p.Status
}
if len(update) == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无更新字段"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).Update(update); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// ResetSecret 重置密钥(仅机密客户端),返回新明文,仅此一次显示
// POST /platform/authClient/resetSecret/:id
func (c *PlatformAuthClientController) ResetSecret() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
var row models.AuthClient
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
if row.AppType != models.AuthAppTypeWeb {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "仅「Web 后端」类型的应用需要密钥,SPA/APP 使用 PKCE"})
return
}
plain, hashed, err := generateClientSecret()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("id", id).
Update(map[string]interface{}{"client_secret": hashed}); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "重置成功,请妥善保存新密钥",
"data": map[string]interface{}{"client_secret": plain},
})
}
// Delete 停用应用(逻辑删除:status=0,避免误删导致线上应用无法登录)
// DELETE /platform/authClient/delete/:id
func (c *PlatformAuthClientController) Delete() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("id", id).
Update(map[string]interface{}{"status": 0}); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "停用失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已停用"})
}
// ---------------------------------------------------------------- 工具
func (c *PlatformAuthClientController) parseID() (uint64, error) {
var id uint64
_, err := fmt.Sscanf(c.Ctx.Input.Param(":id"), "%d", &id)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid id")
}
return id, nil
}
func existsAuthClient(field, value string) bool {
return models.Orm.QueryTable(new(models.AuthClient)).Filter(field, value).Exist()
}
// generateClientSecret 生成密钥:明文只在创建/重置时返回一次,库里存 sha256 哈希
func generateClientSecret() (plain, hashed string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", err
}
plain = "yzs_" + hex.EncodeToString(buf)
sum := sha256.Sum256([]byte(plain))
return plain, hex.EncodeToString(sum[:]), nil
}
// validateJSONStringArray 校验字段为合法的字符串数组(为空时跳过)
func validateJSONStringArray(p *string) error {
if p == nil || strings.TrimSpace(*p) == "" {
return nil
}
var arr []string
return json.Unmarshal([]byte(*p), &arr)
}
func derefStrAuth(p *string) string {
if p == nil {
return ""
}
return *p
}
func defaultStrAuth(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return strings.TrimSpace(v)
}
func defaultIntAuth(v, def int) int {
if v <= 0 {
return def
}
return v
}