Files
api/services/nginx/app/routes/authRoute.php
T

690 lines
29 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 objects\passkeys_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->exists() && $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 = $subuser ?: $user;
$totp = new totp();
$secret = $totp->generateSecret();
$principal->setTwoFactorSecret($secret);
if ($principal instanceof subusers_o) {
$name = $principal->username->value() ?? ('subuser#' . (string)$principal->id);
} else {
$name = $principal->customer_number->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 = $subuser ?: $user;
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 = $subuser ?: $user;
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();
$token_type = $token->type->value();
$user_id = (int)$token->user_id->value();
if ($token_type === '2FA_VERIFICATION_USER') {
$user = (new users_o())->getUserById($user_id);
if ($user->exists() && $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 === '2FA_VERIFICATION_SUBUSER') {
$subuser = (new subusers_o())->select($user_id);
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->exists() && $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->exists()) {
// 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);
}
});
$this->post('/auth/passkey/challenge', function () {
global $response;
$this->requireRecaptcha();
$customer_number = self::getParameter('customer_number');
$user_id = 0;
$allowCredentials = [];
if ($customer_number !== null) {
$customer_number = (int)$customer_number;
self::requireType($customer_number, $this->type_int());
self::requireMinValue($customer_number, 1);
// Attempt to locate user; do not reveal existence in response
$user = (new users_o())->getUserByCustomerNumber($customer_number);
if ($user->exists()) {
$user_id = (int)$user->id;
// Load passkeys for this user (non-subuser)
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
// Ensure we return a simple array of credentials (without pagination wrapper)
if (isset($list['items']) && is_array($list['items'])) {
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
} elseif (is_array($list)) {
$allowCredentials = array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
}
}
}
// Generate a challenge (Base64URL without padding)
$challenge_token = bin2hex(random_bytes(32));
$rawChallenge = hex2bin($challenge_token);
$challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '=');
// rpId for passkeys
$rpId = 'truckwash.io';
$host = $_SERVER['HTTP_HOST'] ?? '';
if (str_starts_with($host, 'localhost')) {
$rpId = 'localhost';
}
// Create an ephemeral token to bind the challenge to the (potential) user
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
$response->success([
'challenge_token' => $challenge_token,
'publicKey' => [
'challenge' => $challenge,
'rpId' => $rpId,
'timeout' => 60000,
'userVerification' => 'preferred',
'allowCredentials' => $allowCredentials
]
]);
});
$this->post('/auth/passkey/verify', function () {
global $response, $db;
$this->requireRecaptcha();
self::requireParameters(['challenge_token', 'id', 'response']);
$challenge_token_str = (string)self::getParameter('challenge_token');
$credential_id = (string)self::getParameter('id');
$webauthn_response = self::getParameter('response');
if (!is_array($webauthn_response)) {
$response->error('Invalid response format', 400);
}
$token_o = new tokens_o();
try {
$token = $token_o->getToken($challenge_token_str);
} catch (Exception $e) {
$response->error('Invalid or expired challenge token', 401);
}
if ($token->type->value() !== 'PASSKEY_CHALLENGE') {
$response->error('Invalid token type', 401);
}
// Find the passkey
$passkeys = new passkeys_o();
$fields = ['id', 'user_id', 'is_subuser', 'public_key', 'sign_count', 'algorithm'];
$found = $passkeys->getFieldsWhere(['credential_id' => $credential_id], $fields);
if (empty($found)) {
$response->error('Passkey not found', 401);
}
$passkey_data = $found[0];
$user_id = (int)$passkey_data['user_id'];
$is_subuser = (bool)$passkey_data['is_subuser'];
// If the challenge was bound to a specific user, verify it matches
if ((int)$token->user_id->value() > 0 && (int)$token->user_id->value() !== $user_id) {
$response->error('Passkey does not match requested user', 401);
}
// WebAuthn signature verification
$clientDataJSON = $webauthn_response['clientDataJSON'] ?? null;
$authenticatorData = $webauthn_response['authenticatorData'] ?? null;
$signature = $webauthn_response['signature'] ?? null;
if (!$clientDataJSON || !$authenticatorData || !$signature) {
$response->error('Missing WebAuthn response fields', 400);
}
$decode = function ($data) {
return base64_decode(strtr($data, '-_', '+/'));
};
$rawClientDataJSON = $decode($clientDataJSON);
$rawAuthenticatorData = $decode($authenticatorData);
$rawSignature = $decode($signature);
$clientData = json_decode($rawClientDataJSON, true);
if (!$clientData || !isset($clientData['challenge'])) {
$response->error('Invalid clientDataJSON', 400);
}
// Reconstruct and verify challenge
$expectedChallenge = rtrim(strtr(base64_encode(hex2bin($challenge_token_str)), '+/', '-_'), '=');
if ($clientData['challenge'] !== $expectedChallenge) {
$response->error('Challenge mismatch', 401);
}
// Verify signature: S = ES256(authenticatorData || hash(clientDataJSON))
$clientDataHash = hash('sha256', $rawClientDataJSON, true);
$dataToVerify = $rawAuthenticatorData . $clientDataHash;
$publicKey = $passkey_data['public_key'];
// If the public key is not in PEM format, assume it's base64url encoded DER
if (strpos($publicKey, '-----BEGIN PUBLIC KEY-----') === false) {
$rawPublicKey = $decode($publicKey);
$publicKey = "-----BEGIN PUBLIC KEY-----\n" .
chunk_split(base64_encode($rawPublicKey), 64, "\n") .
"-----END PUBLIC KEY-----";
}
// WebAuthn signatures are DER encoded, which openssl_verify accepts.
$algo = OPENSSL_ALGO_SHA256;
$verifyResult = openssl_verify($dataToVerify, $rawSignature, $publicKey, $algo);
if ($verifyResult !== 1) {
(new logs_o())->add('auth', 'global', 0, $user_id, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Signature verification failed for credential ' . $credential_id);
$response->error('Invalid signature', 401);
}
// Success! Update sign_count
$p_obj = (new passkeys_o())->select((int)$passkey_data['id']);
$p_obj->update(['sign_count' => (int)$passkey_data['sign_count'] + 1]);
$token_o->delete($challenge_token_str);
$auth = new authentication();
if ($is_subuser) {
$subuser = (new subusers_o())->select($user_id);
$session = $subuser->generateSession();
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Subuser login via passkey');
$response->success(['session' => $session]);
} else {
$user = (new users_o())->getUserById($user_id);
$customer_number = (int)$user->customer_number->value();
$new_token = $auth->create_token($customer_number);
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Customer login via passkey');
$response->success(['token' => $new_token]);
}
});
}
}