Files
90CNServer/server/internal/platform/rpc/authn.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

346 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 rpc
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"longheng.io/server/internal/platform/replay"
"longheng.io/server/pkg/contracts"
)
// 历史背景:LongHeng 的 internal RPC 直到本改动前完全明文 —— Bus / NATS / Redis
// PubSub 上谁能连进来就能伪造 RPCRequest。生产部署只能靠网络隔离做防线,
// 一旦 sidecar 漏接、test pod 误配置或同集群另一个服务被攻陷,攻击者就能
// 直接调用 logic 内部 handler。
//
// 本文件提供应用层 HMAC-SHA256 鉴权,作为 mTLS / 内网隔离之外的纵深防御:
// - 共享 secret 由部署期 Secret/KMS 注入,旋转策略由项目侧实现。
// - 签名覆盖关键字段(RequestID / SourceNodeID / TargetNodeID / Route /
// SentAt / payload 哈希),任何字段被改都会失效。
// - 时间戳偏移检查缩小重放窗口;生产多节点应接入 replay.RedisStore 共享 nonce 表。
// - Sign* / Verify* 是无状态纯函数,便于在 AuthenticatedTransport 之外的
// 场景(例如直接走 HTTP 的 Admin 内部接口)单独使用。
const (
// AuthSignatureMetadataKey 是签名字段在 RPCRequest.Metadata /
// RPCResponse.Metadata 上的固定键。
AuthSignatureMetadataKey = "x-longheng-signature"
// AuthAlgorithmMetadataKey 用于在 metadata 里声明算法版本,便于未来兼容
// 多版本(HMAC-SHA256 / HMAC-SHA512 / Ed25519)。
AuthAlgorithmMetadataKey = "x-longheng-signature-alg"
// AuthAlgorithmHMACSHA256 是当前唯一支持的算法标识。
AuthAlgorithmHMACSHA256 = "HMAC-SHA256"
// DefaultAuthMaxSkew 是默认的双向时间偏移容差。生产建议保持 5min 量级,
// 太小容易因为 NTP 抖动误拒,太大会扩大重放窗口。
DefaultAuthMaxSkew = 5 * time.Minute
)
var (
// ErrAuthSecretRequired 在构造 Authenticator 时 secret 为空返回。
ErrAuthSecretRequired = errors.New("rpcauth: shared secret is required")
// ErrAuthSignatureMissing 表示请求 / 响应缺少签名 metadata。
ErrAuthSignatureMissing = errors.New("rpcauth: signature missing")
// ErrAuthSignatureInvalid 表示签名不匹配(密钥不对或字段被改)。
ErrAuthSignatureInvalid = errors.New("rpcauth: signature invalid")
// ErrAuthAlgUnsup 表示对端声明了不支持的签名算法。
ErrAuthAlgUnsup = errors.New("rpcauth: algorithm not supported")
// ErrAuthTimestampSkew 表示请求 / 响应的 SentAt 偏移超出允许窗口。
ErrAuthTimestampSkew = errors.New("rpcauth: timestamp outside max skew")
// ErrAuthReqNoMeta 在签名时 RPCRequest 为 nil 等场景下返回。
ErrAuthReqNoMeta = errors.New("rpcauth: request is nil")
// ErrAuthReplayDetected 表示同一 SourceNodeID + RequestID 已在 MaxSkew 窗口内验过;可用 errors.Is 判定。
ErrAuthReplayDetected = errors.New("rpcauth: replay detected")
// ErrAuthReplayCheckFailed 表示 nonce store 不可用;可用 errors.Is 保留底层 replay/redis 错误。
ErrAuthReplayCheckFailed = errors.New("rpcauth: replay check failed")
)
// Authenticator 持有共享密钥和默认偏移窗口,是 RPC 鉴权的核心入口。
// 所有方法在常量时间比较签名(使用 hmac.Equal / subtle.ConstantTimeCompare),
// 不会通过失败用时长泄露签名长度。
type Authenticator struct {
secret []byte
maxSkew time.Duration
now func() time.Time
replay replay.Store
}
// AuthenticatorOptions 用于构造 Authenticator。Secret 必填;MaxSkew=0 使用
// DefaultAuthMaxSkew;ReplayStore 为空时保持旧的纯签名校验语义。
type AuthenticatorOptions struct {
Secret []byte
MaxSkew time.Duration
Now func() time.Time
ReplayStore replay.Store
}
// NewAuthenticator 校验配置并返回 Authenticator。secret 为空会返回
// ErrAuthSecretRequired,避免装配代码静默退化为"无鉴权"。
func NewAuthenticator(opts AuthenticatorOptions) (*Authenticator, error) {
if len(opts.Secret) == 0 {
return nil, ErrAuthSecretRequired
}
skew := opts.MaxSkew
if skew <= 0 {
skew = DefaultAuthMaxSkew
}
now := opts.Now
if now == nil {
now = time.Now
}
// 复制 secret,避免调用方后续修改影响 Authenticator。
secret := make([]byte, len(opts.Secret))
copy(secret, opts.Secret)
return &Authenticator{secret: secret, maxSkew: skew, now: now, replay: opts.ReplayStore}, nil
}
// SignRequest 计算 RPCRequest 的 HMAC 签名并写到 Metadata。req 不能为 nil。
// SentAt 为零值时会自动填当前时间;Metadata 为 nil 时会被初始化。
func (a *Authenticator) SignRequest(req *contracts.RPCRequest) error {
if a == nil {
return ErrAuthSecretRequired
}
if req == nil {
return ErrAuthReqNoMeta
}
if req.SentAt.IsZero() {
req.SentAt = a.now().UTC()
}
if req.Metadata == nil {
req.Metadata = map[string]string{}
}
sig := a.requestSignature(*req)
req.Metadata[AuthSignatureMetadataKey] = sig
req.Metadata[AuthAlgorithmMetadataKey] = AuthAlgorithmHMACSHA256
return nil
}
// VerifyRequest 检查 RPCRequest 是否有合法签名 + 时间戳在允许偏移内。
// 缺签名返回 ErrAuthSignatureMissing;签名不匹配返回 ErrAuthSignatureInvalid;
// 时间戳偏移过大返回 ErrAuthTimestampSkew;算法不支持返回 ErrAuthAlgUnsup;
// nonce 重复返回 ErrAuthReplayDetected。
func (a *Authenticator) VerifyRequest(req contracts.RPCRequest) error {
return a.VerifyRequestContext(context.Background(), req)
}
// VerifyRequestContext 与 VerifyRequest 语义一致,但会把 ctx 传给 ReplayStore。
// 生产 Redis nonce 表应继承调用链超时,否则鉴权路径可能被 Redis 卡死拖垮 handler。
func (a *Authenticator) VerifyRequestContext(ctx context.Context, req contracts.RPCRequest) error {
if a == nil {
return ErrAuthSecretRequired
}
sig := strings.TrimSpace(req.Metadata[AuthSignatureMetadataKey])
if sig == "" {
return ErrAuthSignatureMissing
}
if alg := strings.TrimSpace(req.Metadata[AuthAlgorithmMetadataKey]); alg != "" && alg != AuthAlgorithmHMACSHA256 {
return fmt.Errorf("%w: %s", ErrAuthAlgUnsup, alg)
}
if strings.TrimSpace(req.RequestID) == "" || strings.TrimSpace(req.SourceNodeID) == "" {
return fmt.Errorf("%w: request_id and source_node_id are required", ErrAuthSignatureMissing)
}
expected := a.requestSignature(req)
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
return ErrAuthSignatureInvalid
}
if err := a.checkSkew(req.SentAt); err != nil {
return err
}
if err := a.checkReplay(ctx, req); err != nil {
return err
}
return nil
}
// SignResponse 与 SignRequest 对称,用于网关 / logic 之间的响应消息。
func (a *Authenticator) SignResponse(resp *contracts.RPCResponse) error {
if a == nil {
return ErrAuthSecretRequired
}
if resp == nil {
return ErrAuthReqNoMeta
}
if resp.SentAt.IsZero() {
resp.SentAt = a.now().UTC()
}
if resp.Metadata == nil {
resp.Metadata = map[string]string{}
}
sig := a.responseSignature(*resp)
resp.Metadata[AuthSignatureMetadataKey] = sig
resp.Metadata[AuthAlgorithmMetadataKey] = AuthAlgorithmHMACSHA256
return nil
}
// VerifyResponse 校验 RPCResponse 的签名。语义与 VerifyRequest 一致。
func (a *Authenticator) VerifyResponse(resp contracts.RPCResponse) error {
return a.VerifyResponseContext(context.Background(), resp)
}
// VerifyResponseContext 与 VerifyResponse 语义一致,但会把 ctx 传给 ReplayStore。
func (a *Authenticator) VerifyResponseContext(ctx context.Context, resp contracts.RPCResponse) error {
if a == nil {
return ErrAuthSecretRequired
}
sig := strings.TrimSpace(resp.Metadata[AuthSignatureMetadataKey])
if sig == "" {
return ErrAuthSignatureMissing
}
if alg := strings.TrimSpace(resp.Metadata[AuthAlgorithmMetadataKey]); alg != "" && alg != AuthAlgorithmHMACSHA256 {
return fmt.Errorf("%w: %s", ErrAuthAlgUnsup, alg)
}
if strings.TrimSpace(resp.RequestID) == "" || strings.TrimSpace(resp.SourceNodeID) == "" || strings.TrimSpace(resp.TargetNodeID) == "" {
return fmt.Errorf("%w: request_id, source_node_id and target_node_id are required", ErrAuthSignatureMissing)
}
expected := a.responseSignature(resp)
if subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) != 1 {
return ErrAuthSignatureInvalid
}
if err := a.checkSkew(resp.SentAt); err != nil {
return err
}
if err := a.checkResponseReplay(ctx, resp); err != nil {
return err
}
return nil
}
// MaxSkew 暴露当前的偏移窗口,便于装配代码做断言或日志。
func (a *Authenticator) MaxSkew() time.Duration {
if a == nil {
return 0
}
return a.maxSkew
}
func (a *Authenticator) checkSkew(at time.Time) error {
if a.maxSkew <= 0 {
return nil
}
if at.IsZero() {
return ErrAuthTimestampSkew
}
now := a.now().UTC()
diff := now.Sub(at.UTC())
if diff > a.maxSkew || diff < -a.maxSkew {
return ErrAuthTimestampSkew
}
return nil
}
func (a *Authenticator) checkReplay(ctx context.Context, req contracts.RPCRequest) error {
if a.replay == nil {
return nil
}
return a.checkReplayKey(ctx, replayKey(req))
}
func (a *Authenticator) checkResponseReplay(ctx context.Context, resp contracts.RPCResponse) error {
if a.replay == nil {
return nil
}
return a.checkReplayKey(ctx, responseReplayKey(resp))
}
func (a *Authenticator) checkReplayKey(ctx context.Context, key string) error {
if ctx == nil {
ctx = context.Background()
}
first, err := a.replay.Seen(ctx, key, authReplayTTL(a.maxSkew))
if err != nil {
return fmt.Errorf("%w: %w", ErrAuthReplayCheckFailed, err)
}
if !first {
return ErrAuthReplayDetected
}
return nil
}
func replayKey(req contracts.RPCRequest) string {
source := strings.TrimSpace(req.SourceNodeID)
requestID := strings.TrimSpace(req.RequestID)
if source == "" || requestID == "" {
return ""
}
return "v1:" + encodeReplayKeyPart(source) + ":" + encodeReplayKeyPart(requestID)
}
func responseReplayKey(resp contracts.RPCResponse) string {
source := strings.TrimSpace(resp.SourceNodeID)
target := strings.TrimSpace(resp.TargetNodeID)
requestID := strings.TrimSpace(resp.RequestID)
if source == "" || target == "" || requestID == "" {
return ""
}
return "v1:resp:" + encodeReplayKeyPart(source) + ":" + encodeReplayKeyPart(target) + ":" + encodeReplayKeyPart(requestID)
}
func encodeReplayKeyPart(value string) string {
if value == "" {
return "_"
}
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
func authReplayTTL(maxSkew time.Duration) time.Duration {
if maxSkew <= 0 {
return 2*DefaultAuthMaxSkew + time.Minute
}
return 2*maxSkew + time.Minute
}
// requestSignature 拼出 canonical string 然后 HMAC。canonical 故意排除
// Metadata(避免循环依赖签名字段本身),只覆盖 routing / identity / 时间
// / payload 哈希这几个攻击面最大的字段。
func (a *Authenticator) requestSignature(req contracts.RPCRequest) string {
canonical := strings.Join([]string{
"rpc.request",
req.RequestID,
req.SourceService,
req.SourceNodeID,
req.TargetNodeID,
req.Route,
req.SentAt.UTC().Format(time.RFC3339Nano),
hashPayload(req.Payload),
}, "\n")
return a.compute(canonical)
}
// responseSignature 与 request 对称,但用 Error 字段替代 Route,强制响应里
// 即使内容相同的失败也对应不同的签名(攻击者无法复制一个 OK 响应去顶替一个失败的)。
func (a *Authenticator) responseSignature(resp contracts.RPCResponse) string {
canonical := strings.Join([]string{
"rpc.response",
resp.RequestID,
resp.SourceNodeID,
resp.TargetNodeID,
resp.Error,
resp.SentAt.UTC().Format(time.RFC3339Nano),
hashPayload(resp.Payload),
}, "\n")
return a.compute(canonical)
}
func (a *Authenticator) compute(canonical string) string {
mac := hmac.New(sha256.New, a.secret)
_, _ = mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
}
// hashPayload 把 payload 摘要成 sha256,避免 canonical string 巨大、CPU 浪费在
// HMAC 内部分块上。空 payload 显式返回固定标记,便于跨语言重现。
func hashPayload(payload []byte) string {
if len(payload) == 0 {
return "empty"
}
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}