diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 904c62f..f8c7ceb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,29 +8,46 @@ ## Project Structure - Config: `config.yaml` (live-reloaded via file watcher in `meshbot/config.py`) - Bot: `meshbot/bot.py` - Meshtastic TCP, commands use `config.get("bot.command_prefix")` -- Web: `meshbot/webserver.py` - aiohttp + WebSocket -- DB: `meshbot/database.py` - SQLite via aiosqlite +- Auth: `meshbot/auth.py` - Session-Middleware, Passwort-Hashing, Auth-Routen, Admin-API, Email +- Web: `meshbot/webserver.py` - aiohttp + WebSocket + Auth-Integration +- DB: `meshbot/database.py` - SQLite via aiosqlite (nodes, messages, commands, users, tokens, email_logs) - Scheduler: `meshbot/scheduler.py` - Cron-based job scheduler - Frontend: `static/` - Bootstrap 5.3 dark/light theme, AdminLTE-style layout - Entry: `main.py` ## Pages & Routes - `/` - Dashboard (`static/index.html`, `static/js/dashboard.js`) -- `/scheduler` - Scheduler (`static/scheduler.html`, `static/js/scheduler.js`) -- `/map` - Leaflet map (`static/map.html`, `static/js/map.js`) -- `/settings` - Node config (`static/settings.html`, `static/js/settings.js`) +- `/scheduler` - Scheduler (`static/scheduler.html`, `static/js/scheduler.js`) - Admin only +- `/map` - Leaflet map (`static/map.html`, `static/js/map.js`) - Public +- `/settings` - Node config (`static/settings.html`, `static/js/settings.js`) - Admin only +- `/login` + `/register` - Auth (`static/login.html`, `static/js/login.js`) +- `/admin` - User management (`static/admin.html`, `static/js/admin.js`) - Admin only - `/ws` - WebSocket endpoint -- API: `/api/nodes`, `/api/messages`, `/api/stats`, `/api/send`, `/api/node/config`, `/api/scheduler/jobs` +- Auth: `/auth/login`, `/auth/register`, `/auth/logout`, `/auth/verify`, `/auth/set-password`, `/auth/forgot-password`, `/auth/reset-password` +- API: `/api/nodes`, `/api/messages`, `/api/stats`, `/api/send` (user), `/api/node/config` (admin), `/api/scheduler/jobs` (admin) +- API Auth: `/api/auth/me`, `/api/admin/users`, `/api/admin/users/{id}/role`, `/api/admin/users/{id}/verify` + +## Rollen & Zugriffsrechte +| Bereich | Public | User | Admin | +|---------|--------|------|-------| +| `/map`, `/` (Nodes, Stats) | Ja | Ja | Ja | +| Dashboard Nachrichten + Senden | Nein | Ja | Ja | +| `/scheduler`, `/settings` | Nein | Nein | Ja | +| `/admin` | Nein | Nein | Ja | ## Frontend Layout Pattern - All pages use consistent AdminLTE-style: top-navbar (46px), sidebar (200px), content-wrapper -- Sidebar nav with active state, 4 entries: Dashboard, Scheduler, Karte, Einstellungen -- Each JS file has: theme toggle (localStorage), sidebar toggle (mobile), page-specific logic +- Sidebar nav with active state, 5 entries: Dashboard, Scheduler, Karte, Einstellungen, Benutzer +- Admin-only sidebar entries use class `sidebar-admin` (hidden via JS if not admin) +- Navbar: User-Name + Logout button (logged in) or Login button (not logged in) +- Each JS file has: auth check (`/api/auth/me`), updateNavbar(), updateSidebar(), theme toggle, sidebar toggle - Shared styles in `static/css/style.css` ## Key Details - Meshtastic host configured in config.yaml, not env vars - Bot start: `/home/peter/meshdd-bot/venv/bin/python main.py` - Forgejo remote with token in URL -- Current version: 0.4.0 +- Current version: 0.5.0 - Protobuf objects converted via `google.protobuf.json_format.MessageToDict()` +- Auth: bcrypt (12 rounds), aiohttp-session EncryptedCookieStorage, aiosmtplib for emails +- SMTP fallback: if no smtp.host configured, verification links logged to console diff --git a/CHANGELOG.md b/CHANGELOG.md index d402aea..4584821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [0.5.0] - 2026-02-16 +### Added +- Benutzerverwaltung mit Session-basierter Authentifizierung +- Registrierung mit E-Mail-Verifikation (OTP-Token via aiosmtplib) +- Passwort-Hashing mit bcrypt (12 Rounds, min. 8 Zeichen) +- Benutzerrollen: public (nicht eingeloggt), user, admin +- Login/Register/Passwort-vergessen Seite (`/login`, `/register`) +- Admin-Benutzerverwaltung (`/admin`) mit Rolle aendern, verifizieren, loeschen +- Session-Management via aiohttp-session mit EncryptedCookieStorage +- Auth-Middleware setzt `request['user']` auf allen Routen +- API-Endpoint `GET /api/auth/me` fuer Frontend-Rollenabfrage +- Auth-Routen: login, register, logout, verify, set-password, forgot-password, reset-password +- Admin-API: users CRUD, Rolle aendern, manuell verifizieren +- Navbar zeigt User-Name + Logout oder Login-Button auf allen Seiten +- Sidebar zeigt Scheduler/Settings/Admin nur fuer Admins (JS-gesteuert) + +### Changed +- Dashboard: Nachrichten-Card und Sende-Card nur fuer eingeloggte User sichtbar +- API `/api/send` erfordert User-Login +- API `/api/node/config`, `/api/scheduler/*` erfordern Admin-Rolle +- Neue DB-Tabellen: users, tokens, email_logs +- config.yaml: auth + smtp Sektionen hinzugefuegt +- requirements.txt: bcrypt, aiohttp-session, cryptography, aiosmtplib + ## [0.4.0] - 2026-02-16 ### Summary - Node-Einstellungen Seite, Karte im Sidebar-Layout diff --git a/config.yaml b/config.yaml index a6e052e..f46bde9 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -version: "0.4.0" +version: "0.5.0" bot: name: "MeshDD-Bot" @@ -14,3 +14,15 @@ web: database: path: "meshdd.db" + +auth: + secret_key: "change-this-secret-key-32bytes!!" + session_max_age: 86400 + +smtp: + host: "" + port: 587 + user: "" + password: "" + from: "MeshDD-Bot " + app_url: "http://localhost:8080" diff --git a/meshbot/auth.py b/meshbot/auth.py new file mode 100644 index 0000000..7dec119 --- /dev/null +++ b/meshbot/auth.py @@ -0,0 +1,380 @@ +import base64 +import logging +import time +import uuid + +import bcrypt +from aiohttp import web +import aiohttp_session +from aiohttp_session.cookie_storage import EncryptedCookieStorage +from cryptography.fernet import Fernet + +from meshbot import config + +logger = logging.getLogger(__name__) + + +# ── Password hashing ───────────────────────────────── + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def check_password(password: str, hashed: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) + + +# ── Session setup ──────────────────────────────────── + +def setup_session(app: web.Application): + secret_key = config.get("auth.secret_key", "change-this-secret-key-32bytes!!") + # Fernet requires a 32-byte url-safe base64-encoded key + fernet_key = base64.urlsafe_b64encode(secret_key.encode("utf-8")[:32]) + max_age = config.get("auth.session_max_age", 86400) + storage = EncryptedCookieStorage( + fernet_key, + cookie_name="meshdd_session", + max_age=max_age, + ) + aiohttp_session.setup(app, storage) + + +# ── Auth middleware ─────────────────────────────────── + +@web.middleware +async def auth_middleware(request: web.Request, handler): + request["user"] = None + try: + session = await aiohttp_session.get_session(request) + if session.get("user_id"): + request["user"] = { + "id": session["user_id"], + "email": session.get("email"), + "name": session.get("name"), + "role": session.get("role"), + } + except Exception: + pass + return await handler(request) + + +# ── Helper checks ──────────────────────────────────── + +def require_user(request: web.Request): + if not request["user"]: + raise web.HTTPFound("/login") + + +def require_admin(request: web.Request): + if not request["user"]: + raise web.HTTPFound("/login") + if request["user"]["role"] != "admin": + raise web.HTTPForbidden(text="Admin access required") + + +def require_user_api(request: web.Request): + if not request["user"]: + raise web.HTTPUnauthorized(text="Login required") + + +def require_admin_api(request: web.Request): + if not request["user"]: + raise web.HTTPUnauthorized(text="Login required") + if request["user"]["role"] != "admin": + raise web.HTTPForbidden(text="Admin access required") + + +# ── Email sending ──────────────────────────────────── + +async def send_verification_email(db, email: str, token: str): + app_url = config.get("smtp.app_url", "http://localhost:8080") + verify_url = f"{app_url}/auth/verify?token={token}" + subject = "MeshDD-Bot - E-Mail verifizieren" + html_body = f""" +

