feat: 引入签名认证、加密工具包及大量goctl代码生成模板,并更新API、Admin和Node服务逻辑。
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package cryptox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateKey: SHA256(secret)[:32]
|
||||
func generateKey(secret string) []byte {
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return h[:32]
|
||||
}
|
||||
|
||||
// generateIv: SHA256(hex(md5(nonce)) + secret)[:16]
|
||||
func generateIv(nonce, secret string) []byte {
|
||||
md5h := md5.Sum([]byte(nonce))
|
||||
md5hex := hex.EncodeToString(md5h[:])
|
||||
sha := sha256.Sum256([]byte(md5hex + secret))
|
||||
return sha[:16]
|
||||
}
|
||||
|
||||
// Encrypt 加密明文,返回 base64密文 和 nonce(hex(UnixNano))
|
||||
func Encrypt(plaintext []byte, secret string) (dataB64 string, nonce string, err error) {
|
||||
nonce = fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
key := generateKey(secret)
|
||||
iv := generateIv(nonce, secret)
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
padded := pkcs7Pad(plaintext, aes.BlockSize)
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
dst := make([]byte, len(padded))
|
||||
mode.CryptBlocks(dst, padded)
|
||||
dataB64 = base64.StdEncoding.EncodeToString(dst)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt 解密,nonce 用于派生 IV
|
||||
func Decrypt(dataB64, secret, nonce string) ([]byte, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(dataB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := generateKey(secret)
|
||||
iv := generateIv(nonce, secret)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length not multiple of block size")
|
||||
}
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
dst := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(dst, ciphertext)
|
||||
return pkcs7Unpad(dst)
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(data)%blockSize
|
||||
padding := make([]byte, pad)
|
||||
for i := range padding {
|
||||
padding[i] = byte(pad)
|
||||
}
|
||||
return append(data, padding...)
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty data")
|
||||
}
|
||||
pad := int(data[len(data)-1])
|
||||
if pad == 0 || pad > aes.BlockSize {
|
||||
return nil, fmt.Errorf("invalid padding size: %d", pad)
|
||||
}
|
||||
return data[:len(data)-pad], nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cryptox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptRoundtrip(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
plain := []byte(`{"user_id":1,"action":"login"}`)
|
||||
dataB64, nonce, err := Encrypt(plain, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Decrypt(dataB64, secret, nonce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(plain) {
|
||||
t.Fatalf("roundtrip mismatch: got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWrongNonce(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
plain := []byte(`{"foo":"bar"}`)
|
||||
dataB64, _, err := Encrypt(plain, secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Decrypt(dataB64, secret, "wrongnonce")
|
||||
if err == nil {
|
||||
t.Fatal("expected error with wrong nonce")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package cryptox
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// CheckPasswordHash 检查密码和 Hash 是否匹配
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GeneratePasswordHash 生成密码的 Hash
|
||||
func GeneratePasswordHash(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package result
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zero-ppanel/zero-ppanel/pkg/xerr"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
@@ -23,3 +26,44 @@ func Error(code int, msg string) *Response {
|
||||
func OkJson(w http.ResponseWriter, data interface{}) {
|
||||
httpx.OkJson(w, Success(data))
|
||||
}
|
||||
|
||||
func Parse(r *http.Request, v interface{}) error {
|
||||
return httpx.Parse(r, v)
|
||||
}
|
||||
|
||||
// HttpResult global HTTP error handler
|
||||
func HttpResult(r *http.Request, w http.ResponseWriter, resp interface{}, err error) {
|
||||
if err == nil {
|
||||
httpx.WriteJson(w, http.StatusOK, Success(resp))
|
||||
return
|
||||
}
|
||||
|
||||
code, res := ErrHandler(err)
|
||||
logx.WithContext(r.Context()).Errorf("【API-ERR】 : %+v ", err)
|
||||
httpx.WriteJson(w, code, res)
|
||||
}
|
||||
|
||||
// ErrHandler 按照 go-zero 开发规范,处理全局错误
|
||||
func ErrHandler(err error) (int, any) {
|
||||
errCode := xerr.ServerError
|
||||
errMsg := xerr.MapErrMsg(errCode)
|
||||
|
||||
// 直接从错误中提取自定义代码和消息
|
||||
// 不进行过度包装,直接判定是否满足业务错误特征
|
||||
if e, ok := err.(interface{
|
||||
GetErrCode() int
|
||||
GetErrMsg() string
|
||||
}); ok {
|
||||
errCode = e.GetErrCode()
|
||||
errMsg = e.GetErrMsg()
|
||||
} else if gstatus, ok := status.FromError(err); ok {
|
||||
// 识别 gRPC 错误
|
||||
grpcCode := uint32(gstatus.Code())
|
||||
if xerr.IsCodeErr(grpcCode) {
|
||||
errCode = int(grpcCode)
|
||||
errMsg = gstatus.Message()
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusOK, Error(errCode, errMsg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildStringToSign 构造待签字符串
|
||||
// StringToSign = METHOD\nPATH\nCANONICAL_QUERY\nBODY_SHA256\nX-App-Id\nX-Timestamp\nX-Nonce
|
||||
func BuildStringToSign(method, path, rawQuery string, body []byte, appId, timestamp, nonce string) string {
|
||||
canonical := canonicalQuery(rawQuery)
|
||||
bodyHash := sha256Hex(body)
|
||||
parts := []string{
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
canonical,
|
||||
bodyHash,
|
||||
appId,
|
||||
timestamp,
|
||||
nonce,
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func canonicalQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return rawQuery
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, values.Get(k)))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package signature
|
||||
|
||||
type SignatureConf struct {
|
||||
AppSecrets map[string]string
|
||||
ValidWindowSeconds int64 // 默认 300
|
||||
SkipPrefixes []string
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package signature
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrSignatureMissing = errors.New("signature missing")
|
||||
ErrSignatureExpired = errors.New("signature expired")
|
||||
ErrSignatureInvalid = errors.New("signature invalid")
|
||||
ErrSignatureReplay = errors.New("signature replay")
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/stores/redis"
|
||||
)
|
||||
|
||||
// NonceStore 防重放存储接口
|
||||
type NonceStore interface {
|
||||
// SetIfNotExists 返回 (true, nil) 表示 key 已存在(重放攻击)
|
||||
SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error)
|
||||
}
|
||||
|
||||
type RedisNonceStore struct {
|
||||
client *redis.Redis
|
||||
}
|
||||
|
||||
func NewRedisNonceStore(client *redis.Redis) *RedisNonceStore {
|
||||
return &RedisNonceStore{client: client}
|
||||
}
|
||||
|
||||
func (s *RedisNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, ttlSeconds int64) (bool, error) {
|
||||
key := "sig:nonce:" + appId + ":" + nonce
|
||||
// go-zero redis.Redis.SetNX 返回 true 表示设置成功(首次)
|
||||
ok, err := s.client.SetnxEx(key, "1", int(ttlSeconds))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// SetnxEx ok=true 首次,ok=false 已存在(重放)
|
||||
return !ok, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockNonceStore 内存实现,用于测试
|
||||
type mockNonceStore struct {
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func newMockStore() *mockNonceStore {
|
||||
return &mockNonceStore{seen: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (m *mockNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) {
|
||||
key := appId + ":" + nonce
|
||||
if m.seen[key] {
|
||||
return true, nil
|
||||
}
|
||||
m.seen[key] = true
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func makeSignature(secret, stringToSign string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestValidate_Success(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
sts := BuildStringToSign("POST", "/api/v1/order", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce)
|
||||
sig := makeSignature("uB4G,XxL2{7b", sts)
|
||||
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Expired(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix()-400, 10) // 已过期 400s
|
||||
nonce := "abc"
|
||||
sts := BuildStringToSign("GET", "/api/v1/user/info", "", nil, "web-client", ts, nonce)
|
||||
sig := makeSignature("uB4G,XxL2{7b", sts)
|
||||
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureExpired {
|
||||
t.Fatalf("expected ErrSignatureExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Replay(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
store := newMockStore()
|
||||
v := NewValidator(conf, store)
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "same-nonce-replay"
|
||||
sts := BuildStringToSign("GET", "/api/v1/user/info", "", nil, "web-client", ts, nonce)
|
||||
sig := makeSignature("uB4G,XxL2{7b", sts)
|
||||
|
||||
_ = v.Validate(context.Background(), "web-client", ts, nonce, sig, sts)
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureReplay {
|
||||
t.Fatalf("expected ErrSignatureReplay, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_InvalidSignature(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "nonce-invalid-sig"
|
||||
sts := BuildStringToSign("POST", "/api/v1/order", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce)
|
||||
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, "badsignature", sts); err != ErrSignatureInvalid {
|
||||
t.Fatalf("expected ErrSignatureInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Validator 验证签名
|
||||
type Validator struct {
|
||||
conf SignatureConf
|
||||
nonceStore NonceStore
|
||||
}
|
||||
|
||||
func NewValidator(conf SignatureConf, store NonceStore) *Validator {
|
||||
return &Validator{conf: conf, nonceStore: store}
|
||||
}
|
||||
|
||||
func (v *Validator) windowSeconds() int64 {
|
||||
if v.conf.ValidWindowSeconds <= 0 {
|
||||
return 300
|
||||
}
|
||||
return v.conf.ValidWindowSeconds
|
||||
}
|
||||
|
||||
// Validate 验证请求签名,返回具体错误便于调用方映射到 xerr 错误码
|
||||
func (v *Validator) Validate(ctx context.Context, appId, timestamp, nonce, signature, stringToSign string) error {
|
||||
// 1. appId 是否存在
|
||||
secret, ok := v.conf.AppSecrets[appId]
|
||||
if !ok {
|
||||
return ErrSignatureMissing
|
||||
}
|
||||
|
||||
// 2. 时间窗口
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return ErrSignatureExpired
|
||||
}
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > v.windowSeconds() {
|
||||
return ErrSignatureExpired
|
||||
}
|
||||
|
||||
// 3. Nonce 防重放(Redis 不可用时降级,仅时间窗口)
|
||||
replayed, err := v.nonceStore.SetIfNotExists(ctx, appId, nonce, v.windowSeconds()+60)
|
||||
if err == nil && replayed {
|
||||
return ErrSignatureReplay
|
||||
}
|
||||
|
||||
// 4. HMAC 验签(防时序攻击)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,4 +19,9 @@ const (
|
||||
PaymentFailed = 4001
|
||||
SubscribeExpired = 5001
|
||||
NodeAuthFailed = 6001
|
||||
SignatureMissing = 7001
|
||||
SignatureExpired = 7002
|
||||
SignatureInvalid = 7003
|
||||
SignatureReplay = 7004
|
||||
DecryptFailed = 7005
|
||||
)
|
||||
|
||||
@@ -19,6 +19,18 @@ var codeText = map[int]string{
|
||||
PaymentFailed: "支付失败",
|
||||
SubscribeExpired: "订阅已过期",
|
||||
NodeAuthFailed: "节点认证失败",
|
||||
SignatureMissing: "缺少签名头",
|
||||
SignatureExpired: "签名已过期",
|
||||
SignatureInvalid: "签名错误",
|
||||
SignatureReplay: "重放攻击",
|
||||
DecryptFailed: "解密失败",
|
||||
}
|
||||
|
||||
func IsCodeErr(errcode uint32) bool {
|
||||
if _, ok := codeText[int(errcode)]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func MapErrMsg(code int) string {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package xerr
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ICodeError 定义业务错误接口,用于跨包识别
|
||||
type ICodeError interface {
|
||||
GetErrCode() int
|
||||
GetErrMsg() string
|
||||
}
|
||||
|
||||
type CodeError struct {
|
||||
errCode int
|
||||
errMsg string
|
||||
}
|
||||
|
||||
// 错误返回的 Error() 方法
|
||||
func (e *CodeError) Error() string {
|
||||
return fmt.Sprintf("ErrCode:%d,ErrMsg:%s", e.errCode, e.errMsg)
|
||||
}
|
||||
|
||||
func (e *CodeError) GetErrCode() int {
|
||||
return e.errCode
|
||||
}
|
||||
|
||||
func (e *CodeError) GetErrMsg() string {
|
||||
return e.errMsg
|
||||
}
|
||||
|
||||
func NewErrCodeMsg(errCode int, errMsg string) *CodeError {
|
||||
return &CodeError{errCode: errCode, errMsg: errMsg}
|
||||
}
|
||||
|
||||
func NewErrCode(errCode int) *CodeError {
|
||||
return &CodeError{errCode: errCode, errMsg: MapErrMsg(errCode)}
|
||||
}
|
||||
|
||||
func NewErrMsg(errMsg string) *CodeError {
|
||||
return &CodeError{errCode: ServerError, errMsg: errMsg}
|
||||
}
|
||||
Reference in New Issue
Block a user