Compare commits

...

3 Commits

23 changed files with 628 additions and 60 deletions

View File

@ -1,7 +1,23 @@
# .env.development: Environment variables for local development # .env.development: Environment variables for local development
# Set composer cache directory # composer: Set cache directory
COMPOSER_CACHE_DIR=./.composer COMPOSER_CACHE_DIR=./.composer
# Disable Twig cache # MariaDB container
MYSQL_RANDOM_ROOT_PASSWORD=yes
MYSQL_DATABASE=mailusers
MYSQL_USER=mailaccountadmin
MYSQL_PASSWORD=mailaccountadmin
# App settings
APP_DEBUG=true
# - Disable Twig cache
TWIG_CACHE_DIR= TWIG_CACHE_DIR=
TWIG_STRICT=true
# - Database credentials
DB_HOST=db
DB_DATABASE=mailusers
DB_USER=mailaccountadmin
DB_PASSWORD=mailaccountadmin

View File

@ -12,3 +12,21 @@ services:
volumes: volumes:
- ./:/var/www/ - ./:/var/www/
user: ${DOCKER_UID} user: ${DOCKER_UID}
db:
image: mariadb
env_file:
- .env.develop
ports:
- 13306:3306
volumes:
- db_data:/var/lib/mysql
- ./sql/init_tables.sql:/docker-entrypoint-initdb.d/init_tables.sql:ro
adminer:
image: adminer
ports:
- 8099:8080
volumes:
db_data:

View File

@ -9,11 +9,13 @@ use MailAccountAdmin\Routes;
use MailAccountAdmin\Settings; use MailAccountAdmin\Settings;
use Slim\Factory\AppFactory; use Slim\Factory\AppFactory;
session_start();
$settings = new Settings(); $settings = new Settings();
$container = Dependencies::createContainer($settings); $container = Dependencies::createContainer($settings);
$app = AppFactory::createFromContainer($container); $app = AppFactory::createFromContainer($container);
Middlewares::setMiddlewares($app); Middlewares::setMiddlewares($app, $settings);
Routes::setRoutes($app); Routes::setRoutes($app);
$app->run(); $app->run();

50
public/static/style.css Normal file
View File

@ -0,0 +1,50 @@
html, body {
max-width: 100%;
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
}
/* --- Header --- */
header {
margin: 1em;
}
header h1 {
margin: 1em;
}
/* --- Login page --- */
main.login_page {
margin: 2em;
padding: 1em;
border: 1px gray solid;
width: auto;
}
main.login_page h2 {
margin: 0 0 0.5em 0;
}
main.login_page table td {
padding: 0.2em;
}
/* --- Text and other styling --- */
.error {
background: #ff4444;
width: 30em;
margin: 1em 0;
padding: 1em;
}
button {
padding: 0.2em 1em;
}

50
sql/init_tables.sql Normal file
View File

@ -0,0 +1,50 @@
-- Create tables
SET NAMES utf8mb4;
-- TODO create on prod
CREATE TABLE `admin_users`
(
`admin_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`is_active` tinyint(3) unsigned NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT NOW(),
`modified_at` datetime NOT NULL DEFAULT NOW(),
PRIMARY KEY (`admin_id`),
UNIQUE KEY `username` (`username`)
) DEFAULT CHARSET = utf8mb4;
-- Create initial admin user for development (password is 'admin')
INSERT INTO `admin_users`
(`username`, `password`, `is_active`, `created_at`, `modified_at`)
VALUES ('admin', '$2y$10$zaNOBUk4PBlhDZD40h35CeyUxiqixi9LTrxlAxnrckXd95hcCctl6', '1', NOW(), NOW());
-- TODO rename on prod `users` -> `mail_users`
CREATE TABLE `mail_users`
(
`user_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`is_active` tinyint(3) unsigned NOT NULL DEFAULT 1,
`home_dir` varchar(255) NOT NULL,
`memo` text DEFAULT NULL,
`created_at` datetime NOT NULL DEFAULT NOW(),
`modified_at` datetime NOT NULL DEFAULT NOW(),
PRIMARY KEY (`user_id`),
UNIQUE KEY `username` (`username`)
) DEFAULT CHARSET = utf8mb4;
CREATE TABLE `mail_aliases`
(
`alias_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL,
`mail_address` varchar(255) NOT NULL,
`created_at` datetime NOT NULL DEFAULT NOW(),
`modified_at` datetime NOT NULL DEFAULT NOW(),
PRIMARY KEY (`alias_id`),
UNIQUE KEY `mail_address` (`mail_address`),
KEY `user_id` (`user_id`),
-- TODO rename on prod `users` -> `mail_users`
CONSTRAINT `mail_aliases_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `mail_users` (`user_id`)
) DEFAULT CHARSET = utf8mb4;

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Auth;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
class AuthMiddleware implements MiddlewareInterface
{
/**
* {@inheritdoc}
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$uri = $request->getUri();
// TODO: Lots of stuff. Session middleware, auth handler class, etc...
if ($uri->getPath() !== '/login') {
// Check authorization via session
// TODO username or user ID?
if (empty($_SESSION['username'])) {
// Not logged in -> Redirect to /login
$response = new Response();
return $response
->withHeader('Location', '/login')
->withStatus(303);
}
}
return $handler->handle($request);
}
}

33
src/Common/UserHelper.php Normal file
View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Common;
use http\Exception\RuntimeException;
use MailAccountAdmin\Models\AdminUser;
use MailAccountAdmin\Repositories\AdminUserRepository;
class UserHelper
{
/** @var AdminUserRepository */
private $adminUserRepository;
public function __construct(AdminUserRepository $adminUserRepository)
{
$this->adminUserRepository = $adminUserRepository;
}
public function isLoggedIn(): bool
{
return !empty($_SESSION['username']);
}
public function getCurrentUser(): AdminUser
{
$username = $_SESSION['username'] ?? null;
if (empty($username)) {
throw new RuntimeException('Not logged in!');
}
return $this->adminUserRepository->getUserByName($username);
}
}