MeshDD-Bot Registrierung

+

Klicke auf den folgenden Link, um dein Passwort zu setzen und dein Konto zu aktivieren:

+

{verify_url}

+

Der Link ist 24 Stunden gueltig.

+""" + + smtp_host = config.get("smtp.host", "") + if not smtp_host: + logger.info("SMTP not configured - verification link: %s", verify_url) + await db.log_email(email, subject, "console", "SMTP not configured") + return + + try: + import aiosmtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = config.get("smtp.from", "MeshDD-Bot ") + msg["To"] = email + msg.attach(MIMEText(html_body, "html")) + + await aiosmtplib.send( + msg, + hostname=smtp_host, + port=config.get("smtp.port", 587), + username=config.get("smtp.user", ""), + password=config.get("smtp.password", ""), + start_tls=True, + ) + await db.log_email(email, subject, "sent") + logger.info("Verification email sent to %s", email) + except Exception as e: + logger.error("Failed to send email to %s: %s", email, e) + await db.log_email(email, subject, "error", str(e)) + + +async def send_reset_email(db, email: str, token: str): + app_url = config.get("smtp.app_url", "http://localhost:8080") + reset_url = f"{app_url}/auth/reset-password?token={token}" + subject = "MeshDD-Bot - Passwort zuruecksetzen" + html_body = f""" +

Passwort zuruecksetzen

+

Klicke auf den folgenden Link, um dein Passwort zurueckzusetzen:

+

