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
317 lines
11 KiB
PHP
Executable File
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,
|
|
],
|
|
]);
|
|
}
|
|
} |