Files
drivetimeplaner/www/api.fahrschuldesk.de/app/Http/Controllers/AuthController.php
Hermes Agent 5d87e4975c Add HttpOnly cookie auth for cross-domain SPA login
Problem: Frontend JS bundle used credentials:'same-origin' which
dropped cookies on cross-domain requests. Login worked (200) but
the subsequent /auth/me check returned 401, leaving the user stuck
on the login screen.

Fix:
- AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh
- Cookie uses Domain=.fahrschultermin.de (shared between frontend
  and api subdomain), Secure, SameSite=Lax, Max-Age=8h
- JwtMiddleware reads JWT from Authorization header OR cookie
- Added AuthController::me() endpoint (was missing, caused 500)
- Logout endpoint clears the cookie
- Frontend index.php patches fetch() to use credentials:'include'
  for all /api/v1/* calls
2026-06-04 20:33:31 +02:00

317 lines
11 KiB
PHP
Executable File

<?php
/**
* Auth Controller
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Support\Database;
use App\Support\Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
class AuthController
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
/**
* POST /api/auth/login
*/
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
if (empty($email) || empty($password)) {
return Response::validationError($response, [
'email' => 'Email is required',
'password' => 'Password is required',
]);
}
// Check if it's a platform admin login
$admin = Database::fetch(
'SELECT * FROM platform_admins WHERE email = $1',
[$email]
);
if ($admin && password_verify($password, $admin['password_hash'])) {
return $this->createTokenResponse($response, $admin, 'platform_admin');
}
// Check if it's a tenant user login
$user = Database::fetch(
'SELECT u.*, t.status as tenant_status
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.email = $1',
[$email]
);
if ($user && password_verify($password, $user['password_hash'])) {
if ($user['tenant_status'] !== 'active') {
return Response::error($response, 'Account is not active', 403);
}
if ($user['status'] !== 'active') {
return Response::error($response, 'User is inactive', 403);
}
// Update last login
Database::update('users', ['last_login_at' => 'NOW()'], 'id = $1', [$user['id']]);
return $this->createTokenResponse($response, $user, 'user');
}
return Response::error($response, 'Invalid credentials', 401);
}
/**
* POST /api/auth/register
*/
public function register(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$name = trim($body['name'] ?? '');
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
$phone = trim($body['phone'] ?? '');
$address = trim($body['address'] ?? '');
// Validation
$errors = [];
if (empty($name)) $errors['name'] = 'Name is required';
if (empty($email)) $errors['email'] = 'Email is required';
elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors['email'] = 'Invalid email format';
if (empty($password)) $errors['password'] = 'Password is required';
elseif (strlen($password) < 8) $errors['password'] = 'Password must be at least 8 characters';
if (!empty($errors)) {
return Response::validationError($response, $errors);
}
// Check if email already exists
$exists = Database::fetch(
'SELECT id FROM tenants WHERE email = $1',
[$email]
);
if ($exists) {
return Response::error($response, 'Email already registered', 409);
}
// Generate slug from name
$slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', $name));
$originalSlug = $slug;
$counter = 1;
while (Database::fetch('SELECT id FROM tenants WHERE slug = $1', [$slug])) {
$slug = $originalSlug . '-' . $counter++;
}
// Create tenant
$tenantId = Database::insert('tenants', [
'name' => $name,
'slug' => $slug,
'email' => $email,
'phone' => $phone,
'address' => $address,
'status' => 'pending_approval',
]);
// Create first user (admin of the tenant)
$userId = Database::insert('users', [
'tenant_id' => $tenantId,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'first_name' => $name,
'last_name' => '',
'user_type' => 'admin',
'status' => 'pending', // Needs email verification first
]);
// Enable default modules (office, calendar, messenger)
$defaultModules = Database::fetchAll('SELECT id FROM modules WHERE key IN (\'office\', \'calendar\', \'messenger\')');
foreach ($defaultModules as $module) {
Database::insert('tenant_modules', [
'tenant_id' => $tenantId,
'module_id' => $module['id'],
'status' => 'enabled',
]);
}
return Response::success($response, [
'tenant_id' => $tenantId,
'user_id' => $userId,
'message' => 'Registration successful. Pending admin approval.',
], 'Registration successful', 201);
}
/**
* POST /api/auth/verify-email
*/
public function verifyEmail(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$token = $body['token'] ?? '';
if (empty($token)) {
return Response::validationError($response, ['token' => 'Token is required']);
}
// TODO: Verify token and activate user
return Response::success($response, null, 'Email verified successfully');
}
/**
* GET /api/auth/me
*/
public function me(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$auth = $request->getAttribute('auth');
if ($auth['type'] === 'platform_admin') {
$data = Database::fetch(
'SELECT id, email, name, role, created_at, last_login_at FROM platform_admins WHERE id = $1',
[$auth['id']]
);
$data['type'] = 'platform_admin';
} else {
$data = Database::fetch(
'SELECT u.id, u.email, u.first_name, u.last_name, u.user_type, u.status, u.avatar_url, u.last_login_at,
t.id as tenant_id, t.name as tenant_name, t.slug as tenant_slug, t.status as tenant_status
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.id = $1',
[$auth['id']]
);
$data['type'] = 'user';
}
return Response::success($response, $data);
}
/**
* POST /api/auth/refresh
*/
public function refresh(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$refreshToken = $body['refresh_token'] ?? '';
if (empty($refreshToken)) {
return Response::validationError($response, ['refresh_token' => 'Refresh token is required']);
}
try {
$decoded = JWT::decode($refreshToken, new Key($this->config['jwt']['secret'], 'HS256'));
if ($decoded->type !== 'refresh') {
return Response::error($response, 'Invalid token type', 401);
}
// Get user/admin
if ($decoded->user_type === 'platform_admin') {
$user = Database::fetch('SELECT * FROM platform_admins WHERE id = $1', [$decoded->sub]);
} else {
$user = Database::fetch('SELECT * FROM users WHERE id = $1', [$decoded->sub]);
}
if (!$user) {
return Response::error($response, 'User not found', 401);
}
return $this->createTokenResponse($response, $user, $decoded->user_type);
} catch (\Exception $e) {
return Response::error($response, 'Invalid refresh token', 401);
}
}
/**
* POST /api/auth/logout
*/
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
// JWT is stateless, so logout is handled client-side
// Here we could blacklist the token if needed
return Response::success($response, null, 'Logged out successfully');
}
/**
* POST /api/auth/forgot-password
*/
public function forgotPassword(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$body = $request->getParsedBody();
$email = trim($body['email'] ?? '');
if (empty($email)) {
return Response::validationError($response, ['email' => 'Email is required']);
}
// Check if email exists
$admin = Database::fetch('SELECT id FROM platform_admins WHERE email = $1', [$email]);
$user = Database::fetch('SELECT id FROM users WHERE email = $1', [$email]);
// Always return success to prevent email enumeration
if (!$admin && !$user) {
return Response::success($response, null, 'If the email exists, a reset link has been sent');
}
// TODO: Generate reset token, send email
return Response::success($response, null, 'If the email exists, a reset link has been sent');
}
/**
* Create JWT token response
*/
private function createTokenResponse(ResponseInterface $response, array $user, string $type): ResponseInterface
{
$now = time();
$expiry = $this->config['jwt']['expiry'];
$refreshExpiry = $this->config['jwt']['refresh_expiry'];
$payload = [
'iss' => 'fahrschuldesk',
'sub' => $user['id'],
'iat' => $now,
'exp' => $now + $expiry,
'type' => 'access',
'user_type' => $type,
];
$refreshPayload = [
'iss' => 'fahrschuldesk',
'sub' => $user['id'],
'iat' => $now,
'exp' => $now + $refreshExpiry,
'type' => 'refresh',
'user_type' => $type,
];
$accessToken = JWT::encode($payload, $this->config['jwt']['secret'], 'HS256');
$refreshToken = JWT::encode($refreshPayload, $this->config['jwt']['secret'], 'HS256');
return Response::json($response, [
'success' => true,
'data' => [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $expiry,
],
]);
}
}