Implement account edit; add SessionHelper, PasswordHelper and ActionResult

This commit is contained in:
Lexi / Zoe 2021-08-15 17:40:35 +02:00
parent 32add30c9d
commit 6ace072841
Signed by: binaryDiv
GPG key ID: F8D4956E224DA232
15 changed files with 637 additions and 68 deletions

View file

@ -3,9 +3,11 @@ declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Accounts;
use MailAccountAdmin\Common\ActionResult;
use MailAccountAdmin\Common\SessionHelper;
use MailAccountAdmin\Common\UserHelper;
use MailAccountAdmin\Exceptions\InputValidationError;
use MailAccountAdmin\Frontend\BaseController;
use MailAccountAdmin\Models\Account;
use MailAccountAdmin\Repositories\AccountRepository;
use MailAccountAdmin\Repositories\AliasRepository;
use Psr\Http\Message\ResponseInterface as Response;
@ -14,14 +16,17 @@ use Slim\Views\Twig;
class AccountController extends BaseController
{
/** @var AccountHandler */
private $accountHandler;
/** @var AccountRepository */
private $accountRepository;
/** @var AliasRepository */
private $aliasRepository;
public function __construct(Twig $view, UserHelper $userHelper, AccountRepository $accountRepository, AliasRepository $aliasRepository)
public function __construct(Twig $view, SessionHelper $sessionHelper, UserHelper $userHelper, AccountHandler $accountHandler, AccountRepository $accountRepository, AliasRepository $aliasRepository)
{
parent::__construct($view, $userHelper);
parent::__construct($view, $sessionHelper, $userHelper);
$this->accountHandler = $accountHandler;
$this->accountRepository = $accountRepository;
$this->aliasRepository = $aliasRepository;
}
@ -33,13 +38,9 @@ class AccountController extends BaseController
{
// Parse query parameters for filters
$queryParams = $request->getQueryParams();
$filterByDomain = $queryParams['domain'] ?? null;
$renderData = [
'filterDomain' => $filterByDomain,
'accountList' => $this->accountRepository->fetchAccountList($filterByDomain),
];
$filterByDomain = $queryParams['domain'] ?? '';
$renderData = $this->accountHandler->listAccounts($filterByDomain);
return $this->view->render($response, 'accounts.html.twig', $renderData);
}
@ -51,28 +52,7 @@ class AccountController extends BaseController
// Parse URL arguments
$accountId = (int)$args['id'];
// Get account data from database
$account = $this->accountRepository->fetchAccountById($accountId);
// Don't display the password hash, but at least the type of hash (used hash algorithm)
if ($account->getPasswordHash() === '') {
$passwordHashType = 'empty';
} else {
$passwordHashInfo = password_get_info($account->getPasswordHash());
$passwordHashType = $passwordHashInfo['algoName'] ?? 'unknown';
}
// Get list of aliases for this account
$aliases = $this->aliasRepository->fetchAliasesForUserId($accountId);
$renderData = [
'id' => $accountId,
'accountUsername' => $account->getUsername(),
'account' => $account,
'passwordHashType' => $passwordHashType,
'aliases' => $aliases,
];
$renderData = $this->accountHandler->getAccountDetails($accountId);
return $this->view->render($response, 'account_details.html.twig', $renderData);
}
@ -81,6 +61,7 @@ class AccountController extends BaseController
public function showAccountCreate(Request $request, Response $response): Response
{
// TODO: just a placeholder
return $this->showAccounts($request, $response);
}
@ -95,28 +76,52 @@ class AccountController extends BaseController
// Get account data from database
$account = $this->accountRepository->fetchAccountById($accountId);
// Render page
return $this->renderEditPage($response, $account);
}
public function editAccount(Request $request, Response $response, array $args): Response
{
// TODO: just a placeholder
$this->view->getEnvironment()->addGlobal('error', 'Not implemented yet!');
return $this->showAccountEdit($request, $response, $args);
}
private function renderEditPage(Response $response, Account $account, array $extraRenderData = []): Response
{
$renderData = [
'id' => $account->getId(),
'accountUsername' => $account->getUsername(),
'account' => $account,
];
return $this->view->render($response, 'account_edit.html.twig', array_merge($renderData, $extraRenderData));
$lastActionResult = $this->sessionHelper->getLastActionResult();
if ($lastActionResult !== null) {
$resultData = $lastActionResult->isSuccess()
? ['success' => $lastActionResult->getMessage()]
: ['error' => $lastActionResult->getMessage()];
$resultData['editData'] = $lastActionResult->getInputData();
$renderData = array_merge($renderData, $resultData);
}
return $this->view->render($response, 'account_edit.html.twig', $renderData);
}
public function editAccount(Request $request, Response $response, array $args): Response
{
// Parse URL arguments
$accountId = (int)$args['id'];
// Parse form data
$editData = $request->getParsedBody();
$errorMessage = null;
try {
// Validate input
$validatedEditData = AccountEditData::createFromArray($editData);
$this->accountHandler->editAccountData($accountId, $validatedEditData);
} catch (InputValidationError $e) {
$errorMessage = $e->getMessage();
}
if (empty($errorMessage)) {
$this->sessionHelper->setLastActionResult(ActionResult::createSuccessResult('Account data was saved.'));
} else {
$this->sessionHelper->setLastActionResult(ActionResult::createErrorResult($errorMessage, $editData));
}
// Redirect to edit form page via GET (PRG)
return $response->withHeader('Location', '/accounts/' . $accountId . '/edit')->withStatus(303);
}
// -- /accounts/{id}/delete - Delete account
public function showAccountDelete(Request $request, Response $response, array $args): Response

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Accounts;
use MailAccountAdmin\Exceptions\InputValidationError;
class AccountEditData
{
/** @var null|string */
private $username;
/** @var bool */
private $usernameCreateAlias;
/** @var bool */
private $usernameReplaceAlias;
/** @var null|string */
private $password;
/** @var bool */
private $active;
/** @var null|string */
private $homeDir;
/** @var null|string */
private $memo;
private function __construct(?string $username, bool $usernameCreateAlias, bool $usernameReplaceAlias, ?string $password, bool $active,
?string $homeDir, ?string $memo)
{
$this->username = $username;
$this->usernameCreateAlias = $usernameCreateAlias;
$this->usernameReplaceAlias = $usernameReplaceAlias;
$this->password = $password;
$this->active = $active;
$this->homeDir = $homeDir;
$this->memo = $memo;
}
public static function createFromArray($raw): self
{
return new self(
self::validateUsername(trim($raw['username'] ?? '')),
self::validateBoolOption(trim($raw['username_create_alias'] ?? '')),
self::validateBoolOption(trim($raw['username_replace_alias'] ?? '')),
self::validatePassword(trim($raw['password'] ?? ''), trim($raw['password_repeat'] ?? '')),
self::validateBoolOption(trim($raw['is_active'] ?? '')),
self::validateHomeDir(trim($raw['home_dir'] ?? '')),
trim($raw['memo'] ?? '')
);
}
// Input validation
private static function validateUsername(string $username): ?string
{
if ($username === '') {
return null;
}
if (strlen($username) > 100) {
throw new InputValidationError('Username is too long.');
}
$username = strtolower($username);
if (!preg_match('/^[a-z0-9._+-]+@[a-z0-9.-]+$/', $username) || preg_match('/^\\.|\\.\\.|\\.@|@\\.|\\.$/', $username)) {
throw new InputValidationError('Username is not valid (must be a valid mail address).');
}
return $username;
}
private static function validatePassword(string $password, string $passwordRepeat): ?string
{
if ($password === '') {
return null;
}
if ($password !== $passwordRepeat) {
throw new InputValidationError('Passwords do not match.');
}
return $password;
}
private static function validateHomeDir(string $homeDir): ?string
{
if ($homeDir === '') {
return null;
}
if (strlen($homeDir) > 100) {
throw new InputValidationError('Home directory is too long.');
}
$homeDir = trim($homeDir, '/');
if (!preg_match('!^[a-z0-9._+-]+(/[a-z0-9._+-]+)*$!i', $homeDir)) {
throw new InputValidationError('Home directory is not a valid path.');
}
return $homeDir;
}
private static function validateBoolOption(string $raw): bool
{
return $raw !== '';
}
// Getters
public function getUsername(): ?string
{
return $this->username;
}
public function getUsernameCreateAlias(): bool
{
return $this->usernameCreateAlias;
}
public function getUsernameReplaceAlias(): bool
{
return $this->usernameReplaceAlias;
}
public function getPassword(): ?string
{
return $this->password;
}
public function getActive(): bool
{
return $this->active;
}
public function getHomeDir(): ?string
{
return $this->homeDir;
}
public function getMemo(): string
{
return $this->memo;
}
}

