0.10.1: 安全与业务逻辑加固、新品牌与部署加固

三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
This commit is contained in:
2026-08-13 10:50:51 +08:00
parent b536672000
commit 9501751792
136 changed files with 8024 additions and 1476 deletions
+68 -8
View File
@@ -25,6 +25,41 @@ type Principal struct {
Role string `json:"role,omitempty"`
Purpose string `json:"purpose"`
IssuedAt int64 `json:"issued_at"`
// AuthVersion 是签发会话时账号的凭据版本;凭据变更(改密/2FA 变更)会
// 递增该版本,旧版本会话在 Authenticate 时被拒绝,被盗会话无法在
// 凭据轮换后继续存活。
AuthVersion int64 `json:"auth_version,omitempty"`
}
// authVersionTTL 必须严格大于会话 TTL(上限 7 天,由 config 校验保证):
// 版本键过期时所有旧会话已自然过期,凭据变更后旧会话不会复活。
const authVersionTTL = 14 * 24 * time.Hour
func authVersionKey(kind Kind, subjectID string) string {
return "gateway:auth-version:" + string(kind) + ":" + subjectID
}
// AuthVersion 返回账号当前凭据版本;从未变更过则为 0。
func (s *SessionStore) AuthVersion(ctx context.Context, kind Kind, subjectID string) int64 {
if s.client == nil {
return 0
}
value, err := s.client.Get(ctx, authVersionKey(kind, subjectID)).Int64()
if err != nil {
return 0
}
return value
}
// BumpAuthVersion 使账号的全部既有会话失效(改密、2FA 启用/停用等凭据变更后调用)。
// Redis 不可用时静默失败:会话仍按 TTL 自然过期,凭据变更的即时失效降级为延迟生效。
func (s *SessionStore) BumpAuthVersion(ctx context.Context, kind Kind, subjectID string) {
if s.client == nil {
return
}
key := authVersionKey(kind, subjectID)
_ = s.client.Incr(ctx, key).Err()
_ = s.client.Expire(ctx, key, authVersionTTL).Err()
}
type SessionStore struct {
@@ -56,6 +91,7 @@ func (s *SessionStore) create(ctx context.Context, principal Principal, ttl time
}
token := base64.RawURLEncoding.EncodeToString(random)
principal.IssuedAt = time.Now().Unix()
principal.AuthVersion = s.AuthVersion(ctx, principal.Kind, principal.SubjectID)
payload, err := json.Marshal(principal)
if err != nil {
return "", err
@@ -85,18 +121,16 @@ func (s *SessionStore) Authenticate(ctx context.Context, authorization string, e
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "session" {
return Principal{}, ErrInvalidSession
}
return principal, nil
}
func (s *SessionStore) AuthenticatePending(ctx context.Context, token string, expected Kind) (Principal, error) {
principal, err := s.authenticateToken(ctx, token)
if err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" {
// 凭据版本不匹配:改密/2FA 变更后旧会话一律失效。
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
return Principal{}, ErrInvalidSession
}
return principal, nil
}
func (s *SessionStore) authenticateToken(ctx context.Context, token string) (Principal, error) {
// AuthenticatePending 只读取(不消费)挑战令牌,供 CompleteTOTPLogin 在
// 验证前解析 principal;验证码输错时令牌保留可重试。
func (s *SessionStore) AuthenticatePending(ctx context.Context, token string, expected Kind) (Principal, error) {
if s.client == nil {
return Principal{}, ErrUnavailable
}
@@ -108,7 +142,33 @@ func (s *SessionStore) authenticateToken(ctx context.Context, token string) (Pri
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
var principal Principal
if err := json.Unmarshal(payload, &principal); err != nil {
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" {
return Principal{}, ErrInvalidSession
}
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
return Principal{}, ErrInvalidSession
}
return principal, nil
}
// ConsumePending 原子消费挑战令牌(GetDel):验证通过后调用,并发请求用同一
// 令牌时只有一个能成功,防止一次 2FA 挑战铸出两个会话。
func (s *SessionStore) ConsumePending(ctx context.Context, token string, expected Kind) (Principal, error) {
if s.client == nil {
return Principal{}, ErrUnavailable
}
payload, err := s.client.GetDel(ctx, sessionKey(strings.TrimSpace(token))).Bytes()
if errors.Is(err, redis.Nil) {
return Principal{}, ErrInvalidSession
}
if err != nil {
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
var principal Principal
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" {
return Principal{}, ErrInvalidSession
}
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
return Principal{}, ErrInvalidSession
}
return principal, nil