View File

@ -4,15 +4,20 @@ declare(strict_types=1);
namespace MailAccountAdmin; namespace MailAccountAdmin;
use DI\Container; use DI\Container;
use MailAccountAdmin\Login\LoginController; use MailAccountAdmin\Common\UserHelper;
use MailAccountAdmin\Frontend\Login\LoginController;
use MailAccountAdmin\Frontend\Dashboard\DashboardController;
use MailAccountAdmin\Repositories\AdminUserRepository;
use PDO;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
use Slim\Views\Twig; use Slim\Views\Twig;
class Dependencies class Dependencies
{ {
private const SETTINGS = 'settings'; public const SETTINGS = 'settings';
private const TWIG = 'view'; public const TWIG = 'view';
private const TWIG_TEMPLATE_DIR = __DIR__ . '/../templates'; private const TWIG_TEMPLATE_DIR = __DIR__ . '/../templates';
public const DATABASE = 'database';
public static function createContainer(Settings $settings): Container public static function createContainer(Settings $settings): Container
{ {
@ -29,10 +34,50 @@ class Dependencies
return Twig::create(self::TWIG_TEMPLATE_DIR, $settings->getTwigSettings()); return Twig::create(self::TWIG_TEMPLATE_DIR, $settings->getTwigSettings());
}); });
// Login, registration, authentication // Database connection
$container->set(self::DATABASE, function (ContainerInterface $c) {
/** @var Settings $settings */
$settings = $c->get(self::SETTINGS);
$dbSettings = $settings->getDatabaseSettings();
return new PDO(
"mysql:dbname={$dbSettings['dbname']};host={$dbSettings['host']};port={$dbSettings['port']}",
$dbSettings['username'],
$dbSettings['password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
],
);
});
// Repositories
$container->set(AdminUserRepository::class, function (ContainerInterface $c) {
return new AdminUserRepository(
$c->get(self::DATABASE),
);
});
// Helper classes
$container->set(UserHelper::class, function (ContainerInterface $c) {
return new UserHelper(
$c->get(AdminUserRepository::class),
);
});
// Login page
$container->set(LoginController::class, function (ContainerInterface $c) { $container->set(LoginController::class, function (ContainerInterface $c) {
return new LoginController( return new LoginController(
$c->get(self::TWIG) $c->get(self::TWIG),
$c->get(UserHelper::class),
$c->get(AdminUserRepository::class),
);
});
// Dashboard
$container->set(DashboardController::class, function (ContainerInterface $c) {
return new DashboardController(
$c->get(self::TWIG),
$c->get(UserHelper::class),
); );
}); });

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Exceptions;
class AdminUserNotFoundException extends AppException
{
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Exceptions;
use RuntimeException;
class AppException extends RuntimeException
{
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Frontend;
use MailAccountAdmin\Common\UserHelper;
use Slim\Views\Twig;
class BaseController
{
/** @var Twig */
protected $view;
/** @var UserHelper */
protected $userHelper;
public function __construct(Twig $view, UserHelper $userHelper)
{
$this->view = $view;
$this->userHelper = $userHelper;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Dashboard;
use MailAccountAdmin\Frontend\BaseController;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class DashboardController extends BaseController
{
public function showDashboard(Request $request, Response $response): Response
{
$currentUser = $this->userHelper->getCurrentUser();
$renderData = [
'username' => $currentUser->getUsername(),
'user' => $currentUser,
];
return $this->view->render($response, 'dashboard.html.twig', $renderData);
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Frontend\Login;
use MailAccountAdmin\Common\UserHelper;
use MailAccountAdmin\Exceptions\AdminUserNotFoundException;
use MailAccountAdmin\Frontend\BaseController;
use MailAccountAdmin\Repositories\AdminUserRepository;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class LoginController extends BaseController
{
/** @var AdminUserRepository */
private $adminUserRepository;
public function __construct(Twig $view, UserHelper $userHelper, AdminUserRepository $adminUserRepository)
{
parent::__construct($view, $userHelper);
$this->adminUserRepository = $adminUserRepository;
}
private function renderLoginPage(Response $response, array $renderData = []): Response
{
return $this->view->render($response, 'login.html.twig', $renderData);
}
public function showLoginPage(Request $request, Response $response): Response
{
if ($this->userHelper->isLoggedIn()) {
// Already logged in, redirect to dashboard
return $response
->withHeader('Location', '/')
->withStatus(303);
}
return $this->renderLoginPage($response);
}
public function authenticateUser(Request $request, Response $response): Response
{
$params = (array)$request->getParsedBody();
if (empty($params['username'])) {
return $this->renderLoginPage($response, ['error' => 'Missing username!']);
} elseif (empty($params['password'])) {
return $this->renderLoginPage($response, ['error' => 'Missing password!']);
}
$loginUsername = $params['username'];
$loginPassword = $params['password'];
try {
$user = $this->adminUserRepository->getUserByName($loginUsername);
}
catch (AdminUserNotFoundException $e) {
$user = null;
}
if ($user !== null && password_verify($loginPassword, $user->getPasswordHash())) {
$_SESSION['username'] = $user->getUsername();
return $response
->withHeader('Location', '/')
->withStatus(303);
} else {
return $this->renderLoginPage($response, ['error' => 'Wrong username or password!']);
}
}
public function logoutUser(Request $request, Response $response): Response
{
session_destroy();
return $response
->withHeader('Location', '/login')
->withStatus(303);
}
}

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Login;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class LoginController
{
/** @var Twig */
private $view;
public function __construct(Twig $view)
{
$this->view = $view;
}
public function showLoginPage(Request $request, Response $response): Response
{
$renderData = [
];
return $this->view->render($response, 'login.html', $renderData);
}
}

View File

@ -3,13 +3,18 @@ declare(strict_types=1);
namespace MailAccountAdmin; namespace MailAccountAdmin;
use MailAccountAdmin\Auth\AuthMiddleware;
use Slim\App; use Slim\App;
use Slim\Views\TwigMiddleware; use Slim\Views\TwigMiddleware;
class Middlewares class Middlewares
{ {
public static function setMiddlewares(App $app): void public static function setMiddlewares(App $app, Settings $settings): void
{ {
$displayErrorDetails = $settings->isDebugMode();
$app->addErrorMiddleware($displayErrorDetails, true, true);
$app->add(new AuthMiddleware());
$app->add(TwigMiddleware::createFromContainer($app)); $app->add(TwigMiddleware::createFromContainer($app));
} }
} }

75
src/Models/AdminUser.php Normal file
View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Models;
use DateTimeImmutable;
class AdminUser
{
/** @var int */
private $id;
/** @var string */
private $username;
/** @var string */
private $passwordHash;
/** @var bool */
private $active;
/** @var DateTimeImmutable */
private $createdAt;
/** @var DateTimeImmutable */
private $modifiedAt;
private function __construct(int $id, string $username, string $passwordHash, bool $isActive,
DateTimeImmutable $createdAt, DateTimeImmutable $modifiedAt)
{
$this->id = $id;
$this->username = $username;
$this->passwordHash = $passwordHash;
$this->active = $isActive;
$this->createdAt = $createdAt;
$this->modifiedAt = $modifiedAt;
}
public static function createFromArray(array $data): self
{
return new self(
(int)$data['admin_id'],
$data['username'],
$data['password'],
$data['is_active'] === '1',
new DateTimeImmutable($data['created_at']),
new DateTimeImmutable($data['modified_at']),
);
}
public function getId(): int
{
return $this->id;
}
public function getUsername(): string
{
return $this->username;
}
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function isActive(): bool
{
return $this->active;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getModifiedAt(): DateTimeImmutable
{
return $this->modifiedAt;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Repositories;
use MailAccountAdmin\Exceptions\AdminUserNotFoundException;
use MailAccountAdmin\Models\AdminUser;
use PDO;
class AdminUserRepository
{
/** @var PDO */
private $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* @throws AdminUserNotFoundException
*/
public function getUserByName(string $username): AdminUser
{
$statement = $this->pdo->prepare('SELECT * FROM admin_users WHERE username = :username LIMIT 1');
$statement->execute(['username' => $username]);
if ($statement->rowCount() < 1) {
throw new AdminUserNotFoundException("Admin with username '$username' was not found.");
}
$row = $statement->fetch(PDO::FETCH_ASSOC);
return AdminUser::createFromArray($row);
}
}

View File

@ -3,13 +3,20 @@ declare(strict_types=1);
namespace MailAccountAdmin; namespace MailAccountAdmin;
use MailAccountAdmin\Login\LoginController; use MailAccountAdmin\Frontend\Dashboard\DashboardController;
use MailAccountAdmin\Frontend\Login\LoginController;
use Slim\App; use Slim\App;
class Routes class Routes
{ {
public static function setRoutes(App $app): void public static function setRoutes(App $app): void
{ {
$app->get('/', LoginController::class . ':showLoginPage'); // Login
$app->get('/login', LoginController::class . ':showLoginPage');
$app->post('/login', LoginController::class . ':authenticateUser');
$app->get('/logout', LoginController::class . ':logoutUser');
// Dashboard
$app->get('/', DashboardController::class . ':showDashboard');
} }
} }

View File

@ -5,12 +5,27 @@ namespace MailAccountAdmin;
class Settings class Settings
{ {
public function isDebugMode(): bool
{
return getenv('APP_DEBUG') === 'true';
}
public function getTwigSettings(): array public function getTwigSettings(): array
{ {
$cacheDir = getenv('TWIG_CACHE_DIR');
return [ return [
'cache' => !empty($cacheDir) ? $cacheDir : false, 'cache' => getenv('TWIG_CACHE_DIR') ?: false,
'strict_variables' => getenv('TWIG_STRICT') === 'true',
];
}
public function getDatabaseSettings(): array
{
return [
'host' => getenv('DB_HOST') ?: 'localhost',
'port' => getenv('DB_PORT') ?: 3306,
'dbname' => getenv('DB_DATABASE') ?: '',
'username' => getenv('DB_USER') ?: '',
'password' => getenv('DB_PASSWORD') ?: '',
]; ];
} }
} }

23
templates/base.html.twig Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Untitled page{% endblock %} - MailAccountAdmin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>MailAccountAdmin</h1>
</header>
<main>
{% block content %}
Nothing to see here...
{% endblock %}
</main>
</body>
</html>

View File

@ -0,0 +1,20 @@
{% extends "base.html.twig" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h2>Dashboard</h2>
<p>Hello, {{ username }}!</p>
<pre>
ID: {{ user.getId() }}
username: {{ user.getUsername() }}
password: {{ user.getPasswordHash() }}
is_active: {{ user.isActive() }}
created_at: {{ user.getCreatedAt() | date() }}
modified_at: {{ user.getModifiedAt() | date() }}
</pre>
<a href="/logout">Logout.</a>
{% endblock %}

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MailAccountAdmin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- <link rel="stylesheet" href=""> -->
</head>
<body>
<h1>MailAccountAdmin - Login</h1>
<b>Hello.</b>
<!-- <script src="" async defer></script> -->
</body>
</html>

42
templates/login.html.twig Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Login - MailAccountAdmin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<h1>MailAccountAdmin</h1>
</header>
<main class="login_page">
<h2>Login</h2>
<form action="/login" method="POST">
{% if error is defined %}
<div class="error">{{ error }}</div>
{% endif %}
<table>
<tr>
<td><label for="username">Username</label></td>
<td><input type="text" id="username" name="username"></td>
</tr>
<tr>
<td><label for="password">Password</label></td>
<td><input type="password" id="password" name="password"></td>
</tr>
<tr>
<td></td>
<td><button type="submit">Login</button></td>
</tr>
</table>
</form>
</main>
</body>
</html>