View file

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Accounts;
use MailAccountAdmin\Common\PasswordHelper;
use MailAccountAdmin\Exceptions\AccountNotFoundException;
use MailAccountAdmin\Exceptions\InputValidationError;
use MailAccountAdmin\Repositories\AccountRepository;
use MailAccountAdmin\Repositories\AliasRepository;
class AccountHandler
{
/** @var AccountRepository */
private $accountRepository;
/** @var AliasRepository */
private $aliasRepository;
/** @var PasswordHelper */
private $passwordHelper;
public function __construct(AccountRepository $accountRepository, AliasRepository $aliasRepository, PasswordHelper $passwordHelper)
{
$this->accountRepository = $accountRepository;
$this->aliasRepository = $aliasRepository;
$this->passwordHelper = $passwordHelper;
}
// -- /accounts - List all accounts
public function listAccounts(string $filterByDomain): array
{
$accountList = $this->accountRepository->fetchAccountList($filterByDomain);
return [
'filterDomain' => $filterByDomain,
'accountList' => $accountList,
];
}
// -- /accounts/{id} - Show account details
public function getAccountDetails(int $accountId): array
{
// Get account data from database
$account = $this->accountRepository->fetchAccountById($accountId);
// Don't display the password hash, but at least the type of hash (used hash algorithm)
$passwordHashType = $this->passwordHelper->getPasswordHashType($account->getPasswordHash());
// Get list of aliases for this account
$aliases = $this->aliasRepository->fetchAliasesForUserId($accountId);
return [
'id' => $accountId,
'accountUsername' => $account->getUsername(),
'account' => $account,
'passwordHashType' => $passwordHashType,
'aliases' => $aliases,
];
}
// -- /accounts/{id}/edit - Edit account data
public function editAccountData(int $accountId, AccountEditData $editData): void
{
// Check if account exists
try {
$account = $this->accountRepository->fetchAccountById($accountId);
} catch (AccountNotFoundException $e) {
throw new InputValidationError('Account with ID ' . $accountId . ' does not exist!');
}
// TODO: Use database transactions (beginTransaction/commit/rollBack in BaseRepository maybe?)
$newUsername = $editData->getUsername();
if ($newUsername === $account->getUsername()) {
// Username is unchanged
$newUsername = null;
}
// TODO: This feature is not supported yet. If the alias exists, the availability check (next) would fail anyway.
if ($editData->getUsernameReplaceAlias()) {
throw new InputValidationError('Replace alias: Not implemented yet.');
}
// Check if new username is still available
if ($newUsername !== null) {
if (!$this->accountRepository->checkUsernameAvailable($newUsername) || !$this->aliasRepository->checkAliasAvailable($newUsername)) {
throw new InputValidationError("Username \"$newUsername\" is not available.");
}
}
// Create alias for old username (if wanted)
if ($editData->getUsernameCreateAlias()) {
$oldUsername = $account->getUsername();
if (!$this->aliasRepository->checkAliasAvailable($oldUsername)) {
throw new InputValidationError("Alias \"$oldUsername\" cannot be created: Alias already exists.");
}
$this->aliasRepository->createNewAlias($accountId, $oldUsername);
}
// Hash new password
$newPasswordHash = null;
if ($editData->getPassword() !== null) {
$newPasswordHash = $this->passwordHelper->hashPassword($editData->getPassword());
}
// Update account in database
$this->accountRepository->updateAccountWithId(
$accountId,
$newUsername,
$newPasswordHash,
$editData->getActive(),
$editData->getHomeDir(),
$editData->getMemo()
);
// Remove existing alias for new username (if wanted)
// TODO: See above. This is the point where the alias should be deleted.
// if ($editData->getUsernameReplaceAlias()) {
// throw new InputValidationError('Replace alias: Not implemented yet.');
// }
}
}

