diff --git a/backend/api/auth.go b/backend/api/auth.go new file mode 100644 index 0000000..086f096 --- /dev/null +++ b/backend/api/auth.go @@ -0,0 +1,144 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/cynfo/rmm-backend/db" + "github.com/cynfo/rmm-backend/models" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type AuthHandler struct { + db *db.Database + secret []byte +} + +type JWTClaims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func NewAuthHandler(database *db.Database, jwtSecret string) *AuthHandler { + secret := jwtSecret + if secret == "" { + b := make([]byte, 32) + rand.Read(b) + secret = hex.EncodeToString(b) + log.Println("JWT Secret automatisch generiert (nicht persistent)") + } + return &AuthHandler{db: database, secret: []byte(secret)} +} + +func (a *AuthHandler) EnsureDefaultAdmin() { + count, err := a.db.UserCount() + if err != nil { + log.Printf("User-Count Fehler: %v", err) + return + } + if count == 0 { + _, err := a.db.CreateUser("admin", "admin", "Administrator", "admin") + if err != nil { + log.Printf("Default-Admin anlegen fehlgeschlagen: %v", err) + } else { + log.Println("Default-Admin angelegt (admin/admin) - bitte Passwort aendern!") + } + } +} + +func (a *AuthHandler) GenerateToken(user *models.User) (string, time.Time, error) { + expiresAt := time.Now().Add(8 * time.Hour) + claims := JWTClaims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(a.secret) + return signed, expiresAt, err +} + +func (a *AuthHandler) ValidateToken(tokenStr string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return a.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*JWTClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + return claims, nil +} + +// POST /api/v1/auth/login +func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req models.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") + return + } + + user, err := a.db.GetUserByUsername(req.Username) + if err != nil || user == nil { + writeError(w, http.StatusUnauthorized, "Ungueltige Anmeldedaten") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + writeError(w, http.StatusUnauthorized, "Ungueltige Anmeldedaten") + return + } + + token, expiresAt, err := a.GenerateToken(user) + if err != nil { + writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen") + return + } + + a.db.UpdateUserLastLogin(user.ID) + + writeJSON(w, http.StatusOK, models.LoginResponse{ + Token: token, + ExpiresAt: expiresAt.Format(time.RFC3339), + User: *user, + }) +} + +// GET /api/v1/auth/me +func (a *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(UserContextKey).(*JWTClaims) + if !ok { + writeError(w, http.StatusUnauthorized, "Nicht authentifiziert") + return + } + + user, err := a.db.GetUserByUsername(claims.Username) + if err != nil || user == nil { + writeError(w, http.StatusNotFound, "User nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, user) +} + +// SetupAuthRoutes registriert Auth-Routes (ohne Auth-Middleware) +func (a *AuthHandler) SetupAuthRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /api/v1/auth/login", a.Login) +} diff --git a/backend/api/customers.go b/backend/api/customers.go new file mode 100644 index 0000000..927516f --- /dev/null +++ b/backend/api/customers.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" +) + +// GET /api/v1/customers +func (h *Handler) listCustomers(w http.ResponseWriter, r *http.Request) { + customers, err := h.db.GetCustomers() + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + writeJSON(w, http.StatusOK, customers) +} + +// POST /api/v1/customers +func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) { + var req struct { + Number string `json:"number"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Number == "" || req.Name == "" { + writeError(w, http.StatusBadRequest, "number und name sind erforderlich") + return + } + c, err := h.db.CreateCustomer(req.Number, req.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen") + return + } + writeJSON(w, http.StatusCreated, c) +} + +// GET /api/v1/customers/{id} +func (h *Handler) getCustomer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige ID") + return + } + c, err := h.db.GetCustomer(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + if c == nil { + writeError(w, http.StatusNotFound, "Kunde nicht gefunden") + return + } + writeJSON(w, http.StatusOK, c) +} + +// PUT /api/v1/customers/{id} +func (h *Handler) updateCustomer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige ID") + return + } + var req struct { + Number string `json:"number"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Number == "" || req.Name == "" { + writeError(w, http.StatusBadRequest, "number und name sind erforderlich") + return + } + if err := h.db.UpdateCustomer(id, req.Number, req.Name); err != nil { + writeError(w, http.StatusInternalServerError, "Aktualisierung fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Kunde aktualisiert"}) +} + +// DELETE /api/v1/customers/{id} +func (h *Handler) deleteCustomer(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige ID") + return + } + deleted, err := h.db.DeleteCustomer(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "Loeschen fehlgeschlagen") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "Kunde nicht gefunden") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Kunde geloescht"}) +} + +// GET /api/v1/customers/{id}/agents +func (h *Handler) getCustomerAgents(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige ID") + return + } + agents, err := h.db.GetAgentsByCustomer(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + writeJSON(w, http.StatusOK, agents) +} + +// PUT /api/v1/agents/{id}/customer +func (h *Handler) assignAgentCustomer(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + var req struct { + CustomerID int `json:"customer_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.CustomerID == 0 { + writeError(w, http.StatusBadRequest, "customer_id erforderlich") + return + } + if err := h.db.AssignAgentCustomer(agentID, req.CustomerID); err != nil { + writeError(w, http.StatusInternalServerError, "Zuordnung fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Agent zugeordnet"}) +} + +// DELETE /api/v1/agents/{id}/customer +func (h *Handler) unassignAgentCustomer(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + if err := h.db.UnassignAgentCustomer(agentID); err != nil { + writeError(w, http.StatusInternalServerError, "Zuordnung entfernen fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Zuordnung entfernt"}) +} diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 3bbc637..3a1e4b5 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -35,6 +35,23 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("GET /api/v1/agents/{id}/system", h.getSystemData) mux.HandleFunc("DELETE /api/v1/agents/{id}", h.deleteAgent) + // Customer-Routes + mux.HandleFunc("GET /api/v1/customers", h.listCustomers) + mux.HandleFunc("POST /api/v1/customers", h.createCustomer) + mux.HandleFunc("GET /api/v1/customers/{id}", h.getCustomer) + mux.HandleFunc("PUT /api/v1/customers/{id}", h.updateCustomer) + mux.HandleFunc("DELETE /api/v1/customers/{id}", h.deleteCustomer) + mux.HandleFunc("GET /api/v1/customers/{id}/agents", h.getCustomerAgents) + + // Agent-Customer Zuordnung + mux.HandleFunc("PUT /api/v1/agents/{id}/customer", h.assignAgentCustomer) + mux.HandleFunc("DELETE /api/v1/agents/{id}/customer", h.unassignAgentCustomer) + + // User-Routes + mux.HandleFunc("GET /api/v1/users", h.listUsers) + mux.HandleFunc("POST /api/v1/users", h.createUser) + mux.HandleFunc("DELETE /api/v1/users/{id}", h.deleteUser) + // WebSocket und Tunnel-Routes h.setupTunnelRoutes(mux, hub) } diff --git a/backend/api/middleware.go b/backend/api/middleware.go index 11a880b..8a16a79 100644 --- a/backend/api/middleware.go +++ b/backend/api/middleware.go @@ -1,11 +1,17 @@ package api import ( + "context" "log" "net/http" + "strings" "time" ) +type contextKey string + +const UserContextKey contextKey = "user" + // APIKeyAuth - Middleware fuer API-Key Authentifizierung func APIKeyAuth(validKeys []string, next http.Handler) http.Handler { keySet := make(map[string]bool, len(validKeys)) @@ -15,7 +21,6 @@ func APIKeyAuth(validKeys []string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("X-API-Key") - // Fallback: Query-Parameter (fuer WebSocket-Verbindungen) if key == "" { key = r.URL.Query().Get("api_key") } @@ -27,6 +32,54 @@ func APIKeyAuth(validKeys []string, next http.Handler) http.Handler { }) } +// CombinedAuth - API-Key ODER JWT Token +func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler { + keySet := make(map[string]bool, len(validKeys)) + for _, k := range validKeys { + keySet[k] = true + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Try API key first + key := r.Header.Get("X-API-Key") + if key == "" { + key = r.URL.Query().Get("api_key") + } + if key != "" && keySet[key] { + next.ServeHTTP(w, r) + return + } + + // Try JWT + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := auth.ValidateToken(token) + if err == nil { + ctx := context.WithValue(r.Context(), UserContextKey, claims) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + }) +} + +// CORS Middleware +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} + // Logging - Request-Logging Middleware func Logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/api/users.go b/backend/api/users.go new file mode 100644 index 0000000..e5d8096 --- /dev/null +++ b/backend/api/users.go @@ -0,0 +1,59 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" +) + +// GET /api/v1/users +func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.db.GetUsers() + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + writeJSON(w, http.StatusOK, users) +} + +// POST /api/v1/users +func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "username und password sind erforderlich") + return + } + if req.Role == "" { + req.Role = "admin" + } + u, err := h.db.CreateUser(req.Username, req.Password, req.DisplayName, req.Role) + if err != nil { + writeError(w, http.StatusInternalServerError, "User anlegen fehlgeschlagen") + return + } + writeJSON(w, http.StatusCreated, u) +} + +// DELETE /api/v1/users/{id} +func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige ID") + return + } + deleted, err := h.db.DeleteUser(id) + if err != nil { + writeError(w, http.StatusInternalServerError, "Loeschen fehlgeschlagen") + return + } + if !deleted { + writeError(w, http.StatusNotFound, "User nicht gefunden") + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "User geloescht"}) +} diff --git a/backend/config/config.go b/backend/config/config.go index 7112808..259c45c 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -9,10 +9,11 @@ import ( ) type Config struct { - ListenAddr string `yaml:"listen_addr"` - TLSCert string `yaml:"tls_cert"` - TLSKey string `yaml:"tls_key"` - APIKeys []string `yaml:"api_keys"` + ListenAddr string `yaml:"listen_addr"` + TLSCert string `yaml:"tls_cert"` + TLSKey string `yaml:"tls_key"` + APIKeys []string `yaml:"api_keys"` + JWTSecret string `yaml:"jwt_secret"` Database db.DBConfig `yaml:"database"` } diff --git a/backend/db/customers.go b/backend/db/customers.go new file mode 100644 index 0000000..8b2fd7f --- /dev/null +++ b/backend/db/customers.go @@ -0,0 +1,67 @@ +package db + +import ( + "database/sql" + + "github.com/cynfo/rmm-backend/models" +) + +func (d *Database) CreateCustomer(number, name string) (*models.Customer, error) { + var c models.Customer + err := d.db.QueryRow( + "INSERT INTO customers (number, name) VALUES ($1, $2) RETURNING id, number, name, created_at", + number, name, + ).Scan(&c.ID, &c.Number, &c.Name, &c.CreatedAt) + if err != nil { + return nil, err + } + return &c, nil +} + +func (d *Database) GetCustomers() ([]models.Customer, error) { + rows, err := d.db.Query("SELECT id, number, name, created_at FROM customers ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + var customers []models.Customer + for rows.Next() { + var c models.Customer + if err := rows.Scan(&c.ID, &c.Number, &c.Name, &c.CreatedAt); err != nil { + return nil, err + } + customers = append(customers, c) + } + if customers == nil { + customers = []models.Customer{} + } + return customers, rows.Err() +} + +func (d *Database) GetCustomer(id int) (*models.Customer, error) { + var c models.Customer + err := d.db.QueryRow("SELECT id, number, name, created_at FROM customers WHERE id = $1", id). + Scan(&c.ID, &c.Number, &c.Name, &c.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &c, nil +} + +func (d *Database) UpdateCustomer(id int, number, name string) error { + _, err := d.db.Exec("UPDATE customers SET number = $1, name = $2 WHERE id = $3", number, name, id) + return err +} + +func (d *Database) DeleteCustomer(id int) (bool, error) { + res, err := d.db.Exec("DELETE FROM customers WHERE id = $1", id) + if err != nil { + return false, err + } + rows, _ := res.RowsAffected() + return rows > 0, nil +} diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 522f484..62c6226 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -97,12 +97,33 @@ func (d *Database) migrate() error { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_agent_events_agent ON agent_events(agent_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS customers ( + id SERIAL PRIMARY KEY, + number TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login TIMESTAMPTZ + ); ` if _, err := d.db.Exec(schema); err != nil { return fmt.Errorf("Schema anlegen: %w", err) } + // Agents um customer_id erweitern + d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id)") + d.db.Exec("CREATE INDEX IF NOT EXISTS idx_agents_customer ON agents(customer_id)") + // Metrics Hypertable metricsSchema := ` CREATE TABLE IF NOT EXISTS metrics ( @@ -321,7 +342,7 @@ func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data * } func (d *Database) GetAgents() ([]models.Agent, error) { - rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents ORDER BY name") + rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents ORDER BY name") if err != nil { return nil, err } @@ -331,13 +352,18 @@ func (d *Database) GetAgents() ([]models.Agent, error) { for rows.Next() { var a models.Agent var lastHB sql.NullTime + var custID sql.NullInt64 - if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID); err != nil { return nil, err } if lastHB.Valid { a.LastHeartbeat = &lastHB.Time } + if custID.Valid { + id := int(custID.Int64) + a.CustomerID = &id + } agents = append(agents, a) } if agents == nil { @@ -370,11 +396,12 @@ func (d *Database) GetAgentByHostname(hostname string) (*models.Agent, error) { func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error) { var a models.Agent var lastHB sql.NullTime + var custID sql.NullInt64 err := d.db.QueryRow( - "SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE id = $1", + "SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents WHERE id = $1", id, - ).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB) + ).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID) if err == sql.ErrNoRows { return nil, nil, nil @@ -385,6 +412,10 @@ func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error if lastHB.Valid { a.LastHeartbeat = &lastHB.Time } + if custID.Valid { + cid := int(custID.Int64) + a.CustomerID = &cid + } // Systemdaten laden var dataJSON sql.NullString @@ -729,6 +760,48 @@ func (d *Database) GetAllAgentEvents(eventType string, limit int) ([]models.Agen return events, rows.Err() } +// --- Agent-Customer Zuordnung --- + +func (d *Database) AssignAgentCustomer(agentID string, customerID int) error { + _, err := d.db.Exec("UPDATE agents SET customer_id = $1 WHERE id = $2", customerID, agentID) + return err +} + +func (d *Database) UnassignAgentCustomer(agentID string) error { + _, err := d.db.Exec("UPDATE agents SET customer_id = NULL WHERE id = $1", agentID) + return err +} + +func (d *Database) GetAgentsByCustomer(customerID int) ([]models.Agent, error) { + rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat, customer_id FROM agents WHERE customer_id = $1 ORDER BY name", customerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var agents []models.Agent + for rows.Next() { + var a models.Agent + var lastHB sql.NullTime + var custID sql.NullInt64 + if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB, &custID); err != nil { + return nil, err + } + if lastHB.Valid { + a.LastHeartbeat = &lastHB.Time + } + if custID.Valid { + cid := int(custID.Int64) + a.CustomerID = &cid + } + agents = append(agents, a) + } + if agents == nil { + agents = []models.Agent{} + } + return agents, rows.Err() +} + // GetAgentStatus berechnet den Status eines Agents aus last_heartbeat func (d *Database) GetAgentStatus(agentID string) string { var lastHB sql.NullTime diff --git a/backend/db/users.go b/backend/db/users.go new file mode 100644 index 0000000..0269c84 --- /dev/null +++ b/backend/db/users.go @@ -0,0 +1,89 @@ +package db + +import ( + "database/sql" + + "github.com/cynfo/rmm-backend/models" + "golang.org/x/crypto/bcrypt" +) + +func (d *Database) CreateUser(username, password, displayName, role string) (*models.User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + var u models.User + err = d.db.QueryRow( + "INSERT INTO users (username, password_hash, display_name, role) VALUES ($1, $2, $3, $4) RETURNING id, username, display_name, role, created_at", + username, string(hash), displayName, role, + ).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.CreatedAt) + if err != nil { + return nil, err + } + return &u, nil +} + +func (d *Database) GetUserByUsername(username string) (*models.User, error) { + var u models.User + var lastLogin sql.NullTime + err := d.db.QueryRow( + "SELECT id, username, password_hash, display_name, role, created_at, last_login FROM users WHERE username = $1", + username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.DisplayName, &u.Role, &u.CreatedAt, &lastLogin) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if lastLogin.Valid { + u.LastLogin = &lastLogin.Time + } + return &u, nil +} + +func (d *Database) GetUsers() ([]models.User, error) { + rows, err := d.db.Query("SELECT id, username, display_name, role, created_at, last_login FROM users ORDER BY username") + if err != nil { + return nil, err + } + defer rows.Close() + + var users []models.User + for rows.Next() { + var u models.User + var lastLogin sql.NullTime + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.CreatedAt, &lastLogin); err != nil { + return nil, err + } + if lastLogin.Valid { + u.LastLogin = &lastLogin.Time + } + users = append(users, u) + } + if users == nil { + users = []models.User{} + } + return users, rows.Err() +} + +func (d *Database) UpdateUserLastLogin(id int) error { + _, err := d.db.Exec("UPDATE users SET last_login = NOW() WHERE id = $1", id) + return err +} + +func (d *Database) DeleteUser(id int) (bool, error) { + res, err := d.db.Exec("DELETE FROM users WHERE id = $1", id) + if err != nil { + return false, err + } + rows, _ := res.RowsAffected() + return rows > 0, nil +} + +func (d *Database) UserCount() (int, error) { + var count int + err := d.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + return count, err +} diff --git a/backend/go.mod b/backend/go.mod index dd593b4..5c62797 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,11 +9,13 @@ require ( ) require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index d3cbaac..1ad8e69 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -25,10 +27,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/main.go b/backend/main.go index b960da0..336058b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -57,15 +57,30 @@ func main() { mon := monitor.New(database) go mon.Run() - // Router aufbauen - mux := http.NewServeMux() - handler := api.NewHandler(database) - handler.SetupRoutes(mux, hub) + // Auth-Handler initialisieren + authHandler := api.NewAuthHandler(database, cfg.JWTSecret) + authHandler.EnsureDefaultAdmin() - // Middleware-Chain: Logging -> Auth -> Handler + // Router aufbauen - Auth-Routes ohne Auth-Middleware + authMux := http.NewServeMux() + authHandler.SetupAuthRoutes(authMux) + + // Geschuetzte Routes + protectedMux := http.NewServeMux() + handler := api.NewHandler(database) + handler.SetupRoutes(protectedMux, hub) + // GET /api/v1/auth/me braucht JWT + protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me) + + // Combined router: auth routes ungeschuetzt, rest mit CombinedAuth + mux := http.NewServeMux() + mux.Handle("POST /api/v1/auth/login", authMux) + mux.Handle("/", api.CombinedAuth(cfg.APIKeys, authHandler, protectedMux)) + + // Middleware-Chain: CORS -> Logging -> Handler var chain http.Handler = mux - chain = api.APIKeyAuth(cfg.APIKeys, chain) chain = api.Logging(chain) + chain = api.CORS(chain) log.Printf("RMM Backend startet auf %s (TLS)", cfg.ListenAddr) log.Printf("API-Keys konfiguriert: %d", len(cfg.APIKeys)) diff --git a/backend/models/agent.go b/backend/models/agent.go index 770e00e..d2e3009 100644 --- a/backend/models/agent.go +++ b/backend/models/agent.go @@ -11,6 +11,7 @@ type Agent struct { OPNsenseVersion string `json:"opnsense_version"` RegisteredAt time.Time `json:"registered_at"` LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"` + CustomerID *int `json:"customer_id,omitempty"` } // AgentResponse erweitert Agent um den berechneten Status diff --git a/backend/models/customer.go b/backend/models/customer.go new file mode 100644 index 0000000..e919c0b --- /dev/null +++ b/backend/models/customer.go @@ -0,0 +1,10 @@ +package models + +import "time" + +type Customer struct { + ID int `json:"id"` + Number string `json:"number"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/models/user.go b/backend/models/user.go new file mode 100644 index 0000000..7e4492c --- /dev/null +++ b/backend/models/user.go @@ -0,0 +1,24 @@ +package models + +import "time" + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + LastLogin *time.Time `json:"last_login,omitempty"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + User User `json:"user"` +}