392 lines
12 KiB
Go
392 lines
12 KiB
Go
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
|
||
}
|