diff --git a/.env.develop b/.env.develop index e6cfe04..968fd4b 100644 --- a/.env.develop +++ b/.env.develop @@ -1,7 +1,23 @@ # .env.development: Environment variables for local development -# Set composer cache directory +# composer: Set cache directory 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_STRICT=true + +# - Database credentials +DB_HOST=db +DB_DATABASE=mailusers +DB_USER=mailaccountadmin +DB_PASSWORD=mailaccountadmin diff --git a/docker-compose.yml b/docker-compose.yml index f91cc48..f742930 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,21 @@ services: volumes: - ./:/var/www/ 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: diff --git a/public/index.php b/public/index.php index 34eb697..37641db 100644 --- a/public/index.php +++ b/public/index.php @@ -9,11 +9,13 @@ use MailAccountAdmin\Routes; use MailAccountAdmin\Settings; use Slim\Factory\AppFactory; +session_start(); + $settings = new Settings(); $container = Dependencies::createContainer($settings); $app = AppFactory::createFromContainer($container); -Middlewares::setMiddlewares($app); +Middlewares::setMiddlewares($app, $settings); Routes::setRoutes($app); $app->run(); diff --git a/public/static/style.css b/public/static/style.css new file mode 100644 index 0000000..6db3384 --- /dev/null +++ b/public/static/style.css @@ -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; +} diff --git a/sql/init_tables.sql b/sql/init_tables.sql new file mode 100644 index 0000000..f45577d --- /dev/null +++ b/sql/init_tables.sql @@ -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; diff --git a/src/Auth/AuthMiddleware.php b/src/Auth/AuthMiddleware.php new file mode 100644 index 0000000..6738f6e --- /dev/null +++ b/src/Auth/AuthMiddleware.php @@ -0,0 +1,36 @@ +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); + } +} diff --git a/src/Common/UserHelper.php b/src/Common/UserHelper.php new file mode 100644 index 0000000..e67f56b --- /dev/null +++ b/src/Common/UserHelper.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/src/Dependencies.php b/src/Dependencies.php index d82b70a..9f2e901 100644 --- a/src/Dependencies.php +++ b/src/Dependencies.php @@ -4,15 +4,20 @@ declare(strict_types=1); namespace MailAccountAdmin; 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 Slim\Views\Twig; class Dependencies { - private const SETTINGS = 'settings'; - private const TWIG = 'view'; + public const SETTINGS = 'settings'; + public const TWIG = 'view'; private const TWIG_TEMPLATE_DIR = __DIR__ . '/../templates'; + public const DATABASE = 'database'; public static function createContainer(Settings $settings): Container { @@ -29,10 +34,50 @@ class Dependencies 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) { 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), ); }); diff --git a/src/Exceptions/AdminUserNotFoundException.php b/src/Exceptions/AdminUserNotFoundException.php new file mode 100644 index 0000000..413b5ff --- /dev/null +++ b/src/Exceptions/AdminUserNotFoundException.php @@ -0,0 +1,8 @@ +view = $view; + $this->userHelper = $userHelper; + } +} diff --git a/src/Frontend/Dashboard/DashboardController.php b/src/Frontend/Dashboard/DashboardController.php new file mode 100644 index 0000000..7538686 --- /dev/null +++ b/src/Frontend/Dashboard/DashboardController.php @@ -0,0 +1,23 @@ +userHelper->getCurrentUser(); + + $renderData = [ + 'username' => $currentUser->getUsername(), + 'user' => $currentUser, + ]; + + return $this->view->render($response, 'dashboard.html.twig', $renderData); + } +} diff --git a/src/Frontend/Login/LoginController.php b/src/Frontend/Login/LoginController.php new file mode 100644 index 0000000..f486c44 --- /dev/null +++ b/src/Frontend/Login/LoginController.php @@ -0,0 +1,80 @@ +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); + } +} diff --git a/src/Login/LoginController.php b/src/Login/LoginController.php deleted file mode 100644 index c0bc80c..0000000 --- a/src/Login/LoginController.php +++ /dev/null @@ -1,27 +0,0 @@ -view = $view; - } - - public function showLoginPage(Request $request, Response $response): Response - { - $renderData = [ - ]; - - return $this->view->render($response, 'login.html', $renderData); - } -} diff --git a/src/Middlewares.php b/src/Middlewares.php index cbadfc7..375e481 100644 --- a/src/Middlewares.php +++ b/src/Middlewares.php @@ -3,13 +3,18 @@ declare(strict_types=1); namespace MailAccountAdmin; +use MailAccountAdmin\Auth\AuthMiddleware; use Slim\App; use Slim\Views\TwigMiddleware; 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)); } } diff --git a/src/Models/AdminUser.php b/src/Models/AdminUser.php new file mode 100644 index 0000000..cef9c7b --- /dev/null +++ b/src/Models/AdminUser.php @@ -0,0 +1,75 @@ +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; + } +} diff --git a/src/Repositories/AdminUserRepository.php b/src/Repositories/AdminUserRepository.php new file mode 100644 index 0000000..31e2a41 --- /dev/null +++ b/src/Repositories/AdminUserRepository.php @@ -0,0 +1,35 @@ +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); + } +} diff --git a/src/Routes.php b/src/Routes.php index 9c7520a..a90372f 100644 --- a/src/Routes.php +++ b/src/Routes.php @@ -3,13 +3,20 @@ declare(strict_types=1); namespace MailAccountAdmin; -use MailAccountAdmin\Login\LoginController; +use MailAccountAdmin\Frontend\Dashboard\DashboardController; +use MailAccountAdmin\Frontend\Login\LoginController; use Slim\App; class Routes { 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'); } } diff --git a/src/Settings.php b/src/Settings.php index b36160f..ca111a0 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -5,12 +5,27 @@ namespace MailAccountAdmin; class Settings { + public function isDebugMode(): bool + { + return getenv('APP_DEBUG') === 'true'; + } + public function getTwigSettings(): array { - $cacheDir = getenv('TWIG_CACHE_DIR'); - 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') ?: '', ]; } } diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..916fab1 --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,23 @@ + + + + + {% block title %}Untitled page{% endblock %} - MailAccountAdmin + + + + + + +
+

