From ce429afcf2ff89bd06383be5d92691dd07af8139 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 4 Sep 2026 19:09:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=86=E7=A0=81=E5=BC=BA=E5=BA=A6=E4=B8=8B?= =?UTF-8?q?=E9=99=90=E3=80=81=E7=A6=BB=E7=BA=BF=E6=8E=A8=E9=80=81=E4=B8=8B?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、密码规则:至少 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 --- im/backend/internal/app/admin_create_user.go | 2 +- .../internal/app/admin_create_user_test.go | 6 +- im/backend/internal/app/admin_operations.go | 6 +- im/backend/internal/app/app.go | 2 + im/backend/internal/app/auth.go | 18 +- .../internal/app/client_productization.go | 8 +- im/backend/internal/app/im.go | 6 +- im/backend/internal/app/integration.go | 8 + im/backend/internal/app/public_auth.go | 17 +- im/backend/internal/app/push.go | 267 +++++++++++++++++ .../internal/app/push_integration_test.go | 279 ++++++++++++++++++ .../app/register_sms_gate_integration_test.go | 64 ++++ im/backend/internal/app/security_test.go | 9 +- im/backend/migrations/035_offline_push.sql | 10 + 14 files changed, 686 insertions(+), 16 deletions(-) create mode 100644 im/backend/internal/app/push.go create mode 100644 im/backend/internal/app/push_integration_test.go create mode 100644 im/backend/migrations/035_offline_push.sql diff --git a/im/backend/internal/app/admin_create_user.go b/im/backend/internal/app/admin_create_user.go index fb21c8e..aedf7c7 100644 --- a/im/backend/internal/app/admin_create_user.go +++ b/im/backend/internal/app/admin_create_user.go @@ -37,7 +37,7 @@ func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) { return } if !validUserPassword(req.Password) { - fail(w, http.StatusBadRequest, 20001, "请输入初始密码") + fail(w, http.StatusBadRequest, 20001, passwordRule) return } if req.Nickname == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || len([]rune(req.City)) > 50 || len([]rune(req.Bio)) > 500 { diff --git a/im/backend/internal/app/admin_create_user_test.go b/im/backend/internal/app/admin_create_user_test.go index 9883ade..f050059 100644 --- a/im/backend/internal/app/admin_create_user_test.go +++ b/im/backend/internal/app/admin_create_user_test.go @@ -284,8 +284,10 @@ func TestAdminCreateUserDuplicatePhonePreservesAccount(t *testing.T) { } } -func TestAdminCreatedUserCanLoginWithSimpleOrLongPassword(t *testing.T) { - for _, password := range []string{"1", "letters", "中文", strings.Repeat("长密码", 30)} { +// Admin-set passwords follow the same rule as self-service ones, and whatever +// passes it has to survive the round trip to a working login. +func TestAdminCreatedUserCanLoginWithVariedPasswords(t *testing.T) { + for _, password := range []string{"letters8", "中文密码八个字符", "Str0ng!Passw0rd", strings.Repeat("长密码", 30)} { a, store, handler, token := provisioningApp(t) payload := validProvisionPayload() payload["password"] = password diff --git a/im/backend/internal/app/admin_operations.go b/im/backend/internal/app/admin_operations.go index bbc1e10..eda204a 100644 --- a/im/backend/internal/app/admin_operations.go +++ b/im/backend/internal/app/admin_operations.go @@ -298,10 +298,14 @@ func (a *App) adminResetUserPassword(w http.ResponseWriter, r *http.Request) { var req struct { NewPassword string `json:"newPassword"` } - if err != nil || decode(r, &req) != nil || !validUserPassword(req.NewPassword) { + if err != nil || decode(r, &req) != nil { fail(w, 400, 20001, "请填写有效的用户编号和新密码") return } + if !validUserPassword(req.NewPassword) { + fail(w, 400, 20001, passwordRule) + return + } hash, err := hashPassword(req.NewPassword) if err != nil { fail(w, 500, 50001, "密码加密失败") diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index 4004d83..d48218a 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -38,6 +38,8 @@ type App struct { config Config db *sql.DB hub *Hub + // Cached push auth token; the provider rate-limits the auth endpoint. + push pushAuth } type apiResponse struct { diff --git a/im/backend/internal/app/auth.go b/im/backend/internal/app/auth.go index ed19630..591fae9 100644 --- a/im/backend/internal/app/auth.go +++ b/im/backend/internal/app/auth.go @@ -192,8 +192,24 @@ func checkPassword(hash, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil } +// 8 characters and not all digits. Phone verification used to be the barrier +// to junk accounts; with the SMS switch off the password is what is left, and +// a 4-digit password is not one. Only setting a password is checked — existing +// accounts keep signing in with whatever they already have. +const passwordRule = "密码至少 8 位,且不能全是数字" + func validUserPassword(password string) bool { - return password != "" + // Both bounds count characters. Counting the upper one in bytes would refuse + // a 43-character Chinese password while accepting a 128-character English one. + if length := len([]rune(password)); length < 8 || length > 128 { + return false + } + for _, character := range password { + if character < '0' || character > '9' { + return true + } + } + return false } func validPhone(value string) bool { diff --git a/im/backend/internal/app/client_productization.go b/im/backend/internal/app/client_productization.go index d0e9c86..b8df5f0 100644 --- a/im/backend/internal/app/client_productization.go +++ b/im/backend/internal/app/client_productization.go @@ -169,8 +169,12 @@ func (a *App) changeUserPassword(w http.ResponseWriter, r *http.Request) { CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` } - if decode(r, &req) != nil || req.CurrentPassword == "" || !validUserPassword(req.NewPassword) { - fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码") + if decode(r, &req) != nil || req.CurrentPassword == "" { + fail(w, http.StatusBadRequest, 20001, "请填写当前密码和新密码") + return + } + if !validUserPassword(req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, passwordRule) return } var oldHash string diff --git a/im/backend/internal/app/im.go b/im/backend/internal/app/im.go index 9e910e3..b3947ce 100644 --- a/im/backend/internal/app/im.go +++ b/im/backend/internal/app/im.go @@ -728,7 +728,11 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI return item, nil, err } a.enqueueAIReply(ctx, conversationID, senderID, members, messageID) - return messageView{ID: messageID, ConversationID: conversationID, Seq: seq, SenderID: senderID, ClientMsgID: clientMsgID, Type: messageType, Content: content, CreatedAt: time.Now()}, members, nil + item = messageView{ID: messageID, ConversationID: conversationID, Seq: seq, SenderID: senderID, ClientMsgID: clientMsgID, Type: messageType, Content: content, CreatedAt: time.Now()} + // Everyone connected gets this over the socket; this is for the ones who are + // not, and it runs on its own so a slow push service cannot delay the send. + a.notifyOfflineMessage(conversationID, senderID, members, item) + return item, members, nil } func (a *App) loadMessage(r *http.Request, id int64) messageView { diff --git a/im/backend/internal/app/integration.go b/im/backend/internal/app/integration.go index 00646de..233bb89 100644 --- a/im/backend/internal/app/integration.go +++ b/im/backend/internal/app/integration.go @@ -191,6 +191,14 @@ var integrationSpecs = map[string][]integrationFieldSpec{ {Key: "ai.fallback_text", Label: "失败兜底文案", Input: "text", Description: "模型调用失败时发送的内容,留空则静默不回复"}, {Key: "ai.log_retention_days", Label: "调用日志保留天数", Input: "number", Description: "建议 7 到 180 天"}, }, + "push": { + {Key: "push.enabled", Label: "启用离线推送", Input: "boolean", Required: true, Description: "App 退到后台或未运行时,新消息通过厂商通道提醒;关闭后只有在线时的实时推送"}, + {Key: "push.app_id", Label: "AppID", Input: "text", Required: true, Description: "个推 / UniPush 应用的 AppID,需与客户端打包配置一致"}, + {Key: "push.app_key", Label: "AppKey", Input: "text", Required: true, Description: "个推 / UniPush 应用的 AppKey"}, + {Key: "push.master_secret", Label: "MasterSecret", Input: "secret", Required: true, Description: "服务端鉴权密钥,AES-GCM 加密保存;不得写入客户端"}, + {Key: "push.base_url", Label: "接口地址", Input: "text", Description: "默认 https://restapi.getui.com,私有化部署时替换"}, + {Key: "push.show_preview", Label: "通知显示消息内容", Input: "boolean", Required: true, Description: "关闭后只提示「给你发来一条消息」,不在锁屏上暴露正文"}, + }, "payment": { {Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"}, {Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"}, diff --git a/im/backend/internal/app/public_auth.go b/im/backend/internal/app/public_auth.go index 158a5d4..4dcac3e 100644 --- a/im/backend/internal/app/public_auth.go +++ b/im/backend/internal/app/public_auth.go @@ -94,8 +94,13 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { fail(w, http.StatusBadRequest, 20001, err.Error()) return } - if !validPhone(req.Phone) || !validUserPassword(req.Password) || strings.TrimSpace(req.Nickname) == "" || len([]rune(strings.TrimSpace(req.Nickname))) > 50 { - fail(w, http.StatusBadRequest, 20001, "请填写有效的手机号、昵称和密码") + if !validPhone(req.Phone) || strings.TrimSpace(req.Nickname) == "" || len([]rune(strings.TrimSpace(req.Nickname))) > 50 { + fail(w, http.StatusBadRequest, 20001, "请填写有效的手机号和昵称") + return + } + // Said separately: "请填写有效的密码" leaves the reader guessing what is wrong. + if !validUserPassword(req.Password) { + fail(w, http.StatusBadRequest, 20001, passwordRule) return } if !a.rateLimit(w, r, "register_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "register_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { @@ -210,8 +215,12 @@ func (a *App) loginSMS(w http.ResponseWriter, r *http.Request) { func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { var req authRequest - if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 || !validUserPassword(req.Password) { - fail(w, 400, 20001, "请填写有效的手机号、验证码和新密码") + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 { + fail(w, 400, 20001, "请填写有效的手机号和验证码") + return + } + if !validUserPassword(req.Password) { + fail(w, 400, 20001, passwordRule) return } if !a.rateLimit(w, r, "password_reset_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "password_reset_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { diff --git a/im/backend/internal/app/push.go b/im/backend/internal/app/push.go new file mode 100644 index 0000000..cff4ab9 --- /dev/null +++ b/im/backend/internal/app/push.go @@ -0,0 +1,267 @@ +package app + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Until now the app collected push tokens and never sent anything: with the App +// in the background there is no socket, so a message simply arrived silently. +// This is the other half — deciding who should be told, and telling them. +// +// The decision is deliberately conservative. A push goes out only to a +// recipient who is not connected right now, has not muted the conversation, and +// has not switched the notification off. Everyone else already sees the message +// where they are looking. + +const ( + pushTokenSafetyWindow = 5 * time.Minute + pushRequestTimeout = 8 * time.Second + pushPreviewRunes = 30 +) + +type pushTarget struct { + UserID int64 + Token string + Platform string +} + +type pushCredentials struct { + AppID string + AppKey string + MasterSecret string + BaseURL string +} + +func (c pushCredentials) complete() bool { + return c.AppID != "" && c.AppKey != "" && c.MasterSecret != "" +} + +type pushAuth struct { + mu sync.Mutex + token string + expires time.Time + forKey string +} + +func (a *App) pushSettings(ctx context.Context) (pushCredentials, bool) { + if !a.configBool(ctx, "push.enabled", false) { + return pushCredentials{}, false + } + credentials := pushCredentials{ + AppID: strings.TrimSpace(a.configPlain(ctx, "push.app_id", "")), + AppKey: strings.TrimSpace(a.configPlain(ctx, "push.app_key", "")), + MasterSecret: strings.TrimSpace(a.configPlain(ctx, "push.master_secret", "")), + BaseURL: strings.TrimRight(strings.TrimSpace(a.configPlain(ctx, "push.base_url", "https://restapi.getui.com")), "/"), + } + if credentials.BaseURL == "" { + credentials.BaseURL = "https://restapi.getui.com" + } + return credentials, credentials.complete() +} + +// token returns a cached auth token, refreshing it when it is close to expiry. +// Getui allows 100 auth calls a minute, so caching is not optional. +func (a *App) pushToken(ctx context.Context, credentials pushCredentials) (string, error) { + a.push.mu.Lock() + defer a.push.mu.Unlock() + key := credentials.AppID + ":" + credentials.AppKey + if a.push.forKey == key && a.push.token != "" && time.Now().Add(pushTokenSafetyWindow).Before(a.push.expires) { + return a.push.token, nil + } + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + digest := sha256.Sum256([]byte(credentials.AppKey + timestamp + credentials.MasterSecret)) + body, err := a.pushPost(ctx, credentials, "/auth", "", map[string]string{ + "sign": hex.EncodeToString(digest[:]), + "timestamp": timestamp, + "appkey": credentials.AppKey, + }) + if err != nil { + return "", err + } + var payload struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Token string `json:"token"` + ExpireTime string `json:"expire_time"` + } `json:"data"` + } + if err = json.Unmarshal(body, &payload); err != nil { + return "", fmt.Errorf("推送鉴权响应无法解析") + } + if payload.Code != 0 || payload.Data.Token == "" { + return "", fmt.Errorf("推送鉴权失败:%s", payload.Msg) + } + expires := time.Now().Add(12 * time.Hour) + if milliseconds, convErr := strconv.ParseInt(payload.Data.ExpireTime, 10, 64); convErr == nil && milliseconds > 0 { + expires = time.UnixMilli(milliseconds) + } + a.push.token, a.push.expires, a.push.forKey = payload.Data.Token, expires, key + return a.push.token, nil +} + +func (a *App) pushPost(ctx context.Context, credentials pushCredentials, path, token string, payload any) ([]byte, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + endpoint := credentials.BaseURL + "/v2/" + credentials.AppID + path + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json;charset=utf-8") + if token != "" { + request.Header.Set("token", token) + } + response, err := (&http.Client{Timeout: pushRequestTimeout}).Do(request) + if err != nil { + return nil, fmt.Errorf("推送服务不可达") + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("推送服务返回 HTTP %d", response.StatusCode) + } + return body, nil +} + +// pushRecipients is the whole policy in one query: who, of these members, is +// away from the app and still wants to hear about it. +func (a *App) pushRecipients(ctx context.Context, conversationID, senderID int64, members []int64) []pushTarget { + targets := []pushTarget{} + for _, member := range members { + if member == senderID || a.hub.online(member) { + continue + } + var muted, allowed int + if err := a.db.QueryRowContext(ctx, `SELECT m.muted,COALESCE(ns.im_enabled,1) + FROM im_conversation_members m LEFT JOIN user_notification_settings ns ON ns.user_id=m.user_id + WHERE m.conversation_id=? AND m.user_id=? AND m.status=1`, conversationID, member).Scan(&muted, &allowed); err != nil { + continue + } + if muted == 1 || allowed != 1 { + continue + } + rows, err := a.db.QueryContext(ctx, `SELECT push_token,platform FROM user_push_tokens WHERE user_id=? AND status=1 AND push_token<>''`, member) + if err != nil { + continue + } + for rows.Next() { + target := pushTarget{UserID: member} + if rows.Scan(&target.Token, &target.Platform) == nil { + targets = append(targets, target) + } + } + _ = rows.Close() + } + return targets +} + +func pushPreview(messageType int, content any, showPreview bool) string { + switch messageType { + case messageTypeImage: + return "[图片]" + case messageTypeVoice: + return "[语音]" + } + if !showPreview { + return "给你发来一条消息" + } + data, _ := content.(map[string]any) + text, _ := data["text"].(string) + text = strings.TrimSpace(text) + if text == "" { + return "给你发来一条消息" + } + if runes := []rune(text); len(runes) > pushPreviewRunes { + text = string(runes[:pushPreviewRunes]) + "…" + } + return text +} + +// notifyOfflineMessage runs after the send has already succeeded, on its own +// context: a slow or broken push service must never delay or fail a message +// that is already delivered to everyone who is online. +func (a *App) notifyOfflineMessage(conversationID, senderID int64, members []int64, item messageView) { + credentials, ready := a.pushSettings(context.Background()) + if !ready { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + targets := a.pushRecipients(ctx, conversationID, senderID, members) + if len(targets) == 0 { + return + } + var sender string + _ = a.db.QueryRowContext(ctx, `SELECT nickname FROM user_profiles WHERE user_id=?`, senderID).Scan(&sender) + if strings.TrimSpace(sender) == "" { + sender = "新消息" + } + body := pushPreview(item.Type, item.Content, a.configBool(ctx, "push.show_preview", true)) + token, err := a.pushToken(ctx, credentials) + if err != nil { + log.Printf("push: 鉴权失败 conversation=%d: %v", conversationID, err) + return + } + for _, target := range targets { + if err := a.sendPush(ctx, credentials, token, target, sender, body, conversationID); err != nil { + log.Printf("push: 下发失败 user=%d: %v", target.UserID, err) + } + } + }() +} + +func (a *App) sendPush(ctx context.Context, credentials pushCredentials, token string, target pushTarget, title, body string, conversationID int64) error { + payload := map[string]any{ + "request_id": randomToken()[:24], + "settings": map[string]any{"ttl": 3600000}, + "audience": map[string]any{"cid": []string{target.Token}}, + "push_message": map[string]any{ + "notification": map[string]any{ + "title": title, + "body": body, + "click_type": "payload", + // The client opens the conversation from this payload. + "payload": fmt.Sprintf(`{"type":"conversation","conversationId":%d}`, conversationID), + }, + }, + } + raw, err := a.pushPost(ctx, credentials, "/push/single/cid", token, payload) + if err != nil { + return err + } + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err = json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("推送响应无法解析") + } + // 10001/10002 mean the client id is unknown or retired; stop pushing to it. + if result.Code == 10001 || result.Code == 10002 { + _, _ = a.db.ExecContext(ctx, `UPDATE user_push_tokens SET status=0 WHERE user_id=? AND push_token=?`, target.UserID, target.Token) + return nil + } + if result.Code != 0 { + return fmt.Errorf("推送服务返回 %d:%s", result.Code, result.Msg) + } + return nil +} diff --git a/im/backend/internal/app/push_integration_test.go b/im/backend/internal/app/push_integration_test.go new file mode 100644 index 0000000..f8b15f5 --- /dev/null +++ b/im/backend/internal/app/push_integration_test.go @@ -0,0 +1,279 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +type capturedPush struct { + mu sync.Mutex + paths []string + auths []map[string]any + sends []map[string]any +} + +func (c *capturedPush) record(path string, body map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + c.paths = append(c.paths, path) + if strings.HasSuffix(path, "/auth") { + c.auths = append(c.auths, body) + return + } + c.sends = append(c.sends, body) +} + +func (c *capturedPush) snapshot() ([]string, []map[string]any, []map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string{}, c.paths...), append([]map[string]any{}, c.auths...), append([]map[string]any{}, c.sends...) +} + +func pushFixture(t *testing.T, reply func(path string) string) (*App, *capturedPush) { + t.Helper() + db := isolatedIMDatabase(t) + captured := &capturedPush{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + var body map[string]any + _ = json.Unmarshal(raw, &body) + if !strings.HasSuffix(r.URL.Path, "/auth") { + body["__token_header"] = r.Header.Get("token") + } + captured.record(r.URL.Path, body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(reply(r.URL.Path))) + })) + t.Cleanup(server.Close) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + for _, statement := range []string{ + `INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('push.enabled','true','boolean',''),('push.app_id','APP123','text',''),('push.app_key','KEY456','text',''), + ('push.show_preview','true','boolean',''),('membership.free_unanswered_message_limit','0','integer','') + ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + secret, err := a.encryptSecret("SECRET789") + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('push.master_secret',?,'secret','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value),value_type='secret'`, secret); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('push.base_url',?,'text','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`, server.URL); err != nil { + t.Fatal(err) + } + return a, captured +} + +func okReply(path string) string { + if strings.HasSuffix(path, "/auth") { + return `{"code":0,"msg":"success","data":{"token":"auth-token-1","expire_time":"99999999999999"}}` + } + return `{"code":0,"msg":"success"}` +} + +// The App collected push tokens for months and never sent anything: a message +// arriving while the app was closed made no sound at all. +func TestOfflineMessagePushMySQL(t *testing.T) { + a, captured := pushFixture(t, okReply) + db := a.db + if _, err := db.Exec(`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(2,'device-2','unipush','cid-of-user-2','android',1)`); err != nil { + t.Fatal(err) + } + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil { + t.Fatal(err) + } + settle := func() { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if _, _, sends := captured.snapshot(); len(sends) > 0 { + return + } + time.Sleep(30 * time.Millisecond) + } + } + + if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "push-1", 1, map[string]any{"text": "在吗,明天有空吗"}); err != nil { + t.Fatal(err) + } + settle() + paths, auths, sends := captured.snapshot() + if len(sends) != 1 { + t.Fatalf("应当下发一条推送: paths=%v", paths) + } + if len(auths) != 1 || auths[0]["appkey"] != "KEY456" || auths[0]["sign"] == "" { + t.Fatalf("鉴权请求不正确: %v", auths) + } + if !strings.Contains(paths[0], "/v2/APP123/auth") || !strings.Contains(paths[1], "/v2/APP123/push/single/cid") { + t.Fatalf("接口路径不正确: %v", paths) + } + if sends[0]["__token_header"] != "auth-token-1" { + t.Fatalf("推送请求必须带鉴权 token: %v", sends[0]) + } + audience := sends[0]["audience"].(map[string]any)["cid"].([]any) + if len(audience) != 1 || audience[0] != "cid-of-user-2" { + t.Fatalf("收件人不正确: %v", audience) + } + notification := sends[0]["push_message"].(map[string]any)["notification"].(map[string]any) + if notification["title"] != "IM测试1" || notification["body"] != "在吗,明天有空吗" { + t.Fatalf("通知内容: %v", notification) + } + + // The auth token is reused: the provider rate-limits that endpoint hard. + if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "push-2", 1, map[string]any{"text": "再问一次"}); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if _, _, sends := captured.snapshot(); len(sends) > 1 { + break + } + time.Sleep(30 * time.Millisecond) + } + if _, auths, sends := captured.snapshot(); len(auths) != 1 || len(sends) != 2 { + t.Fatalf("第二次不应重新鉴权: auths=%d sends=%d", len(auths), len(sends)) + } +} + +func TestPushIsSkippedForEveryoneWhoDoesNotNeedItMySQL(t *testing.T) { + a, captured := pushFixture(t, okReply) + db := a.db + for _, statement := range []string{ + `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(2,'device-2','unipush','cid-2','android',1)`, + `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(3,'device-3','unipush','cid-3','android',1)`, + `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(1,'device-1','unipush','cid-1','android',1)`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil { + t.Fatal(err) + } + // The client message id is ASCII only, so the case name and the id differ. + attempt := 0 + quiet := func(reason string) { + t.Helper() + attempt++ + if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, fmt.Sprintf("quiet-%d", attempt), 1, map[string]any{"text": "在吗"}); err != nil { + t.Fatal(err) + } + time.Sleep(400 * time.Millisecond) + if _, _, sends := captured.snapshot(); len(sends) != 0 { + t.Fatalf("%s: 不应下发推送,实际 %d 条", reason, len(sends)) + } + } + + // Muted conversation. + if _, err := db.Exec(`UPDATE im_conversation_members SET muted=1 WHERE conversation_id=? AND user_id=2`, created.ID); err != nil { + t.Fatal(err) + } + quiet("会话已免打扰") + if _, err := db.Exec(`UPDATE im_conversation_members SET muted=0 WHERE conversation_id=? AND user_id=2`, created.ID); err != nil { + t.Fatal(err) + } + + // Notifications switched off by the recipient. + if _, err := db.Exec(`INSERT INTO user_notification_settings(user_id,im_enabled) VALUES(2,0) ON DUPLICATE KEY UPDATE im_enabled=0`); err != nil { + t.Fatal(err) + } + quiet("用户关闭了消息通知") + if _, err := db.Exec(`UPDATE user_notification_settings SET im_enabled=1 WHERE user_id=2`); err != nil { + t.Fatal(err) + } + + // The switch in the admin console. + if _, err := db.Exec(`UPDATE system_configs SET config_value='false' WHERE config_key='push.enabled'`); err != nil { + t.Fatal(err) + } + quiet("总开关关闭") + if _, err := db.Exec(`UPDATE system_configs SET config_value='true' WHERE config_key='push.enabled'`); err != nil { + t.Fatal(err) + } + + // Nobody pushes to themselves, and an online recipient already has it. + if _, _, err := a.persistMessageContext(context.Background(), created.ID, 2, "own-message", 1, map[string]any{"text": "我自己发的"}); err != nil { + t.Fatal(err) + } + time.Sleep(400 * time.Millisecond) + _, _, sends := captured.snapshot() + if len(sends) != 1 { + t.Fatalf("只应给对方发 1 条,实际 %d", len(sends)) + } + audience := sends[0]["audience"].(map[string]any)["cid"].([]any) + if len(audience) != 1 || audience[0] != "cid-1" { + t.Fatalf("推送应当只发给对方,实际收件人 %v", audience) + } +} + +func TestPushPreviewRespectsTheContentSwitch(t *testing.T) { + long := strings.Repeat("很长的一句话", 20) + if got := pushPreview(1, map[string]any{"text": long}, true); len([]rune(got)) != pushPreviewRunes+1 { + t.Fatalf("预览应当截断: %d", len([]rune(got))) + } + if got := pushPreview(1, map[string]any{"text": "晚上一起吃饭吗"}, true); got != "晚上一起吃饭吗" { + t.Fatalf("got %q", got) + } + // Locked screens are public: with the switch off nothing of the text leaks. + if got := pushPreview(1, map[string]any{"text": "银行卡密码是 1234"}, false); strings.Contains(got, "1234") { + t.Fatalf("关闭预览后不得泄露正文: %q", got) + } + if got := pushPreview(messageTypeImage, nil, true); got != "[图片]" { + t.Fatalf("got %q", got) + } + if got := pushPreview(messageTypeVoice, nil, true); got != "[语音]" { + t.Fatalf("got %q", got) + } +} + +// A client id the provider no longer knows is retired, so it is not retried on +// every single message from then on. +func TestRetiredPushTokenIsDisabledMySQL(t *testing.T) { + a, _ := pushFixture(t, func(path string) string { + if strings.HasSuffix(path, "/auth") { + return `{"code":0,"msg":"success","data":{"token":"auth-token-1","expire_time":"99999999999999"}}` + } + return `{"code":10001,"msg":"cid is invalid"}` + }) + db := a.db + if _, err := db.Exec(`INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,status) VALUES(2,'device-2','unipush','stale-cid','android',1)`); err != nil { + t.Fatal(err) + } + var created struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200), &created); err != nil { + t.Fatal(err) + } + if _, _, err := a.persistMessageContext(context.Background(), created.ID, 1, "stale", 1, map[string]any{"text": "在吗"}); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + var status int + if db.QueryRow(`SELECT status FROM user_push_tokens WHERE push_token='stale-cid'`).Scan(&status) == nil && status == 0 { + return + } + time.Sleep(30 * time.Millisecond) + } + t.Fatal("失效的 cid 应当被停用") +} diff --git a/im/backend/internal/app/register_sms_gate_integration_test.go b/im/backend/internal/app/register_sms_gate_integration_test.go index 3877592..2e4bf76 100644 --- a/im/backend/internal/app/register_sms_gate_integration_test.go +++ b/im/backend/internal/app/register_sms_gate_integration_test.go @@ -117,3 +117,67 @@ func TestRegisterWithoutSMSVerificationMySQL(t *testing.T) { } }) } + +// With phone verification switchable off, the password is the only thing left +// standing between a script and an account. It is checked when a password is +// set — never when one is used, so existing accounts keep signing in. +func TestPasswordRuleAppliesWhereverAPasswordIsSet(t *testing.T) { + for _, weak := range []string{"", "1", "1234567", "12345678", "000000000000", "短密码"} { + if validUserPassword(weak) { + t.Fatalf("%q 不应通过", weak) + } + } + for _, strong := range []string{"passw0rd", "12345678a", "一二三四五六七八", "Str0ng!Passw0rd", "abcdefgh"} { + if !validUserPassword(strong) { + t.Fatalf("%q 应当通过", strong) + } + } + // A 129-character password is a paste accident, not a stronger secret — + // counted in characters, so a long Chinese passphrase is not punished. + if validUserPassword(strings.Repeat("a", 129)) { + t.Fatal("过长的密码应当被拒绝") + } + if !validUserPassword(strings.Repeat("长密码", 30)) { + t.Fatal("90 个汉字的密码应当通过:上限按字符数而不是字节数") + } +} + +func TestPasswordRuleIsExplainedRatherThanLumpedInMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + if _, err := db.Exec(`INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES('sms.enabled','false','boolean','') ON DUPLICATE KEY UPDATE config_value=VALUES(config_value)`); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + a.register(w, httptest.NewRequest("POST", "/api/v1/auth/register", + strings.NewReader(`{"phone":"13911100001","code":"","password":"1234","nickname":"弱密码","deviceId":"test-device"}`))) + if w.Code != 400 || !strings.Contains(w.Body.String(), "密码至少 8 位") { + t.Fatalf("HTTP %d: %s", w.Code, w.Body.String()) + } + // The same account with a real password goes through. + ok := httptest.NewRecorder() + a.register(ok, httptest.NewRequest("POST", "/api/v1/auth/register", + strings.NewReader(`{"phone":"13911100001","code":"","password":"passw0rd","nickname":"正常密码","deviceId":"test-device"}`))) + if ok.Code != 200 { + t.Fatalf("HTTP %d: %s", ok.Code, ok.Body.String()) + } + // Signing in is never re-validated, so accounts created before the rule keep working. + if _, err := db.Exec(`UPDATE users SET password_hash=? WHERE phone_hash=?`, mustHash(t, "1234"), phoneHash("13911100001")); err != nil { + t.Fatal(err) + } + login := httptest.NewRecorder() + a.loginPassword(login, httptest.NewRequest("POST", "/api/v1/auth/login", + strings.NewReader(`{"phone":"13911100001","password":"1234","deviceId":"test-device"}`))) + if login.Code != 200 { + t.Fatalf("老账号必须还能登录: HTTP %d %s", login.Code, login.Body.String()) + } +} + +func mustHash(t *testing.T, password string) string { + t.Helper() + hash, err := hashPassword(password) + if err != nil { + t.Fatal(err) + } + return hash +} diff --git a/im/backend/internal/app/security_test.go b/im/backend/internal/app/security_test.go index 7cf4990..8ee8ae8 100644 --- a/im/backend/internal/app/security_test.go +++ b/im/backend/internal/app/security_test.go @@ -87,11 +87,12 @@ func TestPasswordAndPhoneRules(t *testing.T) { } } -func TestPasswordsWithoutStrengthRestrictions(t *testing.T) { +// 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)} { - if !validUserPassword(password) { - t.Fatalf("password with %d bytes was rejected by policy", len(password)) - } 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) diff --git a/im/backend/migrations/035_offline_push.sql b/im/backend/migrations/035_offline_push.sql new file mode 100644 index 0000000..440fb6b --- /dev/null +++ b/im/backend/migrations/035_offline_push.sql @@ -0,0 +1,10 @@ +-- 离线推送配置。管理端保存集成配置用的是 UPDATE,行不存在就会静默保存不上, +-- 所以这些键必须先建出来。凭据留空,由运营在「平台配置」里填写。 +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('push.enabled','false','boolean','App 退到后台或未运行时,新消息是否通过厂商通道提醒'), + ('push.app_id','','string','个推 / UniPush 应用 AppID'), + ('push.app_key','','string','个推 / UniPush 应用 AppKey'), + ('push.master_secret','','secret','服务端鉴权密钥,AES-GCM 加密保存'), + ('push.base_url','https://restapi.getui.com','string','推送服务接口地址'), + ('push.show_preview','true','boolean','通知是否显示消息正文;关闭后只提示收到新消息') +ON DUPLICATE KEY UPDATE description=VALUES(description);