Files
kefu/im/backend/internal/app/security_test.go
T
Your NameandClaude Opus 5 ce429afcf2 密码强度下限、离线推送下发
一、密码规则:至少 8 位且不能全是数字。短信验证可以在后台关掉,关掉之后
密码就是账号质量的唯一门槛,而此前 validUserPassword 只检查非空——"1"
也是合法密码。规则只在「设置密码」时校验(注册、找回、改密、后台建号与
后台重置),登录不再校验,已有账号照常使用。上下限都按字符数计算,否则
43 个汉字的密码会因为字节数超限被拒。各处错误提示改为直接说明规则,
而不是笼统的一句「请填写有效的密码」。

二、离线推送:此前客户端一直在上报 push token,服务端从未下发过任何东西,
App 退到后台或被杀掉时新消息完全没有提醒(MESSAGE_PUSH 只是 WebSocket
帧名)。补上服务端下发:
- 只发给「此刻不在线 + 未对该会话免打扰 + 未关闭消息通知」的接收者,
  在线的人已经从实时通道拿到了。
- 鉴权 token 按 provider 的过期时间缓存,个推的 auth 接口限流很紧。
- 失效的 cid(10001/10002)就地停用,不再每条消息重试一次。
- 整个过程在独立 goroutine 与独立 context 上进行,推送服务再慢也不会
  拖慢或拖垮一条已经发出的消息。
- 是否显示正文由 push.show_preview 控制,关闭后锁屏上不出现消息内容。
凭据在管理端「离线推送」页填写(迁移 035 先建出配置行——集成配置保存
走的是 UPDATE,行不存在会静默保存不上)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:09:33 +08:00

138 lines
4.9 KiB
Go

package app
import (
"bytes"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
func productionConfigForTest() Config {
return Config{
Port: 8888,
DSN: "xingyu:password@tcp(mysql:3306)/im",
JWTSecret: "0123456789abcdef0123456789abcdef",
ConfigEncryptionKey: "abcdef0123456789abcdef0123456789",
Environment: "production",
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
}
}
func TestValidateProductionConfig(t *testing.T) {
config := productionConfigForTest()
if err := validateConfig(config); err != nil {
t.Fatalf("expected a valid production config: %v", err)
}
tests := []struct {
name string
mutate func(*Config)
}{
{"root database account", func(config *Config) { config.DSN = "root:root@tcp(mysql:3306)/im" }},
{"weak jwt", func(config *Config) { config.JWTSecret = "short" }},
{"missing encryption key", func(config *Config) { config.ConfigEncryptionKey = "" }},
{"wildcard cors", func(config *Config) { config.AllowedOrigins = []string{"*"} }},
{"insecure origin", func(config *Config) { config.AllowedOrigins = []string{"http://app.example.com"} }},
{"demo seed", func(config *Config) { config.SeedDemo = true }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
invalid := config
test.mutate(&invalid)
if err := validateConfig(invalid); err == nil {
t.Fatal("expected production validation to reject config")
}
})
}
}
func TestPhoneEncryptionRoundTrip(t *testing.T) {
application := &App{config: productionConfigForTest()}
phone := "13800138000"
first, err := application.encryptPhone(phone)
if err != nil {
t.Fatal(err)
}
second, err := application.encryptPhone(phone)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(first, second) {
t.Fatal("phone encryption must use a unique random nonce")
}
decrypted, err := application.decryptPhone(first)
if err != nil || decrypted != phone {
t.Fatalf("unexpected decrypted phone %q: %v", decrypted, err)
}
}
func TestProductionURLValidation(t *testing.T) {
if !validHTTPSURL("https://pay.example.com/create") {
t.Fatal("expected HTTPS URL to be accepted")
}
for _, value := range []string{"http://pay.example.com", "javascript:alert(1)", "https:///missing-host", ""} {
if validHTTPSURL(value) {
t.Fatalf("expected URL to be rejected: %s", value)
}
}
}
func TestPasswordAndPhoneRules(t *testing.T) {
if validUserPassword("") {
t.Fatal("an account must still have a password")
}
if !validPhone("13800138000") || validPhone("23800138000") || validPhone("1380013800x") {
t.Fatal("phone policy is not enforced")
}
}
// Storage and verification carry no policy of their own: any string a person
// already has must keep working, including the ones the sign-up rule would now
// refuse. The rule lives at the point a password is set — see
// TestPasswordRuleAppliesWhereverAPasswordIsSet.
func TestAnyExistingPasswordStillHashesAndVerifies(t *testing.T) {
for _, password := range []string{"1", "123456", "a", "password", "!", "中", " ", strings.Repeat("a", 72), strings.Repeat("a", 73), strings.Repeat("密码", 100)} {
hash, err := hashPassword(password)
if err != nil || !checkPassword(hash, password) {
t.Fatalf("password with %d bytes cannot be stored and verified: %v", len(password), err)
}
if checkPassword(hash, password+"x") || checkPassword(hash, "") {
t.Fatal("password comparison ignored a suffix or accepted an empty password")
}
config := productionConfigForTest()
config.BootstrapAdminPassword = password
if err := validateConfig(config); err != nil {
t.Fatal("bootstrap admin still enforces password strength:", err)
}
}
}
func TestLongPasswordHashesAndLegacyCompatibility(t *testing.T) {
legacyPassword := strings.Repeat("x", 72)
legacy, err := bcrypt.GenerateFromPassword([]byte(legacyPassword), bcrypt.MinCost)
if err != nil || !checkPassword(string(legacy), legacyPassword) || checkPassword(string(legacy), legacyPassword+"suffix") {
t.Fatal("legacy bcrypt credentials must work without accepting truncated input")
}
password := legacyPassword + "a"
first, err := hashPassword(password)
if err != nil {
t.Fatal(err)
}
second, err := hashPassword(password)
if err != nil {
t.Fatal(err)
}
if first == second || !strings.HasPrefix(first, longPasswordHashPrefix) || len(first) > 255 {
t.Fatal("long passwords require unique salts and a hash that fits existing storage")
}
if !checkPassword(first, password) || checkPassword(first, legacyPassword+"b") || checkPassword(first, legacyPassword) {
t.Fatal("the entire long password must participate in verification")
}
for _, invalid := range []string{"", "plaintext", "$argon2id$", strings.Replace(first, "m=19456", "m=999999999", 1), first[:len(first)-1], longPasswordHashPrefix + strings.Repeat("!", 66)} {
if checkPassword(invalid, password) {
t.Fatal("malformed hash was accepted")
}
}
}