Add two-factor authentication support for users and subusers

- Extend `users_o` and `subusers_o` with `two_factor_enabled` and `two_factor_secret` properties.
- Implement methods for managing 2FA (`isTwoFactorEnabled`, `setTwoFactorSecret`, `verify_2fa_code`) in authentication logic.
- Add 2FA handling in login flows for both users and subusers, including token generation and validation.
- Introduce `totp` class for TOTP-based authentication, including QR code generation and code verification.
- Add test cases for 2FA functionality (`TwoFactorAuthTest.php`) and coverage for login scenarios with 2FA.
- Update OpenAPI specifications to include 2FA flows (`auth/2fa/setup`, `auth/2fa/enable`, `auth/2fa/verify`, `auth/2fa/disable`).
This commit is contained in:
Jeppe Bundgaard
2026-02-23 17:00:18 +01:00
parent 827fafd46b
commit f6b526f4ef
9 changed files with 899 additions and 21 deletions
+119 -3
View File
@@ -6,12 +6,14 @@ use classes\authentication;
use classes\economic;
use classes\email;
use classes\recaptcha;
use classes\totp;
use classes\virkdata;
use Exception;
use objects\customer_password_reset_keys_o;
use objects\logs_o;
use objects\tokens_o;
use objects\users_o;
use objects\subusers_o;
use traits\route_t;
class authRoute
@@ -52,6 +54,12 @@ class authRoute
$response->error('Invalid credentials', 401);
}
// If the credentials are valid, create a token
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
if ($user->isTwoFactorEnabled()) {
$token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER');
$response->success(['2fa_required' => true, '2fa_token' => $token]);
}
$token = (new authentication())->create_token($data['customer_number']);
// Return the token
$response->success(['token' => $token]);
@@ -89,10 +97,111 @@ class authRoute
if (!$user) {
$response->error('User not found', 400);
}
$user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray();
$user_data['two_factor_enabled'] = $user->isTwoFactorEnabled();
// Return the (session) user object
$response->success(
($user->includeIncludes(['economicCustomer', 'permissions'])->asArray())
);
$response->success($user_data);
});
$this->post('/auth/2fa/setup', function () {
global $response;
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
if ($user === false && $subuser === false) {
$response->error('Unauthorized', 401);
}
$principal = $user ?: $subuser;
$totp = new totp();
$secret = $totp->generateSecret();
$principal->setTwoFactorSecret($secret);
$name = $user ? $principal->customer_number->value() : $principal->username->value();
$qrCodeUrl = $totp->getQrCodeUrl($secret, $name, 'Truck Wash');
$response->success([
'secret' => $secret,
'qr_code_url' => $qrCodeUrl
]);
});
$this->post('/auth/2fa/enable', function () {
global $response;
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
if ($user === false && $subuser === false) {
$response->error('Unauthorized', 401);
}
self::requireParameters(['code']);
$code = (string)self::getParameter('code');
$principal = $user ?: $subuser;
if ($auth->verify_2fa_code($principal, $code)) {
$principal->setTwoFactorEnabled(true);
$response->success(['message' => '2FA enabled successfully']);
} else {
$response->error('Invalid 2FA code', 400);
}
});
$this->post('/auth/2fa/disable', function () {
global $response;
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
if ($user === false && $subuser === false) {
$response->error('Unauthorized', 401);
}
self::requireParameters(['code']);
$code = (string)self::getParameter('code');
$principal = $user ?: $subuser;
if ($auth->verify_2fa_code($principal, $code)) {
$principal->setTwoFactorEnabled(false);
$principal->setTwoFactorSecret(null);
$response->success(['message' => '2FA disabled successfully']);
} else {
$response->error('Invalid 2FA code', 400);
}
});
$this->post('/auth/2fa/verify', function () {
global $response;
self::requireParameters(['2fa_token', 'code']);
$token_str = (string)self::getParameter('2fa_token');
$code = (string)self::getParameter('code');
$token_o = new tokens_o();
try {
$token = $token_o->getToken($token_str);
} catch (Exception $e) {
$response->error('Invalid or expired 2FA token', 401);
}
$auth = new authentication();
if ($token->type->value() === '2FA_VERIFICATION_USER') {
$user = (new users_o())->getUserById($token->user_id->value());
if ($auth->verify_2fa_code($user, $code)) {
$token_o->delete($token_str);
$new_token = $auth->create_employee_token($user->id); // Works for both users and employees
$response->success(['token' => $new_token]);
}
} elseif ($token->type->value() === '2FA_VERIFICATION_SUBUSER') {
$subuser = (new subusers_o())->select($token->user_id->value());
if ($auth->verify_2fa_code($subuser, $code)) {
$token_o->delete($token_str);
$new_token = $subuser->generateSession();
$response->success(['session' => $new_token]);
}
}
$response->error('Invalid 2FA code', 400);
});
$this->post('/auth/employee/login', function () {
@@ -119,6 +228,13 @@ class authRoute
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Employee number: ' . $data['user_id']);
$response->error('Invalid credentials', 401);
}
$user = (new users_o())->getUserById($data['user_id']);
if ($user->isTwoFactorEnabled()) {
$token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER');
$response->success(['2fa_required' => true, '2fa_token' => $token]);
}
// If the credentials are valid, create a token
$token = (new authentication())->create_employee_token($data['user_id']);
// Return the token