452 lines
14 KiB
PHP
452 lines
14 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() {
|
|
// We handle auth ourselves
|
|
$this->needlogin = false;
|
|
|
|
// Try to load user if session exists
|
|
$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) {
|
|
// Normalize names
|
|
$moduleName = ucfirst(strtolower($module));
|
|
$submoduleName = ucfirst(strtolower($submodule));
|
|
|
|
// Check authentication for API calls
|
|
if (!$this->user || !$this->user->id) {
|
|
self::returnJson(['success' => false, 'error' => 'Not authenticated'], 401);
|
|
return;
|
|
}
|
|
|
|
// Build handler path
|
|
$handlerFile = APPDIR . "MobileApp/Modules/{$moduleName}/{$submoduleName}/{$submoduleName}Handler.php";
|
|
|
|
if (!file_exists($handlerFile)) {
|
|
self::returnJson(['success' => false, 'error' => "Module 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);
|
|
}
|
|
}
|