Files
thetool/application/MobileApp/MobileAppController.php
2026-01-18 21:02:01 +01:00

475 lines
15 KiB
PHP

<?php
/**
* MobileApp Controller
*
* Main dispatcher for the Mobile PWA application.
*
* URL Structure:
* - /MobileApp → Main app (Vue SPA)
* - /MobileApp/auth/{action} → Auth endpoints (login/logout/check)
* - /MobileApp/{module}/{submodule} → Module view (handled by Vue SPA)
* - /MobileApp/{module}/{submodule}/{action} → API endpoints
*
* Example:
* - /MobileApp → Shows main menu
* - /MobileApp/Lager/Inventur → Shows stocktake (handled by Vue)
* - /MobileApp/Lager/Inventur/getActiveStocktakes → API call
*/
class MobileAppController extends mfBaseController {
protected $user;
protected function init() {
$this->needlogin = false;
$me = mfValuecache::singleton()->get("me");
if (!$me) {
if (mfLoginController::isLoggedIn()) {
$me = new User();
$me->loadMe();
mfValuecache::singleton()->set("me", $me);
}
}
$this->user = $me;
}
/**
* Main dispatcher
*/
public function indexAction() {
$module = $this->request->module ?? null;
$submodule = $this->request->submodule ?? null;
$endpoint = $this->request->endpoint ?? null;
// Auth endpoints: /MobileApp/auth/{action}
if (strtolower($module) === 'auth') {
return $this->handleAuth($submodule ?? 'check');
}
// API call: /MobileApp/{module}/{submodule}/{endpoint}
if ($module && $submodule && $endpoint) {
return $this->handleApiCall($module, $submodule, $endpoint);
}
// Everything else: render the main Vue SPA
// The Vue app handles internal routing for /MobileApp, /MobileApp/Lager, /MobileApp/Lager/Inventur, etc.
return $this->renderApp();
}
/**
* Render the main Vue SPA
*/
protected function renderApp() {
$this->layout()->setTemplate("MobileApp/App");
$this->layout()->set("JSGlobals", [
'BASE_PATH' => '/MobileApp',
'USER' => $this->user ? [
'id' => $this->user->id,
'name' => $this->user->name,
'username' => $this->user->username,
] : null,
'INITIAL_PATH' => $_SERVER['REQUEST_URI'] ?? '/MobileApp',
]);
}
/**
* Handle authentication endpoints
*/
protected function handleAuth($action) {
switch (strtolower($action)) {
case 'login':
return $this->authLogin();
case 'verify2fa':
return $this->authVerify2FA();
case 'resend2fa':
return $this->authResend2FA();
case 'logout':
return $this->authLogout();
case 'check':
return $this->authCheck();
default:
self::returnJson(['success' => false, 'error' => 'Unknown auth endpoint'], 404);
}
}
/**
* POST /MobileApp/auth/login
*
* Step 1 of authentication. If 2FA is required, returns requires2FA: true
* and the frontend should proceed to verify2fa endpoint.
*/
protected function authLogin() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$username = $postData['username'] ?? '';
$password = $postData['password'] ?? '';
$rememberMe = $postData['rememberMe'] ?? false;
if (!$username || !$password) {
self::returnJson(['success' => false, 'message' => 'Benutzername und Passwort erforderlich']);
return;
}
$db = FronkDB::singleton();
$escapedUsername = $db->escape($username);
$res = $db->select(MFUSERTABLE, "*", "username='$escapedUsername'");
if (!$db->num_rows($res)) {
sleep(1);
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
return;
}
$userRow = $db->fetch_object($res);
if ($userRow->active == 0) {
self::returnJson(['success' => false, 'message' => 'Benutzer ist deaktiviert']);
return;
}
$hash = $userRow->password;
$salt = substr($hash, 0, 16);
$passhash = mfLoginController::generatePasswordHash($password, $salt);
if ($passhash !== $hash) {
sleep(1);
self::returnJson(['success' => false, 'message' => 'Ungültige Anmeldedaten']);
return;
}
// Check if 2FA is required
if ($userRow->twofactor !== "0") {
// Generate and send 2FA code
$twoFactor = new UserTwofactor($userRow->id);
$twoFactor->sendCode();
// Store pending auth in session for 2FA verification
$_SESSION['mobileapp_2fa_pending'] = [
'user_id' => $userRow->id,
'username' => $userRow->username,
'remember_me' => $rememberMe,
'timestamp' => time()
];
// Determine delivery method for UI feedback
$deliveryMethod = $userRow->twofactor == 1 ? 'email' : 'sms';
$maskedTarget = $deliveryMethod === 'email'
? $this->maskEmail($userRow->email)
: $this->maskPhone($userRow->mobile);
self::returnJson([
'success' => false,
'requires2FA' => true,
'deliveryMethod' => $deliveryMethod,
'maskedTarget' => $maskedTarget,
'message' => 'Verifizierungscode wurde gesendet'
]);
return;
}
// No 2FA - complete login directly
$this->completeLogin($userRow, $rememberMe);
}
/**
* POST /MobileApp/auth/verify2fa
*
* Step 2 of authentication - verify the 2FA code
*/
protected function authVerify2FA() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
$postData = json_decode(file_get_contents('php://input'), true) ?? [];
$code = $postData['code'] ?? '';
// Check for pending 2FA session
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
return;
}
$pending = $_SESSION['mobileapp_2fa_pending'];
// Check if pending session is expired (10 minutes max)
if (time() - $pending['timestamp'] > 600) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
return;
}
if (!$code || strlen($code) !== 5) {
self::returnJson(['success' => false, 'message' => 'Bitte gib den 5-stelligen Code ein']);
return;
}
$db = FronkDB::singleton();
$userId = intval($pending['user_id']);
// Get user's 2FA code and timestamp
$res = $db->select(MFUSERTABLE, "twofactorcode, twofactortimestamp, username", "id = {$userId}");
if (!$db->num_rows($res)) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Benutzer nicht gefunden']);
return;
}
$userRow = $db->fetch_object($res);
$storedCode = $userRow->twofactorcode;
$codeTimestamp = intval($userRow->twofactortimestamp);
// Check if code is expired (5 minutes)
if (time() - $codeTimestamp > 300) {
self::returnJson(['success' => false, 'message' => 'Code abgelaufen. Bitte neuen Code anfordern.', 'codeExpired' => true]);
return;
}
// Verify code
if ($code !== $storedCode) {
sleep(1); // Rate limiting
self::returnJson(['success' => false, 'message' => 'Ungültiger Code']);
return;
}
// Clear the 2FA code
$twoFactor = new UserTwofactor($userId);
$twoFactor->removeCode();
// Clear pending session
unset($_SESSION['mobileapp_2fa_pending']);
// Get full user row for login completion
$res = $db->select(MFUSERTABLE, "*", "id = {$userId}");
$userRow = $db->fetch_object($res);
// Complete login
$this->completeLogin($userRow, $pending['remember_me']);
}
/**
* POST /MobileApp/auth/resend2fa
*
* Resend the 2FA code
*/
protected function authResend2FA() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
self::returnJson(['success' => false, 'error' => 'Method not allowed'], 405);
return;
}
// Check for pending 2FA session
if (!isset($_SESSION['mobileapp_2fa_pending'])) {
self::returnJson(['success' => false, 'message' => 'Keine ausstehende Verifizierung']);
return;
}
$pending = $_SESSION['mobileapp_2fa_pending'];
// Check if pending session is expired (10 minutes max)
if (time() - $pending['timestamp'] > 600) {
unset($_SESSION['mobileapp_2fa_pending']);
self::returnJson(['success' => false, 'message' => 'Sitzung abgelaufen. Bitte erneut anmelden.', 'expired' => true]);
return;
}
// Resend 2FA code
$twoFactor = new UserTwofactor($pending['user_id']);
$twoFactor->sendCode();
self::returnJson([
'success' => true,
'message' => 'Neuer Code wurde gesendet'
]);
}
/**
* Complete the login process after password (and optionally 2FA) verification
*/
protected function completeLogin($userRow, $rememberMe) {
$db = FronkDB::singleton();
$db->update(MFUSERTABLE, [
'ip' => $_SERVER['REMOTE_ADDR'],
'sessionid' => session_id()
], "id = {$userRow->id}");
$_SESSION[MFAPPNAME . '_username'] = $userRow->username;
$_SESSION[MFAPPNAME . '_ip'] = $_SERVER['REMOTE_ADDR'];
if ($rememberMe) {
UserToken::generateToken($userRow->id);
}
$user = new User();
$user->loadMe();
self::returnJson([
'success' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
}
/**
* Mask email address for privacy (e.g., j***@example.com)
*/
protected function maskEmail($email) {
if (!$email) return '***';
$parts = explode('@', $email);
if (count($parts) !== 2) return '***';
$local = $parts[0];
$domain = $parts[1];
$masked = strlen($local) > 1 ? $local[0] . str_repeat('*', min(5, strlen($local) - 1)) : '*';
return $masked . '@' . $domain;
}
/**
* Mask phone number for privacy (e.g., +43***123)
*/
protected function maskPhone($phone) {
if (!$phone) return '***';
$phone = preg_replace('/\s+/', '', $phone);
if (strlen($phone) < 6) return '***';
return substr($phone, 0, 3) . str_repeat('*', strlen($phone) - 6) . substr($phone, -3);
}
/**
* POST /MobileApp/auth/logout
*/
protected function authLogout() {
mfLoginController::staticLogout();
self::returnJson(['success' => true]);
}
/**
* GET /MobileApp/auth/check
*/
protected function authCheck() {
if (mfLoginController::isLoggedIn()) {
$user = new User();
$user->loadMe();
if ($user->id) {
self::returnJson([
'authenticated' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
return;
}
}
UserToken::checkToken();
if (isset($_SESSION[MFAPPNAME . '_username']) && $_SESSION[MFAPPNAME . '_username']) {
$user = new User();
$user->loadMe();
if ($user->id) {
self::returnJson([
'authenticated' => true,
'user' => [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
]
]);
return;
}
}
self::returnJson(['authenticated' => false]);
}
/**
* Handle API calls to module endpoints
* /MobileApp/{module}/{submodule}/{endpoint}
*/
protected function handleApiCall($module, $submodule, $endpoint) {
// Check authentication for API calls
if (!$this->user || !$this->user->id) {
self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
return;
}
// Find module directory (case-insensitive)
$moduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules", $module);
if (!$moduleName) {
self::returnJson(['success' => false, 'error' => "Module not found: {$module}"], 404);
return;
}
// Find submodule directory (case-insensitive)
$submoduleName = $this->findModuleDirectory(APPDIR . "MobileApp/Modules/{$moduleName}", $submodule);
if (!$submoduleName) {
self::returnJson(['success' => false, 'error' => "Submodule not found: {$module}/{$submodule}"], 404);
return;
}
// Build handler path
$handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
if (!file_exists($handlerFile)) {
self::returnJson(['success' => false, 'error' => "Handler not found: {$moduleName}/{$submoduleName}"], 404);
return;
}
require_once $handlerFile;
$handlerClass = "{$submoduleName}Handler";
if (!class_exists($handlerClass)) {
self::returnJson(['success' => false, 'error' => "Handler class not found"], 500);
return;
}
$handler = new $handlerClass($this->request, $this->user, $this);
// Check permissions
if (!$handler->checkPermission()) {
self::returnJson(['success' => false, 'error' => 'Permission denied'], 403);
return;
}
// Route to method
$method = $endpoint . 'Action';
if (method_exists($handler, $method)) {
return $handler->$method();
}
if (method_exists($handler, $endpoint)) {
return $handler->$endpoint();
}
self::returnJson(['success' => false, 'error' => "Endpoint not found: {$endpoint}"], 404);
}
/**
* Find directory with case-insensitive matching
* Required for Linux compatibility where filesystem is case-sensitive
*/
protected function findModuleDirectory($basePath, $name) {
if (!is_dir($basePath)) return null;
$dirs = scandir($basePath);
foreach ($dirs as $dir) {
if ($dir === '.' || $dir === '..') continue;
if (strtolower($dir) === strtolower($name) && is_dir($basePath . '/' . $dir)) {
return $dir;
}
}
return null;
}
}