543 lines
18 KiB
PHP
543 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Auth\Controller;
|
|
|
|
use App\Config\ConfigLoader;
|
|
use App\Config\AppConfig;
|
|
use App\Module\Email\EmailSenderInterface;
|
|
use App\Module\PlanetGenerator\Service\PlanetGenerator;
|
|
use App\Shared\Http\JsonResponder;
|
|
use PDO;
|
|
use PDOException;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use Slim\Psr7\Response;
|
|
|
|
final class AuthController
|
|
{
|
|
private const PASSWORD_MIN_LENGTH = 8;
|
|
private const TITLE_MIN_LENGTH = 2;
|
|
private const TITLE_MAX_LENGTH = 40;
|
|
private const USERNAME_MIN_LENGTH = 3;
|
|
private const USERNAME_MAX_LENGTH = 20;
|
|
|
|
public function __construct(
|
|
private PDO $pdo,
|
|
private ConfigLoader $configLoader,
|
|
private PlanetGenerator $planetGenerator,
|
|
private EmailSenderInterface $emailSender,
|
|
private AppConfig $appConfig
|
|
) {
|
|
}
|
|
|
|
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
|
{
|
|
$body = $this->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<string,mixed>
|
|
*/
|
|
private function parseBody(ServerRequestInterface $request): array
|
|
{
|
|
$body = $request->getParsedBody();
|
|
if (!is_array($body)) {
|
|
return [];
|
|
}
|
|
return $body;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $draft
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed> $user
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
private function getDraft(): array
|
|
{
|
|
$this->startSession();
|
|
$draft = $_SESSION['reg_draft'] ?? [];
|
|
return is_array($draft) ? $draft : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $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,
|
|
]);
|
|
}
|
|
}
|