From 63676f7f3415bd7250e9ab005e6e39c28e545358 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 3 Jun 2026 23:58:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- db.go | 17 ++ db_test.go | 32 +++- login_log_store.go | 18 ++ meshmap_frontend/src/App.vue | 33 +++- meshmap_frontend/src/api.ts | 5 + .../src/components/AdminDashboard.vue | 174 +----------------- .../src/components/AdminLoginLogs.vue | 92 +++++++++ .../src/components/AdminUsers.vue | 163 ++++++++++++++++ meshmap_frontend/src/style.css | 51 ++++- meshmap_frontend/src/types.ts | 18 ++ web.go | 33 ++++ 12 files changed, 464 insertions(+), 176 deletions(-) create mode 100644 login_log_store.go create mode 100644 meshmap_frontend/src/components/AdminLoginLogs.vue create mode 100644 meshmap_frontend/src/components/AdminUsers.vue diff --git a/README.md b/README.md index 708cdd8..b82be51 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ go run . 构建后的文件位于项目根目录 `dist/`,Gin 会提供静态文件服务;`/api` 路径保留给后端接口。 -管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。 +管理页面位于 `/admin`,默认管理员账号为 `admin` / `admin`。生产环境请修改 `web.admin.password` 或设置 `MESH_ADMIN_PASSWORD`,并配置固定的 `web.admin.session_secret` 或 `MESH_ADMIN_SESSION_SECRET`;如果 `session_secret` 为空,程序会在启动时生成临时签名密钥,重启后需要重新登录。后台页面包括 `/admin` 服务状态、`/admin/users` 用户管理、`/admin/log/login` 登录日志。后台支持新增管理员用户和修改用户密码;密码使用 bcrypt hash 保存,API 不会返回密码 hash。修改密码不会立即使已签发 Session 失效,当前 Session 到期或退出登录后才需要使用新密码。登录成功和失败都会记录到登录日志,包含用户名、结果、原因、来源地址、User-Agent 和时间。 常用 API: @@ -127,6 +127,7 @@ POST /api/admin/login POST /api/admin/logout GET /api/admin/me GET /api/admin/mqtt/status +GET /api/admin/log/login GET /api/admin/users POST /api/admin/users PUT /api/admin/users/:id/password @@ -163,6 +164,7 @@ meshtastic: 程序默认启用 SQLite,数据库表迁移和操作由 GORM 执行,并持久化以下数据: +- `login_log`:追加保存后台登录成功和失败日志 - `nodeinfo`:保存 `type == "nodeinfo"` 的节点身份和设备信息 - `map_report`:保存 `type == "map_report"` 的地图报告信息,前端地图从该表读取 - `text_message`:追加保存 `type == "text_message"` 的聊天消息 diff --git a/db.go b/db.go index d4220b6..93f25c6 100644 --- a/db.go +++ b/db.go @@ -75,6 +75,22 @@ func (userRecord) TableName() string { return "users" } +type loginLogRecord struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement"` + Username string `gorm:"column:username;index"` + UserID *uint64 `gorm:"column:user_id;index"` + Success bool `gorm:"column:success;not null;index"` + Reason string `gorm:"column:reason;not null"` + RemoteAddr string `gorm:"column:remote_addr"` + RemoteHost string `gorm:"column:remote_host"` + UserAgent string `gorm:"column:user_agent"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"` +} + +func (loginLogRecord) TableName() string { + return "login_log" +} + type nodeInfoRecord struct { NodeID string `gorm:"column:node_id;primaryKey;not null"` NodeNum int64 `gorm:"column:node_num;not null;index"` @@ -268,6 +284,7 @@ func (s *store) migrate() error { model any }{ {label: "users", model: &userRecord{}}, + {label: "login_log", model: &loginLogRecord{}}, {label: "nodeinfo", model: &nodeInfoRecord{}}, {label: "map_report", model: &mapReportRecord{}}, {label: "text_message", model: &textMessageRecord{}}, diff --git a/db_test.go b/db_test.go index 8b8a9b1..413b4d9 100644 --- a/db_test.go +++ b/db_test.go @@ -14,7 +14,7 @@ func TestOpenStoreCreatesTables(t *testing.T) { st := openTestStore(t) defer st.Close() - for _, table := range []string{"users", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { + for _, table := range []string{"users", "login_log", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { var name string if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { t.Fatalf("%s table missing: %v", table, err) @@ -290,6 +290,36 @@ func TestUpdateUserPasswordMissingUser(t *testing.T) { } } +func TestInsertAndListLoginLogs(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + userID := uint64(1) + if err := st.InsertLoginLog(loginLogRecord{Username: "admin", UserID: &userID, Success: true, Reason: "success", RemoteAddr: "127.0.0.1:1234", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { + t.Fatalf("InsertLoginLog(success) error = %v", err) + } + if err := st.InsertLoginLog(loginLogRecord{Username: "admin", Success: false, Reason: "invalid username or password", RemoteAddr: "127.0.0.1:1235", RemoteHost: "127.0.0.1", UserAgent: "test-agent"}); err != nil { + t.Fatalf("InsertLoginLog(failure) error = %v", err) + } + + logs, err := st.ListLoginLogs(listOptions{Limit: 10}) + if err != nil { + t.Fatalf("ListLoginLogs() error = %v", err) + } + if len(logs) != 2 { + t.Fatalf("login logs len = %d, want 2", len(logs)) + } + if logs[0].ID <= logs[1].ID { + t.Fatalf("login logs not newest first: ids %d, %d", logs[0].ID, logs[1].ID) + } + if logs[0].Success || logs[0].Reason != "invalid username or password" { + t.Fatalf("latest log = %#v, want failure", logs[0]) + } + if logs[1].UserID == nil || *logs[1].UserID != userID || !logs[1].Success { + t.Fatalf("success log = %#v, want user id and success", logs[1]) + } +} + func TestInsertTextMessageAppendsRows(t *testing.T) { st := openTestStore(t) defer st.Close() diff --git a/login_log_store.go b/login_log_store.go new file mode 100644 index 0000000..92f6813 --- /dev/null +++ b/login_log_store.go @@ -0,0 +1,18 @@ +package main + +func (s *store) InsertLoginLog(log loginLogRecord) error { + return s.db.Create(&log).Error +} + +func (s *store) ListLoginLogs(opts listOptions) ([]loginLogRecord, error) { + opts = normalizeListOptions(opts) + var rows []loginLogRecord + q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset) + if opts.Since != nil { + q = q.Where("created_at >= ?", *opts.Since) + } + if opts.Until != nil { + q = q.Where("created_at <= ?", *opts.Until) + } + return rows, q.Find(&rows).Error +} diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 2a5debe..55d2ea2 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -1,14 +1,17 @@ + + diff --git a/meshmap_frontend/src/components/AdminUsers.vue b/meshmap_frontend/src/components/AdminUsers.vue new file mode 100644 index 0000000..76fccd2 --- /dev/null +++ b/meshmap_frontend/src/components/AdminUsers.vue @@ -0,0 +1,163 @@ + + + diff --git a/meshmap_frontend/src/style.css b/meshmap_frontend/src/style.css index 4d87fcf..d7a3af6 100644 --- a/meshmap_frontend/src/style.css +++ b/meshmap_frontend/src/style.css @@ -348,12 +348,43 @@ h3 { } .admin-login, -.admin-dashboard { +.admin-dashboard, +.admin-session-card { max-width: 1100px; margin: 0 auto; width: 100%; } +.admin-session-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; +} + +.admin-nav { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.admin-nav a { + border-radius: 999px; + padding: 7px 10px; + color: #334155; + text-decoration: none; + font-size: 13px; + font-weight: 700; + background: #e2e8f0; +} + +.admin-nav a.active { + color: #fff; + background: #2563eb; +} + .admin-form { display: grid; gap: 14px; @@ -408,6 +439,24 @@ h3 { background: #f0fdf4; } +.log-badge { + display: inline-flex; + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + font-weight: 700; +} + +.log-success { + color: #166534; + background: #dcfce7; +} + +.log-failure { + color: #991b1b; + background: #fee2e2; +} + .admin-dashboard { display: grid; gap: 12px; diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 6727432..ef313f9 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -106,6 +106,24 @@ export interface AdminManagedUserResponse { user: AdminManagedUser } +export interface AdminLoginLog { + id: number + username: string + user_id: number | null + success: boolean + reason: string + remote_addr: string + remote_host: string + user_agent: string + created_at: string +} + +export interface AdminLoginLogsResponse { + items: AdminLoginLog[] + limit: number + offset: number +} + export interface AdminMqttClient { client_id: string username: string diff --git a/web.go b/web.go index e590b4b..6201483 100644 --- a/web.go +++ b/web.go @@ -103,15 +103,32 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, userDTO := func(user userRecord) gin.H { return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt} } + loginLogDTO := func(row loginLogRecord) gin.H { + return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt} + } + remoteInfo := func(c *gin.Context) (string, string) { + remoteAddr := c.Request.RemoteAddr + remoteHost, _, err := net.SplitHostPort(remoteAddr) + if err != nil || remoteHost == "" { + remoteHost = remoteAddr + } + return remoteAddr, remoteHost + } + recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) { + remoteAddr, remoteHost := remoteInfo(c) + _ = store.InsertLoginLog(loginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")}) + } r.POST("/login", func(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { + recordLogin(c, "", nil, false, "invalid request") c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"}) return } user, err := store.GetUserByUsername(req.Username) if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) { + recordLogin(c, req.Username, nil, false, "invalid username or password") c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } @@ -120,6 +137,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + recordLogin(c, req.Username, &user.ID, true, "success") http.SetCookie(c.Writer, cookie) c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)}) }) @@ -192,6 +210,14 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, } c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)}) }) + protected.GET("/log/login", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListLoginLogs(opts) + writeListResponse(c, rows, opts, err, loginLogDTO) + }) } func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) { @@ -396,6 +422,13 @@ func ptrInt64(value *int64) any { return *value } +func ptrUint64(value *uint64) any { + if value == nil { + return nil + } + return *value +} + func ptrFloat64(value *float64) any { if value == nil { return nil