{reset_url}

+

Der Link ist 24 Stunden gueltig.

+""" + + smtp_host = config.get("smtp.host", "") + if not smtp_host: + logger.info("SMTP not configured - reset link: %s", reset_url) + await db.log_email(email, subject, "console", "SMTP not configured") + return + + try: + import aiosmtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = config.get("smtp.from", "MeshDD-Bot ") + msg["To"] = email + msg.attach(MIMEText(html_body, "html")) + + await aiosmtplib.send( + msg, + hostname=smtp_host, + port=config.get("smtp.port", 587), + username=config.get("smtp.user", ""), + password=config.get("smtp.password", ""), + start_tls=True, + ) + await db.log_email(email, subject, "sent") + logger.info("Reset email sent to %s", email) + except Exception as e: + logger.error("Failed to send reset email to %s: %s", email, e) + await db.log_email(email, subject, "error", str(e)) + + +# ── Auth route handlers ────────────────────────────── + +def setup_auth_routes(app: web.Application, db): + async def login_post(request: web.Request) -> web.Response: + data = await request.json() + email = data.get("email", "").strip().lower() + password = data.get("password", "") + + if not email or not password: + return web.json_response({"error": "E-Mail und Passwort erforderlich"}, status=400) + + user = await db.get_user_by_email(email) + if not user or not check_password(password, user["password"]): + return web.json_response({"error": "Ungueltige Anmeldedaten"}, status=401) + + if not user["is_verified"]: + return web.json_response({"error": "Konto nicht verifiziert. Bitte pruefe deine E-Mails."}, status=403) + + session = await aiohttp_session.new_session(request) + session["user_id"] = user["id"] + session["email"] = user["email"] + session["name"] = user["name"] + session["role"] = user["role"] + + return web.json_response({ + "id": user["id"], + "email": user["email"], + "name": user["name"], + "role": user["role"], + }) + + async def register_post(request: web.Request) -> web.Response: + data = await request.json() + name = data.get("name", "").strip() + email = data.get("email", "").strip().lower() + + if not name or not email: + return web.json_response({"error": "Name und E-Mail erforderlich"}, status=400) + + existing = await db.get_user_by_email(email) + if existing: + return web.json_response({"error": "E-Mail bereits registriert"}, status=409) + + # Create user with temporary password hash (will be set during verification) + temp_hash = hash_password(uuid.uuid4().hex) + user = await db.create_user(email=email, password=temp_hash, name=name) + + # Generate verification token + token = uuid.uuid4().hex + expires_at = time.time() + 86400 # 24h + await db.create_token(user["id"], token, "verify", expires_at) + + # Send email + await send_verification_email(db, email, token) + + return web.json_response({"ok": True, "message": "Registrierung erfolgreich. Pruefe deine E-Mails."}) + + async def logout_get(request: web.Request) -> web.Response: + session = await aiohttp_session.get_session(request) + session.invalidate() + raise web.HTTPFound("/") + + async def verify_get(request: web.Request) -> web.Response: + token = request.query.get("token", "") + if not token: + return web.Response(text="Token fehlt", status=400) + + token_row = await db.get_valid_token(token, "verify") + if not token_row: + return web.Response(text="Token ungueltig oder abgelaufen", status=400) + + # Serve the set-password form + import os + static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") + return web.FileResponse(os.path.join(static_dir, "login.html")) + + async def set_password_post(request: web.Request) -> web.Response: + data = await request.json() + token = data.get("token", "") + password = data.get("password", "") + + if not token or not password: + return web.json_response({"error": "Token und Passwort erforderlich"}, status=400) + + if len(password) < 8: + return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400) + + token_row = await db.get_valid_token(token, "verify") + if not token_row: + return web.json_response({"error": "Token ungueltig oder abgelaufen"}, status=400) + + hashed = hash_password(password) + await db.update_user(token_row["user_id"], password=hashed, is_verified=1) + await db.mark_token_used(token_row["id"]) + + return web.json_response({"ok": True, "message": "Passwort gesetzt. Du kannst dich jetzt anmelden."}) + + async def forgot_password_post(request: web.Request) -> web.Response: + data = await request.json() + email = data.get("email", "").strip().lower() + + if not email: + return web.json_response({"error": "E-Mail erforderlich"}, status=400) + + user = await db.get_user_by_email(email) + if not user: + # Don't reveal whether email exists + return web.json_response({"ok": True, "message": "Falls die E-Mail registriert ist, wurde ein Link gesendet."}) + + token = uuid.uuid4().hex + expires_at = time.time() + 86400 # 24h + await db.create_token(user["id"], token, "reset", expires_at) + await send_reset_email(db, email, token) + + return web.json_response({"ok": True, "message": "Falls die E-Mail registriert ist, wurde ein Link gesendet."}) + + async def reset_password_get(request: web.Request) -> web.Response: + token = request.query.get("token", "") + if not token: + return web.Response(text="Token fehlt", status=400) + + token_row = await db.get_valid_token(token, "reset") + if not token_row: + return web.Response(text="Token ungueltig oder abgelaufen", status=400) + + import os + static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") + return web.FileResponse(os.path.join(static_dir, "login.html")) + + async def reset_password_post(request: web.Request) -> web.Response: + data = await request.json() + token = data.get("token", "") + password = data.get("password", "") + + if not token or not password: + return web.json_response({"error": "Token und Passwort erforderlich"}, status=400) + + if len(password) < 8: + return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400) + + token_row = await db.get_valid_token(token, "reset") + if not token_row: + return web.json_response({"error": "Token ungueltig oder abgelaufen"}, status=400) + + hashed = hash_password(password) + await db.update_user(token_row["user_id"], password=hashed) + await db.mark_token_used(token_row["id"]) + + return web.json_response({"ok": True, "message": "Passwort geaendert. Du kannst dich jetzt anmelden."}) + + async def me_get(request: web.Request) -> web.Response: + user = request["user"] + if not user: + return web.json_response(None, status=401) + return web.json_response(user) + + # ── Admin API ──────────────────────────────────── + + async def admin_users_get(request: web.Request) -> web.Response: + require_admin_api(request) + users = await db.get_all_users() + return web.json_response(users) + + async def admin_user_role(request: web.Request) -> web.Response: + require_admin_api(request) + user_id = int(request.match_info["id"]) + data = await request.json() + role = data.get("role", "") + if role not in ("user", "admin"): + return web.json_response({"error": "Ungueltige Rolle"}, status=400) + await db.update_user(user_id, role=role) + users = await db.get_all_users() + return web.json_response(users) + + async def admin_user_verify(request: web.Request) -> web.Response: + require_admin_api(request) + user_id = int(request.match_info["id"]) + await db.update_user(user_id, is_verified=1) + users = await db.get_all_users() + return web.json_response(users) + + async def admin_user_delete(request: web.Request) -> web.Response: + require_admin_api(request) + user_id = int(request.match_info["id"]) + # Prevent self-deletion + if request["user"]["id"] == user_id: + return web.json_response({"error": "Eigenen Account kann man nicht loeschen"}, status=400) + await db.delete_user(user_id) + users = await db.get_all_users() + return web.json_response(users) + + # Register routes + app.router.add_post("/auth/login", login_post) + app.router.add_post("/auth/register", register_post) + app.router.add_get("/auth/logout", logout_get) + app.router.add_get("/auth/verify", verify_get) + app.router.add_post("/auth/set-password", set_password_post) + app.router.add_post("/auth/forgot-password", forgot_password_post) + app.router.add_get("/auth/reset-password", reset_password_get) + app.router.add_post("/auth/reset-password", reset_password_post) + app.router.add_get("/api/auth/me", me_get) + + app.router.add_get("/api/admin/users", admin_users_get) + app.router.add_post("/api/admin/users/{id}/role", admin_user_role) + app.router.add_post("/api/admin/users/{id}/verify", admin_user_verify) + app.router.add_delete("/api/admin/users/{id}", admin_user_delete) diff --git a/meshbot/database.py b/meshbot/database.py index 18b70e2..5f522d2 100644 --- a/meshbot/database.py +++ b/meshbot/database.py @@ -57,9 +57,42 @@ class Database: timestamp REAL, command TEXT ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + is_verified INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + expires_at REAL NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS email_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + created_at REAL NOT NULL + ); """) await self.db.commit() + # ── Node methods ────────────────────────────────── + async def upsert_node(self, node_id: str, **kwargs) -> dict: now = time.time() existing = await self.get_node(node_id) @@ -107,6 +140,8 @@ class Database: ) as cursor: return [dict(row) async for row in cursor] + # ── Message methods ─────────────────────────────── + async def insert_message(self, from_node: str, to_node: str, channel: int, portnum: str, payload: str) -> dict: now = time.time() @@ -127,6 +162,8 @@ class Database: ) as cursor: return [dict(row) async for row in cursor] + # ── Command methods ─────────────────────────────── + async def insert_command(self, command: str): now = time.time() await self.db.execute( @@ -152,3 +189,87 @@ class Database: ) as cursor: stats["command_breakdown"] = {row[0]: row[1] async for row in cursor} return stats + + # ── User methods ────────────────────────────────── + + async def create_user(self, email: str, password: str, name: str, role: str = "user", is_verified: int = 0) -> dict: + now = time.time() + cursor = await self.db.execute( + "INSERT INTO users (email, password, name, role, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (email, password, name, role, is_verified, now, now), + ) + await self.db.commit() + return await self.get_user_by_id(cursor.lastrowid) + + async def get_user_by_email(self, email: str) -> dict | None: + async with self.db.execute( + "SELECT * FROM users WHERE email = ?", (email,) + ) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + async def get_user_by_id(self, user_id: int) -> dict | None: + async with self.db.execute( + "SELECT * FROM users WHERE id = ?", (user_id,) + ) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + async def get_all_users(self) -> list[dict]: + async with self.db.execute( + "SELECT id, email, name, role, is_verified, created_at, updated_at FROM users ORDER BY created_at DESC" + ) as cursor: + return [dict(row) async for row in cursor] + + async def update_user(self, user_id: int, **kwargs) -> dict | None: + if not kwargs: + return await self.get_user_by_id(user_id) + kwargs["updated_at"] = time.time() + set_clause = ", ".join(f"{k} = ?" for k in kwargs) + values = list(kwargs.values()) + [user_id] + await self.db.execute( + f"UPDATE users SET {set_clause} WHERE id = ?", values + ) + await self.db.commit() + return await self.get_user_by_id(user_id) + + async def delete_user(self, user_id: int) -> bool: + cursor = await self.db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + await self.db.commit() + return cursor.rowcount > 0 + + # ── Token methods ───────────────────────────────── + + async def create_token(self, user_id: int, token: str, token_type: str, expires_at: float) -> dict: + now = time.time() + cursor = await self.db.execute( + "INSERT INTO tokens (user_id, token, type, expires_at, used, created_at) VALUES (?, ?, ?, ?, 0, ?)", + (user_id, token, token_type, expires_at, now), + ) + await self.db.commit() + async with self.db.execute("SELECT * FROM tokens WHERE id = ?", (cursor.lastrowid,)) as c: + row = await c.fetchone() + return dict(row) if row else {} + + async def get_valid_token(self, token: str, token_type: str) -> dict | None: + now = time.time() + async with self.db.execute( + "SELECT * FROM tokens WHERE token = ? AND type = ? AND used = 0 AND expires_at > ?", + (token, token_type, now), + ) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + + async def mark_token_used(self, token_id: int): + await self.db.execute("UPDATE tokens SET used = 1 WHERE id = ?", (token_id,)) + await self.db.commit() + + # ── Email log methods ───────────────────────────── + + async def log_email(self, recipient: str, subject: str, status: str, error_message: str = None): + now = time.time() + await self.db.execute( + "INSERT INTO email_logs (recipient, subject, status, error_message, created_at) VALUES (?, ?, ?, ?, ?)", + (recipient, subject, status, error_message, now), + ) + await self.db.commit() diff --git a/meshbot/webserver.py b/meshbot/webserver.py index 0476d95..b8d88c6 100644 --- a/meshbot/webserver.py +++ b/meshbot/webserver.py @@ -7,6 +7,7 @@ from aiohttp import web from meshbot import config from meshbot.database import Database +from meshbot.auth import setup_session, auth_middleware, setup_auth_routes, require_user_api, require_admin_api logger = logging.getLogger(__name__) @@ -35,7 +36,10 @@ class WebServer: self.bot = bot self.scheduler = scheduler self.app = web.Application() + setup_session(self.app) + self.app.middlewares.append(auth_middleware) self._setup_routes() + setup_auth_routes(self.app, db) def _setup_routes(self): self.app.router.add_get("/ws", self._ws_handler) @@ -48,6 +52,9 @@ class WebServer: self.app.router.add_delete("/api/scheduler/jobs/{name}", self._api_scheduler_delete) self.app.router.add_post("/api/send", self._api_send) self.app.router.add_get("/api/node/config", self._api_node_config) + self.app.router.add_get("/login", self._serve_login) + self.app.router.add_get("/register", self._serve_login) + self.app.router.add_get("/admin", self._serve_admin) self.app.router.add_get("/settings", self._serve_settings) self.app.router.add_get("/scheduler", self._serve_scheduler) self.app.router.add_get("/map", self._serve_map) @@ -102,6 +109,7 @@ class WebServer: return web.json_response(stats) async def _api_send(self, request: web.Request) -> web.Response: + require_user_api(request) if not self.bot: return web.json_response({"error": "Bot not available"}, status=503) data = await request.json() @@ -113,6 +121,7 @@ class WebServer: return web.json_response({"ok": True}) async def _api_node_config(self, request: web.Request) -> web.Response: + require_admin_api(request) if not self.bot: return web.json_response({"error": "Bot not available"}, status=503) try: @@ -122,6 +131,12 @@ class WebServer: logger.exception("Error getting node config") return web.json_response({"error": "Failed to get config"}, status=500) + async def _serve_login(self, request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(STATIC_DIR, "login.html")) + + async def _serve_admin(self, request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(STATIC_DIR, "admin.html")) + async def _serve_settings(self, request: web.Request) -> web.Response: return web.FileResponse(os.path.join(STATIC_DIR, "settings.html")) @@ -140,6 +155,7 @@ class WebServer: return web.json_response(self.scheduler.get_jobs()) async def _api_scheduler_add(self, request: web.Request) -> web.Response: + require_admin_api(request) if not self.scheduler: return web.json_response({"error": "Scheduler not available"}, status=503) job = await request.json() @@ -149,6 +165,7 @@ class WebServer: return web.json_response(jobs, status=201) async def _api_scheduler_update(self, request: web.Request) -> web.Response: + require_admin_api(request) if not self.scheduler: return web.json_response({"error": "Scheduler not available"}, status=503) name = request.match_info["name"] @@ -161,6 +178,7 @@ class WebServer: return web.json_response(jobs) async def _api_scheduler_delete(self, request: web.Request) -> web.Response: + require_admin_api(request) if not self.scheduler: return web.json_response({"error": "Scheduler not available"}, status=503) name = request.match_info["name"] diff --git a/requirements.txt b/requirements.txt index 03866ff..f8073c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,7 @@ meshtastic>=2.7.7 aiohttp>=3.9.0 aiosqlite>=0.19.0 pyyaml>=6.0 +bcrypt>=4.0.0 +aiohttp-session>=2.12.0 +cryptography>=41.0.0 +aiosmtplib>=3.0.0 diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..45c765f --- /dev/null +++ b/static/admin.html @@ -0,0 +1,87 @@ + + + + + + MeshDD-Bot Admin + + + + + + + + + + + + + + +
+
Benutzerverwaltung
+ +
+
+ + + + + + + + + + + + + + +
NameE-MailRolleVerifiziertRegistriertAktionen
Lade...
+
+
+
+ + + + + diff --git a/static/index.html b/static/index.html index cdca48a..efbf86d 100644 --- a/static/index.html +++ b/static/index.html @@ -23,6 +23,15 @@ Verbinde... + + + + + + + + Login + @@ -35,15 +44,18 @@ Dashboard - + Scheduler Karte - + Einstellungen + + Benutzer + @@ -97,8 +109,8 @@ - -
+ +
@@ -136,8 +148,8 @@
- -
+ +
Nachrichten diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..43f4c50 --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,151 @@ +let currentUser = null; +let users = []; + +// Auth check +fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { + currentUser = u; + updateNavbar(); + updateSidebar(); + if (!u || u.role !== 'admin') { + document.getElementById('usersTable').innerHTML = + 'Zugriff verweigert'; + return; + } + loadUsers(); +}); + +function updateNavbar() { + if (currentUser) { + document.getElementById('userName').textContent = currentUser.name; + document.getElementById('userMenu').classList.remove('d-none'); + document.getElementById('loginBtn').classList.add('d-none'); + } else { + document.getElementById('userMenu').classList.add('d-none'); + document.getElementById('loginBtn').classList.remove('d-none'); + } +} + +function updateSidebar() { + const isAdmin = currentUser && currentUser.role === 'admin'; + document.querySelectorAll('.sidebar-admin').forEach(el => { + el.style.display = isAdmin ? '' : 'none'; + }); +} + +async function loadUsers() { + try { + const resp = await fetch('/api/admin/users'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + users = await resp.json(); + renderUsers(); + } catch (e) { + document.getElementById('usersTable').innerHTML = + 'Fehler beim Laden'; + } +} + +function renderUsers() { + const tbody = document.getElementById('usersTable'); + if (users.length === 0) { + tbody.innerHTML = 'Keine Benutzer'; + return; + } + tbody.innerHTML = users.map(user => { + const roleBadge = user.role === 'admin' + ? 'Admin' + : 'User'; + const verifiedIcon = user.is_verified + ? '' + : ''; + const created = user.created_at ? new Date(user.created_at * 1000).toLocaleDateString('de-DE') : '-'; + const isSelf = currentUser && currentUser.id === user.id; + + let actions = ''; + if (!isSelf) { + const newRole = user.role === 'admin' ? 'user' : 'admin'; + const roleLabel = user.role === 'admin' ? 'User' : 'Admin'; + actions += ``; + if (!user.is_verified) { + actions += ``; + } + actions += ``; + } else { + actions = 'Du'; + } + + return ` + ${escapeHtml(user.name)} + ${escapeHtml(user.email)} + ${roleBadge} + ${verifiedIcon} + ${created} + ${actions} + `; + }).join(''); +} + +async function changeRole(id, role) { + try { + const resp = await fetch(`/api/admin/users/${id}/role`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }) + }); + if (resp.ok) { users = await resp.json(); renderUsers(); } + } catch (e) { console.error('Role change failed:', e); } +} + +async function verifyUser(id) { + try { + const resp = await fetch(`/api/admin/users/${id}/verify`, { method: 'POST' }); + if (resp.ok) { users = await resp.json(); renderUsers(); } + } catch (e) { console.error('Verify failed:', e); } +} + +async function deleteUser(id, name) { + if (!confirm(`Benutzer "${name}" wirklich loeschen?`)) return; + try { + const resp = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); + if (resp.ok) { users = await resp.json(); renderUsers(); } + } catch (e) { console.error('Delete failed:', e); } +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Theme toggle +const themeToggle = document.getElementById('themeToggle'); +const themeIcon = document.getElementById('themeIcon'); + +function applyTheme(theme) { + document.documentElement.setAttribute('data-bs-theme', theme); + themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill'; + localStorage.setItem('theme', theme); +} + +applyTheme(localStorage.getItem('theme') || 'dark'); + +themeToggle.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-bs-theme'); + applyTheme(current === 'dark' ? 'light' : 'dark'); +}); + +// Sidebar toggle (mobile) +const sidebarToggle = document.getElementById('sidebarToggle'); +const sidebar = document.getElementById('sidebar'); +const sidebarBackdrop = document.getElementById('sidebarBackdrop'); + +if (sidebarToggle) { + sidebarToggle.addEventListener('click', () => sidebar.classList.toggle('open')); + sidebarBackdrop.addEventListener('click', () => sidebar.classList.remove('open')); +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 0c78096..1f488f1 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -4,11 +4,48 @@ const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const nodeCountBadge = document.getElementById('nodeCountBadge'); +let currentUser = null; let nodes = {}; let channels = {}; let myNodeId = null; let ws; +// Auth check +fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { + currentUser = u; + updateNavbar(); + updateSidebar(); + updateVisibility(); +}); + +function updateNavbar() { + if (currentUser) { + document.getElementById('userName').textContent = currentUser.name; + document.getElementById('userMenu').classList.remove('d-none'); + document.getElementById('loginBtn').classList.add('d-none'); + } else { + document.getElementById('userMenu').classList.add('d-none'); + document.getElementById('loginBtn').classList.remove('d-none'); + } +} + +function updateSidebar() { + const isAdmin = currentUser && currentUser.role === 'admin'; + document.querySelectorAll('.sidebar-admin').forEach(el => { + el.style.display = isAdmin ? '' : 'none'; + }); +} + +function updateVisibility() { + const loggedIn = !!currentUser; + const sendCard = document.getElementById('sendCard'); + const messagesCard = document.getElementById('messagesCard'); + if (loggedIn) { + sendCard.classList.remove('d-none'); + messagesCard.classList.remove('d-none'); + } +} + function connectWebSocket() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}/ws`); diff --git a/static/js/login.js b/static/js/login.js new file mode 100644 index 0000000..a9c7c85 --- /dev/null +++ b/static/js/login.js @@ -0,0 +1,158 @@ +// Theme toggle +const themeToggle = document.getElementById('themeToggle'); +const themeIcon = document.getElementById('themeIcon'); + +function applyTheme(theme) { + document.documentElement.setAttribute('data-bs-theme', theme); + themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill'; + localStorage.setItem('theme', theme); +} + +applyTheme(localStorage.getItem('theme') || 'dark'); + +themeToggle.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-bs-theme'); + applyTheme(current === 'dark' ? 'light' : 'dark'); +}); + +// View switching +const views = { + login: document.getElementById('loginView'), + register: document.getElementById('registerView'), + forgot: document.getElementById('forgotView'), + setPassword: document.getElementById('setPasswordView'), +}; + +function showView(name) { + Object.values(views).forEach(v => v.classList.add('d-none')); + views[name].classList.remove('d-none'); +} + +document.getElementById('showRegister').addEventListener('click', (e) => { e.preventDefault(); showView('register'); }); +document.getElementById('showLoginFromReg').addEventListener('click', (e) => { e.preventDefault(); showView('login'); }); +document.getElementById('showForgot').addEventListener('click', (e) => { e.preventDefault(); showView('forgot'); }); +document.getElementById('showLoginFromForgot').addEventListener('click', (e) => { e.preventDefault(); showView('login'); }); + +// Check URL for token (verify or reset-password) +const urlParams = new URLSearchParams(window.location.search); +const token = urlParams.get('token'); +const isVerify = window.location.pathname === '/auth/verify'; +const isReset = window.location.pathname === '/auth/reset-password'; + +if (token && (isVerify || isReset)) { + showView('setPassword'); + document.getElementById('setPasswordTitle').textContent = isVerify ? 'Passwort setzen' : 'Passwort zuruecksetzen'; +} + +// Show register view if on /register +if (window.location.pathname === '/register') { + showView('register'); +} + +function showAlert(id, message, type) { + const el = document.getElementById(id); + el.className = `alert alert-${type} py-1 small`; + el.textContent = message; +} + +// Login +document.getElementById('btnLogin').addEventListener('click', async () => { + const email = document.getElementById('loginEmail').value.trim(); + const password = document.getElementById('loginPassword').value; + if (!email || !password) return; + + try { + const resp = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await resp.json(); + if (resp.ok) { + window.location.href = '/'; + } else { + showAlert('loginAlert', data.error || 'Anmeldung fehlgeschlagen', 'danger'); + } + } catch (e) { + showAlert('loginAlert', 'Verbindungsfehler', 'danger'); + } +}); + +document.getElementById('loginPassword').addEventListener('keydown', (e) => { + if (e.key === 'Enter') document.getElementById('btnLogin').click(); +}); + +// Register +document.getElementById('btnRegister').addEventListener('click', async () => { + const name = document.getElementById('registerName').value.trim(); + const email = document.getElementById('registerEmail').value.trim(); + if (!name || !email) return; + + try { + const resp = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email }) + }); + const data = await resp.json(); + if (resp.ok) { + showAlert('registerAlert', data.message || 'Registrierung erfolgreich', 'success'); + } else { + showAlert('registerAlert', data.error || 'Registrierung fehlgeschlagen', 'danger'); + } + } catch (e) { + showAlert('registerAlert', 'Verbindungsfehler', 'danger'); + } +}); + +// Forgot password +document.getElementById('btnForgot').addEventListener('click', async () => { + const email = document.getElementById('forgotEmail').value.trim(); + if (!email) return; + + try { + const resp = await fetch('/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + const data = await resp.json(); + showAlert('forgotAlert', data.message || 'Link gesendet', 'success'); + } catch (e) { + showAlert('forgotAlert', 'Verbindungsfehler', 'danger'); + } +}); + +// Set password (verify + reset) +document.getElementById('btnSetPassword').addEventListener('click', async () => { + const password = document.getElementById('newPassword').value; + const confirm = document.getElementById('confirmPassword').value; + + if (password.length < 8) { + showAlert('setPasswordAlert', 'Passwort muss mindestens 8 Zeichen lang sein', 'danger'); + return; + } + if (password !== confirm) { + showAlert('setPasswordAlert', 'Passwoerter stimmen nicht ueberein', 'danger'); + return; + } + + const endpoint = isReset ? '/auth/reset-password' : '/auth/set-password'; + + try { + const resp = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }) + }); + const data = await resp.json(); + if (resp.ok) { + showAlert('setPasswordAlert', data.message || 'Passwort gespeichert', 'success'); + setTimeout(() => { window.location.href = '/login'; }, 2000); + } else { + showAlert('setPasswordAlert', data.error || 'Fehler', 'danger'); + } + } catch (e) { + showAlert('setPasswordAlert', 'Verbindungsfehler', 'danger'); + } +}); diff --git a/static/js/map.js b/static/js/map.js index a3f2e15..b2c70f8 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -3,8 +3,34 @@ const nodeData = {}; const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const nodeCount = document.getElementById('nodeCount'); +let currentUser = null; let ws; +// Auth check +fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { + currentUser = u; + updateNavbar(); + updateSidebar(); +}); + +function updateNavbar() { + if (currentUser) { + document.getElementById('userName').textContent = currentUser.name; + document.getElementById('userMenu').classList.remove('d-none'); + document.getElementById('loginBtn').classList.add('d-none'); + } else { + document.getElementById('userMenu').classList.add('d-none'); + document.getElementById('loginBtn').classList.remove('d-none'); + } +} + +function updateSidebar() { + const isAdmin = currentUser && currentUser.role === 'admin'; + document.querySelectorAll('.sidebar-admin').forEach(el => { + el.style.display = isAdmin ? '' : 'none'; + }); +} + const hopColors = { 0: '#2196F3', 1: '#4CAF50', diff --git a/static/js/scheduler.js b/static/js/scheduler.js index 60cd70c..51984b0 100644 --- a/static/js/scheduler.js +++ b/static/js/scheduler.js @@ -1,8 +1,34 @@ const jobsTable = document.getElementById('jobsTable'); const jobModal = new bootstrap.Modal(document.getElementById('jobModal')); +let currentUser = null; let jobs = []; let editMode = false; +// Auth check +fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { + currentUser = u; + updateNavbar(); + updateSidebar(); +}); + +function updateNavbar() { + if (currentUser) { + document.getElementById('userName').textContent = currentUser.name; + document.getElementById('userMenu').classList.remove('d-none'); + document.getElementById('loginBtn').classList.add('d-none'); + } else { + document.getElementById('userMenu').classList.add('d-none'); + document.getElementById('loginBtn').classList.remove('d-none'); + } +} + +function updateSidebar() { + const isAdmin = currentUser && currentUser.role === 'admin'; + document.querySelectorAll('.sidebar-admin').forEach(el => { + el.style.display = isAdmin ? '' : 'none'; + }); +} + // Theme toggle const themeToggle = document.getElementById('themeToggle'); const themeIcon = document.getElementById('themeIcon'); diff --git a/static/js/settings.js b/static/js/settings.js index 749a565..bfa72cb 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1,3 +1,30 @@ +let currentUser = null; + +// Auth check +fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { + currentUser = u; + updateNavbar(); + updateSidebar(); +}); + +function updateNavbar() { + if (currentUser) { + document.getElementById('userName').textContent = currentUser.name; + document.getElementById('userMenu').classList.remove('d-none'); + document.getElementById('loginBtn').classList.add('d-none'); + } else { + document.getElementById('userMenu').classList.add('d-none'); + document.getElementById('loginBtn').classList.remove('d-none'); + } +} + +function updateSidebar() { + const isAdmin = currentUser && currentUser.role === 'admin'; + document.querySelectorAll('.sidebar-admin').forEach(el => { + el.style.display = isAdmin ? '' : 'none'; + }); +} + // Labels for display const deviceLabels = { long_name: "Name", diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..7091c2e --- /dev/null +++ b/static/login.html @@ -0,0 +1,123 @@ + + + + + + MeshDD-Bot Login + + + + + + + + + +
+
+ + +
+
+
+ Anmelden +
+
+
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+
+ Registrieren +
+
+
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+
+ Passwort vergessen +
+
+
+
+ + +
+ + +
+
+
+ + +
+
+
+ Passwort setzen +
+
+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ + + + + diff --git a/static/map.html b/static/map.html index ffc41db..afe53ca 100644 --- a/static/map.html +++ b/static/map.html @@ -24,6 +24,15 @@ Verbinde... 0 Nodes + + + + + + + + Login + @@ -36,15 +45,18 @@ Dashboard - + Scheduler Karte - + Einstellungen + + Benutzer + diff --git a/static/scheduler.html b/static/scheduler.html index 4d1bf25..c670947 100644 --- a/static/scheduler.html +++ b/static/scheduler.html @@ -17,9 +17,20 @@ MeshDD-Bot - +
+ + + + + + + + Login + + +
@@ -28,15 +39,18 @@ Dashboard - + Scheduler Karte - + Einstellungen + + Benutzer + diff --git a/static/settings.html b/static/settings.html index 8f4ca8b..29fd1fa 100644 --- a/static/settings.html +++ b/static/settings.html @@ -17,9 +17,20 @@ MeshDD-Bot - +
+ + + + + + + + Login + + +
@@ -28,15 +39,18 @@ Dashboard - + Scheduler Karte - + Einstellungen + + Benutzer +