密码强度下限、离线推送下发

一、密码规则:至少 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>
This commit is contained in:
Your Name
2026-09-04 19:09:33 +08:00
co-authored by Claude Opus 5
parent f313979e88
commit ce429afcf2
14 changed files with 686 additions and 16 deletions
+1 -1
View File
@@ -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 {
@@ -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
+5 -1
View File
@@ -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, "密码加密失败")
+2
View File
@@ -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 {
+17 -1
View File
@@ -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 {
@@ -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
+5 -1
View File
@@ -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 {
+8
View File
@@ -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"},
+13 -4
View File
@@ -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) {
+267
View File
@@ -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
}
@@ -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 应当被停用")
}
@@ -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
}
+5 -4
View File
@@ -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)
@@ -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);