Add tenant dashboard (Phase 2)

Dashboard Features:
- Login page with session-based auth
- Overview page with live stats (viewers, stream status)
- Stream settings (URL, type configuration)
- Branding editor (colors, texts, custom CSS)
- Settings page (weather, content toggles, UI options)

New Files:
- dashboard/index.php: Main overview with stats
- dashboard/login.php: Authentication page
- dashboard/logout.php: Session cleanup
- dashboard/stream.php: Stream configuration
- dashboard/branding.php: Visual customization
- dashboard/settings.php: Feature toggles
- dashboard/templates/layout.php: Shared layout
- dashboard/api/stats.php: Stats API endpoint
- dashboard/assets/dashboard.css: Modern dashboard UI
- dashboard/assets/dashboard.js: Client-side functionality
- src/Auth/AuthManager.php: Secure auth with Argon2, remember-me

Auth Features:
- Secure password hashing (Argon2ID)
- Remember-me tokens
- Role-based access (super_admin, tenant_admin, tenant_user)
- Legacy fallback for existing admin credentials
This commit is contained in:
Claude
2026-01-23 17:09:38 +00:00
parent 402604b4cc
commit 7bd62b3527
11 changed files with 2174 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
<?php
/**
* AuthManager - Sichere Authentifizierung für Dashboard
*/
namespace AuroraLivecam\Auth;
use AuroraLivecam\Core\Database;
class AuthManager
{
private Database $db;
private bool $dbAvailable = false;
public function __construct(?Database $db = null)
{
$this->db = $db ?? Database::getInstance();
$this->checkDbAvailability();
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
private function checkDbAvailability(): void
{
try {
$this->db->fetchOne("SELECT 1 FROM users LIMIT 1");
$this->dbAvailable = true;
} catch (\Exception $e) {
$this->dbAvailable = false;
}
}
/**
* Registriert einen neuen Benutzer
*/
public function register(array $data): int
{
if (!$this->dbAvailable) {
throw new \Exception('Database not available');
}
// Validierung
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new \Exception('Invalid email address');
}
if (empty($data['password']) || strlen($data['password']) < 8) {
throw new \Exception('Password must be at least 8 characters');
}
// Prüfe ob Email bereits existiert
$existing = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [$data['email']]);
if ($existing) {
throw new \Exception('Email already registered');
}
// Benutzer erstellen
return $this->db->insert('users', [
'tenant_id' => $data['tenant_id'] ?? null,
'email' => strtolower($data['email']),
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'name' => $data['name'] ?? null,
'role' => $data['role'] ?? 'tenant_user',
]);
}
/**
* Login mit Email und Passwort
*/
public function login(string $email, string $password, bool $remember = false): bool
{
// Legacy-Modus (hardcoded admin)
if (!$this->dbAvailable) {
return $this->legacyLogin($email, $password);
}
$user = $this->db->fetchOne(
"SELECT u.*, t.name as tenant_name, t.slug as tenant_slug
FROM users u
LEFT JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = ?",
[strtolower($email)]
);
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
// Session setzen
$this->setSession($user);
// Last login aktualisieren
$this->db->update('users', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$user['id']]);
// Remember-Me Cookie
if ($remember) {
$this->setRememberToken($user['id']);
}
return true;
}
/**
* Legacy Login (kompatibel mit altem AdminManager)
*/
private function legacyLogin(string $email, string $password): bool
{
// Alte hardcoded Credentials als Fallback
if ($email === 'admin' && $password === 'sonne4000$$$$Q') {
$_SESSION['admin'] = true;
$_SESSION['user'] = [
'id' => 0,
'email' => 'admin',
'name' => 'Administrator',
'role' => 'super_admin',
'tenant_id' => null,
];
return true;
}
return false;
}
/**
* Setzt die Session-Daten
*/
private function setSession(array $user): void
{
$_SESSION['admin'] = true; // Kompatibilität mit Legacy
$_SESSION['user'] = [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'role' => $user['role'],
'tenant_id' => $user['tenant_id'],
'tenant_name' => $user['tenant_name'] ?? null,
'tenant_slug' => $user['tenant_slug'] ?? null,
];
}
/**
* Setzt Remember-Me Token
*/
private function setRememberToken(int $userId): void
{
$token = bin2hex(random_bytes(32));
$hashedToken = hash('sha256', $token);
$this->db->update('users', ['remember_token' => $hashedToken], 'id = ?', [$userId]);
setcookie('remember_token', $token, [
'expires' => time() + (86400 * 30), // 30 Tage
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax'
]);
}
/**
* Prüft Remember-Me Cookie
*/
public function checkRememberToken(): bool
{
if (!isset($_COOKIE['remember_token']) || !$this->dbAvailable) {
return false;
}
$hashedToken = hash('sha256', $_COOKIE['remember_token']);
$user = $this->db->fetchOne(
"SELECT u.*, t.name as tenant_name, t.slug as tenant_slug
FROM users u
LEFT JOIN tenants t ON u.tenant_id = t.id
WHERE u.remember_token = ?",
[$hashedToken]
);
if ($user) {
$this->setSession($user);
return true;
}
return false;
}
/**
* Logout
*/
public function logout(): void
{
// Remember-Token löschen
if ($this->isLoggedIn() && $this->dbAvailable) {
$userId = $_SESSION['user']['id'] ?? 0;
if ($userId > 0) {
$this->db->update('users', ['remember_token' => null], 'id = ?', [$userId]);
}
}
// Cookie löschen
setcookie('remember_token', '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
]);
// Session zerstören
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
}
/**
* Prüft ob User eingeloggt ist
*/
public function isLoggedIn(): bool
{
return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
}
/**
* Gibt den aktuellen User zurück
*/
public function getUser(): ?array
{
return $_SESSION['user'] ?? null;
}
/**
* Prüft ob User eine bestimmte Rolle hat
*/
public function hasRole(string $role): bool
{
$user = $this->getUser();
return $user && $user['role'] === $role;
}
/**
* Prüft ob User Super-Admin ist
*/
public function isSuperAdmin(): bool
{
return $this->hasRole('super_admin');
}
/**
* Prüft ob User Tenant-Admin ist
*/
public function isTenantAdmin(): bool
{
return $this->hasRole('tenant_admin') || $this->hasRole('super_admin');
}
/**
* Gibt die Tenant-ID des aktuellen Users zurück
*/
public function getTenantId(): ?int
{
$user = $this->getUser();
return $user ? ($user['tenant_id'] ?? null) : null;
}
/**
* Prüft ob User Zugriff auf einen bestimmten Tenant hat
*/
public function canAccessTenant(int $tenantId): bool
{
if ($this->isSuperAdmin()) {
return true;
}
return $this->getTenantId() === $tenantId;
}
/**
* Ändert das Passwort
*/
public function changePassword(int $userId, string $currentPassword, string $newPassword): bool
{
if (!$this->dbAvailable) {
return false;
}
$user = $this->db->fetchOne("SELECT password_hash FROM users WHERE id = ?", [$userId]);
if (!$user || !password_verify($currentPassword, $user['password_hash'])) {
return false;
}
if (strlen($newPassword) < 8) {
throw new \Exception('Password must be at least 8 characters');
}
return $this->db->update('users', [
'password_hash' => password_hash($newPassword, PASSWORD_ARGON2ID)
], 'id = ?', [$userId]) > 0;
}
/**
* Generiert ein Passwort-Reset-Token
*/
public function generateResetToken(string $email): ?string
{
if (!$this->dbAvailable) {
return null;
}
$user = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [strtolower($email)]);
if (!$user) {
return null; // Keine Info leaken ob Email existiert
}
$token = bin2hex(random_bytes(32));
// Token würde normalerweise in separater Tabelle mit Ablaufzeit gespeichert
// Für jetzt: vereinfachte Version
return $token;
}
/**
* Middleware: Erfordert Login
*/
public function requireLogin(): void
{
if (!$this->isLoggedIn()) {
if (!$this->checkRememberToken()) {
header('Location: /dashboard/login.php');
exit;
}
}
}
/**
* Middleware: Erfordert bestimmte Rolle
*/
public function requireRole(string $role): void
{
$this->requireLogin();
if (!$this->hasRole($role) && !$this->isSuperAdmin()) {
http_response_code(403);
echo "Access denied";
exit;
}
}
}