密码强度下限、离线推送下发
一、密码规则:至少 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:
co-authored by
Claude Opus 5
parent
f313979e88
commit
ce429afcf2
@@ -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
|
||||
|
||||
@@ -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, "密码加密失败")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user