Complete and secure public customer/driver registration, authoritative limited-backoffice department scope, one-time employee QR login, and pricing concurrency for the Sæby demo.
1248 lines
53 KiB
PHP
1248 lines
53 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\account_deletion_service;
|
|
use classes\economic;
|
|
use classes\email;
|
|
use classes\limited_backoffice_exception;
|
|
use classes\limited_backoffice_login_grant_service;
|
|
use classes\release_manager;
|
|
use classes\recaptcha;
|
|
use classes\security_policy_service;
|
|
use classes\slack;
|
|
use classes\totp;
|
|
use classes\virkdata;
|
|
use classes\webauthn;
|
|
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;
|
|
|
|
require_once WD . '/classes/security_policy_service.php';
|
|
require_once WD . '/classes/account_deletion_service.php';
|
|
require_once WD . '/classes/limited_backoffice_login_grant_service.php';
|
|
|
|
class authRoute
|
|
{
|
|
use route_t;
|
|
|
|
private const PUBLIC_REGISTRATION_RATE_LIMIT = 5;
|
|
private const PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS = 15 * 60;
|
|
|
|
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
|
|
{
|
|
return 'passkey_challenge_principal:' . $challengeToken;
|
|
}
|
|
|
|
private function setPasskeyChallengePrincipal(string $challengeToken, string $principalType): void
|
|
{
|
|
if (!defined('redis')) {
|
|
return;
|
|
}
|
|
constant('redis')->setEx($this->passkeyChallengePrincipalCacheKey($challengeToken), $principalType, 5 * 60);
|
|
}
|
|
|
|
private function getPasskeyChallengePrincipal(string $challengeToken): string
|
|
{
|
|
if (!defined('redis')) {
|
|
return 'discoverable';
|
|
}
|
|
$principalType = constant('redis')->get($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
|
return is_string($principalType) && in_array($principalType, ['user', 'subuser'], true)
|
|
? $principalType
|
|
: 'discoverable';
|
|
}
|
|
|
|
private function clearPasskeyChallengePrincipal(string $challengeToken): void
|
|
{
|
|
if (!defined('redis')) {
|
|
return;
|
|
}
|
|
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
|
|
}
|
|
|
|
private function requirePublicRegistrationRateLimit(
|
|
string $scope,
|
|
string $identifier,
|
|
int $limit = self::PUBLIC_REGISTRATION_RATE_LIMIT,
|
|
int $windowSeconds = self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS,
|
|
bool $includeClientIp = true
|
|
): void {
|
|
global $response;
|
|
|
|
if (!defined('redis')) {
|
|
if (PHP_SAPI === 'cli') {
|
|
return;
|
|
}
|
|
$response->error('Registration protection is temporarily unavailable.', 503);
|
|
}
|
|
|
|
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
|
|
$key = 'auth_public_registration_throttle:' . preg_replace('/[^a-z0-9:_-]/i', '_', $scope) . ':' . hash(
|
|
'sha256',
|
|
($includeClientIp ? $remoteAddress . ':' : '') . $identifier
|
|
);
|
|
try {
|
|
$attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds);
|
|
} catch (\Throwable $exception) {
|
|
error_log('[auth] public registration throttle unavailable: ' . $exception->getMessage());
|
|
$response->error('Registration protection is temporarily unavailable.', 503);
|
|
}
|
|
if ($attempts > $limit) {
|
|
$response->error('Too many registration attempts. Please wait and try again.', 429);
|
|
}
|
|
}
|
|
|
|
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
|
|
{
|
|
if ($userId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$passkeys = new passkeys_o();
|
|
$passkeys->setAdditionalWhereClause(
|
|
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0') . ' AND `deleted_at` IS NULL'
|
|
);
|
|
$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,
|
|
];
|
|
});
|
|
|
|
if (isset($list['items']) && is_array($list['items'])) {
|
|
$list = $list['items'];
|
|
}
|
|
|
|
return is_array($list)
|
|
? array_values(array_filter($list, function ($item) {
|
|
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
|
|
}))
|
|
: [];
|
|
}
|
|
|
|
public function run(): void
|
|
{
|
|
$this->post('/auth/limited-backoffice-login-grants/exchange', function () {
|
|
global $response;
|
|
|
|
try {
|
|
$payload = json_decode(file_get_contents('php://input'), true);
|
|
$grant = is_array($payload) ? trim((string)($payload['grant'] ?? '')) : '';
|
|
$response->success((new limited_backoffice_login_grant_service())->exchange($grant));
|
|
} catch (limited_backoffice_exception $exception) {
|
|
$response->error($exception->payload(), $exception->statusCode());
|
|
}
|
|
});
|
|
|
|
$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);
|
|
}
|
|
|
|
// Use Redis-backed user and property caches to validate credentials with a single user load
|
|
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
|
|
if (!$user->exists() || !$user->hasPassword()) {
|
|
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
|
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
|
|
$response->error('Invalid credentials', 401);
|
|
}
|
|
if (account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
|
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'account_unavailable']);
|
|
$response->error('Invalid credentials', 401);
|
|
}
|
|
|
|
$isCredentialsValid = false;
|
|
try {
|
|
$isCredentialsValid = $user->passwordMatches($data['password']);
|
|
} catch (Exception $e) {
|
|
$isCredentialsValid = false;
|
|
}
|
|
|
|
// 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']);
|
|
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'invalid_password']);
|
|
$response->error('Invalid credentials', 401);
|
|
}
|
|
|
|
// If the credentials are valid, check 2FA and create a token
|
|
if ($user->exists() && $user->isTwoFactorEnabled()) {
|
|
$token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER');
|
|
$response->success(['2fa_required' => true, '2fa_token' => $token]);
|
|
}
|
|
|
|
// Create the auth token without reloading the user from DB
|
|
$token = (new authentication())->create_token_by_user_id((int)$user->id);
|
|
// 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);
|
|
// Clear any cached session for this token
|
|
try { redis->clear_auth_session($token); } catch (\Throwable $e) {}
|
|
// Clear any cached subuser session for this token
|
|
try { (new subusers_o())->invalidateSessionToken($token); } catch (\Throwable $e) {}
|
|
// 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);
|
|
}
|
|
|
|
// Try Redis cache first for session payload
|
|
try {
|
|
$cached = redis->get_auth_session($token);
|
|
if (is_array($cached)) {
|
|
$cached = $this->appendRuntimeConfig($cached);
|
|
try {
|
|
redis->cache_auth_session($token, $cached, 60);
|
|
} catch (\Throwable $e) {
|
|
// Best-effort caching only
|
|
}
|
|
$response->success($cached);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Ignore Redis errors and continue to compute session
|
|
}
|
|
|
|
// 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();
|
|
$user_data = $this->appendRuntimeConfig($user_data);
|
|
|
|
// Cache the session payload briefly to reduce DB load on hot paths
|
|
try {
|
|
redis->cache_auth_session($token, $user_data, 60);
|
|
} catch (\Throwable $e) {
|
|
// Best-effort caching only
|
|
}
|
|
|
|
// 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()
|
|
&& !account_deletion_service::principalIsBlocked('customer', $user_id)
|
|
&& $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 (
|
|
!account_deletion_service::principalIsBlocked('subuser', $user_id)
|
|
&& $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']);
|
|
$this->observeLoginFailure('employee', (string)$data['user_id'], ['reason' => 'invalid_credentials']);
|
|
$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' => defined('redis'),
|
|
'limit' => self::PUBLIC_REGISTRATION_RATE_LIMIT,
|
|
'remaining' => self::PUBLIC_REGISTRATION_RATE_LIMIT,
|
|
'reset' => self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS,
|
|
'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');
|
|
$contactPhoneCountryCode = self::isParametersSet(['contactPhoneCountryCode'])
|
|
? (int)self::getParameter('contactPhoneCountryCode')
|
|
: 45;
|
|
$ean = null;
|
|
/**
|
|
* 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 (!filter_var((string)$invoiceEmail, FILTER_VALIDATE_EMAIL)) {
|
|
$response->error('Invalid invoice email format', 400);
|
|
}
|
|
}
|
|
if (self::isParametersSet(['contactEmail']) && !is_null($contactEmail)) {
|
|
self::requireType($contactEmail, $this->type_string());
|
|
self::requireMinLength('contactEmail', 5);
|
|
self::requireMaxLength('contactEmail', 255);
|
|
if (!filter_var((string)$contactEmail, FILTER_VALIDATE_EMAIL)) {
|
|
$response->error('Invalid contact email format', 400);
|
|
}
|
|
}
|
|
/**
|
|
* 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 ($contactPhoneCountryCode < 1 || $contactPhoneCountryCode > 999) {
|
|
$response->error('Invalid contact phone country code', 400);
|
|
}
|
|
$this->requirePublicRegistrationRateLimit('customer_ip', 'all');
|
|
$this->requirePublicRegistrationRateLimit(
|
|
'customer_identity',
|
|
'cvr:' . (string)$cvr . ':phone:' . $companyPhone,
|
|
3,
|
|
60 * 60,
|
|
false
|
|
);
|
|
if (self::isParametersSet(['ean'])) {
|
|
try {
|
|
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
|
|
} catch (\InvalidArgumentException $exception) {
|
|
$response->error($exception->getMessage(), 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = new economic();
|
|
$economic_response = $this->searchEconomicCustomersByCvr($economic, (string)$cvr);
|
|
|
|
$localUserExists = $this->localCustomerNumberExists($companyPhone);
|
|
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone);
|
|
|
|
if ($matchingEconomicCustomer !== null) {
|
|
if ($localUserExists) {
|
|
$response->error('Company phone number already registered', 400);
|
|
}
|
|
|
|
$customer = $this->bootstrapLocalCustomerOrFail($companyPhone, $matchingEconomicCustomer);
|
|
$this->hydrateRegistrationContact(
|
|
$customer,
|
|
$companyPhone,
|
|
(string)$contactEmail,
|
|
(int)$contactPhone,
|
|
$contactPhoneCountryCode,
|
|
is_scalar($contactName) ? (string)$contactName : null
|
|
);
|
|
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
|
$response->success($matchingEconomicCustomer, 200);
|
|
}
|
|
|
|
if (count($economic_response) > 0) {
|
|
$existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economic_response[0]);
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONFLICT', [
|
|
'phase' => 'search',
|
|
'cvr' => (string)$cvr,
|
|
'requestedCustomerNumber' => $companyPhone,
|
|
'existingCustomerNumber' => $existingEconomicCustomerNumber,
|
|
]);
|
|
$response->error(
|
|
'CVR already registered under customer number '
|
|
. $existingEconomicCustomerNumber
|
|
. '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.',
|
|
409
|
|
);
|
|
}
|
|
|
|
// Get the CVR company information used for the e-conomic customer payload.
|
|
$companyInformation = null;
|
|
try {
|
|
$companyInformation = (new virkdata())->getCompanyInformation((string)$cvr, '', []);
|
|
} catch (Exception $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_FAILED', [
|
|
'phase' => 'cvr_lookup',
|
|
'cvr' => (string)$cvr,
|
|
'requestedCustomerNumber' => $companyPhone,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
|
}
|
|
|
|
$name = trim((string)($companyInformation->name ?? ''));
|
|
if ($name === '') {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOOKUP_INVALID_RESPONSE', [
|
|
'phase' => 'cvr_lookup',
|
|
'cvr' => (string)$cvr,
|
|
'requestedCustomerNumber' => $companyPhone,
|
|
]);
|
|
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
|
|
}
|
|
|
|
if ($localUserExists) {
|
|
$response->error('Company phone number already registered', 400);
|
|
}
|
|
|
|
try {
|
|
$result = $economic->createCustomer(
|
|
(int)$companyPhone,
|
|
$name,
|
|
(int)$cvr,
|
|
(string)$invoiceEmail,
|
|
(int)$companyPhone,
|
|
(int)$contactPhone,
|
|
$companyInformation,
|
|
$ean,
|
|
);
|
|
} catch (Exception $exception) {
|
|
$recoveredCustomer = $this->recoverRegistrationAfterCreateFailure(
|
|
$economic,
|
|
(string)$cvr,
|
|
$companyPhone,
|
|
(string)$invoiceEmail,
|
|
(string)$contactEmail,
|
|
(int)$contactPhone,
|
|
$contactPhoneCountryCode,
|
|
is_scalar($contactName) ? (string)$contactName : null
|
|
);
|
|
|
|
if ($recoveredCustomer !== null) {
|
|
$response->success($recoveredCustomer, 200);
|
|
}
|
|
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_FAILED', [
|
|
'phase' => 'create',
|
|
'cvr' => (string)$cvr,
|
|
'requestedCustomerNumber' => $companyPhone,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
$response->error('Failed to create customer in e-conomic.', 502);
|
|
}
|
|
|
|
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [
|
|
'phase' => 'create',
|
|
'cvr' => (string)$cvr,
|
|
'requestedCustomerNumber' => $companyPhone,
|
|
'response' => $result,
|
|
]);
|
|
$response->error('Customer creation did not return a valid customer number.', 500);
|
|
}
|
|
|
|
$createdCustomerNumber = (int)$result->customerNumber;
|
|
if ($createdCustomerNumber !== $companyPhone) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONFLICT', [
|
|
'phase' => 'create',
|
|
'cvr' => (string)$cvr,
|
|
'requestedCustomerNumber' => $companyPhone,
|
|
'createdCustomerNumber' => $createdCustomerNumber,
|
|
'logId' => isset($result->logId) ? (string)$result->logId : null,
|
|
'message' => isset($result->message) ? (string)$result->message : null,
|
|
]);
|
|
$response->error(
|
|
'E-conomic created the customer under customer number '
|
|
. $createdCustomerNumber
|
|
. ' instead of the submitted phone number '
|
|
. $companyPhone
|
|
. '. Manual cleanup or reassignment is required before retrying.',
|
|
409
|
|
);
|
|
}
|
|
|
|
$customer = $this->bootstrapLocalCustomerOrFail($companyPhone, $result);
|
|
$this->hydrateRegistrationContact(
|
|
$customer,
|
|
$companyPhone,
|
|
(string)$contactEmail,
|
|
(int)$contactPhone,
|
|
$contactPhoneCountryCode,
|
|
is_scalar($contactName) ? (string)$contactName : null
|
|
);
|
|
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
|
|
$response->success($result, 201);
|
|
});
|
|
|
|
$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.']);
|
|
}
|
|
if (account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
|
$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';
|
|
$valid_hours = (int)(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS / 3600);
|
|
$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 $valid_hours timer.";
|
|
|
|
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();
|
|
|
|
$principal_type = strtolower(trim((string)(self::getParameter('principal_type') ?? self::getParameter('user_type') ?? '')));
|
|
if ($principal_type === '') {
|
|
$principal_type = self::getParameter('customer_number') !== null ? 'user' : 'discoverable';
|
|
}
|
|
if (!in_array($principal_type, ['user', 'subuser', 'discoverable'], true)) {
|
|
$response->error('Invalid principal_type', 400);
|
|
}
|
|
|
|
$customer_number = self::getParameter('customer_number');
|
|
$user_id = 0;
|
|
$allowCredentials = [];
|
|
|
|
if ($principal_type === 'subuser') {
|
|
$subuser = null;
|
|
if (self::isParametersSet(['subuser_id'])) {
|
|
$subuser_id = (int)self::getParameter('subuser_id');
|
|
self::requireType($subuser_id, $this->type_int());
|
|
self::requireMinValue($subuser_id, 1);
|
|
$candidate = (new subusers_o())->select($subuser_id);
|
|
if ($candidate->exists()) {
|
|
$candidate->getObjectProperties();
|
|
$subuser = $candidate;
|
|
}
|
|
} elseif (self::isParametersSet(['username'])) {
|
|
$username = (string)self::getParameter('username');
|
|
self::requireType($username, $this->type_string());
|
|
self::requireMinLength('username', 3);
|
|
self::requireMaxLength('username', 255);
|
|
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
|
} elseif (self::isParametersSet(['phone_country_code', 'phone'])) {
|
|
$phone_country_code = (int)self::getParameter('phone_country_code');
|
|
$phone = (int)self::getParameter('phone');
|
|
self::requireType($phone_country_code, $this->type_int());
|
|
self::requireMinLength('phone_country_code', 1);
|
|
self::requireMaxLength('phone_country_code', 3);
|
|
self::requireType($phone, $this->type_int());
|
|
self::requireMinLength('phone', 4);
|
|
self::requireMaxLength('phone', 15);
|
|
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
|
}
|
|
|
|
if ($subuser !== null) {
|
|
if (!account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
|
$user_id = (int)$subuser->id;
|
|
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
|
}
|
|
}
|
|
} elseif ($customer_number !== null) {
|
|
$principal_type = 'user';
|
|
$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()) {
|
|
if (!account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
|
$user_id = (int)$user->id;
|
|
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
$originHost = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST);
|
|
$httpHost = isset($_SERVER['HTTP_HOST']) ? explode(':', (string)$_SERVER['HTTP_HOST'])[0] : null;
|
|
$serverName = $_SERVER['SERVER_NAME'] ?? null;
|
|
$rpId = $originHost ?: $httpHost ?: $serverName ?: 'truckwash.io';
|
|
|
|
// Create an ephemeral token to bind the challenge to the (potential) user
|
|
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
|
|
$this->setPasskeyChallengePrincipal(
|
|
$challenge_token,
|
|
$principal_type === 'subuser' ? 'subuser' : ($principal_type === 'user' ? 'user' : 'discoverable')
|
|
);
|
|
|
|
$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;
|
|
$this->requireRecaptcha();
|
|
|
|
// Parse JSON body
|
|
$data = json_decode(file_get_contents('php://input'), true);
|
|
if (!is_array($data)) {
|
|
$data = [];
|
|
}
|
|
|
|
if (!isset($data['challenge_token']) || !is_string($data['challenge_token']) || strlen($data['challenge_token']) < 10) {
|
|
$response->error('Invalid or missing challenge_token', 400);
|
|
}
|
|
if (!isset($data['credential'])) {
|
|
$response->error('Missing credential', 400);
|
|
}
|
|
|
|
$challenge_token = (string)$data['challenge_token'];
|
|
$credentialPayload = $data['credential'];
|
|
$credentialJson = is_string($credentialPayload) ? $credentialPayload : json_encode($credentialPayload, JSON_UNESCAPED_SLASHES);
|
|
if (!is_string($credentialJson) || $credentialJson === false) {
|
|
$response->error('Invalid credential payload', 400);
|
|
}
|
|
|
|
// Load and validate the ephemeral challenge token
|
|
$token_o = new tokens_o();
|
|
try {
|
|
$token = $token_o->getToken($challenge_token);
|
|
} catch (Exception $e) {
|
|
$response->error('Invalid or expired challenge', 401);
|
|
}
|
|
$token_type = $token->type->value();
|
|
if ($token_type !== 'PASSKEY_CHALLENGE') {
|
|
$response->error('Invalid token type', 401);
|
|
}
|
|
$challengePrincipalType = $this->getPasskeyChallengePrincipal($challenge_token);
|
|
|
|
// Determine rpId/host
|
|
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
|
|
|
|
// Extract credential id
|
|
$credentialArr = is_array($credentialPayload) ? $credentialPayload : json_decode($credentialJson, true);
|
|
$credentialId = $credentialArr['id'] ?? $credentialArr['rawId'] ?? null;
|
|
if (!is_string($credentialId) || $credentialId === '') {
|
|
$response->error('Invalid credential id', 400);
|
|
}
|
|
|
|
// Find corresponding stored passkey
|
|
$user_id_hint = (int)$token->user_id->value();
|
|
$passkeyRepo = new passkeys_o();
|
|
$passkey = $passkeyRepo->findByCredentialId($credentialId, $user_id_hint > 0 ? $user_id_hint : null);
|
|
if ($passkey === null) {
|
|
// As a fallback for discoverable credentials, try without user hint
|
|
$passkey = (new passkeys_o())->findByCredentialId($credentialId, null);
|
|
}
|
|
if ($passkey === null) {
|
|
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential');
|
|
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'unknown_credential', 'user_id_hint' => $user_id_hint]);
|
|
$response->error('Invalid credential', 404);
|
|
}
|
|
|
|
// Verify assertion using the WebAuthn library
|
|
$wa = new webauthn();
|
|
$ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host);
|
|
if (!$ok) {
|
|
(new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed');
|
|
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'assertion_failed', 'user_id_hint' => $user_id_hint]);
|
|
$response->error('Invalid passkey assertion', 401);
|
|
}
|
|
|
|
// Success → issue session token accordingly and delete the challenge token
|
|
$issued_to_user_id = (int)$passkey->user_id->value();
|
|
$is_subuser = (bool)$passkey->is_subuser->value();
|
|
if (account_deletion_service::principalIsBlocked($is_subuser ? 'subuser' : 'customer', $issued_to_user_id)) {
|
|
$token_o->delete($challenge_token);
|
|
$this->clearPasskeyChallengePrincipal($challenge_token);
|
|
$response->error('Invalid credential', 401);
|
|
}
|
|
if (
|
|
($challengePrincipalType === 'subuser' && !$is_subuser)
|
|
|| ($challengePrincipalType === 'user' && $is_subuser)
|
|
) {
|
|
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
|
|
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'principal_mismatch', 'user_id_hint' => $user_id_hint]);
|
|
$token_o->delete($challenge_token);
|
|
$this->clearPasskeyChallengePrincipal($challenge_token);
|
|
$response->error('Invalid credential', 401);
|
|
}
|
|
$token_o->delete($challenge_token);
|
|
$this->clearPasskeyChallengePrincipal($challenge_token);
|
|
|
|
(new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted');
|
|
|
|
if ($is_subuser) {
|
|
$subuser = (new subusers_o())->select($issued_to_user_id);
|
|
$session = $subuser->generateSession();
|
|
$response->success(['session' => $session]);
|
|
} else {
|
|
// For customers, user_id stores the customer number
|
|
$auth = new authentication();
|
|
$jwt = $auth->create_token_by_user_id($issued_to_user_id);
|
|
$response->success(['token' => $jwt]);
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
private function localCustomerNumberExists(int $customerNumber): bool
|
|
{
|
|
$rows = (new users_o())->getFieldsWhere([
|
|
'customer_number' => (string)$customerNumber,
|
|
], ['id']);
|
|
|
|
return count($rows) > 0;
|
|
}
|
|
|
|
private function searchEconomicCustomersByCvr(economic $economic, string $cvr): array
|
|
{
|
|
$economic_response = ($economic->customers->customers->search([
|
|
'corporateIdentificationNumber' => $cvr,
|
|
], [
|
|
'skipPages' => 0,
|
|
'pageSize' => 1,
|
|
])->collection);
|
|
|
|
return is_array($economic_response) ? $economic_response : [];
|
|
}
|
|
|
|
private function recoverRegistrationAfterCreateFailure(
|
|
economic $economic,
|
|
string $cvr,
|
|
int $customerNumber,
|
|
string $invoiceEmail,
|
|
string $contactEmail,
|
|
int $contactPhone,
|
|
int $contactPhoneCountryCode,
|
|
?string $contactName
|
|
): ?object {
|
|
// The upstream POST can commit before the client receives a validation/transport error.
|
|
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
|
|
try {
|
|
$economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr);
|
|
} catch (Exception $searchException) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_RECOVERY_SEARCH_FAILED', [
|
|
'phase' => 'create_recovery',
|
|
'cvr' => $cvr,
|
|
'requestedCustomerNumber' => $customerNumber,
|
|
'message' => $searchException->getMessage(),
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $customerNumber);
|
|
if ($matchingEconomicCustomer === null || $this->localCustomerNumberExists($customerNumber)) {
|
|
return null;
|
|
}
|
|
|
|
$customer = $this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
|
|
$this->hydrateRegistrationContact(
|
|
$customer,
|
|
$customerNumber,
|
|
$contactEmail,
|
|
$contactPhone,
|
|
$contactPhoneCountryCode,
|
|
$contactName
|
|
);
|
|
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
|
|
|
|
return $matchingEconomicCustomer;
|
|
}
|
|
|
|
private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
|
|
{
|
|
foreach ($customers as $customer) {
|
|
if (!is_object($customer)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) {
|
|
return $customer;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractEconomicCustomerNumber(object $customer): int
|
|
{
|
|
if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) {
|
|
return 0;
|
|
}
|
|
|
|
return (int)$customer->customerNumber;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function bootstrapLocalCustomerOrFail(int $customerNumber, ?object $economicCustomer = null): users_o
|
|
{
|
|
global $response;
|
|
|
|
$customer = new users_o();
|
|
try {
|
|
$customer = $customer->getUserByCustomerNumber($customerNumber);
|
|
if (method_exists($customer, 'exists') && $customer->exists()) {
|
|
return $customer;
|
|
}
|
|
} catch (Exception $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_LOOKUP_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
|
|
if (
|
|
$economicCustomer !== null
|
|
&& $this->extractEconomicCustomerNumber($economicCustomer) === $customerNumber
|
|
&& method_exists($customer, 'importCustomerFromEconomicCustomerData')
|
|
) {
|
|
try {
|
|
$importedCustomer = $customer->importCustomerFromEconomicCustomerData($economicCustomer);
|
|
if (is_object($importedCustomer) && method_exists($importedCustomer, 'exists') && $importedCustomer->exists()) {
|
|
return $importedCustomer;
|
|
}
|
|
} catch (Exception $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_SNAPSHOT_BOOTSTRAP_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
]);
|
|
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
|
|
}
|
|
|
|
private function hydrateRegistrationContact(
|
|
users_o $customer,
|
|
int $customerNumber,
|
|
string $contactEmail,
|
|
int $contactPhone,
|
|
int $contactPhoneCountryCode,
|
|
?string $contactName
|
|
): void {
|
|
try {
|
|
$customer->hydrateRegistrationContact(
|
|
$contactEmail,
|
|
$contactPhone,
|
|
$contactPhoneCountryCode,
|
|
$contactName
|
|
);
|
|
} catch (\Throwable $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONTACT_HYDRATION_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function sendRegistrationWelcomeEmails(int $customerNumber, string $invoiceEmail): void
|
|
{
|
|
$email = new email();
|
|
$jimmyEmail = 'jm@truckwash.dk';
|
|
//$infoEmail = "info@truckwash.dk";
|
|
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
|
|
foreach ([$jimmyEmail, $invoiceEmail] as $recipient) {
|
|
try {
|
|
$email->sendWelcomeEmailToCustomer($customerNumber, $recipient);
|
|
} catch (\Throwable $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_WELCOME_EMAIL_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
'recipientHash' => hash('sha256', strtolower(trim($recipient))),
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
try {
|
|
$email->sendNewCustomerRegistrationNotifications($customerNumber);
|
|
} catch (\Throwable $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
|
|
try {
|
|
(new slack())->send_customer_registration_notification($customerNumber);
|
|
} catch (\Throwable $exception) {
|
|
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
|
|
'customerNumber' => $customerNumber,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function logRegisterCvrIssue(string $action, array $context): void
|
|
{
|
|
$message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($message === false) {
|
|
$message = 'Unable to encode register/cvr context';
|
|
}
|
|
|
|
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
|
|
}
|
|
|
|
private function observeLoginFailure(string $principalType, string|int $identifier, array $metadata = []): void
|
|
{
|
|
try {
|
|
(new security_policy_service())->observeLoginFailure($principalType, $identifier, $metadata);
|
|
} catch (\Throwable) {
|
|
// Security observation must not change authentication responses.
|
|
}
|
|
}
|
|
|
|
private function appendRuntimeConfig(array $payload): array
|
|
{
|
|
$payload['runtime_config'] = array_replace_recursive(
|
|
is_array($payload['runtime_config'] ?? null) ? $payload['runtime_config'] : [],
|
|
[
|
|
'economic' => [
|
|
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
|
|
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
|
|
],
|
|
'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()),
|
|
]
|
|
);
|
|
|
|
return $payload;
|
|
}
|
|
}
|