diff --git a/README.md b/README.md index f051be3..708cdd8 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,17 @@ web: host: 0.0.0.0 port: 8080 static_dir: ./dist + admin: + username: admin + password: admin + session_secret: "" + session_secure: false ``` 配置优先级: ```text -内置默认值 < 配置文件 < 命令行参数 +内置默认值 < 配置文件 < 环境变量 < 命令行参数 ``` 也可以用命令行临时覆盖监听地址、PSK 和 TLS 设置: @@ -112,10 +117,19 @@ 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 到期或退出登录后才需要使用新密码。 + 常用 API: ```text GET /api/health +POST /api/admin/login +POST /api/admin/logout +GET /api/admin/me +GET /api/admin/mqtt/status +GET /api/admin/users +POST /api/admin/users +PUT /api/admin/users/:id/password GET /api/nodeinfo GET /api/nodeinfo/:id GET /api/map-reports diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..f924ac4 --- /dev/null +++ b/auth.go @@ -0,0 +1,147 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +const ( + adminRole = "admin" + adminSessionCookie = "mesh_admin_session" +) + +type adminUserDTO struct { + Username string `json:"username"` + Role string `json:"role"` +} + +type sessionClaims struct { + UserID uint64 `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + Expires int64 `json:"expires"` +} + +type sessionManager struct { + secret []byte + secure bool + ttl time.Duration +} + +func newSessionManager(cfg webAdminConfig) (*sessionManager, error) { + secret := strings.TrimSpace(cfg.SessionSecret) + if secret == "" { + generated := make([]byte, 32) + if _, err := rand.Read(generated); err != nil { + return nil, fmt.Errorf("generate admin session secret: %w", err) + } + return &sessionManager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil + } + return &sessionManager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func verifyPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func adminUserResponse(user userRecord) adminUserDTO { + return adminUserDTO{Username: user.Username, Role: user.Role} +} + +func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) { + claims := sessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()} + data, err := json.Marshal(claims) + if err != nil { + return nil, err + } + payload := base64.RawURLEncoding.EncodeToString(data) + signature := sm.sign(payload) + return &http.Cookie{ + Name: adminSessionCookie, + Value: payload + "." + signature, + Path: "/", + MaxAge: int(sm.ttl.Seconds()), + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + }, nil +} + +func (sm *sessionManager) clearCookie() *http.Cookie { + return &http.Cookie{ + Name: adminSessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: sm.secure, + SameSite: http.SameSiteLaxMode, + } +} + +func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, error) { + cookie, err := c.Cookie(adminSessionCookie) + if err != nil { + return nil, err + } + parts := strings.Split(cookie, ".") + if len(parts) != 2 { + return nil, errors.New("invalid session") + } + if !hmac.Equal([]byte(parts[1]), []byte(sm.sign(parts[0]))) { + return nil, errors.New("invalid session signature") + } + data, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, err + } + var claims sessionClaims + if err := json.Unmarshal(data, &claims); err != nil { + return nil, err + } + if claims.Expires <= time.Now().Unix() { + return nil, errors.New("session expired") + } + if claims.Role != adminRole { + return nil, errors.New("admin required") + } + return &claims, nil +} + +func (sm *sessionManager) sign(payload string) string { + mac := hmac.New(sha256.New, sm.secret) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func requireAdmin(sm *sessionManager) gin.HandlerFunc { + return func(c *gin.Context) { + claims, err := sm.claimsFromRequest(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"}) + c.Abort() + return + } + c.Set("admin_claims", claims) + c.Next() + } +} diff --git a/config.go b/config.go index cf6b004..ab875dd 100644 --- a/config.go +++ b/config.go @@ -51,10 +51,18 @@ type mysqlConfig struct { } type webConfig struct { - Enabled bool `yaml:"enabled"` - Host string `yaml:"host"` - Port int `yaml:"port"` - StaticDir string `yaml:"static_dir"` + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Port int `yaml:"port"` + StaticDir string `yaml:"static_dir"` + Admin webAdminConfig `yaml:"admin"` +} + +type webAdminConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + SessionSecret string `yaml:"session_secret"` + SessionSecure bool `yaml:"session_secure"` } type rawConfig struct { @@ -95,10 +103,18 @@ type rawMySQLConfig struct { } type rawWebConfig struct { - Enabled *bool `yaml:"enabled"` - Host *string `yaml:"host"` - Port *int `yaml:"port"` - StaticDir *string `yaml:"static_dir"` + Enabled *bool `yaml:"enabled"` + Host *string `yaml:"host"` + Port *int `yaml:"port"` + StaticDir *string `yaml:"static_dir"` + Admin *rawWebAdminConfig `yaml:"admin"` +} + +type rawWebAdminConfig struct { + Username *string `yaml:"username"` + Password *string `yaml:"password"` + SessionSecret *string `yaml:"session_secret"` + SessionSecure *bool `yaml:"session_secure"` } // defaultConfig 返回内置默认配置。 @@ -126,6 +142,12 @@ func defaultConfig() *config { Host: "0.0.0.0", Port: 8080, StaticDir: "./dist", + Admin: webAdminConfig{ + Username: "admin", + Password: "admin", + SessionSecret: "", + SessionSecure: false, + }, }, } } @@ -290,6 +312,30 @@ func normalizeConfig(raw rawConfig) (*config, bool) { } else { cfg.Web.StaticDir = *raw.Web.StaticDir } + if raw.Web.Admin == nil { + changed = true + } else { + if raw.Web.Admin.Username == nil { + changed = true + } else { + cfg.Web.Admin.Username = *raw.Web.Admin.Username + } + if raw.Web.Admin.Password == nil { + changed = true + } else { + cfg.Web.Admin.Password = *raw.Web.Admin.Password + } + if raw.Web.Admin.SessionSecret == nil { + changed = true + } else { + cfg.Web.Admin.SessionSecret = *raw.Web.Admin.SessionSecret + } + if raw.Web.Admin.SessionSecure == nil { + changed = true + } else { + cfg.Web.Admin.SessionSecure = *raw.Web.Admin.SessionSecure + } + } } return cfg, changed @@ -318,6 +364,12 @@ func validateConfig(cfg *config) error { if cfg.Web.StaticDir == "" { return fmt.Errorf("web.static_dir is required when web is enabled") } + if cfg.Web.Admin.Username == "" { + return fmt.Errorf("web.admin.username is required when web is enabled") + } + if cfg.Web.Admin.Password == "" { + return fmt.Errorf("web.admin.password is required when web is enabled") + } } return nil } diff --git a/db.go b/db.go index 49b3984..d4220b6 100644 --- a/db.go +++ b/db.go @@ -62,6 +62,19 @@ type MQTTClientRecordFields struct { MQTTRemotePort *string `gorm:"column:mqtt_remote_port"` } +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"` + Role string `gorm:"column:role;not null;index"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` +} + +func (userRecord) TableName() string { + return "users" +} + type nodeInfoRecord struct { NodeID string `gorm:"column:node_id;primaryKey;not null"` NodeNum int64 `gorm:"column:node_num;not null;index"` @@ -254,6 +267,7 @@ func (s *store) migrate() error { label string model any }{ + {label: "users", model: &userRecord{}}, {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 7244ea6..8b8a9b1 100644 --- a/db_test.go +++ b/db_test.go @@ -2,16 +2,19 @@ package main import ( "database/sql" + "errors" "path/filepath" "strings" "testing" + + "gorm.io/gorm" ) func TestOpenStoreCreatesTables(t *testing.T) { st := openTestStore(t) defer st.Close() - for _, table := range []string{"nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} { + for _, table := range []string{"users", "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) @@ -187,6 +190,106 @@ func TestNodeInfoNullablePublicKey(t *testing.T) { } } +func TestEnsureDefaultAdminCreatesAdminUser(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if err := st.EnsureDefaultAdmin("admin", "admin"); err != nil { + t.Fatalf("EnsureDefaultAdmin() error = %v", err) + } + + user, err := st.GetUserByUsername("admin") + if err != nil { + t.Fatalf("GetUserByUsername() error = %v", err) + } + if user.Role != adminRole { + t.Fatalf("role = %q, want admin", user.Role) + } + if user.PasswordHash == "admin" || user.PasswordHash == "" { + t.Fatalf("password hash = %q, want bcrypt hash", user.PasswordHash) + } + if !verifyPassword(user.PasswordHash, "admin") { + t.Fatalf("admin password did not verify") + } +} + +func TestEnsureDefaultAdminDoesNotOverwriteExistingUser(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if err := st.EnsureDefaultAdmin("admin", "first"); err != nil { + t.Fatalf("first EnsureDefaultAdmin() error = %v", err) + } + if err := st.EnsureDefaultAdmin("admin", "second"); err != nil { + t.Fatalf("second EnsureDefaultAdmin() error = %v", err) + } + user, err := st.GetUserByUsername("admin") + if err != nil { + t.Fatalf("GetUserByUsername() error = %v", err) + } + if !verifyPassword(user.PasswordHash, "first") || verifyPassword(user.PasswordHash, "second") { + t.Fatalf("admin password was overwritten") + } +} + +func TestCreateAdminUserCreatesHashedAdmin(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + user, err := st.CreateAdminUser("new-admin", "secret") + if err != nil { + t.Fatalf("CreateAdminUser() error = %v", err) + } + if user.Username != "new-admin" || user.Role != adminRole { + t.Fatalf("user = %#v, want new-admin admin", user) + } + if user.PasswordHash == "secret" || !verifyPassword(user.PasswordHash, "secret") { + t.Fatalf("password hash did not verify") + } +} + +func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if _, err := st.CreateAdminUser("new-admin", "secret"); err != nil { + t.Fatalf("first CreateAdminUser() error = %v", err) + } + if _, err := st.CreateAdminUser("new-admin", "secret"); !errors.Is(err, errUserAlreadyExists) { + t.Fatalf("duplicate CreateAdminUser() error = %v, want errUserAlreadyExists", err) + } +} + +func TestUpdateUserPasswordChangesHash(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + user, err := st.CreateAdminUser("new-admin", "old-secret") + if err != nil { + t.Fatalf("CreateAdminUser() error = %v", err) + } + oldHash := user.PasswordHash + updated, err := st.UpdateUserPassword(user.ID, "new-secret") + if err != nil { + t.Fatalf("UpdateUserPassword() error = %v", err) + } + if updated.PasswordHash == oldHash { + t.Fatalf("password hash did not change") + } + if verifyPassword(updated.PasswordHash, "old-secret") || !verifyPassword(updated.PasswordHash, "new-secret") { + t.Fatalf("updated password verification mismatch") + } +} + +func TestUpdateUserPasswordMissingUser(t *testing.T) { + st := openTestStore(t) + defer st.Close() + + if _, err := st.UpdateUserPassword(999, "new-secret"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("UpdateUserPassword() error = %v, want record not found", err) + } +} + func TestInsertTextMessageAppendsRows(t *testing.T) { st := openTestStore(t) defer st.Close() diff --git a/main.go b/main.go index eaafd5f..3b33e3b 100644 --- a/main.go +++ b/main.go @@ -161,8 +161,16 @@ func parseArgs() (*config, error) { flag.StringVar(&cfg.Web.Host, "web-host", cfg.Web.Host, "Web server listen host") flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port") flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory") + flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username") flag.Parse() + if value := os.Getenv("MESH_ADMIN_PASSWORD"); value != "" { + cfg.Web.Admin.Password = value + } + if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" { + cfg.Web.Admin.SessionSecret = value + } + if err := validateConfig(cfg); err != nil { return nil, err } @@ -181,8 +189,11 @@ func run(cfg *config) error { return err } defer store.Close() + if err := store.EnsureDefaultAdmin(cfg.Web.Admin.Username, cfg.Web.Admin.Password); err != nil { + return err + } - server, _, err := startMQTTServer(cfg, store) + server, mqttAddr, err := startMQTTServer(cfg, store) if err != nil { return err } @@ -190,7 +201,12 @@ func run(cfg *config) error { var httpServer *http.Server errCh := make(chan error, 1) if cfg.Web.Enabled { - httpServer = newHTTPServer(cfg.Web, store) + sessions, err := newSessionManager(cfg.Web.Admin) + if err != nil { + return err + } + mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled} + httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus) go func() { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err diff --git a/meshmap_frontend/src/App.vue b/meshmap_frontend/src/App.vue index 0fa7f2f..2a5debe 100644 --- a/meshmap_frontend/src/App.vue +++ b/meshmap_frontend/src/App.vue @@ -1,10 +1,16 @@ + + diff --git a/meshmap_frontend/src/components/AdminLogin.vue b/meshmap_frontend/src/components/AdminLogin.vue new file mode 100644 index 0000000..8abcd11 --- /dev/null +++ b/meshmap_frontend/src/components/AdminLogin.vue @@ -0,0 +1,51 @@ + + + diff --git a/meshmap_frontend/src/style.css b/meshmap_frontend/src/style.css index e82797d..4d87fcf 100644 --- a/meshmap_frontend/src/style.css +++ b/meshmap_frontend/src/style.css @@ -78,7 +78,8 @@ h3 { color: #475569; } -.topbar button { +.topbar button, +.topbar-link { border: 0; border-radius: 10px; padding: 9px 16px; @@ -87,6 +88,10 @@ h3 { font-weight: 700; } +.topbar-link { + text-decoration: none; +} + .topbar button:disabled { opacity: 0.6; } @@ -337,6 +342,110 @@ h3 { background: #eff6ff; } +.admin-loading { + padding: 24px; + color: #64748b; +} + +.admin-login, +.admin-dashboard { + max-width: 1100px; + margin: 0 auto; + width: 100%; +} + +.admin-form { + display: grid; + gap: 14px; + padding: 18px; +} + +.admin-user-form { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + align-items: end; +} + +.admin-form label { + display: grid; + gap: 6px; + color: #334155; + font-size: 14px; + font-weight: 700; +} + +.admin-form input { + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px 12px; + font: inherit; +} + +.admin-form button, +.admin-actions button, +.admin-button { + border: 0; + border-radius: 10px; + padding: 9px 16px; + color: #fff; + font-weight: 700; + background: #2563eb; +} + +.admin-table-input { + min-width: 160px; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 8px 10px; + font: inherit; +} + +.success { + margin: 0 16px 12px; + border: 1px solid #bbf7d0; + border-radius: 14px; + padding: 10px 12px; + color: #166534; + background: #f0fdf4; +} + +.admin-dashboard { + display: grid; + gap: 12px; +} + +.admin-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.admin-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + padding: 16px; +} + +.admin-status-grid div { + display: grid; + gap: 5px; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 12px; + background: #f8fafc; +} + +.admin-status-grid span { + color: #64748b; + font-size: 13px; +} + +.admin-status-grid strong { + color: #0f172a; +} + .pagination { display: flex; align-items: center; diff --git a/meshmap_frontend/src/types.ts b/meshmap_frontend/src/types.ts index 85fab22..6727432 100644 --- a/meshmap_frontend/src/types.ts +++ b/meshmap_frontend/src/types.ts @@ -80,3 +80,62 @@ export interface MapNode { } export type NodeInfoById = Record + +export interface AdminUser { + username: string + role: string +} + +export interface AdminLoginResponse { + user: AdminUser +} + +export interface AdminManagedUser { + id: number + username: string + role: string + created_at: string + updated_at: string +} + +export interface AdminUsersResponse { + items: AdminManagedUser[] +} + +export interface AdminManagedUserResponse { + user: AdminManagedUser +} + +export interface AdminMqttClient { + client_id: string + username: string + listener: string + remote_addr: string + remote_host: string + remote_port: string +} + +export interface AdminMqttStatus { + running: boolean + address: string + tls: boolean + version: string + started: number + uptime: number + bytes_received: number + bytes_sent: number + clients_connected: number + clients_disconnected: number + clients_maximum: number + clients_total: number + messages_received: number + messages_sent: number + messages_dropped: number + retained: number + inflight: number + inflight_dropped: number + subscriptions: number + packets_received: number + packets_sent: number + clients: AdminMqttClient[] +} diff --git a/mqtt_status.go b/mqtt_status.go new file mode 100644 index 0000000..0360040 --- /dev/null +++ b/mqtt_status.go @@ -0,0 +1,94 @@ +package main + +import ( + mqtt "github.com/mochi-mqtt/server/v2" +) + +type mqttStatusProvider interface { + Status() adminMqttStatus +} + +type mqttRuntimeStatus struct { + server *mqtt.Server + address string + tls bool +} + +type adminMqttStatus struct { + Running bool `json:"running"` + Address string `json:"address"` + TLS bool `json:"tls"` + Version string `json:"version"` + Started int64 `json:"started"` + Uptime int64 `json:"uptime"` + BytesReceived int64 `json:"bytes_received"` + BytesSent int64 `json:"bytes_sent"` + ClientsConnected int64 `json:"clients_connected"` + ClientsDisconnected int64 `json:"clients_disconnected"` + ClientsMaximum int64 `json:"clients_maximum"` + ClientsTotal int64 `json:"clients_total"` + MessagesReceived int64 `json:"messages_received"` + MessagesSent int64 `json:"messages_sent"` + MessagesDropped int64 `json:"messages_dropped"` + Retained int64 `json:"retained"` + Inflight int64 `json:"inflight"` + InflightDropped int64 `json:"inflight_dropped"` + Subscriptions int64 `json:"subscriptions"` + PacketsReceived int64 `json:"packets_received"` + PacketsSent int64 `json:"packets_sent"` + Clients []adminMqttClient `json:"clients"` +} + +type adminMqttClient struct { + ClientID string `json:"client_id"` + Username string `json:"username"` + Listener string `json:"listener"` + RemoteAddr string `json:"remote_addr"` + RemoteHost string `json:"remote_host"` + RemotePort string `json:"remote_port"` +} + +func (m mqttRuntimeStatus) Status() adminMqttStatus { + if m.server == nil || m.server.Info == nil { + return adminMqttStatus{Running: false, Address: m.address, TLS: m.tls} + } + info := m.server.Info.Clone() + status := adminMqttStatus{ + Running: true, + Address: m.address, + TLS: m.tls, + Version: info.Version, + Started: info.Started, + Uptime: info.Uptime, + BytesReceived: info.BytesReceived, + BytesSent: info.BytesSent, + ClientsConnected: info.ClientsConnected, + ClientsDisconnected: info.ClientsDisconnected, + ClientsMaximum: info.ClientsMaximum, + ClientsTotal: info.ClientsTotal, + MessagesReceived: info.MessagesReceived, + MessagesSent: info.MessagesSent, + MessagesDropped: info.MessagesDropped, + Retained: info.Retained, + Inflight: info.Inflight, + InflightDropped: info.InflightDropped, + Subscriptions: info.Subscriptions, + PacketsReceived: info.PacketsReceived, + PacketsSent: info.PacketsSent, + } + for _, client := range m.server.Clients.GetAll() { + if client == nil || client.Closed() { + continue + } + clientInfo := mqttClientInfoFromClient(client) + status.Clients = append(status.Clients, adminMqttClient{ + ClientID: clientInfo.ClientID, + Username: clientInfo.Username, + Listener: clientInfo.Listener, + RemoteAddr: clientInfo.RemoteAddr, + RemoteHost: clientInfo.RemoteHost, + RemotePort: clientInfo.RemotePort, + }) + } + return status +} diff --git a/user_store.go b/user_store.go new file mode 100644 index 0000000..37bfee8 --- /dev/null +++ b/user_store.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +var errUserAlreadyExists = errors.New("user already exists") + +func (s *store) GetUserByUsername(username string) (*userRecord, error) { + var user userRecord + if err := s.db.Where("username = ?", username).Take(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (s *store) GetUserByID(id uint64) (*userRecord, error) { + var user userRecord + if err := s.db.Where("id = ?", id).Take(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (s *store) ListUsers() ([]userRecord, error) { + var users []userRecord + return users, s.db.Order("id ASC").Find(&users).Error +} + +func (s *store) CreateAdminUser(username, password string) (*userRecord, error) { + username = strings.TrimSpace(username) + if username == "" { + return nil, fmt.Errorf("username is required") + } + if password == "" { + return nil, fmt.Errorf("password is required") + } + if _, err := s.GetUserByUsername(username); err == nil { + return nil, errUserAlreadyExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + hash, err := hashPassword(password) + if err != nil { + return nil, fmt.Errorf("hash user password: %w", err) + } + user := userRecord{Username: username, PasswordHash: hash, Role: adminRole} + if err := s.db.Create(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, error) { + if id == 0 { + return nil, fmt.Errorf("user id is required") + } + if password == "" { + return nil, fmt.Errorf("password is required") + } + user, err := s.GetUserByID(id) + if err != nil { + return nil, err + } + hash, err := hashPassword(password) + 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 { + return nil, err + } + user.PasswordHash = hash + return s.GetUserByID(id) +} + +func (s *store) EnsureDefaultAdmin(username, password string) error { + var existing userRecord + err := s.db.Where("username = ?", username).Take(&existing).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + hash, err := hashPassword(password) + if err != nil { + return fmt.Errorf("hash admin password: %w", err) + } + user := userRecord{Username: username, PasswordHash: hash, Role: adminRole} + if err := s.db.Create(&user).Error; err != nil { + return fmt.Errorf("create default admin user: %w", err) + } + return nil +} diff --git a/web.go b/web.go index 9f7e392..e590b4b 100644 --- a/web.go +++ b/web.go @@ -14,17 +14,19 @@ import ( "gorm.io/gorm" ) -func newHTTPServer(cfg webConfig, store *store) *http.Server { +func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider) *http.Server { return &http.Server{ Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)), - Handler: newRouter(cfg, store), + Handler: newRouter(cfg, store, sessions, mqttStatus), } } -func newRouter(cfg webConfig, store *store) *gin.Engine { +func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) - registerAPIRoutes(r.Group("/api"), store) + api := r.Group("/api") + registerAPIRoutes(api, store) + registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus) registerStaticRoutes(r, cfg.StaticDir) return r } @@ -86,6 +88,112 @@ func registerAPIRoutes(r gin.IRouter, store *store) { }) } +func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider) { + type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + } + type createUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + } + type updatePasswordRequest struct { + Password string `json:"password"` + } + 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} + } + + r.POST("/login", func(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + 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) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + cookie, err := sessions.newCookie(*user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + http.SetCookie(c.Writer, cookie) + c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)}) + }) + r.POST("/logout", func(c *gin.Context) { + http.SetCookie(c.Writer, sessions.clearCookie()) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + protected := r.Group("") + protected.Use(requireAdmin(sessions)) + protected.GET("/me", func(c *gin.Context) { + claims := c.MustGet("admin_claims").(*sessionClaims) + c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}}) + }) + protected.GET("/mqtt/status", func(c *gin.Context) { + if mqttStatus == nil { + c.JSON(http.StatusOK, adminMqttStatus{Running: false}) + return + } + c.JSON(http.StatusOK, mqttStatus.Status()) + }) + protected.GET("/users", func(c *gin.Context) { + users, err := store.ListUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + items := make([]gin.H, 0, len(users)) + for _, user := range users { + items = append(items, userDTO(user)) + } + c.JSON(http.StatusOK, gin.H{"items": items}) + }) + protected.POST("/users", func(c *gin.Context) { + var req createUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid create user request"}) + return + } + user, err := store.CreateAdminUser(req.Username, req.Password) + if errors.Is(err, errUserAlreadyExists) { + c.JSON(http.StatusConflict, gin.H{"error": "username already exists"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"user": userDTO(*user)}) + }) + protected.PUT("/users/:id/password", func(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) + return + } + var req updatePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid password request"}) + return + } + user, err := store.UpdateUserPassword(id, req.Password) + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"user": userDTO(*user)}) + }) +} + func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) { r.GET(path, func(c *gin.Context) { opts, ok := parseListOptions(c)