Implermented login

This commit is contained in:
Michel Fedde 2024-06-30 22:29:50 +02:00
parent 923d6ca242
commit 51c20b55a0
30 changed files with 2495 additions and 39 deletions

3
.gitignore vendored
View file

@ -123,4 +123,5 @@ Thumbs.db
# ------------
# ignore public directory (we build it on server)
/public
/public
/data/

View file

@ -12,6 +12,14 @@
"laminas/laminas-diactoros": "^3.3",
"laminas/laminas-httphandlerrunner": "^2.10",
"ralouphie/mimey": "^1.0",
"dotenv-org/phpdotenv-vault": "^0.2.4"
"dotenv-org/phpdotenv-vault": "^0.2.4",
"doctrine/orm": "^3.2",
"doctrine/dbal": "^4.0",
"symfony/uid": "^7.1",
"php-curl-class/php-curl-class": "^9.19",
"symfony/cache": "^7.1",
},
"require-dev": {
"ext-xdebug": "*"
}
}

1777
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
$main-container-max-width: 992px;
.navbar :first-child,
.navigation-container,
main {
width: 100%;
max-width: $main-container-max-width;
@ -11,3 +11,30 @@ main {
margin: 0 auto;
}
.ratio-1 {
aspect-ratio: 1;
}
.avatar {
height: 3rem;
.avatar-login-method-icon {
scale: 1.5;
}
}
@include media-breakpoint-down(lg) {
.mode-switch {
text-align: center;
padding-top: 0.5rem;
margin-left: auto;
}
.navigation-container {
.navbar-brand {
margin: 0 auto !important;
}
}
}

View file

@ -1 +1,3 @@
import "./theme";
import "./theme";
import "bootstrap/js/src/collapse";

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace GamesShop\Api;
use Curl\Curl;
use Exception;
use GamesShop\Environment\EnvironmentHandler;
use GamesShop\Errors\ExtendedException;
final class DiscordAPI
{
const string OAUTH_TOKEN_URL = "https://discord.com/api/oauth2/token";
const string USER_ME_URL = 'https://discord.com/api/users/@me';
public function __construct(
private readonly EnvironmentHandler $env
)
{
}
/**
* @return array{id: string, global_name: string, avatar: string, discriminator: int}
* @throws Exception
*/
public function getUserFromCode(string $code, string $redirectUri): array {
$discordEnv = $this->env->getDiscordEnvironment();
$curl = new Curl();
$curl->setHeader('Content-Type', 'application/x-www-form-urlencoded');
$curl->setBasicAuthentication($discordEnv->clientId, $discordEnv->clientSecret);
$data =[
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri
];
$curl->post(self::OAUTH_TOKEN_URL, $data);
if ($curl->error) {
$curl->diagnose();
throw new ExtendedException($curl->errorMessage, [ 'response' => $curl->response, 'data' => $data ]);
}
$accessToken = $curl->response->access_token;
$tokenType = $curl->response->token_type;
$curl = new Curl();
$curl->setHeader("authorization", "$tokenType $accessToken");
$curl->get(self::USER_ME_URL);
if ($curl->error) {
$curl->diagnose();
throw new ExtendedException($curl->errorMessage, [ 'response' => $curl->response, ]);
}
return [
'id' => $curl->response->id,
'global_name' => $curl->response->global_name,
'avatar' => $curl->response->avatar,
'discriminator' => (int) $curl->response->discriminator
];
}
public function getAvatarURL(string $userId, string|int $avatarHash) {
if (is_int($avatarHash)) {
return "https://cdn.discordapp.com/embed/avatars/{$avatarHash}.png";
}
$extension = 'png';
if (str_starts_with($avatarHash, 'a_')) {
$extension = 'gif';
}
return "https://cdn.discordapp.com/avatars/{$userId}/{$avatarHash}.{$extension}";
}
}

View file

@ -33,6 +33,7 @@ final class ContainerHandler
private static function createInstance()
{
self::$instance = new Container();
self::$instance->delegate(new ReflectionContainer(true));
$reflectionContainer = new ReflectionContainer(true);
self::$instance->delegate($reflectionContainer);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GamesShop;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;
use GamesShop\Environment\EnvironmentHandler;
final class DoctrineManager
{
public function setup() {
$container = ContainerHandler::getInstance();
$environmentHandler = $container->get(EnvironmentHandler::class);
$config = ORMSetup::createAttributeMetadataConfiguration(
paths: [ Paths::PHP_SOURCE_PATH . '/Entities' ],
isDevMode: !$environmentHandler->isProduction()
);
$dbEnvironment = $environmentHandler->getDatabaseEnvironment();
$connection = DriverManager::getConnection($dbEnvironment->getDoctrineConfig());
$entityManager = new EntityManager($connection, $config);
$container->addShared(EntityManager::class, $entityManager);
$container->addShared(Connection::class, $connection);
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace GamesShop\Entities\Account;
use Doctrine\ORM\Mapping as ORM;
use GamesShop\Login\LoginMethod;
use GamesShop\Login\UserPermission;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV4;
#[ORM\Entity]
#[ORM\Table(name: "users")]
final class User
{
#[ORM\Id()]
#[ORM\Column]
#[ORM\GeneratedValue]
private int|null $id = null;
#[ORM\Column(type: 'integer', enumType: LoginMethod::class)]
private LoginMethod $loginMethod;
#[ORM\Column]
private string|null $foreignLoginId = null;
#[ORM\Column]
private string $name;
#[ORM\Column]
private string $profilePictureUrl;
#[ORM\Column]
private UserPermission $permission;
public function __construct(LoginMethod $loginMethod, ?string $foreignLoginId, string $name, string $profilePictureUrl, UserPermission $permission)
{
$this->loginMethod = $loginMethod;
$this->foreignLoginId = $foreignLoginId;
$this->name = $name;
$this->profilePictureUrl = $profilePictureUrl;
$this->permission = $permission;
}
public function getId(): ?int
{
return $this->id;
}
public function getLoginMethod(): LoginMethod
{
return $this->loginMethod;
}
public function getForeignLoginId(): ?string
{
return $this->foreignLoginId;
}
public function getName(): string
{
return $this->name;
}
public function getProfilePictureUrl(): string
{
return $this->profilePictureUrl;
}
public function getPermission(): UserPermission
{
return $this->permission;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function setProfilePictureUrl(string $profilePictureUrl): void
{
$this->profilePictureUrl = $profilePictureUrl;
}
public function setPermission(UserPermission $permission): void
{
$this->permission = $permission;
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace GamesShop\Entities;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'attributes')]
final class SystemAttribute
{
#[ORM\Id]
#[ORM\Column]
private string $name;
#[ORM\Column]
private string $value;
public function __construct(string $name, string $value)
{
$this->name = $name;
$this->value = $value;
}
public function getName(): string
{
return $this->name;
}
public function getValue(): string
{
return $this->value;
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GamesShop\Environment;
final readonly class DatabaseEnvironment
{
public function __construct(
public string $driver,
public string $path
)
{
}
public function getDoctrineConfig() {
return [
'driver' => $this->driver,
'path' => $this->path
];
}
}

View file

@ -25,4 +25,16 @@ final class EnvironmentHandler
$_SERVER['DISCORD_CLIENT_LOGIN_URI'],
);
}
public function getDatabaseEnvironment(): DatabaseEnvironment
{
return new DatabaseEnvironment(
$_SERVER['DB_DRIVER'],
$_SERVER['DB_PATH']
);
}
public function isProduction(): bool {
return $_SERVER['PRODUCTION'] === 'true';
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace GamesShop\Errors;
use Exception;
use GamesShop\ContainerHandler;
use Whoops\Handler\HandlerInterface;
use Whoops\Handler\PrettyPageHandler;
final class ExtendedException extends Exception
{
public function __construct(string $message = "", array $additionals = [], int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$handler = ContainerHandler::get(HandlerInterface::class);
if (!($handler instanceof PrettyPageHandler)) {
return;
}
$handler->addDataTable("Additional Info", $additionals);
}
}

View file

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
use Whoops\Run;
final class WhoopsHandler
{
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace GamesShop\Login;
use Doctrine\ORM\EntityManager;
use GamesShop\Api\DiscordAPI;
use GamesShop\ContainerHandler;
use GamesShop\Entities\Account\User;
use Psr\Http\Message\ServerRequestInterface;
final class DiscordLoginProvider implements LoginProvider
{
public function __construct(
private readonly EntityManager $entityManager
)
{
}
public function getUser(ServerRequestInterface $request): User
{
$discordApiHandler = ContainerHandler::get(DiscordAPI::class);
$result = $discordApiHandler->getUserFromCode($request->getQueryParams()['code'], (string)$request->getUri()->withQuery(''));
$repo = $this->entityManager->getRepository(User::class);
$users = $repo->findBy(['loginMethod' => LoginMethod::DISCORD, 'foreignLoginId' => $result['id']]);
$profilePictureUrl = $discordApiHandler->getAvatarURL($result['id'], $result['avatar'] ?? $result['discriminator'] % 5);
if (!empty($users)) {
$user = $users[0];
$user->setName($result['global_name']);
$user->setProfilePictureUrl($profilePictureUrl);
return $user;
}
$newUser = new User(
LoginMethod::DISCORD,
$result['id'],
$result['global_name'],
$profilePictureUrl,
UserPermission::VIEWER
);
return $newUser;
}
}

View file

@ -3,15 +3,74 @@ declare(strict_types=1);
namespace GamesShop\Login;
use Doctrine\ORM\EntityManager;
use Exception;
use GamesShop\ContainerHandler;
use GamesShop\Entities\Account\User;
final class LoginHandler
{
/**
* @return class-string[]
*/
private static array $providers;
public function __construct(
private readonly EntityManager $entityManager
)
{
}
public function isLoggedIn(): bool {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$this->ensureSession();
return isset($_SESSION['accountid']);
}
/**
* @throws Exception
*/
public function getLoginProvider(string $method): LoginProvider {
$providers = self::getProviders();
if (!array_key_exists($method, $providers)) {
throw new Exception("Couldn't find method for login '{$method}'");
}
return ContainerHandler::get($providers[$method]);
}
public function setCurrentUser(User $user) {
$this->ensureSession();
$_SESSION['accountid'] = $user->getId();
}
public function getCurrentUser(): User {
$this->ensureSession();
$userid = $_SESSION['accountid'];
return $this->entityManager->getRepository(User::class)->find($userid);
}
public function deleteSession() {
$this->ensureSession();
session_destroy();
}
private function ensureSession()
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/**
* @return class-string[]
*/
private static function getProviders(): array
{
return self::$providers ??= [
'discord' => DiscordLoginProvider::class
];
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace GamesShop\Login;
enum LoginMethod: int
{
case DISCORD = 1;
public function getIconClass(): string
{
return match ($this) {
self::DISCORD => 'fa-discord',
};
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GamesShop\Login;
use GamesShop\Entities\Account\User;
use Psr\Http\Message\ServerRequestInterface;
interface LoginProvider
{
public function getUser(ServerRequestInterface $request): User;
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace GamesShop\Login;
enum UserPermission : int
{
case VIEWER = 1;
case PROVIDER = 10;
case ADMIN = 100;
public function getHumanReadableName() {
return match ($this) {
self::VIEWER => "Claimer",
self::PROVIDER => "Provider",
self::ADMIN => "Admin",
};
}
}

View file

@ -8,4 +8,6 @@ final class Paths
public const string ROOT_PATH = __DIR__ . '/../..';
public const string PUBLIC_PATH = self::ROOT_PATH . '/public';
public const string SOURCE_PATH = self::ROOT_PATH . '/src';
public const string PHP_SOURCE_PATH = self::SOURCE_PATH . '/php';
}

View file

@ -3,14 +3,25 @@ declare(strict_types=1);
namespace GamesShop\Routing;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use GamesShop\ContainerHandler;
use GamesShop\Environment\EnvironmentHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class LoginRoutes
{
public function __construct(
private readonly EntityManager $entityManager
)
{
}
public function login(ServerRequestInterface $request) {
$discordEnv = ContainerHandler::get(EnvironmentHandler::class)->getDiscordEnvironment();
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderPage(
@ -25,9 +36,42 @@ final class LoginRoutes
return $response;
}
/**
* @throws OptimisticLockException
* @throws ORMException
*/
public function loginCallback(ServerRequestInterface $request, array $args): ResponseInterface {
if (array_key_exists('error', $request->getQueryParams())) {
return new Response\RedirectResponse('/login');
}
$method = $args['method'];
$loginHandler = ContainerHandler::get(LoginHandler::class);
$loginProvider = $loginHandler->getLoginProvider($method);
$user = $loginProvider->getUser($request);
if ($user->getId() === null) {
$this->entityManager->persist($user);
$this->entityManager->flush();
}
$loginHandler->setCurrentUser($user);
return new Response\RedirectResponse('/');
}
public function logout(ServerRequestInterface $request): ResponseInterface
{
$loginHandler = ContainerHandler::get(LoginHandler::class);
$loginHandler->deleteSession();
return new Response\RedirectResponse('/login');
}
public static function addRoutes(\League\Route\Router $router): void {
$routes = ContainerHandler::get(LoginRoutes::class);
$router->get('/login', $routes->login(...));
$router->get('/login-callback/{method:word}', $routes->loginCallback(...));
$router->get('/logout', $routes->logout(...));
}
}

View file

@ -9,13 +9,14 @@ use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use League\Container\Container;
use League\Route\Http\Exception\NotFoundException;
use League\Route\Strategy\ApplicationStrategy;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class Router
{
public function route()
public function route(): ResponseInterface
{
$request = ServerRequestFactory::fromGlobals(
$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
@ -26,11 +27,12 @@ final class Router
IndexRoute::applyRoutes($router);
LoginRoutes::addRoutes($router);
SetupRoute::applyRoutes($router);
ResourceRoute::addRouteEntry($router);
try {
return $router->dispatch($request);
} catch (\League\Route\Http\Exception\NotFoundException $e) {
} catch (NotFoundException $e) {
return (new ErrorRoute())->renderErrorPage(404);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\SystemAttribute;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class SetupRoute
{
public function __construct(
private readonly EntityManager $entityManager,
private readonly LoginHandler $loginHandler
)
{
}
public function __invoke(ServerRequestInterface $request): ResponseInterface {
if (!$this->loginHandler->isLoggedIn()) {
return new RedirectResponse('/login');
}
$repo = $this->entityManager->getRepository(SystemAttribute::class);
$attribute = $repo->find('ADMIN_SETUP_COMPLETED');
if ($attribute) {
return new RedirectResponse('/');
}
$user = $this->loginHandler->getCurrentUser();
$user->setPermission(UserPermission::ADMIN);
$attribute = new SystemAttribute(
'ADMIN_SETUP_COMPLETED',
'true'
);
$this->entityManager->persist($attribute);
$this->entityManager->flush();
return new RedirectResponse('/');
}
public static function applyRoutes(\League\Route\Router $router) {
$router->get('/setup-admin', self::class);
}
}

13
src/php/SetupHandler.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GamesShop;
final class SetupHandler
{
public function __construct(
)
{
}
}

15
src/php/bin/doctrine.php Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env php
<?php
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider;
use GamesShop\ContainerHandler;
require_once __DIR__ . '/../bootstrap.php';
$entityManager = ContainerHandler::get(EntityManager::class);
ConsoleRunner::run(
new SingleManagerProvider($entityManager)
);

11
src/php/bootstrap.php Normal file
View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\DoctrineManager;
use GamesShop\Environment\EnvironmentHandler;
require_once __DIR__ . '/../../vendor/autoload.php';
ContainerHandler::get(EnvironmentHandler::class)->load();
ContainerHandler::get(DoctrineManager::class)->setup();

View file

@ -2,20 +2,23 @@
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\DoctrineManager;
use GamesShop\Environment\EnvironmentHandler;
use GamesShop\Routing\Router;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Whoops\Handler\HandlerInterface;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;
require_once __DIR__ . '/../vendor/autoload.php';
ContainerHandler::get(EnvironmentHandler::class)->load();
require_once __DIR__ . '/../src/php/bootstrap.php';
$whoops = new Run();
$whoops->pushHandler(new PrettyPageHandler);
$prettyPageHandler = new PrettyPageHandler();
$whoops->pushHandler($prettyPageHandler);
$whoops->register();
ContainerHandler::getInstance()->addShared(HandlerInterface::class, $prettyPageHandler);
$router = ContainerHandler::getInstance()->get(Router::class);
$result = $router->route();

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
$loginHandler = ContainerHandler::get(LoginHandler::class);
?>
<?php if ($loginHandler->isLoggedIn()):
$user = $loginHandler->getCurrentUser();
?>
<div class="d-flex avatar justify-content-center">
<div class="avatar-icon h-100 position-relative me-2 ratio-1">
<img src="<?= $user->getProfilePictureUrl(); ?>" class="rounded-circle h-100" alt="User Profile Picture" />
<div class="position-absolute bottom-0 end-0 ratio-1 d-flex align-items-center z-1 avatar-login-method">
<i class="fa-brands <?= $user->getLoginMethod()->getIconClass() ?>"></i>
<span class="position-absolute w-100 h-100 bottom-0 end-0 ratio-1 bg-body rounded-circle z-n1 avatar-login-method-icon"></span>
</div>
</div>
<div class="d-flex flex-column">
<span class="me-2 h-3">
<?= $user->getName() ?>
</span>
<small class="text-muted">
<?= $user->getPermission()->getHumanReadableName() ?>
</small>
</div>
<div class="h-100 d-flex align-items-center ms-2">
<a href="/logout">
<i class="fa-solid fa-arrow-right-to-bracket fa-xl text-danger"></i>
</a>
</div>
</div>
<?php endif ?>

View file

@ -33,30 +33,7 @@ $resource = $resources->getResource($resourceEntry);
<?php endforeach; ?>
</head>
<body class="vh-100 d-flex flex-column">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">Game Shop</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-content" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul>
<div class="d-flex mode-switch">
<button title="Use dark mode" id="dark" class="btn btn-sm btn-default text-secondary">
<i class="fa-regular fa-moon"></i>
</button>
<button title="Use light mode" id="light" class="btn btn-sm btn-default text-secondary">
<i class="fa-regular fa-sun"></i>
</button>
<button title="Use system preferred mode" id="system" class="btn btn-sm btn-default text-secondary">
<i class="fa-solid fa-display"></i>
</button>
</div>
</div>
</div>
</nav>
<?= $this->insert('layout/navbar') ?>
<main class="mt-2 position-relative flex-grow-1">
<?= $this->section('content'); ?>

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
ContainerHandler::get(LoginHandler::class);
?>
<nav class="navbar navbar-expand-lg bg-body-tertiary main-navigation">
<div class="container-fluid navigation-container">
<a class="navbar-brand" href="/">Game Shop</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-content" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul>
<?= $this->insert('layout/accountDisplay'); ?>
</div>
</div>
<div class="d-flex mode-switch me-3">
<button title="Use dark mode" id="dark" class="btn btn-sm btn-default text-secondary">
<i class="fa-regular fa-moon"></i>
</button>
<button title="Use light mode" id="light" class="btn btn-sm btn-default text-secondary">
<i class="fa-regular fa-sun"></i>
</button>
<button title="Use system preferred mode" id="system" class="btn btn-sm btn-default text-secondary">
<i class="fa-solid fa-display"></i>
</button>
</div>
</nav>