View file

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace MailAccountAdmin\Frontend;
use MailAccountAdmin\Common\SessionHelper;
use MailAccountAdmin\Common\UserHelper;
use Slim\Views\Twig;
@ -10,12 +11,15 @@ class BaseController
{
/** @var Twig */
protected $view;
/** @var SessionHelper */
protected $sessionHelper;
/** @var UserHelper */
protected $userHelper;
public function __construct(Twig $view, UserHelper $userHelper)
public function __construct(Twig $view, SessionHelper $sessionHelper, UserHelper $userHelper)
{
$this->view = $view;
$this->sessionHelper = $sessionHelper;
$this->userHelper = $userHelper;
// Register globals

View file

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Domains;
use MailAccountAdmin\Common\SessionHelper;
use MailAccountAdmin\Common\UserHelper;
use MailAccountAdmin\Frontend\BaseController;
use MailAccountAdmin\Repositories\DomainRepository;
@ -15,9 +16,9 @@ class DomainController extends BaseController
/** @var DomainRepository */
private $domainRepository;
public function __construct(Twig $view, UserHelper $userHelper, DomainRepository $domainRepository)
public function __construct(Twig $view, SessionHelper $sessionHelper, UserHelper $userHelper, DomainRepository $domainRepository)
{
parent::__construct($view, $userHelper);
parent::__construct($view, $sessionHelper, $userHelper);
$this->domainRepository = $domainRepository;
}

View file

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Login;
use MailAccountAdmin\Common\PasswordHelper;
use MailAccountAdmin\Common\SessionHelper;
use MailAccountAdmin\Common\UserHelper;
use MailAccountAdmin\Exceptions\AdminUserNotFoundException;
use MailAccountAdmin\Frontend\BaseController;
@ -15,11 +17,15 @@ class LoginController extends BaseController
{
/** @var AdminUserRepository */
private $adminUserRepository;
/** @var PasswordHelper */
private $passwordHelper;
public function __construct(Twig $view, UserHelper $userHelper, AdminUserRepository $adminUserRepository)
public function __construct(Twig $view, SessionHelper $sessionHelper, UserHelper $userHelper, AdminUserRepository $adminUserRepository,
PasswordHelper $passwordHelper)
{
parent::__construct($view, $userHelper);
parent::__construct($view, $sessionHelper, $userHelper);
$this->adminUserRepository = $adminUserRepository;
$this->passwordHelper = $passwordHelper;
}
private function renderLoginPage(Response $response, array $renderData = []): Response
@ -54,19 +60,18 @@ class LoginController extends BaseController
try {
$user = $this->adminUserRepository->getUserByName($loginUsername);
}
catch (AdminUserNotFoundException $e) {
} catch (AdminUserNotFoundException $e) {
$user = null;
}
if ($user === null || !password_verify($loginPassword, $user->getPasswordHash())) {
if ($user === null || !$this->passwordHelper->verifyPassword($loginPassword, $user->getPasswordHash())) {
return $this->renderLoginPage($response, ['error' => 'Wrong username or password!']);
} elseif (!$user->isActive()) {
return $this->renderLoginPage($response, ['error' => 'User is inactive!']);
}
// Set login session
$_SESSION['user_id'] = $user->getId();
$this->sessionHelper->setUserId($user->getId());
return $response
->withHeader('Location', '/')
->withStatus(303);