<?php
/**
* @file
* Contriller for use in the app that adds some constantly used helper methods.
*
* @ToDo Add a server side password validation function to test for all the password requirements.
*/
namespace App\Custom;
use App\Custom\ABFWebService;
use Psr\Log\LoggerInterface;
use stdClass;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
/**
* Controller for the app pages
*/
class ABFController extends AbstractController
{
/**
* Logging.
*
* @var LoggerInterface
*/
protected $logger;
/**
* Session.
*
* @var SessionInterface
*/
protected $session;
/**
* @var RequestStack
*/
protected $requestStack;
/**
* Parameters that we want to use immediately.
*/
protected $params;
/**
* Session timeout in seconds
*
* @var int
*/
protected $sessionTimeout;
/**
* Link to API interface.
*
* @var ABFWebService
*/
protected $api;
/**
* Constructor
*
* @param LoggerInterface $logger Access to the logging system.
* @param RequestStack $requestStack Interface to the PHP request.
* @param ParameterBagInterface $params Interface to the parameters of the app.
*/
public function __construct(LoggerInterface $logger, RequestStack $requestStack, ParameterBagInterface $params)
{
$this->logger = $logger;
$this->requestStack = $requestStack;
$this->params = $params;
$this->session = $this->requestStack->getSession();
$this->sessionTimeout = $this->params->get('session.timeout');
$this->api = $this->getWebservice();
}
/**
* Load an instance of the ABFWebService for communication with the API
*
* @return ABFWebService
*/
protected function getWebservice(): ABFWebService
{
return new ABFWebService(
$this->params->get('api.host'),
$this->params->get('api.port'),
$this->params->get('api.user'),
$this->params->get('api.pass'),
$this->params->get('api.queue')
);
}
/**
* Handler for ABFErrorExceptions.
*
* 412 Errors are sent when the API returns OK = 0.
* 418 Errors are sent when an exception happened in the packet and queue process.
*
* @param ABFErrorException $e The thrown exception.
*
* @return string The message to print.
*/
protected function handleABFError(ABFErrorException $e): string
{
$message = '';
// Log the error locally.
$this->logger->error(sprintf('ABF Error: %d - %s', $e->getCode(), $e->getMessage()));
switch ($e->getCode()) {
case 412:
$message = sprintf("The system could not handle your request at this time. (%s)", $e->getMessage());
break;
case 418:
$message = "The connection to the system was interrupted. Please try your request again";
break;
default:
$message = "An error has occurred in the background. Please try your request again.";
}
return $message;
}
/**
* Sets the session timer.
*
*/
protected function setSessionTimer(): void
{
$this->session->set('t', time());
}
/**
* Check to see if session is still valid for the time peroid.
* Session needs to be cleared at 20min.
*
* @return bool
*/
protected function isSessionValid(): bool
{
// Test if session exists.
if (!$this->session) {
$this->session->start();
return false;
}
$startTime = (int) $this->session->get('t', 0);
// If the app session timeout is 5min or less just use that.
// Otherwise use 80% of the session timeout value.
// The Goal is to have the app timer run out before the api timer for the session.
$period = (600 >= $this->sessionTimeout ? $this->sessionTimeout : (int) $this->sessionTimeout * 0.8);
$now = time();
$this->logger->info(sprintf('Starttime: %d, Period: %d, Now: %d', $startTime, $period, $now));
$this->logger->info(sprintf("Start + period is after now? %s", (($startTime + $period) > $now)));
return ($startTime + $period) > $now;
}
/**
* Create a stable one way encryption of a password.
*
* @param string $password
* The password string to encrypt
*
* @return string
* The encrypted password.
*/
protected function encryptPassword(string $password): string
{
// Use the app secret as the salt.
$salt = $this->params->get('app.secret');
$this->logger->info("Salt: " . $salt);
return crypt(trim($password), $salt);
}
/**
* Encrypt the provided string.
*
* @param string $term
* The string to be encrypted.
*
* @return string
* The encrypted string.
*/
protected function encrypt(string $term): string
{
// Use the app secret as the key.
$key = $this->params->get('app.secret');
// Generate the initialization vector.
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
// Encrypt the provided string.
$encryptedString = openssl_encrypt($term, 'aes-256-cbc', $key, 0, $iv);
// Attach the initialization vector to the encrypted_string
// and base64 endcode the lot.
// phpcs:ignore
return base64_encode($encryptedString . '||' . $iv);
}
/**
* Decrypt the provided string using the provided key.
*
* @param string|null $encryptedString
* The string to decrypt.
*
* @return string
* The original plaintext string.
*/
protected function decrypt(string $encryptedString = null): string
{
if (0 === strlen($encryptedString)) {
// need to have something to return
return '';
}
// Use the app secret as the key.
$key = $this->params->get('app.secret');
// Decode and split apart the encrypted string to get what we want to
// decrypt and the initialization vector.
list($encryptedText, $iv) = explode('||', \base64_decode($encryptedString), 2);
// Perform the final decryption and return th plaintext string.
return openssl_decrypt($encryptedText, 'aes-256-cbc', $key, 0, $iv);
}
/**
* Returns an array of the values for the given key from each of the objects submitted.
*
* @param array $objectArray The array of objects to search.
* @param array $keys The array of keys to use in the search.
*
* @return array Array of dictionaries to return.
*/
protected function getItemsFromObject(array $objectArray, array $keys): array
{
$out = [];
//$this->logger->info('the original object');
//$this->logger->info(print_r($objectArray, true));
foreach ($objectArray as $obj) {
$keysArePresent = true;
foreach ($keys as $key) {
if (!array_key_exists($key, $obj)) {
$keysArePresent = false;
}
}
if ($keysArePresent) {
$thisItem = [];
foreach ($keys as $key) {
$thisItem[$key] = $obj[$key];
}
$out[] = $thisItem;
}
}
//$this->logger->info('the resulting object.');
//$this->logger->info(print_r($out, true));
return $out;
}
/**
* Returns the first and last days of the current month
*
* @param DateTime|null $date Optional date in the month.
*
* @return array Array of dates [firstOfMonth, lastOfMonth].
*/
protected function getMonthStartAndEnd(\DateTime $date = null): array
{
if (!$date) {
$date = new \DateTime();
}
return [
// phpcs:ignore -- Concatenation operator spacing.
$date->format('Y-m') . '-01',
$date->format('Y-m-t'),
];
}
/**
* Takes two dates and returns them oldest first.
*
* @param string $date1 First date.
* @param string $date2 Second date.
*
* @return array Sorted dates.
*/
protected function putDatesInCorrectOrder(string $date1, string $date2): array
{
try {
// Cast the date strings to DateTime.
$d1 = new \DateTime($date1);
$d2 = new \DateTime($date2);
if ($d1 > $d2) {
return [$date2, $date1];
}
} catch (\Exception $e) {
$this->logger->warning(sprintf('Error putting dates in correct order: %s', $e->getMessage()));
}
return [$date1, $date2];
}
/**
* Make sure a given date is not in the past.
* Test a date to make sure it is at least today. If not return today.
*
* @param DateTime $date The date to test.
*
* @return DateTime
*/
protected function dateFloor(\DateTime $date): \DateTime
{
$today = new \DateTime();
// Set the time of the date to the same thing for both.
$today->setTime(0, 0, 0, 0);
$date->setTime(0, 0, 0, 0);
if ($today <= $date) {
return $date;
}
return $today;
}
/**
* Get 18 months ago. Used to limit how far back to get transactions.
*
* @param bool $dateAsString Return the date as a string. Default false.
*
* @return DateTime The first day of the month 18 months prior.
*/
protected function getTwentyFourMonthsAgo(bool $dateAsString = false): \DateTime
{
// Now
$d = new \DateTime();
// Set date to first of the month.
$d->setDate($d->format('Y'), $d->format('m'), 1);
// Subtract 18 months
$d->sub(new \DateInterval('P24M'));
return $dateAsString ? $d->format('Y-m-d') : $d;
}
/**
* Return the subaccount nickname and account as a string.
*
* @param string $account The main account string.
* @param string $subaccount The subaccount to get the nickname for.
* @param string $clientToken The logged in users's token.
* @param string $clientIp The client IP address.
* Default 0.0.0.0
*
* @return string The formatted nickname string.
*/
protected function getAccountNameString(string $account, string $subaccount, string $clientToken, string $clientIp = '0.0.0.0.'): string
{
try {
// Update the session timer
$this->setSessionTimer();
$subaccounts = $this->api->abfGetSubaccounts(
$account,
$clientToken,
$clientIp
);
foreach ($subaccounts as $sub) {
if ($subaccount === $sub['account'] && strlen($sub['nickname'])) {
return sprintf('%s (%s)', $sub['nickname'], $sub['account']);
}
}
} catch (ABFTimeoutException $e) {
$this->logger->warning(sprintf('getAccountNameString: %s', $e->getMessage()));
} catch (ABFErrorException $e) {
$this->logger->warning(sprintf('getAccountNameString: %s', $e->getMessage()));
} catch (\Exception $e) {
$this->logger->warning(sprintf('getAccountNameString: %s', $e->getMessage()));
}
return $subaccount;
}
/**
* Return an array of subaccounts and their balances in cents and dollars.
*
* @param string $account The main account string.
* @param array $subaccounts An array of sub account names to return balances for.
* @param string $clientToken The logged in user's token.
* @param string $clientIp The client's IP address.
* Default = 0.0.0.0
*
* @return array An array with the subaccount names as keys with values array of the balance in dollars and cents.
*/
protected function getSubaccountBalances(string $account, array $subaccounts, string $clientToken, string $clientIp = '0.0.0.0'): array
{
$out = [];
// Preload return array with a reasonable amount.
foreach ($subaccounts as $sa) {
$out[$sa] = [
'dollars' => 10000.00,
'cents' => 1000000,
];
}
try {
// Update the session timer
$this->setSessionTimer();
$result = $this->api->abfGetSubaccounts(
$account,
$clientToken,
$clientIp
);
foreach ($result as $sa) {
$thisSubAccount = $sa['account'];
if (array_key_exists($thisSubAccount, $out)) {
$out[$thisSubAccount]['cents'] = $sa['balance'];
$out[$thisSubAccount]['dollars'] = $sa['balance'] / 100;
}
}
} catch (ABFTimeoutException $e) {
$this->logger->warning(sprintf('getSubaccountBalances Timeout Exception: %s', $e->getMessage()));
} catch (ABFErrorException $e) {
$this->logger->warning(sprintf('getSubaccountBalances Timeout Exception: %s', $this->handleABFError($e)));
} catch (\Exception $e) {
$this->logger->warning(sprintf('getSubaccountBalances Timeout Exception: %s', $e->getMessage()));
}
return $out;
}
/**
* Ping system to send the verification code.
*
* @param string $email The email address of the account manager.
* @param string $adminToken The API admin token.
* @param string $clientIp The client's IP address.
* Default = 0.0.0.0
* @param string|null $phone The phone number of the account manager. Default to null.
* @param bool $resend If this is a resend of the code. Default false.
*
* @return array the type and code of the flash message to present.
*/
protected function sendVerificationCode(string $email, string $adminToken, string $clientIp = '0.0.0.0', string $phone = null, bool $resend = false): array
{
$type = '';
$message = '';
try {
// Update the session timer
$this->setSessionTimer();
if ($phone) {
// Send code.
$sentLink = $this->api->sendSmsVerification(
$phone,
$email,
$adminToken,
$clientIp
);
if (!$sentLink) {
throw new \Exception('Something went wrong sending the verification code to your device. Please try again later.');
}
// Add success message.
$message = $resend
? "The validation code has been re-sent to your device. Please enter this code in the form below. It is valid for one hour."
: "A validation code has been sent to your device. Please enter this code in the form below. It is valid for one hour.";
$type = 'success';
} else {
// Send code.
$sentLink = $this->api->sendEmailVerification(
$email,
$adminToken,
$clientIp
);
if (!$sentLink) {
throw new \Exception('Something went wrong sending the verification code to your email. Please try again later.');
}
// Add success message.
$message = $resend
? "The validation code has been re-sent to your registered email address. Please enter this code in the form below. It is valid for one hour."
: "A validation code has been sent to your registered email address. Please enter this code in the form below. It is valid for one hour.";
$type = 'success';
}
} catch (ABFTimeoutException $e) {
$type = 'danger';
$message = $e->getMessage();
} catch (ABFErrorException $e) {
$type = 'danger';
$message = $this->handleABFError($e);
} catch (\Exception $e) {
$type = 'danger';
$message = $e->getMessage();
}
return [$type, $message];
}
/**
* Returns html describing the password requirements
*
* @return string
*/
protected function getPasswordRequirementText(): string
{
$constraints = $this->getPasswordConstraints();
$specialCharacterList = implode(', ', $this->getCharacters('special'));
$html = '
<div class="password-text">
<p>Valid passwords meet the following criteria:</p>
<ul>
';
$html .= sprintf('<li>Are at least %d characters long.</li>', $constraints->minLength);
$html .= $constraints->requireLowercase ? '<li>Contain at least one lowercase letter.</li>' : '';
$html .= $constraints->requireUppercase ? '<li>Contain at least one uppercase (capital) letter.</li>' : '';
$html .= $constraints->requireNumber ? '<li>Contain at least one number (0 - 9).</li>' : '';
$html .= $constraints->requireSpecialChar ? sprintf('<li>Contain at least one special character (%s).</li>', $specialCharacterList) : '';
$html .= '
</ul>
</div>';
return $html;
}
/**
* Tests to see if a given password meets the system password standards.
*
* @param string $password The password string to validate.
*
* @return array Array containing [bool, string]: Valid state, Error string.
*/
protected function passwordIsValid(string $password): array
{
$constraints = $this->getPasswordConstraints();
try {
// Verify length of password.
if ($constraints->minLength > strlen($password)) {
throw new \Exception(sprintf('The password must be at least %d characters long.', $constraints->minLength));
}
// Verify Lowercase char is present
if ($constraints->requireLowercase) {
if (!$this->stringContains($password, $this->getCharacters('lowercase'))) {
throw new \Exception('The password must contain at least one lowercase letter.');
}
}
// Verify Uppercase char is present
if ($constraints->requireUppercase) {
if (!$this->stringContains($password, $this->getCharacters('uppercase'))) {
throw new \Exception('The password must contain at least one uppercase letter.');
}
}
// Verify number is present
if ($constraints->requireNumber) {
if (!$this->stringContains($password, $this->getCharacters('numbers'))) {
throw new \Exception('The password must contain at least one number');
}
}
// Verify Lowercase char is present
if ($constraints->requireSpecialChar) {
if (!$this->stringContains($password, $this->getCharacters('special'))) {
throw new \Exception('The password must contain at least one special character.');
}
}
return [true, ''];
} catch (\Exception $e) {
return [false, $e->getMessage()];
}
}
/**
* Return an object with all the password constraints
*
* @return stdClass
*/
protected function getPasswordConstraints(): stdClass
{
$obj = new stdClass();
$obj->minLength = (int) $this->params->get('password.min_length');
$obj->requireLowercase = (bool) $this->params->get('password.require_lowercase');
$obj->requireUppercase = (bool) $this->params->get('password.require_uppercase');
$obj->requireNumber = (bool) $this->params->get('password.require_number');
$obj->requireSpecialChar = (bool) $this->params->get('password.require_special_character');
return $obj;
}
/**
* Testing function to see if at least one character in the string is contained in the provided array.
*
* @param string $string The string to test.
* @param array $characterArray The array of characters to test against.
*
* @return bool If the string contains at least one item from the array.
*/
private function stringContains(string $string, array $characterArray): bool
{
for ($i = 0; $i < strlen($string); $i++) {
if (in_array(substr($string, $i, 1), $characterArray)) {
return true;
}
}
return false;
}
/**
* Returns array of characters used in password tests.
*
* @param string $set The array set to return.
*
* @return array
*/
private function getCharacters(string $set): array
{
$out = [];
switch ($set) {
case 'uppercase':
$out = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
break;
case 'numbers':
$out = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
break;
case 'special':
$out = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '.', ',', '-', '_', '+', '=', ':', ';'];
break;
default:
$out = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
}
return $out;
}
}