MailAccountAdmin

+
+ +
+ {% block content %} + Nothing to see here... + {% endblock %} +
+ + + diff --git a/templates/dashboard.html.twig b/templates/dashboard.html.twig new file mode 100644 index 0000000..cf4cb89 --- /dev/null +++ b/templates/dashboard.html.twig @@ -0,0 +1,20 @@ +{% extends "base.html.twig" %} + +{% block title %}Dashboard{% endblock %} + +{% block content %} +

Dashboard

+ +

Hello, {{ username }}!

+ +
+        ID: {{ user.getId() }}
+        username: {{ user.getUsername() }}
+        password: {{ user.getPasswordHash() }}
+        is_active: {{ user.isActive() }}
+        created_at: {{ user.getCreatedAt() | date() }}
+        modified_at: {{ user.getModifiedAt() | date() }}
+    
+ + Logout. +{% endblock %} diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index e46301d..0000000 --- a/templates/login.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - MailAccountAdmin - - - - - - -

MailAccountAdmin - Login

- -Hello. - - - - - diff --git a/templates/login.html.twig b/templates/login.html.twig new file mode 100644 index 0000000..b22a5b8 --- /dev/null +++ b/templates/login.html.twig @@ -0,0 +1,42 @@ + + + + + Login - MailAccountAdmin + + + + + + +
+

MailAccountAdmin

+
+ +
+

Login

+ +
+ {% if error is defined %} +
{{ error }}
+ {% endif %} + + + + + + + + + + + + + + +
+
+
+ + +