parseBody($request); $identifier = trim((string)($body['username_or_email'] ?? '')); $password = (string)($body['password'] ?? ''); if ($identifier === '' || $password === '') { return JsonResponder::withJson($response, [ 'error' => 'invalid_input', 'message' => 'Username/E-Mail und Passwort sind Pflichtfelder.', ], 400); } $user = $this->findUserByIdentifier($identifier); if (!$user || !password_verify($password, (string)($user['password_hash'] ?? ''))) { return JsonResponder::withJson(new Response(), [ 'error' => 'invalid_credentials', 'message' => 'Login fehlgeschlagen.', ], 401); } $this->loginUser((int)$user['id']); return JsonResponder::withJson($response, [ 'user' => $this->buildUserSummary($user), ]); } public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $this->startSession(); $_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(); return JsonResponder::withJson($response, ['ok' => true]); } public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $user = $request->getAttribute('user'); if (!is_array($user) || !isset($user['id'])) { return JsonResponder::withJson(new Response(), [ 'error' => 'auth_required', 'message' => 'Authentifizierung erforderlich.', ], 401); } return JsonResponder::withJson($response, [ 'user' => $this->buildUserSummary($user), ]); } public function registerStep1(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $body = $this->parseBody($request); $raceKey = trim((string)($body['race_key'] ?? '')); $races = $this->configLoader->races(); $raceConfig = $races['races'][$raceKey] ?? null; if ($raceKey === '' || !is_array($raceConfig)) { return JsonResponder::withJson($response, [ 'error' => 'invalid_race', 'message' => 'Ungültige Rasse.', ], 422); } $draft = $this->getDraft(); $draft['race_key'] = $raceKey; $this->saveDraft($draft); return JsonResponder::withJson($response, [ 'draft' => $this->sanitizeDraft($draft), ]); } public function registerStep2(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $draft = $this->getDraft(); if (empty($draft['race_key'])) { return JsonResponder::withJson($response, [ 'error' => 'draft_missing', 'message' => 'Bitte zuerst eine Rasse wählen.', ], 409); } $body = $this->parseBody($request); $avatarKey = trim((string)($body['avatar_key'] ?? '')); $title = $this->sanitizeTitle((string)($body['title'] ?? '')); if ($avatarKey === '' || !$this->avatarExists($avatarKey)) { return JsonResponder::withJson($response, [ 'error' => 'invalid_avatar', 'message' => 'Ungültiger Avatar.', ], 422); } if (!$this->isValidTitle($title)) { return JsonResponder::withJson($response, [ 'error' => 'invalid_title', 'message' => 'Titel muss zwischen 2 und 40 Zeichen lang sein.', ], 422); } $draft['avatar_key'] = $avatarKey; $draft['title'] = $title; $this->saveDraft($draft); return JsonResponder::withJson($response, [ 'draft' => $this->sanitizeDraft($draft), ]); } public function registerStep3(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $draft = $this->getDraft(); if (empty($draft['race_key']) || empty($draft['avatar_key']) || empty($draft['title'])) { return JsonResponder::withJson($response, [ 'error' => 'draft_incomplete', 'message' => 'Bitte Registrierungsschritte 1 und 2 abschließen.', ], 409); } $body = $this->parseBody($request); $username = trim((string)($body['username'] ?? '')); $email = trim((string)($body['email'] ?? '')); $password = (string)($body['password'] ?? ''); if (!$this->isValidUsername($username)) { return JsonResponder::withJson($response, [ 'error' => 'invalid_username', 'message' => 'Username muss zwischen 3 und 20 Zeichen lang sein.', ], 422); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return JsonResponder::withJson($response, [ 'error' => 'invalid_email', 'message' => 'Ungültige E-Mail-Adresse.', ], 422); } if ($this->length($password) < self::PASSWORD_MIN_LENGTH) { return JsonResponder::withJson($response, [ 'error' => 'invalid_password', 'message' => 'Passwort zu kurz (mindestens 8 Zeichen).', ], 422); } $existing = $this->findUserByUsernameOrEmail($username, $email); if ($existing['username'] ?? false) { return JsonResponder::withJson($response, [ 'error' => 'username_taken', 'message' => 'Username bereits vergeben.', ], 409); } if ($existing['email'] ?? false) { return JsonResponder::withJson($response, [ 'error' => 'email_taken', 'message' => 'E-Mail bereits registriert.', ], 409); } $token = bin2hex(random_bytes(16)); $isDev = $this->appConfig->isDevEntwicklung(); try { $this->pdo->beginTransaction(); $stmt = $this->pdo->prepare( 'INSERT INTO users (username, email, password_hash, race_key, title, avatar_key, activation_token, is_active) VALUES (:username, :email, :password_hash, :race_key, :title, :avatar_key, :activation_token, :is_active) RETURNING *' ); $stmt->execute([ 'username' => $username, 'email' => $this->lower($email), 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'race_key' => $draft['race_key'], 'title' => $draft['title'], 'avatar_key' => $draft['avatar_key'], 'activation_token' => $token, 'is_active' => $isDev, ]); $user = $stmt->fetch(); $userId = (int)($user['id'] ?? 0); if ($userId <= 0) { throw new \RuntimeException('User-ID fehlt.'); } $this->assignRole($userId, 'player'); $this->createStarterPlanet($userId); $this->pdo->commit(); } catch (PDOException $e) { $this->pdo->rollBack(); if ($e->getCode() === '23505') { return JsonResponder::withJson($response, [ 'error' => 'duplicate', 'message' => 'Username oder E-Mail bereits registriert.', ], 409); } return JsonResponder::withJson($response, [ 'error' => 'registration_failed', 'message' => 'Registrierung fehlgeschlagen.', ], 500); } catch (\Throwable $e) { $this->pdo->rollBack(); return JsonResponder::withJson($response, [ 'error' => 'registration_failed', 'message' => 'Registrierung fehlgeschlagen.', ], 500); } $this->clearDraft(); if (!$isDev) { $this->sendConfirmationEmail($email, $token); return JsonResponder::withJson($response, [ 'status' => 'pending', 'message' => 'Bestätige deine E-Mail-Adresse via Link.', ], 201); } $this->loginUser((int)$user['id']); return JsonResponder::withJson($response, [ 'user' => $this->buildUserSummary($user), 'status' => 'active', ], 201); } public function confirmRegistration(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $body = $this->parseBody($request); $token = trim((string)($body['token'] ?? '')); if ($token === '') { return JsonResponder::withJson($response, [ 'error' => 'invalid_token', 'message' => 'Token fehlt.', ], 400); } $stmt = $this->pdo->prepare('SELECT * FROM users WHERE activation_token = :token LIMIT 1'); $stmt->execute(['token' => $token]); $user = $stmt->fetch(); if (!$user) { return JsonResponder::withJson($response, [ 'error' => 'invalid_token', 'message' => 'Ungültiges Token.', ], 404); } if ((bool)($user['is_active'] ?? false)) { return JsonResponder::withJson($response, [ 'error' => 'already_active', 'message' => 'Account bereits aktiviert.', ], 409); } $stmt = $this->pdo->prepare( 'UPDATE users SET is_active = TRUE, email_verified_at = :verified, activation_token = NULL WHERE id = :id' ); $stmt->execute([ 'verified' => (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'), 'id' => (int)$user['id'], ]); $this->loginUser((int)$user['id']); return JsonResponder::withJson($response, [ 'user' => $this->buildUserSummary($user), ]); } private function sendConfirmationEmail(string $to, string $token): void { $url = $this->appConfig->getAppUrl(); $link = rtrim($url, '/') . '/auth/register/confirm?token=' . urlencode($token); $body = "Bitte bestätige deinen Account:\n" . $link; $this->emailSender->sendEmail($to, 'E-Mail-Adresse bestätigen', $body); } /** * @return array */ private function parseBody(ServerRequestInterface $request): array { $body = $request->getParsedBody(); if (!is_array($body)) { return []; } return $body; } /** * @param array $draft * @return array */ private function sanitizeDraft(array $draft): array { return [ 'race_key' => $draft['race_key'] ?? null, 'avatar_key' => $draft['avatar_key'] ?? null, 'title' => $draft['title'] ?? null, ]; } private function findUserByIdentifier(string $identifier): ?array { $stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :id OR LOWER(email) = :email'); $stmt->execute([ 'id' => $identifier, 'email' => $this->lower($identifier), ]); $user = $stmt->fetch(); return $user ?: null; } /** * @return array{username:bool,email:bool} */ private function findUserByUsernameOrEmail(string $username, string $email): array { $stmt = $this->pdo->prepare('SELECT username, email FROM users WHERE username = :username OR email = :email'); $stmt->execute([ 'username' => $username, 'email' => $this->lower($email), ]); $rows = $stmt->fetchAll(); $result = ['username' => false, 'email' => false]; foreach ($rows as $row) { if (isset($row['username']) && $row['username'] === $username) { $result['username'] = true; } if (isset($row['email']) && $this->lower((string)$row['email']) === $this->lower($email)) { $result['email'] = true; } } return $result; } /** * @param array $user * @return array */ private function buildUserSummary(array $user): array { return [ 'id' => (int)($user['id'] ?? 0), 'username' => (string)($user['username'] ?? ''), 'email' => (string)($user['email'] ?? ''), 'race_key' => (string)($user['race_key'] ?? ''), 'title' => (string)($user['title'] ?? ''), 'avatar_key' => (string)($user['avatar_key'] ?? ''), ]; } private function avatarExists(string $avatarKey): bool { $config = $this->configLoader->avatars(); $avatars = $config['avatars'] ?? []; foreach ($avatars as $avatar) { if (is_array($avatar) && ($avatar['key'] ?? null) === $avatarKey) { return true; } } return false; } private function isValidTitle(string $title): bool { $len = $this->length($title); return $len >= self::TITLE_MIN_LENGTH && $len <= self::TITLE_MAX_LENGTH; } private function sanitizeTitle(string $title): string { $clean = trim(strip_tags($title)); $clean = preg_replace('/\s+/', ' ', $clean) ?? ''; return $clean; } private function isValidUsername(string $username): bool { $len = $this->length($username); if ($len < self::USERNAME_MIN_LENGTH || $len > self::USERNAME_MAX_LENGTH) { return false; } return (bool)preg_match('/^[A-Za-z0-9_\-]+$/', $username); } private function length(string $value): int { if (function_exists('mb_strlen')) { return mb_strlen($value); } return strlen($value); } private function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value); } return strtolower($value); } private function startSession(): void { if (session_status() === PHP_SESSION_ACTIVE) { return; } $secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; session_start([ 'cookie_httponly' => true, 'cookie_samesite' => 'Lax', 'cookie_secure' => $secure, 'cookie_path' => '/', ]); } private function loginUser(int $userId): void { $this->startSession(); session_regenerate_id(true); $_SESSION['user_id'] = $userId; } /** * @return array */ private function getDraft(): array { $this->startSession(); $draft = $_SESSION['reg_draft'] ?? []; return is_array($draft) ? $draft : []; } /** * @param array $draft */ private function saveDraft(array $draft): void { $this->startSession(); $_SESSION['reg_draft'] = $draft; } private function clearDraft(): void { $this->startSession(); unset($_SESSION['reg_draft']); } private function assignRole(int $userId, string $roleKey): void { $stmt = $this->pdo->prepare( 'INSERT INTO user_roles (user_id, role_id) SELECT :user_id, r.id FROM roles r WHERE r.key = :role_key ON CONFLICT DO NOTHING' ); $stmt->execute([ 'user_id' => $userId, 'role_key' => $roleKey, ]); } private function createStarterPlanet(int $userId): void { $config = $this->configLoader->planetClasses(); $resources = []; foreach (($config['resources'] ?? []) as $res) { $resources[$res] = 500.0; } $seed = random_int(10, 999999); $generated = $this->planetGenerator->generate('temperate', 'normal', $seed); $stmt = $this->pdo->prepare( 'INSERT INTO planets (user_id, name, class_key, planet_seed, temperature_c, modifiers, resources, last_resource_update_at) VALUES (:user_id, :name, :class_key, :planet_seed, :temperature_c, :modifiers, :resources, :last_update) RETURNING id' ); $stmt->execute([ 'user_id' => $userId, 'name' => 'Heimatwelt', 'class_key' => $generated['class_key'], 'planet_seed' => $seed, 'temperature_c' => (int)$generated['temperature_c'], 'modifiers' => json_encode($generated['modifiers'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'resources' => json_encode($resources, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'last_update' => (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s'), ]); $planetId = (int)$stmt->fetchColumn(); $stmt = $this->pdo->prepare( 'INSERT INTO planet_buildings (planet_id, building_key, count, level) VALUES (:planet_id, :building_key, :count, 0) ON CONFLICT (planet_id, building_key) DO NOTHING' ); $stmt->execute([ 'planet_id' => $planetId, 'building_key' => 'build_center', 'count' => 1, ]); } }