fix: restore payment account details

This commit is contained in:
richjohnrambo
2026-10-19 11:14:09 +08:00
parent 21371061ed
commit 1c11c0fa82
5 changed files with 126 additions and 86 deletions
+16 -8
View File
@@ -64,14 +64,15 @@ type RedisConfig struct {
}
type AuthConfig struct {
APIKeys []string `json:"api_keys"`
SessionKeyPattern string `json:"session_key_pattern"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
DataEncryptionKey string `json:"data_encryption_key"`
LookupHMACKey string `json:"lookup_hmac_key"`
FinanceMasterKey string `json:"finance_master_key"`
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
RequiredAPIKey bool `json:"required_api_key"`
APIKeys []string `json:"api_keys"`
SessionKeyPattern string `json:"session_key_pattern"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
DataEncryptionKey string `json:"data_encryption_key"`
LookupHMACKey string `json:"lookup_hmac_key"`
FinanceMasterKey string `json:"finance_master_key"`
FinanceLegacyMasterKeys []string `json:"finance_legacy_master_keys"`
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
RequiredAPIKey bool `json:"required_api_key"`
}
func Load() (*Config, error) {
@@ -85,7 +86,11 @@ func Load() (*Config, error) {
return nil, err
}
}
configuredFinanceMasterKey := strings.TrimSpace(cfg.Auth.FinanceMasterKey)
applyEnv(cfg)
if configuredFinanceMasterKey != "" && configuredFinanceMasterKey != strings.TrimSpace(cfg.Auth.FinanceMasterKey) {
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, configuredFinanceMasterKey)
}
if err := cfg.validate(); err != nil {
return nil, err
}
@@ -134,6 +139,9 @@ func applyEnv(cfg *Config) {
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_FINANCE_LEGACY_MASTER_KEYS")); keys != "" {
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, splitCSV(keys)...)
}
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
ApplyUploadEnv(&cfg.Upload)
+23 -1
View File
@@ -1,6 +1,10 @@
package config
import "testing"
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
cfg := defaultConfig()
@@ -17,6 +21,24 @@ func TestConfigRejectsOtherDatabase(t *testing.T) {
}
}
func TestLoadPreservesConfiguredFinanceKeyAsLegacyWhenEnvironmentOverridesIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
data := []byte(`{"auth":{"finance_master_key":"legacy-finance-master-key-with-at-least-32-characters"}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("OLONS_SERVER_CONFIG", path)
t.Setenv("OLONS_SERVER_FINANCE_MASTER_KEY", "current-finance-master-key-with-at-least-32-characters")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if len(cfg.Auth.FinanceLegacyMasterKeys) != 1 ||
cfg.Auth.FinanceLegacyMasterKeys[0] != "legacy-finance-master-key-with-at-least-32-characters" {
t.Fatalf("configured finance key was not retained for historical data: %#v", cfg.Auth.FinanceLegacyMasterKeys)
}
}
func TestApplyUploadSystemConfig(t *testing.T) {
cfg := UploadConfig{Driver: "local", Region: "us-east-1"}
ApplyUploadSystemConfig(&cfg, map[string]string{
+48 -53
View File
@@ -35,7 +35,6 @@ const (
var (
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
)
type Repository interface {
@@ -59,19 +58,19 @@ type Repository interface {
}
type Service struct {
repository Repository
redis *redis.Client
aead cipher.AEAD
lookupKey []byte
ready bool
now func() time.Time
repository Repository
redis *redis.Client
aead cipher.AEAD
legacyAEADs []cipher.AEAD
lookupKey []byte
ready bool
now func() time.Time
}
type PaymentAccountInput struct {
ID int64 `json:"id"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
IdentityNo string `json:"identity_no"`
AccountNo string `json:"account_no"`
DigitalRMBCode string `json:"digital_rmb_code"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
@@ -84,7 +83,6 @@ type PaymentAccount struct {
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"`
IdentityNoMask string `json:"identity_no_mask"`
AccountNoMask string `json:"account_no_mask"`
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
@@ -99,7 +97,6 @@ type PaymentAccountDetail struct {
ID int64 `json:"id"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
IdentityNo string `json:"identity_no"`
AccountNo string `json:"account_no"`
DigitalRMBCode string `json:"digital_rmb_code"`
WechatQRURL string `json:"wechat_qr_url"`
@@ -266,20 +263,39 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
return service
}
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return service
}
aead, err := cipher.NewGCM(block)
aead, err := newAEAD(encryptionKey)
if err != nil {
return service
}
service.aead = aead
seenLegacyKeys := make(map[string]struct{}, len(cfg.FinanceLegacyMasterKeys))
for _, legacyMasterKey := range cfg.FinanceLegacyMasterKeys {
legacyMasterKey = strings.TrimSpace(legacyMasterKey)
if len([]byte(legacyMasterKey)) < 32 || legacyMasterKey == masterKey {
continue
}
if _, exists := seenLegacyKeys[legacyMasterKey]; exists {
continue
}
legacyAEAD, legacyErr := newAEAD(deriveKey(legacyMasterKey, "data-encryption"))
if legacyErr == nil {
service.legacyAEADs = append(service.legacyAEADs, legacyAEAD)
seenLegacyKeys[legacyMasterKey] = struct{}{}
}
}
service.lookupKey = lookupKey
service.ready = repository != nil && redisClient != nil
return service
}
func newAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func deriveKey(masterKey, purpose string) []byte {
mac := hmac.New(sha256.New, []byte(masterKey))
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
@@ -312,20 +328,13 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
items := make([]PaymentAccountDetail, 0, len(rows))
for _, row := range rows {
holderName, accountNo := row.HolderMask, row.AccountNoMask
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask
digitalRMBCode := row.DigitalRMBCodeMask
masked := true
if s.ready {
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
if holderErr == nil && accountErr == nil {
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
if row.IdentityNoCipher != "" {
if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil {
identityNo = value
} else {
masked = true
}
}
if row.DigitalRMBCodeCipher != "" {
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
digitalRMBCode = value
@@ -337,7 +346,7 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
}
items = append(items, PaymentAccountDetail{
ID: row.ID, BankName: row.BankName, HolderName: holderName,
IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked,
@@ -386,10 +395,6 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
if err != nil {
return store.PaymentAccountCreation{}, err
}
identityNoCipher, err := s.encrypt(input.IdentityNo)
if err != nil {
return store.PaymentAccountCreation{}, err
}
accountCipher, err := s.encrypt(input.AccountNo)
if err != nil {
return store.PaymentAccountCreation{}, err
@@ -406,9 +411,8 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
return store.PaymentAccountCreation{
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
DigitalRMBCodeMask: digitalRMBCodeMask,
WechatQRAttachmentID: input.WechatQRAttachmentID,
@@ -454,13 +458,6 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
identityNo := ""
if account.IdentityNoCipher != "" {
identityNo, err = s.decrypt(account.IdentityNoCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
}
digitalRMBCode := ""
if account.DigitalRMBCodeCipher != "" {
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
@@ -471,7 +468,7 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
snapshot, err := json.Marshal(map[string]any{
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
"bank_name": account.BankName, "holder_name": holderName,
"identity_no": identityNo, "account_no": accountNo,
"account_no": accountNo,
"digital_rmb_code": digitalRMBCode,
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
@@ -849,10 +846,9 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
func normalizePaymentAccount(input *PaymentAccountInput) error {
input.BankName = strings.TrimSpace(input.BankName)
input.HolderName = strings.TrimSpace(input.HolderName)
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
if !validHumanName(input.HolderName) ||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
@@ -865,7 +861,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error {
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
BankName: row.BankName, HolderMask: row.HolderMask,
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
WechatQRAttachmentID: row.WechatQRAttachmentID,
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
@@ -1042,11 +1038,17 @@ func (s *Service) decrypt(value string) (string, error) {
if err != nil || len(payload) <= s.aead.NonceSize() {
return "", errors.New("encrypted value is invalid")
}
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
if err != nil {
return "", err
aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...)
for _, aead := range aeads {
if len(payload) <= aead.NonceSize() {
continue
}
plain, decryptErr := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil)
if decryptErr == nil {
return string(plain), nil
}
}
return string(plain), nil
return "", errors.New("encrypted value cannot be decrypted")
}
func (s *Service) lookupHash(value string) string {
@@ -1116,13 +1118,6 @@ func maskAccount(value string) string {
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
}
func maskIdentityNo(value string) string {
if len(value) <= 8 {
return strings.Repeat("*", len(value))
}
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
}
func validHumanName(value string) bool {
count := utf8.RuneCountInString(value)
if count < 2 || count > 64 {
+37 -11
View File
@@ -135,22 +135,21 @@ func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) {
func TestNormalizePaymentAccount(t *testing.T) {
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
AccountNo: "6222 0000-0000 1234",
DigitalRMBCode: "1234 5678",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil {
t.Fatal(err)
}
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
input.DigitalRMBCode != "12345678" ||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" ||
maskAccount(input.AccountNo) != "6222********1234" {
t.Fatalf("unexpected normalized input: %#v", input)
}
}
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
IdentityNo: "11010119900101123X", AccountNo: "6222000000001234",
AccountNo: "6222000000001234",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil {
t.Fatalf("empty digital RMB code should be optional: %v", err)
@@ -167,11 +166,10 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
})
holder, _ := service.encrypt("张三")
identity, _ := service.encrypt("11010119900101123X")
account, _ := service.encrypt("6222000000001234")
digital, _ := service.encrypt("123456")
repository.details = []store.PaymentAccount{{
ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity,
ID: 7, BankName: "中国工商银行", HolderCipher: holder,
AccountNoCipher: account, DigitalRMBCodeCipher: digital,
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
@@ -180,7 +178,7 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" ||
if len(items) != 1 || items[0].HolderName != "张三" ||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
@@ -188,13 +186,38 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
}
}
func TestPaymentAccountDetailsDecryptsWithLegacyMasterKey(t *testing.T) {
repository := &fakeRepository{}
legacyMasterKey := "legacy-finance-master-key-with-at-least-32-characters"
legacyService := New(repository, &redis.Client{}, config.AuthConfig{FinanceMasterKey: legacyMasterKey})
holder, _ := legacyService.encrypt("张三")
account, _ := legacyService.encrypt("6222000000001234")
digital, _ := legacyService.encrypt("123456")
repository.details = []store.PaymentAccount{{
ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account,
DigitalRMBCodeCipher: digital, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
}}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "current-finance-master-key-with-at-least-32-characters",
FinanceLegacyMasterKeys: []string{legacyMasterKey},
})
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Masked || items[0].HolderName != "张三" ||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" {
t.Fatalf("unexpected legacy account details: %#v", items)
}
}
func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) {
repository := &paymentAccountRepository{}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
})
result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{
ID: 7, BankName: "中国工商银行", HolderName: "李四", IdentityNo: "11010119900101123X",
ID: 7, BankName: "中国工商银行", HolderName: "李四",
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
})
if err != nil {
@@ -226,7 +249,7 @@ func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *
t.Fatal(err)
}
if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" ||
items[0].IdentityNo != "1101**********123X" || items[0].AccountNo != "6222********1234" ||
items[0].AccountNo != "6222********1234" ||
items[0].DigitalRMBCode != "12****78" {
t.Fatalf("unexpected masked fallback: %#v", items)
}
@@ -238,7 +261,7 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
})
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X",
BankName: "中国工商银行", HolderName: "张三",
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
})
if err != nil {
@@ -247,6 +270,9 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" {
t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created)
}
if repository.created.IdentityNoCipher != "" || repository.created.IdentityNoHash != "" || repository.created.IdentityNoMask != "" {
t.Fatalf("identity number must no longer be collected: %#v", repository.created)
}
}
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {