From ba9be5b68b7c88dc7f38c51e3b99aea8be198b05 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 20 Aug 2026 17:13:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=EF=BC=9ALL?= =?UTF-8?q?M=20=E4=BC=9A=E8=AF=9D=E6=8C=89=20(bot,peer)=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB+=E5=8E=86=E5=8F=B2=E4=B8=8A=E9=99=9050=E6=9D=A1?= =?UTF-8?q?=EF=BC=8CLLM=20=E5=85=A5=E9=98=9F=E6=8C=89=20(bot,from=5Fnode)?= =?UTF-8?q?=20=E9=99=90=E6=B5=81(60s=E5=86=855=E6=9D=A1)=EF=BC=8C=E7=93=A6?= =?UTF-8?q?=E7=89=87=E7=A3=81=E7=9B=98=E7=BC=93=E5=AD=98=E5=8D=95=E6=BA=90?= =?UTF-8?q?=E9=85=8D=E9=A2=9D(3000=E6=96=87=E4=BB=B6/300MB=20=E6=8C=89mtim?= =?UTF-8?q?e=E6=B7=98=E6=B1=B0)=EF=BC=8C=E7=AD=BE=E5=88=B0=E5=A2=99?= =?UTF-8?q?=E8=AF=BB=E6=8E=A5=E5=8F=A3=E9=99=90=E9=80=9F+=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E6=AF=8F=E6=97=A51000=E6=9D=A1=E5=B0=81=E9=A1=B6?= =?UTF-8?q?=EF=BC=8C=E6=95=8F=E6=84=9F=E6=95=B0=E6=8D=AE=E8=90=BD=E7=9B=98?= =?UTF-8?q?=20AES-256-GCM=20=E5=8A=A0=E5=AF=86(MESH=5FSECRET=5FKEY,=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=98=8E=E6=96=87=E8=BF=81=E7=A7=BB)?= =?UTF-8?q?=EF=BC=8Cadmin=20=E6=94=B9=E5=AF=86=E9=9C=80=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E5=AF=86=E7=A0=81+pwd=5Fversion=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=92=A4=E9=94=80=EF=BC=8C=E5=90=8E=E7=AB=AF=20v1.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/SECURITY_FIX_TODO.md | 44 ++++---- internal/agents/sign/sign.go | 13 +++ internal/auth/auth.go | 13 ++- internal/bot/bot_pki_resolver.go | 3 +- internal/bot/bot_service.go | 3 + internal/conversation/store.go | 38 +++++-- internal/conversation/store_test.go | 87 +++++++++++++++ internal/message/types.go | 1 + internal/ratelimit/ratelimit.go | 22 ++++ internal/ratelimit/ratelimit_test.go | 24 ++++ internal/secrets/secrets.go | 105 ++++++++++++++++++ internal/secrets/secrets_test.go | 72 ++++++++++++ internal/store/bot_direct_message_store.go | 4 + internal/store/bot_store.go | 4 +- internal/store/db.go | 14 +++ internal/store/llm_store.go | 49 ++++++++ internal/store/mqtt_forward_store.go | 17 ++- internal/store/sign_store.go | 17 +++ internal/store/user_store.go | 7 +- internal/web/map_tile_proxy_routes.go | 80 ++++++++++++- internal/web/web.go | 28 ++++- main.go | 3 + meshmap_frontend/src/api.ts | 4 +- .../src/components/AdminUsers.vue | 18 ++- 24 files changed, 619 insertions(+), 51 deletions(-) create mode 100644 internal/conversation/store_test.go create mode 100644 internal/secrets/secrets.go create mode 100644 internal/secrets/secrets_test.go diff --git a/doc/SECURITY_FIX_TODO.md b/doc/SECURITY_FIX_TODO.md index afd70bb..dd766f5 100644 --- a/doc/SECURITY_FIX_TODO.md +++ b/doc/SECURITY_FIX_TODO.md @@ -53,35 +53,31 @@ ## P2 - 中优先(迭代内) -- [ ] **T8 LLM 会话按 (bot, peer) 隔离** - - 位置:`internal/conversation/store.go:87-98`(`peerNodeID` 参数被忽略,所有 DM 对端共享上下文) - - 方案:`GetOrCreateForBot` 以 `(botID, peerNodeID)` 为键;历史消息数量设上限(如最近 50 条) - - 验收:不同 peer DM 得到独立会话;A 的注入不会影响 B 的回复 +- [x] **T8 LLM 会话按 (bot, peer) 隔离(2026-08-20 完成)** + - `message.Conversation` 增加 `peer_node_id`;`GetOrCreateForBot` 按 (bot, peer) 匹配会话,不同对端互不可见上下文;`AddMessage` 历史上限 50 条;无 peer 调用维持旧行为 + - 验收:不同 peer DM 得到独立会话;A 的注入不影响 B ✓(单测) -- [ ] **T9 LLM 入队限流/白名单** - - 位置:`internal/store/llm_store.go:545-578`;`internal/autoreply/service.go:29,178` - - 方案:按 from 节点维度限流(现仅有 bot 级 10 msg/5s);可选 allowlist 只响应已登记节点 - - 验收:单一来源高频消息只消耗有限 LLM 调用 +- [x] **T9 LLM 入队限流(2026-08-20 完成)** + - `EnqueueLLMMessage` 增加 (bot, from_node) 窗口限流:60 秒内 pending/processing 超过 5 条拒绝(ErrLLMQueueRateLimited);频道/私聊两条入队路径对限流静默跳过 + - 验收:单一来源高频消息只消耗有限 LLM 调用 ✓ -- [ ] **T10 瓦片磁盘缓存设上限** - - 位置:`internal/web/map_tile_proxy_routes.go:174-196` - - 方案:按 sourceHash 限制总字节数/文件数,超限 LRU 淘汰;每 IP 瓦片请求限速 - - 验收:遍历坐标脚本无法使缓存目录超过配额 +- [x] **T10 瓦片磁盘缓存配额(2026-08-20 完成)** + - 单源 3000 文件 / 300MB 双上限,每写 20 个文件触发一次按 mtime 淘汰最旧;防止匿名遍历坐标打满磁盘 + - 验收:缓存目录超配额被收敛 ✓ -- [ ] **T11 sign 强制触发的污染治理** - - 位置:`internal/toolrouter/loop.go:116-161,221-283` - - 方案:同一 from 节点每日限 1 条(后端已有?核实);`/api/signs` 增加频率限制与管理员删除 - - 验收:伪造大量 node_num 刷签到无法批量入库公开墙 +- [x] **T11 sign 污染治理(2026-08-20 完成)** + - 核实:每节点每日去重与 admin 删除已存在;补充:签到墙 `/api/signs`、`/api/signs/daily` 加 IP 限速(60 次/分钟);签到工具增加全站每日 1000 条总量封顶 + - 验收:伪造节点刷签到受每日总量限制;公开墙读接口有限速 ✓ -- [ ] **T12 敏感数据落盘加密** - - 位置:`internal/store/db.go:216-224`(forwarder 密码)、`:263`(bot 私钥)、`:488-498`(LLM api_key) - - 方案:AES-GCM 加密存储,主密钥来自环境变量 `MESH_SECRET_KEY`;API 层维持现有脱敏 - - 验收:直接读 SQLite 文件无法得到可用明文密钥 +- [x] **T12 敏感数据落盘加密(2026-08-20 完成)** + - 新增 `internal/secrets`(AES-256-GCM,密钥由 `MESH_SECRET_KEY` SHA-256 派生,enc:v1: 前缀,无密钥时明文兼容) + - 覆盖:LLM provider api_key、MQTT forwarder source/target 密码、bot X25519 私钥(在 store 写入点加密,读取点透明解密) + - 验收:DB 中为 enc:v1: 密文,明文不可见;旧明文数据可读;未配置密钥时行为不变 ✓(冒烟验证) -- [ ] **T13 admin 密码修改需验证自身当前密码 + session 可撤销** - - 位置:`internal/web/web.go:372-393`;`internal/auth/auth.go:89-149` - - 方案:改他人密码前要求请求方验证自己的密码;claims 增加 `pwd_ver`(密码 hash 版本号),改密后旧 cookie 全部失效 - - 验收:改密后所有已登录会话返回 401 +- [x] **T13 改密需验证当前密码 + session 可撤销(2026-08-20 完成)** + - `PUT /api/admin/users/:id/password` 必须携带请求方自己的 `current_password`,错误返回 403;users 表新增 `pwd_version`,改密自增,session claims 携带 pwd_ver,中间件对照 DB,改密后所有旧会话立即 401 + - 前端 AdminUsers 增加"当前登录密码"输入 + - 验收:改密后旧会话 401、新密码可登录、旧密码不可 ✓(冒烟验证) ## P3 - 低优先(择机) diff --git a/internal/agents/sign/sign.go b/internal/agents/sign/sign.go index 1dd2382..c359961 100644 --- a/internal/agents/sign/sign.go +++ b/internal/agents/sign/sign.go @@ -25,12 +25,16 @@ import ( type SignStore interface { CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error) HasSignedOnDay(nodeID string, day time.Time) (bool, error) + CountSignsOnDay(day time.Time) (int64, error) GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error) CountSigns(opts storepkg.ListOptions) (int64, error) CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error) ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error) } +// maxSignsPerDay 全站每日签到记录总量封顶,防止伪造节点刷公开签到墙。 +const maxSignsPerDay = 1000 + // Tool 是签到工具。 type Tool struct { enabled bool @@ -164,6 +168,15 @@ func (t *Tool) executeSign(ctx context.Context, params signParams, runtime agent return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil } + // 全站每日签到总量封顶,防止伪造海量节点号刷爆公开签到墙。 + todayCount, err := t.store.CountSignsOnDay(now) + if err != nil { + return fmt.Sprintf("签到失败:统计今日签到数时出错:%v", err), nil + } + if todayCount >= maxSignsPerDay { + return "今日签到人数已达上限,请明天再来。", nil + } + signText := buildSignText(params) if signText == "" { // 结构化字段缺失时回退到用户原始文本 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 03ad6d8..8ac2639 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,6 +43,7 @@ type SessionClaims struct { UserID uint64 `json:"user_id"` Username string `json:"username"` Role string `json:"role"` + PwdVer int64 `json:"pwd_ver"` Expires int64 `json:"expires"` } @@ -87,7 +88,7 @@ func AdminUserResponse(user store.UserRecord) AdminUserDTO { // NewCookie 为已登录用户构造一份带签名的 session cookie。 func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) { - claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()} + claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, PwdVer: user.PwdVersion, Expires: time.Now().Add(sm.ttl).Unix()} data, err := json.Marshal(claims) if err != nil { return nil, err @@ -155,7 +156,9 @@ func (sm *Manager) sign(payload string) string { } // RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。 -func RequireAdmin(sm *Manager) gin.HandlerFunc { +// 除签名/过期校验外,还会对照 DB 中用户当前的 pwd_version: +// 改密后旧会话(claims.PwdVer 低于当前版本)立即失效。 +func RequireAdmin(sm *Manager, store *store.Store) gin.HandlerFunc { return func(c *gin.Context) { claims, err := sm.ClaimsFromRequest(c) if err != nil { @@ -163,6 +166,12 @@ func RequireAdmin(sm *Manager) gin.HandlerFunc { c.Abort() return } + user, err := store.GetUserByUsername(claims.Username) + if err != nil || user.Role != AdminRole || user.PwdVersion != claims.PwdVer { + c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"}) + c.Abort() + return + } c.Set(AdminClaimsKey, claims) c.Next() } diff --git a/internal/bot/bot_pki_resolver.go b/internal/bot/bot_pki_resolver.go index 2831516..4d3b77f 100644 --- a/internal/bot/bot_pki_resolver.go +++ b/internal/bot/bot_pki_resolver.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "strings" + "meshtastic_mqtt_server/internal/secrets" storepkg "meshtastic_mqtt_server/internal/store" ) @@ -23,7 +24,7 @@ func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([ if privateKeyB64 == "" { return nil, nil, false } - privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64) + privateKey, err := base64.StdEncoding.DecodeString(secrets.Decrypt(privateKeyB64)) if err != nil || len(privateKey) != 32 { return nil, nil, false } diff --git a/internal/bot/bot_service.go b/internal/bot/bot_service.go index c4f3eea..9d14b5e 100644 --- a/internal/bot/bot_service.go +++ b/internal/bot/bot_service.go @@ -17,6 +17,7 @@ import ( storepkg "meshtastic_mqtt_server/internal/store" "meshtastic_mqtt_server/internal/mqtpp" + "meshtastic_mqtt_server/internal/secrets" ) const botMaxTextBytes = 200 @@ -116,6 +117,7 @@ func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, r if privateKeyB64 == "" { return nil, fmt.Errorf("bot has no private key") } + privateKeyB64 = secrets.Decrypt(privateKeyB64) privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64) if err != nil { return nil, err @@ -312,6 +314,7 @@ func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNode if privateKeyB64 == "" { return nil, fmt.Errorf("bot has no private key, regenerate keys first") } + privateKeyB64 = secrets.Decrypt(privateKeyB64) privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64) if err != nil { return nil, fmt.Errorf("invalid bot private key: %w", err) diff --git a/internal/conversation/store.go b/internal/conversation/store.go index 1e1245a..dd72fea 100644 --- a/internal/conversation/store.go +++ b/internal/conversation/store.go @@ -84,17 +84,35 @@ func (s *Store) Get(id string) (*message.Conversation, error) { return &conv, nil } -// GetOrCreateForBot gets or creates a conversation for a bot +// GetOrCreateForBot gets or creates a conversation for a bot. +// peerNodeID 非空时按 (bot, peer) 隔离会话:不同对端互不可见上下文, +// 防止 A 的私聊内容/提示注入影响 B;peerNodeID 为空时维持旧行为(取最近会话)。 func (s *Store) GetOrCreateForBot(botID uint64, botNodeID string, peerNodeID string) (*message.Conversation, error) { - // Try to find an existing conversation with this peer + peerNodeID = strings.TrimSpace(peerNodeID) convs, err := s.ListForBot(botID) if err == nil && len(convs) > 0 { - // Use the most recent conversation (List already sorts by UpdatedAt desc) - // Note: List returns convs with Messages = nil, so we need to reload - return s.Get(convs[0].ID) + if peerNodeID != "" { + // List 按 UpdatedAt 倒序,取该对端最近一次会话。 + for _, conv := range convs { + if conv.PeerNodeID == peerNodeID { + return s.Get(conv.ID) + } + } + } else { + return s.Get(convs[0].ID) + } } - // Create a new conversation - return s.Create(botID, botNodeID) + conv, err := s.Create(botID, botNodeID) + if err != nil { + return nil, err + } + if peerNodeID != "" { + conv.PeerNodeID = peerNodeID + if err := s.Save(conv); err != nil { + return nil, err + } + } + return conv, nil } // List returns all conversations @@ -167,6 +185,9 @@ func (s *Store) DeleteForBot(botID uint64) error { return nil } +// maxConversationMessages 限制单会话保留的历史消息数,防止上下文无限增长。 +const maxConversationMessages = 50 + // AddMessage adds a message to a conversation func (s *Store) AddMessage(convID string, msg message.ChatMessage) error { conv, err := s.Get(convID) @@ -174,6 +195,9 @@ func (s *Store) AddMessage(convID string, msg message.ChatMessage) error { return err } conv.Messages = append(conv.Messages, msg) + if len(conv.Messages) > maxConversationMessages { + conv.Messages = conv.Messages[len(conv.Messages)-maxConversationMessages:] + } if conv.Title == "" || conv.Title == "新对话" { conv.Title = GenerateTitle(conv.Messages) } diff --git a/internal/conversation/store_test.go b/internal/conversation/store_test.go new file mode 100644 index 0000000..8661bf8 --- /dev/null +++ b/internal/conversation/store_test.go @@ -0,0 +1,87 @@ +package conversation + +import ( + "testing" + + "meshtastic_mqtt_server/internal/message" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + return NewStore(t.TempDir()) +} + +func TestGetOrCreateForBotPeerIsolation(t *testing.T) { + s := newTestStore(t) + + convA, err := s.GetOrCreateForBot(1, "bot1", "nodeA") + if err != nil { + t.Fatal(err) + } + convB, err := s.GetOrCreateForBot(1, "bot1", "nodeB") + if err != nil { + t.Fatal(err) + } + if convA.ID == convB.ID { + t.Fatal("different peers must get different conversations") + } + if convA.PeerNodeID != "nodeA" || convB.PeerNodeID != "nodeB" { + t.Fatalf("peer ids not set: %q %q", convA.PeerNodeID, convB.PeerNodeID) + } + + // 再次获取应命中同一会话(内容互不可见)。 + convA2, err := s.GetOrCreateForBot(1, "bot1", "nodeA") + if err != nil { + t.Fatal(err) + } + if convA2.ID != convA.ID { + t.Fatalf("peer A should reuse its conversation, got %s want %s", convA2.ID, convA.ID) + } + + // 不同 bot 的会话互不影响。 + convOtherBot, err := s.GetOrCreateForBot(2, "bot2", "nodeA") + if err != nil { + t.Fatal(err) + } + if convOtherBot.ID == convA.ID { + t.Fatal("different bots must get different conversations") + } +} + +func TestGetOrCreateForBotLegacyNoPeer(t *testing.T) { + s := newTestStore(t) + c1, err := s.GetOrCreateForBot(1, "bot1", "") + if err != nil { + t.Fatal(err) + } + c2, err := s.GetOrCreateForBot(1, "bot1", "") + if err != nil { + t.Fatal(err) + } + if c1.ID != c2.ID { + t.Fatal("no-peer callers should share the most recent conversation") + } +} + +func TestAddMessageHistoryCap(t *testing.T) { + s := newTestStore(t) + conv, err := s.GetOrCreateForBot(1, "bot1", "nodeA") + if err != nil { + t.Fatal(err) + } + for i := 0; i < 120; i++ { + if err := s.AddMessage(conv.ID, message.ChatMessage{Role: "user", Content: "m"}); err != nil { + t.Fatal(err) + } + } + conv, err = s.Get(conv.ID) + if err != nil { + t.Fatal(err) + } + if len(conv.Messages) != maxConversationMessages { + t.Fatalf("history must be capped at %d, got %d", maxConversationMessages, len(conv.Messages)) + } + if conv.Messages[0].Content != "m" || conv.Messages[len(conv.Messages)-1].Content != "m" { + t.Fatal("messages content intact") + } +} diff --git a/internal/message/types.go b/internal/message/types.go index 932ae4b..bc22b20 100644 --- a/internal/message/types.go +++ b/internal/message/types.go @@ -16,6 +16,7 @@ type Conversation struct { ID string `json:"id"` BotID uint64 `json:"bot_id"` BotNodeID string `json:"bot_node_id"` + PeerNodeID string `json:"peer_node_id,omitempty"` Title string `json:"title"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go index 351277c..5b9d055 100644 --- a/internal/ratelimit/ratelimit.go +++ b/internal/ratelimit/ratelimit.go @@ -149,6 +149,28 @@ func (l *FailureLimiter) Fail(key string) bool { return false } +// Exceeded 记录一次请求,窗口内请求数超过 max 时返回 true(限速,不产生封禁)。 +// 用于读接口的轻量频率限制。 +func (l *FailureLimiter) Exceeded(key string) bool { + if key == "" { + return false + } + now := l.now() + l.mu.Lock() + defer l.mu.Unlock() + st, ok := l.fails[key] + if !ok || now.Sub(st.windowStart) > l.window { + st = &failState{windowStart: now, count: 1} + l.fails[key] = st + if len(l.fails) > l.maxEntries { + l.purgeLocked(now) + } + return false + } + st.count++ + return st.count > l.max +} + // Reset 清除 key 的失败计数。 func (l *FailureLimiter) Reset(key string) { if key == "" { diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go index 61313c6..2040021 100644 --- a/internal/ratelimit/ratelimit_test.go +++ b/internal/ratelimit/ratelimit_test.go @@ -79,3 +79,27 @@ func TestEmptyKeyIgnored(t *testing.T) { t.Fatal("empty key must not be tracked") } } + +func TestExceeded(t *testing.T) { + l := newTestLimiter(t, Options{MaxFailures: 3}) + now := time.Unix(1700000000, 0) + l.now = func() time.Time { return now } + + for i := 0; i < 3; i++ { + if l.Exceeded("ip") { + t.Fatalf("request %d should be allowed", i+1) + } + } + if !l.Exceeded("ip") { + t.Fatal("4th request should be limited") + } + // 窗口过后恢复。 + now = now.Add(2 * time.Minute) + if l.Exceeded("ip") { + t.Fatal("request after window should be allowed") + } + // 不同 key 互不影响。 + if l.Exceeded("other") { + t.Fatal("other key must not be limited") + } +} diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go new file mode 100644 index 0000000..cd23601 --- /dev/null +++ b/internal/secrets/secrets.go @@ -0,0 +1,105 @@ +// Package secrets 提供敏感字段(API key、MQTT 口令、bot 私钥)落盘前的 +// AES-256-GCM 加密与读取时的透明解密。 +// +// 用法:启动时以环境变量 MESH_SECRET_KEY 初始化(SetSecretKey)。 +// 未配置密钥时 Encrypt/Decrypt 均原样透传(兼容旧部署的明文数据); +// 加密值带 "enc:v1:" 前缀,解密时对无前缀的历史明文直接返回。 +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "strings" + "sync" +) + +const prefix = "enc:v1:" + +var ( + mu sync.RWMutex + key []byte +) + +// SetSecretKey 用 MESH_SECRET_KEY 的内容派生 AES-256 密钥;传空则回到明文模式。 +func SetSecretKey(secret string) { + mu.Lock() + defer mu.Unlock() + secret = strings.TrimSpace(secret) + if secret == "" { + key = nil + return + } + sum := sha256.Sum256([]byte(secret)) + key = sum[:] +} + +// Enabled 报告是否已配置加密密钥。 +func Enabled() bool { + mu.RLock() + defer mu.RUnlock() + return len(key) > 0 +} + +// Encrypt 加密明文;未配置密钥时原样返回。 +func Encrypt(plain string) string { + if plain == "" { + return plain + } + mu.RLock() + k := key + mu.RUnlock() + if len(k) == 0 { + return plain + } + block, err := aes.NewCipher(k) + if err != nil { + return plain + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return plain + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return plain + } + sealed := gcm.Seal(nonce, nonce, []byte(plain), nil) + return prefix + base64.RawStdEncoding.EncodeToString(sealed) +} + +// Decrypt 解密 enc:v1: 前缀的值;无前缀(历史明文)或解密失败返回空串。 +func Decrypt(value string) string { + if !strings.HasPrefix(value, prefix) { + return value + } + mu.RLock() + k := key + mu.RUnlock() + if len(k) == 0 { + return value + } + raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(value, prefix)) + if err != nil { + return "" + } + block, err := aes.NewCipher(k) + if err != nil { + return "" + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "" + } + if len(raw) < gcm.NonceSize() { + return "" + } + nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "" + } + return string(plain) +} diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go new file mode 100644 index 0000000..443ce3c --- /dev/null +++ b/internal/secrets/secrets_test.go @@ -0,0 +1,72 @@ +package secrets + +import ( + "strings" + "testing" +) + +func TestRoundTrip(t *testing.T) { + SetSecretKey("test-secret-key") + t.Cleanup(func() { SetSecretKey("") }) + + enc := Encrypt("s3cret-value") + if enc == "s3cret-value" { + t.Fatal("value must be encrypted when key is set") + } + if !strings.HasPrefix(enc, "enc:v1:") { + t.Fatalf("unexpected prefix: %q", enc) + } + if got := Decrypt(enc); got != "s3cret-value" { + t.Fatalf("round trip failed: %q", got) + } +} + +func TestPlaintextPassthroughWhenNoKey(t *testing.T) { + SetSecretKey("") + if Encrypt("plain") != "plain" { + t.Error("must pass through when no key") + } + if Decrypt("plain") != "plain" { + t.Error("must pass through when no key") + } +} + +func TestLegacyPlaintextStillReadable(t *testing.T) { + SetSecretKey("k1") + t.Cleanup(func() { SetSecretKey("") }) + // 旧数据无前缀,解密应原样返回。 + if Decrypt("legacy-plaintext") != "legacy-plaintext" { + t.Error("legacy plaintext must be returned as-is") + } +} + +func TestWrongKeyFailsClosed(t *testing.T) { + SetSecretKey("key-a") + enc := Encrypt("top-secret") + SetSecretKey("key-b") + if Decrypt(enc) != "" { + t.Error("decrypt with wrong key must return empty (fail closed)") + } + SetSecretKey("key-a") + if Decrypt(enc) != "top-secret" { + t.Error("decrypt with correct key must succeed") + } +} + +func TestEmptyValue(t *testing.T) { + SetSecretKey("k") + t.Cleanup(func() { SetSecretKey("") }) + if Encrypt("") != "" { + t.Error("empty value stays empty") + } +} + +func TestUniqueCiphertexts(t *testing.T) { + SetSecretKey("k") + t.Cleanup(func() { SetSecretKey("") }) + a := Encrypt("same") + b := Encrypt("same") + if a == b { + t.Error("random nonce should produce different ciphertexts") + } +} diff --git a/internal/store/bot_direct_message_store.go b/internal/store/bot_direct_message_store.go index 91a9399..077152d 100644 --- a/internal/store/bot_direct_message_store.go +++ b/internal/store/bot_direct_message_store.go @@ -308,6 +308,10 @@ func insertInboundBotDirectMessage(s *Store, record map[string]any, clientInfo M ContentJSON: contentPtr, }) if err != nil { + if errors.Is(err, ErrLLMQueueRateLimited) { + // 限流拒绝是预期行为,静默跳过。 + return nil + } printJSON(map[string]any{ "event": "llm_queue_enqueue_failed", "bot_id": bot.ID, diff --git a/internal/store/bot_store.go b/internal/store/bot_store.go index d314367..cf7c262 100644 --- a/internal/store/bot_store.go +++ b/internal/store/bot_store.go @@ -12,6 +12,7 @@ import ( "unicode/utf8" "meshtastic_mqtt_server/internal/mqtpp" + "meshtastic_mqtt_server/internal/secrets" "gorm.io/gorm" ) @@ -300,7 +301,8 @@ func populateBotNodeKeys(row *BotNodeRecord) error { if err != nil { return err } - row.PrivateKey = base64.StdEncoding.EncodeToString(privateKey.Bytes()) + // 私钥落盘前加密(MESH_SECRET_KEY 未配置时原样存储,兼容旧部署)。 + row.PrivateKey = secrets.Encrypt(base64.StdEncoding.EncodeToString(privateKey.Bytes())) row.PublicKey = base64.StdEncoding.EncodeToString(privateKey.PublicKey().Bytes()) return nil } diff --git a/internal/store/db.go b/internal/store/db.go index ec7c5e1..82608da 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -65,6 +65,7 @@ type UserRecord struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` Username string `gorm:"column:username;not null;uniqueIndex"` PasswordHash string `gorm:"column:password_hash;not null"` + PwdVersion int64 `gorm:"column:pwd_version;not null;default:0"` Role string `gorm:"column:role;not null;index"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` @@ -909,6 +910,19 @@ type DBMigration struct { var dbMigrations = []DBMigration{ {Version: 1, Up: migrateDB1}, {Version: 2, Up: migrateDB2}, + {Version: 3, Up: migrateDB3}, +} + +func migrateDB3(tx *gorm.DB, driver string) error { + // users.pwd_version:改密后旧 session 立即失效的版本号,默认 0。 + // 新库 CreateTable 时已带该列,此处幂等处理老库。 + if tx.Migrator().HasColumn(&UserRecord{}, "pwd_version") { + return nil + } + if err := tx.Exec("ALTER TABLE users ADD COLUMN pwd_version INTEGER NOT NULL DEFAULT 0").Error; err != nil { + return fmt.Errorf("add users.pwd_version: %w", err) + } + return nil } func migrateDB1(tx *gorm.DB, driver string) error { diff --git a/internal/store/llm_store.go b/internal/store/llm_store.go index fa6f34b..b3a4df8 100644 --- a/internal/store/llm_store.go +++ b/internal/store/llm_store.go @@ -7,6 +7,8 @@ import ( "time" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/secrets" ) // ============================================ @@ -23,6 +25,9 @@ func (s *Store) ListLLMProviders(includeInactive bool) ([]LLMProviderRecord, err if err := query.Order("created_at DESC").Find(&rows).Error; err != nil { return nil, fmt.Errorf("list llm providers: %w", err) } + for i := range rows { + rows[i].APIKey = secrets.Decrypt(rows[i].APIKey) + } return rows, nil } @@ -35,11 +40,13 @@ func (s *Store) GetLLMProvider(name string) (*LLMProviderRecord, error) { } return nil, fmt.Errorf("get llm provider %s: %w", name, err) } + record.APIKey = secrets.Decrypt(record.APIKey) return &record, nil } // CreateLLMProvider 创建 LLM Provider func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error { + record.APIKey = secrets.Encrypt(record.APIKey) if err := s.db.Create(record).Error; err != nil { return fmt.Errorf("create llm provider %s: %w", record.Name, err) } @@ -48,6 +55,11 @@ func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error { // UpdateLLMProvider 更新 LLM Provider func (s *Store) UpdateLLMProvider(name string, updates map[string]any) error { + if v, ok := updates["api_key"]; ok { + if s, ok := v.(string); ok { + updates["api_key"] = secrets.Encrypt(s) + } + } if err := s.db.Model(&LLMProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil { return fmt.Errorf("update llm provider %s: %w", name, err) } @@ -289,6 +301,29 @@ type LLMMessageQueueInput struct { ContentJSON *string } +// ErrLLMQueueRateLimited 表示 (bot, from_node) 在窗口期内入队超限被拒绝。 +var ErrLLMQueueRateLimited = errors.New("llm queue rate limited") + +const ( + // llmQueuePerNodeWindow 是单 (bot, from_node) 的入队限流窗口。 + llmQueuePerNodeWindow = time.Minute + // llmQueuePerNodeMax 是窗口期内允许的 pending/processing 消息上限。 + llmQueuePerNodeMax = 5 +) + +// isLLMQueueRateLimited 统计窗口内 (bot, from_node) 的未消费消息数是否达上限。 +func (s *Store) isLLMQueueRateLimited(botID uint64, fromNodeID string) (bool, error) { + var count int64 + err := s.db.Model(&LLMMessageQueueRecord{}). + Where("bot_id = ? AND from_node_id = ? AND status IN (?, ?) AND received_at > ? AND deleted_at IS NULL", + botID, fromNodeID, LLMMessageStatusPending, LLMMessageStatusProcessing, time.Now().Add(-llmQueuePerNodeWindow)). + Count(&count).Error + if err != nil { + return false, err + } + return count >= llmQueuePerNodeMax, nil +} + // EnqueueLLMMessage 将消息添加到 LLM 队列 func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) { var err error @@ -348,6 +383,16 @@ func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueR return nil, fmt.Errorf("check duplicate llm message: %w", err) } + // (bot, from_node) 维度入队限流:窗口内 pending/processing 超过上限即拒绝, + // 防止未认证 mesh 用户高频消息烧光 LLM 配额。 + limited, err := s.isLLMQueueRateLimited(input.BotID, input.FromNodeID) + if err != nil { + return nil, fmt.Errorf("check llm queue rate limit: %w", err) + } + if limited { + return nil, ErrLLMQueueRateLimited + } + now := time.Now() messageType := input.MessageType if messageType == "" { @@ -567,6 +612,10 @@ func enqueueChannelMessageToLLM(s *Store, record map[string]any) error { ContentJSON: contentPtr, }) if err != nil { + if errors.Is(err, ErrLLMQueueRateLimited) { + // 限流拒绝是预期行为,静默跳过。 + continue + } printJSON(map[string]any{ "event": "llm_queue_enqueue_failed", "bot_id": bot.ID, diff --git a/internal/store/mqtt_forward_store.go b/internal/store/mqtt_forward_store.go index 8125f35..d40de99 100644 --- a/internal/store/mqtt_forward_store.go +++ b/internal/store/mqtt_forward_store.go @@ -7,6 +7,8 @@ import ( "time" "gorm.io/gorm" + + "meshtastic_mqtt_server/internal/secrets" ) const ( @@ -59,7 +61,14 @@ func (s *Store) ListMQTTForwarders(opts ListOptions) ([]MQTTForwarderRecord, err Order("id DESC"). Limit(opts.Limit). Offset(opts.Offset) - return rows, q.Find(&rows).Error + if err := q.Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + rows[i].SourcePassword = secrets.Decrypt(rows[i].SourcePassword) + rows[i].TargetPassword = secrets.Decrypt(rows[i].TargetPassword) + } + return rows, nil } func (s *Store) CountMQTTForwarders(opts ListOptions) (int64, error) { @@ -72,6 +81,8 @@ func (s *Store) GetMQTTForwarder(id uint64) (*MQTTForwarderRecord, error) { if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { return nil, err } + row.SourcePassword = secrets.Decrypt(row.SourcePassword) + row.TargetPassword = secrets.Decrypt(row.TargetPassword) return &row, nil } @@ -304,12 +315,12 @@ func mqttForwarderFromInput(input MQTTForwarderInput, existing *MQTTForwarderRec TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS, } if input.SourcePassword != nil { - row.SourcePassword = *input.SourcePassword + row.SourcePassword = secrets.Encrypt(*input.SourcePassword) } else if existing != nil { row.SourcePassword = existing.SourcePassword } if input.TargetPassword != nil { - row.TargetPassword = *input.TargetPassword + row.TargetPassword = secrets.Encrypt(*input.TargetPassword) } else if existing != nil { row.TargetPassword = existing.TargetPassword } diff --git a/internal/store/sign_store.go b/internal/store/sign_store.go index c5a8e0d..1d9659b 100644 --- a/internal/store/sign_store.go +++ b/internal/store/sign_store.go @@ -67,6 +67,23 @@ func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) { return count > 0, nil } +// CountSignsOnDay 统计某自然日(本地时区)的签到记录总数,用于全站每日总量封顶。 +func (s *Store) CountSignsOnDay(day time.Time) (int64, error) { + loc := day.Location() + if loc == nil { + loc = time.Local + } + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + end := start.AddDate(0, 0, 1) + var count int64 + if err := s.db.Model(&SignRecord{}). + Where("sign_time >= ? AND sign_time < ?", start, end). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("count sign on day: %w", err) + } + return count, nil +} + func (s *Store) GetSignByID(id uint64) (*SignRecord, error) { var row SignRecord if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil { diff --git a/internal/store/user_store.go b/internal/store/user_store.go index e9f792c..0a5522b 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -71,7 +71,12 @@ func (s *Store) UpdateUserPassword(id uint64, password string) (*UserRecord, err if err != nil { return nil, fmt.Errorf("hash user password: %w", err) } - if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil { + // 改密同时递增 pwd_version,使该用户所有已签发 session 立即失效。 + if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{ + "password_hash": hash, + "pwd_version": gorm.Expr("pwd_version + 1"), + "updated_at": time.Now(), + }).Error; err != nil { return nil, err } user.PasswordHash = hash diff --git a/internal/web/map_tile_proxy_routes.go b/internal/web/map_tile_proxy_routes.go index aa8bb50..9ddc1de 100644 --- a/internal/web/map_tile_proxy_routes.go +++ b/internal/web/map_tile_proxy_routes.go @@ -10,8 +10,10 @@ import ( "net/netip" "os" "path/filepath" + "sort" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -24,19 +26,27 @@ const ( mapTileCacheControl = "public, max-age=86400" maxMapTileBytes = 10 << 20 maxMapTileRedirects = 2 + // 单个地图源瓦片缓存配额:文件数与总字节双上限,超出后按 mtime 淘汰最旧文件。 + mapTileCacheMaxFilesPerSource = 3000 + mapTileCacheMaxBytesPerSource = 300 << 20 + // 每写这么多文件触发一次清理采样。 + mapTileCachePruneEvery = 20 ) type mapTileProxy struct { - store *storepkg.Store - cacheDir string - client *http.Client + store *storepkg.Store + cacheDir string + client *http.Client + mu sync.Mutex + writeCounts map[string]int64 } func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) { proxy := &mapTileProxy{ - store: store, - cacheDir: cacheDir, - client: newMapTileHTTPClient(), + store: store, + cacheDir: cacheDir, + client: newMapTileHTTPClient(), + writeCounts: make(map[string]int64), } r.GET("/map/:sourceHash", proxy.handle) } @@ -154,9 +164,67 @@ func (p *mapTileProxy) handle(c *gin.Context) { return } _ = writeMapTileCacheFile(cachePath, data) + p.maybePruneTileCache(sourceHash) writeMapTile(c, data) } +// maybePruneTileCache 按采样频率对单个瓦片源做配额清理: +// 文件数或总字节超限时删除 mtime 最旧的瓦片,防止匿名请求打满磁盘。 +func (p *mapTileProxy) maybePruneTileCache(sourceHash string) { + p.mu.Lock() + p.writeCounts[sourceHash]++ + n := p.writeCounts[sourceHash] + p.mu.Unlock() + if n%mapTileCachePruneEvery != 0 { + return + } + pruneMapTileCache(filepath.Join(p.cacheDir, sourceHash), mapTileCacheMaxFilesPerSource, mapTileCacheMaxBytesPerSource) +} + +// pruneMapTileCache 扫描目录下所有 .tile 文件,超限时按 mtime 升序删除最旧文件。 +func pruneMapTileCache(dir string, maxFiles int, maxBytes int64) { + var files []tileCacheEntry + var totalBytes int64 + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".tile") { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + files = append(files, tileCacheEntry{path: path, size: info.Size(), mtime: info.ModTime()}) + totalBytes += info.Size() + return nil + }) + if err != nil || len(files) == 0 { + return + } + if len(files) <= maxFiles && totalBytes <= maxBytes { + return + } + sort.Slice(files, func(i, j int) bool { return files[i].mtime.Before(files[j].mtime) }) + deleted := 0 + for _, f := range files { + if len(files)-deleted <= maxFiles && totalBytes <= maxBytes { + break + } + if os.Remove(f.path) == nil { + deleted++ + totalBytes -= f.size + } + } +} + +type tileCacheEntry struct { + path string + size int64 + mtime time.Time +} + func (p *mapTileProxy) fetchRemoteTile(req *http.Request, template string, tile mapTileCoordinates) ([]byte, int, error) { remoteURL := expandMapTileURLTemplate(template, tile) upstreamReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, remoteURL, nil) diff --git a/internal/web/web.go b/internal/web/web.go index 500aab8..df723ae 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -85,7 +85,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, return r } -const BackendVersion = "1.4.0" +const BackendVersion = "1.5.0" var CommitVersion = "dev" @@ -111,7 +111,13 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str mappkg.RegisterPublicRoutes(r, store) registerMapTileProxyRoutes(r, store, mapTileCacheDir) helppkg.RegisterPublicRoutes(r, store) + // 公开签到墙限速:60 次/分钟/IP,防遍历拉取。 + signsLimiter := ratelimit.New(ratelimit.Options{MaxFailures: 60, Window: time.Minute}) r.GET("/signs", func(c *gin.Context) { + if signsLimiter.Exceeded(c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"}) + return + } opts, ok := parseListOptions(c) if !ok { return @@ -125,6 +131,10 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO) }) r.GET("/signs/daily", func(c *gin.Context) { + if signsLimiter.Exceeded(c.ClientIP()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"}) + return + } opts, ok := parseListOptions(c) if !ok { return @@ -207,7 +217,8 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma Password string `json:"password"` } type updatePasswordRequest struct { - Password string `json:"password"` + Password string `json:"password"` + CurrentPassword string `json:"current_password"` } userDTO := func(user storepkg.UserRecord) gin.H { return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt} @@ -280,7 +291,7 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma }) protected := r.Group("") - protected.Use(auth.RequireAdmin(sessions)) + protected.Use(auth.RequireAdmin(sessions, store)) blockingpkg.RegisterRoutes(protected, store, blocking) signpkg.RegisterAdminRoutes(protected, store) mqttforwardpkg.RegisterRoutes(protected, store, forwarder) @@ -408,6 +419,17 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"}) return } + // 修改任何用户的密码都必须验证请求方自己的当前密码,防止已登录会话被冒用改密。 + claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims) + requester, err := store.GetUserByUsername(claims.Username) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"}) + return + } + if !auth.VerifyPassword(requester.PasswordHash, req.CurrentPassword) { + c.JSON(http.StatusForbidden, gin.H{"error": "current password is incorrect"}) + return + } user, err := store.UpdateUserPassword(id, req.Password) if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) diff --git a/main.go b/main.go index 005de73..ed4475d 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ import ( "meshtastic_mqtt_server/internal/mqtpp" mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" rspkg "meshtastic_mqtt_server/internal/runtimesettings" + "meshtastic_mqtt_server/internal/secrets" storepkg "meshtastic_mqtt_server/internal/store" webpkg "meshtastic_mqtt_server/internal/web" ) @@ -345,6 +346,8 @@ func parseArgs() (*configpkg.Config, error) { if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" { cfg.Web.Admin.SessionSecret = value } + // 敏感数据落盘加密密钥(MESH_SECRET_KEY);未设置时保持明文兼容。 + secrets.SetSecretKey(os.Getenv("MESH_SECRET_KEY")) configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) if err := configpkg.Validate(cfg); err != nil { diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index 125ec58..53dda31 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -307,8 +307,8 @@ export function createAdminUser(username: string, password: string): Promise('/api/admin/users', { username, password }) } -export function updateAdminUserPassword(id: number, password: string): Promise { - return putJSON(`/api/admin/users/${id}/password`, { password }) +export function updateAdminUserPassword(id: number, password: string, currentPassword: string): Promise { + return putJSON(`/api/admin/users/${id}/password`, { password, current_password: currentPassword }) } export function getAdminLoginLogs(limit = 100, offset = 0): Promise { diff --git a/meshmap_frontend/src/components/AdminUsers.vue b/meshmap_frontend/src/components/AdminUsers.vue index e4ff57a..dbc0aa3 100644 --- a/meshmap_frontend/src/components/AdminUsers.vue +++ b/meshmap_frontend/src/components/AdminUsers.vue @@ -16,6 +16,7 @@ const newPassword = ref('') const confirmPassword = ref('') const passwordEdits = ref>({}) const passwordSaving = ref>({}) +const currentPassword = ref('') function formatTime(value: string): string { return new Date(value).toLocaleString() @@ -73,11 +74,16 @@ async function updatePassword(user: AdminManagedUser) { userError.value = '新密码不能为空' return } + if (!currentPassword.value) { + userError.value = '请输入当前登录密码' + return + } passwordSaving.value = { ...passwordSaving.value, [user.id]: true } try { - await updateAdminUserPassword(user.id, password) + await updateAdminUserPassword(user.id, password, currentPassword.value) passwordEdits.value = { ...passwordEdits.value, [user.id]: '' } + currentPassword.value = '' userMessage.value = `${user.username} 的密码已修改` await refreshUsers() } catch (err) { @@ -129,6 +135,7 @@ onMounted(refreshUsers) 创建时间 更新时间 新密码 + 当前登录密码 操作 @@ -148,6 +155,15 @@ onMounted(refreshUsers) placeholder="输入新密码" /> + + +