- 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`).
490 lines
20 KiB
PHP
490 lines
20 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
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
|
|
{
|
|
use route_t;
|
|
|
|
public function run(): void
|
|
{
|
|
$this->post('/auth/login', function () {
|
|
// Get the post data
|
|
global $response;
|
|
$this->requireRecaptcha();
|
|
$data = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($data)) {
|
|
$data = [];
|
|
}
|
|
self::requireParameters(['customer_number', 'password']);
|
|
self::requireType((int)$data['customer_number'], $this->type_int());
|
|
self::requireMinValue((int)$data['customer_number'], 1);
|
|
self::requireType((string)$data['password'], $this->type_string());
|
|
self::requireMinLength('password', 1);
|
|
$data['customer_number'] = (int)$data['customer_number'];
|
|
$data['password'] = (string)$data['password'];
|
|
// Check if the customer number, and password are set
|
|
if (!isset($data['customer_number']) || empty($data['customer_number']) || !is_numeric($data['customer_number']) || $data['customer_number'] < 1) {
|
|
$response->error('Customer number is required', 400);
|
|
}
|
|
if (!isset($data['password']) || empty($data['password']) || strlen($data['password']) < 1) {
|
|
$response->error('Password is required', 400);
|
|
}
|
|
// Try to log the user in
|
|
$isCredentialsValid = (new authentication())->authenticate($data['customer_number'], $data['password']);
|
|
// Log the incident
|
|
if ($isCredentialsValid) {
|
|
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']);
|
|
} else {
|
|
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
|
$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]);
|
|
});
|
|
|
|
$this->get('/auth/logout', function () {
|
|
// Get the token from the headers
|
|
global $response;
|
|
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
|
// Remove the Bearer prefix
|
|
$token = str_replace('Bearer ', '', $token);
|
|
// Check if the token is valid
|
|
if (!(new authentication())->validate_token($token)) {
|
|
$response->error('Invalid token', 401);
|
|
}
|
|
// Delete the token
|
|
(new tokens_o())->delete($token);
|
|
// Return a success message
|
|
$response->success(['message' => 'Logged out']);
|
|
});
|
|
|
|
$this->get('/auth/session', function () {
|
|
// Get the token from the headers
|
|
global $response;
|
|
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; // Default to empty string if not set
|
|
// Remove the Bearer prefix
|
|
$token = str_replace('Bearer ', '', $token);
|
|
// Check if the token is valid
|
|
if (!(new authentication())->validate_token($token)) {
|
|
$response->error('Invalid token', 401);
|
|
}
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the user exists
|
|
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_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 () {
|
|
// Get the post data
|
|
global $response;
|
|
$this->requireRecaptcha();
|
|
$data = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($data)) {
|
|
$data = [];
|
|
}
|
|
// Check if the employee number, and password are set
|
|
if (!isset($data['user_id'])) {
|
|
$response->error('Employee number is required', 400);
|
|
}
|
|
if (!isset($data['password'])) {
|
|
$response->error('Password is required', 400);
|
|
}
|
|
// Try to log the user in
|
|
$isCredentialsValid = (new authentication())->authenticateEmployee($data['user_id'], $data['password']);
|
|
// Log the incident
|
|
if ($isCredentialsValid) {
|
|
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Employee number: ' . $data['user_id']);
|
|
} else {
|
|
(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
|
|
$response->success(['token' => $token]);
|
|
});
|
|
|
|
$this->get('/auth/reCAPTCHA/public', function () {
|
|
// Check if the user:
|
|
// 1. Is rate limited (future feature)
|
|
// 2. Is required to solve a reCAPTCHA
|
|
global $response;
|
|
$recaptcha = (new recaptcha())->getPublicConfig();
|
|
$response->success([
|
|
'rate_limit' => [
|
|
'enabled' => false,
|
|
'limit' => 0,
|
|
'remaining' => 0,
|
|
'reset' => 0,
|
|
'warning' => null
|
|
],
|
|
'recaptcha' => $recaptcha
|
|
]);
|
|
});
|
|
|
|
$this->post('/auth/register/cvr', function () {
|
|
// Get the post data
|
|
global $response;
|
|
$this->requireRecaptcha();
|
|
/**
|
|
* {
|
|
* "cvr": "44794780",
|
|
* "companyPhone": 21754690,
|
|
* "invoiceEmail": "mikkel@truckwash.dk",
|
|
* "contactEmail": "mikkel@truckwash.dk",
|
|
* "contactPhone": 21754690,
|
|
* "searchResult": {
|
|
* "vat": 41004355,
|
|
* "status": "Normal",
|
|
* "name": "Truckwash ApS",
|
|
* "address": "Letland Alle 2",
|
|
* "zipcode": 2630,
|
|
* "city": "Taastrup",
|
|
* "protected": true,
|
|
* "phone": "21754690",
|
|
* "website": null,
|
|
* "email": "mikkel@truckwash.dk",
|
|
* "fax": null,
|
|
* "startdate": "2019-12-11",
|
|
* "enddate": null,
|
|
* "employees": 14,
|
|
* "industrycode": 953190,
|
|
* "industrydesc": "Reparation og vedligeholdelse af motorkøretøjer i.a.n.",
|
|
* "companytype": "APS",
|
|
* "companydesc": "Anpartsselskab",
|
|
* "owners": [
|
|
* "DELOITTE STATSAUTORISERET REVISIONSPARTNERSELSKAB",
|
|
* "MBL Revision I/S",
|
|
* "WASH GROUP A/S"
|
|
* ]
|
|
* }
|
|
* }
|
|
*/
|
|
/**
|
|
* Parameters:
|
|
*/
|
|
self::requireParameters(['cvr', 'companyPhone', 'invoiceEmail', 'contactEmail', 'contactPhone']);
|
|
$cvr = self::getParameter('cvr');
|
|
$companyPhone = (int)self::getParameter('companyPhone');
|
|
$invoiceEmail = self::getParameter('invoiceEmail');
|
|
$contactEmail = self::getParameter('contactEmail');
|
|
$contactPhone = (int)self::getParameter('contactPhone');
|
|
$contactName = self::getParameter('contactName');
|
|
/**
|
|
* Validate
|
|
*/
|
|
self::requireType($cvr, $this->type_string());
|
|
self::requireMinLength('cvr', 8);
|
|
self::requireMaxLength('cvr', 20);
|
|
self::requireType($companyPhone, $this->type_int());
|
|
self::requireMinValue($companyPhone, 10000000);
|
|
self::requireMaxValue($companyPhone, 9999999999);
|
|
if (self::isParametersSet(['invoiceEmail']) && !is_null($invoiceEmail)) {
|
|
self::requireType($invoiceEmail, $this->type_string());
|
|
self::requireMinLength('invoiceEmail', 5);
|
|
self::requireMaxLength('invoiceEmail', 255);
|
|
}
|
|
if (self::isParametersSet(['contactEmail']) && !is_null($contactEmail)) {
|
|
self::requireType($contactEmail, $this->type_string());
|
|
self::requireMinLength('contactEmail', 5);
|
|
self::requireMaxLength('contactEmail', 255);
|
|
}
|
|
/**
|
|
* If the contact phone is set, validate it
|
|
*/
|
|
if (self::isParametersSet(['contactPhone']) && !empty($contactPhone)) {
|
|
self::requireType($contactPhone, $this->type_int());
|
|
self::requireMinValue($contactPhone, 10000000);
|
|
self::requireMaxValue($contactPhone, 9999999999);
|
|
}
|
|
|
|
/**
|
|
* If the contact phone is empty, default to company phone
|
|
*/
|
|
if (empty($contactPhone)) {
|
|
$contactPhone = $companyPhone;
|
|
}
|
|
/**
|
|
* If the emails are empty, default to jb@truckwash.dk
|
|
*/
|
|
if (empty($invoiceEmail)) {
|
|
$invoiceEmail = 'jb@truckwash.dk';
|
|
}
|
|
if (empty($contactEmail)) {
|
|
$contactEmail = 'jb@truckwash.dk';
|
|
}
|
|
|
|
/**
|
|
* Check if the cvr already exists
|
|
*/
|
|
$economic_response = ((new economic())->customers->customers->search([
|
|
'corporateIdentificationNumber' => (string)$cvr,
|
|
], [
|
|
'skipPages' => 0,
|
|
'pageSize' => 1, // Since the limit is 1000, we need to set the page size to 1000.
|
|
])->collection);
|
|
// Check if the customer number is already in use in our system
|
|
if ((new users_o())->getUserByCustomerNumber((int)$companyPhone)->id) {
|
|
$response->error('Company phone number already registered', 400);
|
|
}
|
|
if (count($economic_response) === 0) {
|
|
/**
|
|
* Create the customer in E-conomic
|
|
*/
|
|
// Get the customer name
|
|
$name = (new virkdata())->getCompanyInformation($cvr, '', [])->name;
|
|
/**
|
|
* $economic = new economic();
|
|
* $economic->createCustomer(
|
|
* $customer_number,
|
|
* $name,
|
|
* $cvr,
|
|
* $invoiceEmail,
|
|
* $companyPhone,
|
|
* );
|
|
*/
|
|
$economic = new economic();
|
|
$result = $economic->createCustomer(
|
|
(int)$companyPhone,
|
|
$name,
|
|
(string)$cvr,
|
|
(string)$invoiceEmail,
|
|
(string)$companyPhone,
|
|
);
|
|
$email = new email();
|
|
$jimmyEmail = "jm@truckwash.dk";
|
|
$infoEmail = "info@truckwash.dk";
|
|
$email->sendWelcomeEmailToCustomer((int)$companyPhone, (string)$infoEmail);
|
|
$email->sendWelcomeEmailToCustomer((int)$companyPhone, (string)$jimmyEmail);
|
|
$email->sendWelcomeEmailToCustomer((int)$companyPhone, (string)$invoiceEmail);
|
|
/**
|
|
* Return the result
|
|
*/
|
|
$response->success($result, 201);
|
|
} else {
|
|
$response->error('CVR already registered', 400);
|
|
}
|
|
});
|
|
|
|
$this->post('/auth/password-reset/request', function () {
|
|
global $response;
|
|
$this->requireRecaptcha();
|
|
self::requireParameters(['customer_number']);
|
|
$customer_number = (int)self::getParameter('customer_number');
|
|
|
|
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
|
if (!$user->id) {
|
|
// For security reasons, don't reveal if the user exists
|
|
$response->success(['message' => 'If the customer exists, a password reset email has been sent.']);
|
|
}
|
|
|
|
$email_address = $user->email->value();
|
|
if (empty($email_address)) {
|
|
// If no email is set, we can't send the reset email
|
|
$response->error('Der er ingen email-addresse tilknyttet denne konto, ring venligst på +45 43 71 78 86 for konto gendannelse.', 400);
|
|
}
|
|
|
|
// Generate token
|
|
$token = customer_password_reset_keys_o::generateToken();
|
|
|
|
// Save token
|
|
$reset_key_o = new customer_password_reset_keys_o();
|
|
$reset_key_o->add([
|
|
'customer_id' => $customer_number,
|
|
'token' => $token,
|
|
'note' => 'Requested via API'
|
|
]);
|
|
|
|
// Send email
|
|
$email = new email();
|
|
$reset_link = "https://truckwash.io/auth/password-reset/" . $token;
|
|
|
|
$subject = 'Adgangskode nulstilling';
|
|
$message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:<br><br><a href='$reset_link'>$reset_link</a><br><br>Linket er gyldigt i 1 time.";
|
|
|
|
try {
|
|
$email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null);
|
|
$response->success(['message' => 'If the customer exists, a password reset email has been sent.']);
|
|
} catch (Exception $e) {
|
|
$response->error('Failed to send email: ' . $e->getMessage(), 500);
|
|
}
|
|
});
|
|
|
|
$this->get('/auth/password-reset/validate', function () {
|
|
global $response;
|
|
self::requireParameters(['token']);
|
|
$token = self::getParameter('token');
|
|
|
|
$reset_key_o = new customer_password_reset_keys_o();
|
|
$found_key = $reset_key_o->findValidByToken($token);
|
|
|
|
if ($found_key === null) {
|
|
$response->error('Invalid or expired token', 404);
|
|
}
|
|
|
|
$response->success([
|
|
'valid' => true,
|
|
'customer_id' => $found_key->customer_id->value()
|
|
]);
|
|
});
|
|
|
|
$this->post('/auth/password-reset/set', function () {
|
|
global $response;
|
|
$this->requireRecaptcha();
|
|
self::requireParameters(['token', 'password']);
|
|
$token = self::getParameter('token');
|
|
$password = self::getParameter('password');
|
|
|
|
$reset_key_o = new customer_password_reset_keys_o();
|
|
$found_key = $reset_key_o->findValidByToken($token);
|
|
|
|
if ($found_key === null) {
|
|
$response->error('Invalid or expired token', 404);
|
|
}
|
|
|
|
try {
|
|
$found_key->setPassword($password);
|
|
$response->success(['message' => 'Password updated successfully']);
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
}
|
|
});
|
|
}
|
|
} |