Files
MAcode/tcs-iptv/internal/bff/bff.go
T
selfrelease 8db9d33694 feat(phase3): 备案对接/全国统计/号段管理/BFF安全化/链合约源码
- A.1 备案对接: BindFiling/QueryFiling 关联网标号+备案号
- A.2 监管上报: DailyRegulatoryReport 日报
- B.1 号段管理: ListSegments + /admin/segments
- C.1/C.2 全国统计按省聚合 + 跨省协同(单一可信源天然联动)
- F.2 全国监管大屏: NationalStats(按省/类目/状态)
- B(遗留) 监管大屏BFF: internal/bff + cmd/console-bff, 密钥仅存后端浏览器只用会话令牌
- G 真实链合约源码: contracts/tcs_registry/registry.go (ChainMaker Go)
- 新增9个API+BFF服务; 5项新测试; 端到端BFF验证
- D/E(压测/等保/HSM)/F.1(标准)/真实链部署 标注需外部环境
2026-06-14 17:53:12 +08:00

155 lines
4.0 KiB
Go

// Package bff 实现监管控制台的 Backend-For-Frontend(三期 B)。
//
// 安全目标(替换 MVP 演示态前端直连 + 前端持密钥):
// - 凭证(API Key/Secret)仅存于 BFF 后端,绝不下发浏览器
// - 浏览器用会话令牌(Session Token)访问 BFF
// - BFF 校验会话 + RBAC,再以服务端 HMAC 签名代理到 api-svc
package bff
import (
"bytes"
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/tcs-iptv/tcs/internal/httpx"
)
// roleCred 角色对应的 api-svc 凭证(仅存于 BFF)。
type roleCred struct {
apiKey string
apiSecret string
}
// session 登录会话。
type session struct {
user string
role string
expiresAt time.Time
}
// BFF 控制台后端。
type BFF struct {
apiBase string
creds map[string]roleCred // role -> cred
users map[string]struct{ pass, role string }
mu sync.RWMutex
tokens map[string]session
client *http.Client
}
// New 创建 BFF。apiBase 如 http://localhost:8080
func New(apiBase string) *BFF {
b := &BFF{
apiBase: apiBase,
creds: map[string]roleCred{},
users: map[string]struct{ pass, role string }{},
tokens: map[string]session{},
client: &http.Client{Timeout: 10 * time.Second},
}
return b
}
// SetCred 配置角色凭证(从 Vault/环境加载,不入前端)。
func (b *BFF) SetCred(role, apiKey, apiSecret string) {
b.creds[role] = roleCred{apiKey, apiSecret}
}
// AddUser 配置控制台用户(生产接 SSO/LDAP)。
func (b *BFF) AddUser(user, pass, role string) {
b.users[user] = struct{ pass, role string }{pass, role}
}
func newToken() string {
buf := make([]byte, 24)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
// Login 用户名口令登录,返回会话令牌(不下发任何密钥)。
func (b *BFF) Login(c *gin.Context) {
var req struct{ User, Pass string }
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Error(c, 400, "INVALID_REQUEST", err.Error())
return
}
u, ok := b.users[req.User]
if !ok || u.pass != req.Pass {
httpx.Error(c, 401, "UNAUTHORIZED", "用户名或口令错误")
return
}
tok := newToken()
b.mu.Lock()
b.tokens[tok] = session{user: req.User, role: u.role, expiresAt: time.Now().Add(8 * time.Hour)}
b.mu.Unlock()
httpx.OK(c, gin.H{"token": tok, "role": u.role, "user": req.User})
}
// sessionOf 校验会话令牌。
func (b *BFF) sessionOf(tok string) (session, bool) {
b.mu.RLock()
s, ok := b.tokens[tok]
b.mu.RUnlock()
if !ok || time.Now().After(s.expiresAt) {
return session{}, false
}
return s, true
}
// AuthMiddleware 校验浏览器会话令牌(Bearer)。
func (b *BFF) AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tok := c.GetHeader("X-Session-Token")
s, ok := b.sessionOf(tok)
if !ok {
httpx.Error(c, 401, "UNAUTHORIZED", "会话无效或过期,请重新登录")
c.Abort()
return
}
c.Set("bff_role", s.role)
c.Next()
}
}
// Proxy 以服务端凭证 HMAC 签名后代理到 api-svc(密钥不出 BFF)。
func (b *BFF) Proxy(c *gin.Context) {
role, _ := c.Get("bff_role")
cred, ok := b.creds[role.(string)]
if !ok {
httpx.Error(c, 403, "FORBIDDEN", "角色无对应凭证")
return
}
// 透传 /api/v1/* 路径
path := c.Param("path")
fullPath := "/api/v1" + path
method := c.Request.Method
var body []byte
if c.Request.Body != nil {
body, _ = io.ReadAll(c.Request.Body)
}
sig := httpx.Sign(cred.apiSecret, method, fullPath)
url := b.apiBase + fullPath
if c.Request.URL.RawQuery != "" {
url += "?" + c.Request.URL.RawQuery
}
req, _ := http.NewRequest(method, url, bytes.NewReader(body))
req.Header.Set("Authorization", "TCS "+cred.apiKey+":"+sig)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := b.client.Do(req)
if err != nil {
httpx.Error(c, 502, "BAD_GATEWAY", err.Error())
return
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
c.Data(resp.StatusCode, "application/json", out)
}