Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed248e925 | ||
|
|
f48208ab42 | ||
|
|
1ccf0133db | ||
|
|
cc50434f02 | ||
|
|
e372177034 |
@@ -21,3 +21,4 @@
|
||||
/fuel/app/cache/*/*
|
||||
/fuel/app/config/crypt.php
|
||||
|
||||
.idea
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
# OTP-Login
|
||||
|
||||
In Vereinen ist es wichtig, Helfern für eine bestimmte Zeit CRUD-Rechte zu geben. Es ist meist nicht notwendig, dass der Helfer dazu unbedingt einen Account benötigt.
|
||||
## Vorbemerkung
|
||||
|
||||
Das genaue Vorgehen wird in der Readme bzw. per UML festgelegt.
|
||||
Bei Veranstaltungen kommt es oft vor, dass Clients (Browser auf Endgeräten) zeitweilig für Zugriffe freigegeben werden müssen. Dabei ist es oft nicht möglich, auf den Clients alle Sicherheitsaspekte zu beachten. Meist sind es private Endgeräte, da gerade Vereine auf die Bereitstellung privater Möglichkeiten angewiesen sind. Auch ist es nicht garantiert, dass der Nutzer diese Rechte jedes Mal identisch braucht.
|
||||
|
||||
Das aktuelle Vorgehen ist es, dem Nutzer einen Account zu geben. Dabei hat man aber nur eine geringe Kontrolle des Passwortes und wie oben gesagt der Clients-Sicherheit. Je mehr Rechte der User bekommt, umso anfälliger wird die IT-Infrastruktur für absichtliches wie unabsichtliches Fehlverhalten. Ausserdem entstehen so eine Unmenge an Accounts, die nicht mehr ordentlich gewartet werden können. Das verschärft die Sicherheitsprobleme weiter.
|
||||
|
||||
Als weiteres Problem kommt hinzu, dass das technische Verständnis der Nutzer nicht immer perfekt ist, um auch eine Account-Sicherheit zu gewährleisten. Das Betrifft zum Beispiel:
|
||||
|
||||
* aufschreiben des Passwortes
|
||||
* weitergabe des Passwortes an weitere Personen
|
||||
|
||||
Aus diesen Gründen ist das aktuelle Vorgehen nicht sicher genug.
|
||||
|
||||
## Begrifflichkeiten
|
||||
|
||||
### OTP
|
||||
|
||||
Ausgeschrieben "One-Time-Password". Ist ein nur kurze Zeit gültiges Passwort, dass meist bei der Zwei-Faktor-Authentifizierung Anwendung findet.
|
||||
|
||||
[Wikipedia - Einmalkennwort](https://de.wikipedia.org/wiki/Einmalkennwort)
|
||||
|
||||
### Client
|
||||
|
||||
Damit ist der Browser auf dem Endgerät eines Nutzers gemeint.
|
||||
|
||||
### Veranstalter
|
||||
|
||||
Verantwortliche für die Veranstaltung. Dieser Personenkreis hat am besten den Überblick über mögliche Mitarbeiter bei der Veranstaltung.
|
||||
|
||||
### IT-Administratoren
|
||||
|
||||
Die Verantwortlichen für die IT-Infrastruktur, die bei der Veranstaltung eingesetzt wird.
|
||||
|
||||
## Einfache Beschreibung
|
||||
|
||||
Ganz ohne Accounts geht es nicht. Dabei gibt es nur zwei Gruppen. IT-Administratoren und Veranstalter. Ein IT-Administrator kann einen Veranstalter Rechte geben, um Clients eine oder mehrere Rollen zu geben. Diese Client-Rechte sind Zeitlich begrenzt. Der Account eines Veranstalters ist nicht begrenzt.
|
||||
|
||||
### IT-Administratoren
|
||||
|
||||
* kann Veranstalter anlegen
|
||||
* hat erweitertes technisches Verständnis
|
||||
* kann Clients vorzeitig entfernen
|
||||
|
||||
### Veranstalter
|
||||
|
||||
* kann Clients freischalten
|
||||
* allgemeines technisches Verständnis reicht aus
|
||||
|
||||
### Clients
|
||||
|
||||
* es wird nur ein Browser des Endgerätes freigeschaltet
|
||||
* die Freischaltung ist von vorne rein zeitlich begrenzt
|
||||
|
||||
## Detaillierter Ablauf
|
||||
|
||||
### Phase I
|
||||
|
||||
Ein schon angemeldeter IT-Administrator meldet einen Veranstalter beim System an. Der Veranstalter bekommt die Daten für eine OTP-App und einen Benutzernamen.
|
||||
|
||||
### Phase II
|
||||
|
||||
Ein Veranstalter entscheidet sich, ein Client für eine oder mehrere Rollen freizugeben. Dazu geht er im Browser auf die Freigabe-Seite. Dort muss er folgende Daten Eingeben:
|
||||
|
||||
* Name des Clients
|
||||
* Rolle bzw Rollen des Clients
|
||||
* Benutzername des Veranstalters
|
||||
* gültiges OTP
|
||||
|
||||
Es wird ein *JSON-Web-Token* im Browser als Cookie gespeichert. Dieser Token hat ein Verfallsdatum.
|
||||
|
||||
### Phase III
|
||||
|
||||
Der Nutzer des Clients möchte eine Web-API-Aktivität über die Weboberfläche ausführen. Dazu sendet die Weboberfläche per JavaScript eine Anfrage an die Web-API, bei der der *JSON-Web-Token* mitgesendet wird.
|
||||
Die Web-API überprüft durch den Login-Server die Korrektheit des Tokens. Dann wird die Rolle des Clients ausgewertet und entsprechend das Ergebnis zurückgegeben.
|
||||
|
||||
* HTTP-Code-2XX: Anfrage erfolgreich
|
||||
* HTTP-Code-403: Die Aktion ist nicht erlaubt
|
||||
* HTTP-Code-401: Der Client hat überhaupt keine *JSON-Web-Token*
|
||||
|
||||
## Bekannte Probleme
|
||||
|
||||
Ein Veranstalter wird nur durch ein OTP geschützt. Es ist **keine** Zwei-Faktor-Authentifizierung.
|
||||
Dadurch wird aber auch die Sicherheit des Clients unproblematisch, da der Client nur ein OTP bekommt, dass nur eine kurze Zeit gültig ist.
|
||||
Des Weiteren laufen die Server nur für die Dauer der Veranstaltung. Auch das entschärft die Probleme.
|
||||
@@ -0,0 +1,8 @@
|
||||
.idea/
|
||||
.vscode/
|
||||
coverage/
|
||||
vendor/
|
||||
logs/*
|
||||
!/logs/README.md
|
||||
.phpunit.result.cache
|
||||
/composer.lock
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Settings\SettingsInterface;
|
||||
use DI\ContainerBuilder;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Processor\UidProcessor;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
return function (ContainerBuilder $containerBuilder) {
|
||||
$containerBuilder->addDefinitions([
|
||||
LoggerInterface::class => function (ContainerInterface $c) {
|
||||
$settings = $c->get(SettingsInterface::class);
|
||||
|
||||
$loggerSettings = $settings->get('logger');
|
||||
$logger = new Logger($loggerSettings['name']);
|
||||
|
||||
$processor = new UidProcessor();
|
||||
$logger->pushProcessor($processor);
|
||||
|
||||
$handler = new StreamHandler($loggerSettings['path'], $loggerSettings['level']);
|
||||
$logger->pushHandler($handler);
|
||||
|
||||
return $logger;
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Middleware\SessionMiddleware;
|
||||
use Slim\App;
|
||||
|
||||
return function (App $app) {
|
||||
$app->add(SessionMiddleware::class);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserRepository;
|
||||
use TorstenHettstedt\OptLogin\Infrastructure\Persistence\User\InMemoryUserRepository;
|
||||
use DI\ContainerBuilder;
|
||||
|
||||
return function (ContainerBuilder $containerBuilder) {
|
||||
// Here we map our UserRepository interface to its in memory implementation
|
||||
$containerBuilder->addDefinitions([
|
||||
UserRepository::class => \DI\autowire(InMemoryUserRepository::class),
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\User\ListUsersAction;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\User\ViewUserAction;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\App;
|
||||
use Slim\Interfaces\RouteCollectorProxyInterface as Group;
|
||||
|
||||
return function (App $app) {
|
||||
$app->options('/{routes:.*}', function (Request $request, Response $response) {
|
||||
// CORS Pre-Flight OPTIONS Request Handler
|
||||
return $response;
|
||||
});
|
||||
|
||||
$app->get('/', function (Request $request, Response $response) {
|
||||
$response->getBody()->write('Hello world!');
|
||||
return $response;
|
||||
});
|
||||
|
||||
$app->group('/users', function (Group $group) {
|
||||
$group->get('', ListUsersAction::class);
|
||||
$group->get('/{id}', ViewUserAction::class);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Settings\Settings;
|
||||
use TorstenHettstedt\OptLogin\Application\Settings\SettingsInterface;
|
||||
use DI\ContainerBuilder;
|
||||
use Monolog\Logger;
|
||||
|
||||
return function (ContainerBuilder $containerBuilder) {
|
||||
|
||||
// Global Settings Object
|
||||
$containerBuilder->addDefinitions([
|
||||
SettingsInterface::class => function () {
|
||||
return new Settings([
|
||||
'displayErrorDetails' => true, // Should be set to false in production
|
||||
'logError' => false,
|
||||
'logErrorDetails' => false,
|
||||
'logger' => [
|
||||
'name' => 'slim-app',
|
||||
'path' => isset($_ENV['docker']) ? 'php://stdout' : __DIR__ . '/../logs/app.log',
|
||||
'level' => Logger::DEBUG,
|
||||
],
|
||||
]);
|
||||
}
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
paths:
|
||||
tests: tests
|
||||
output: tests/_output
|
||||
data: tests/_data
|
||||
support: tests/_support
|
||||
envs: tests/_envs
|
||||
actor_suffix: Tester
|
||||
extensions:
|
||||
enabled:
|
||||
- Codeception\Extension\RunFailed
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "torsten-hettstedt/otp-login",
|
||||
"description": "In Vereinen ist es wichtig, Helfern für eine bestimmte Zeit CRUD-Rechte zu geben. Es ist meist nicht notwendig, dass der Helfer dazu unbedingt einen Account benötigt. Das genaue Vorgehen wird in der Readme bzw. per UML festgelegt.",
|
||||
"minimum-stability": "stable",
|
||||
"keywords": [
|
||||
"microframework",
|
||||
"rest",
|
||||
"router",
|
||||
"psr7"
|
||||
],
|
||||
"license": "GPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Torsten Lucke",
|
||||
"email": "arbeit@torsten-hettstedt.de"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.0",
|
||||
"ext-pdo": "*",
|
||||
"ext-json": "*",
|
||||
"monolog/monolog": "^2.4",
|
||||
"php-di/php-di": "^6.3",
|
||||
"slim/psr7": "^1.5",
|
||||
"slim/slim": "^4.10",
|
||||
"php-di/slim-bridge": "^3.1.0",
|
||||
"jetbrains/phpstorm-attributes": "^1.0.0",
|
||||
"myclabs/php-enum": "^1.8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^0.12.80",
|
||||
"codeception/codeception": "^4.1.18",
|
||||
"codeception/module-phpbrowser": "^1.0.0",
|
||||
"codeception/module-asserts": "^1.0.0",
|
||||
"codeception/module-db": "^1.1.0",
|
||||
"codeception/module-rest": "^1.2.8"
|
||||
},
|
||||
"config": {
|
||||
"process-timeout": 0,
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"phpstan/extension-installer": true
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TorstenHettstedt\\OptLogin\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"TorstenHettstedt\\OptLogin\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"start": "php -S localhost:8080 -t public",
|
||||
"test": "vendor/bin/codecept run unit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
version: '3.7'
|
||||
|
||||
volumes:
|
||||
logs:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
slim:
|
||||
image: php:7-alpine
|
||||
working_dir: /var/www
|
||||
command: php -S 0.0.0.0:8080 -t public
|
||||
environment:
|
||||
docker: "true"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- logs:/var/www/logs
|
||||
@@ -0,0 +1 @@
|
||||
Your Slim Framework application's log files will be written to this directory.
|
||||
@@ -0,0 +1,5 @@
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- src
|
||||
- tests/unit
|
||||
@@ -0,0 +1,34 @@
|
||||
Options All -Indexes
|
||||
|
||||
<Files .htaccess>
|
||||
order allow,deny
|
||||
deny from all
|
||||
</Files>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Redirect to HTTPS
|
||||
# RewriteEngine On
|
||||
# RewriteCond %{HTTPS} off
|
||||
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||
|
||||
# Some hosts may require you to use the `RewriteBase` directive.
|
||||
# Determine the RewriteBase automatically and set it as environment variable.
|
||||
# If you are using Apache aliases to do mass virtual hosting or installed the
|
||||
# project in a subdirectory, the base path will be prepended to allow proper
|
||||
# resolution of the index.php file and to redirect to the correct URI. It will
|
||||
# work in environments without path prefix as well, providing a safe, one-size
|
||||
# fits all solution. But as you do not need it in this case, you can comment
|
||||
# the following 2 lines to eliminate the overhead.
|
||||
RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$
|
||||
RewriteRule ^(.*) - [E=BASE:%1]
|
||||
|
||||
# If the above doesn't work you might need to set the `RewriteBase` directive manually, it should be the
|
||||
# absolute physical path to the directory that contains this htaccess file.
|
||||
# RewriteBase /
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Handlers\HttpErrorHandler;
|
||||
use TorstenHettstedt\OptLogin\Application\Handlers\ShutdownHandler;
|
||||
use TorstenHettstedt\OptLogin\Application\ResponseEmitter\ResponseEmitter;
|
||||
use TorstenHettstedt\OptLogin\Application\Settings\SettingsInterface;
|
||||
use DI\ContainerBuilder;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Factory\ServerRequestCreatorFactory;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Instantiate PHP-DI ContainerBuilder
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
|
||||
if (false) { // Should be set to true in production
|
||||
/** @noinspection PhpUnreachableStatementInspection */
|
||||
$containerBuilder->enableCompilation(__DIR__ . '/../var/cache');
|
||||
}
|
||||
|
||||
// Set up settings
|
||||
$settings = require __DIR__ . '/../app/settings.php';
|
||||
$settings($containerBuilder);
|
||||
|
||||
// Set up dependencies
|
||||
$dependencies = require __DIR__ . '/../app/dependencies.php';
|
||||
$dependencies($containerBuilder);
|
||||
|
||||
// Set up repositories
|
||||
$repositories = require __DIR__ . '/../app/repositories.php';
|
||||
$repositories($containerBuilder);
|
||||
|
||||
// Build PHP-DI Container instance
|
||||
$container = $containerBuilder->build();
|
||||
|
||||
// Instantiate the app
|
||||
AppFactory::setContainer($container);
|
||||
$app = AppFactory::create();
|
||||
$callableResolver = $app->getCallableResolver();
|
||||
|
||||
// Register middleware
|
||||
$middleware = require __DIR__ . '/../app/middleware.php';
|
||||
$middleware($app);
|
||||
|
||||
// Register routes
|
||||
$routes = require __DIR__ . '/../app/routes.php';
|
||||
$routes($app);
|
||||
|
||||
/** @var SettingsInterface $settings */
|
||||
$settings = $container->get(SettingsInterface::class);
|
||||
|
||||
$displayErrorDetails = $settings->get('displayErrorDetails');
|
||||
$logError = $settings->get('logError');
|
||||
$logErrorDetails = $settings->get('logErrorDetails');
|
||||
|
||||
// Create Request object from globals
|
||||
$serverRequestCreator = ServerRequestCreatorFactory::create();
|
||||
$request = $serverRequestCreator->createServerRequestFromGlobals();
|
||||
|
||||
// Create Error Handler
|
||||
$responseFactory = $app->getResponseFactory();
|
||||
$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory);
|
||||
|
||||
// Create Shutdown Handler
|
||||
$shutdownHandler = new ShutdownHandler($request, $errorHandler, $displayErrorDetails);
|
||||
register_shutdown_function($shutdownHandler);
|
||||
|
||||
// Add Routing Middleware
|
||||
$app->addRoutingMiddleware();
|
||||
|
||||
// Add Body Parsing Middleware
|
||||
$app->addBodyParsingMiddleware();
|
||||
|
||||
// Add Error Middleware
|
||||
$errorMiddleware = $app->addErrorMiddleware($displayErrorDetails, $logError, $logErrorDetails);
|
||||
$errorMiddleware->setDefaultErrorHandler($errorHandler);
|
||||
|
||||
// Run App & Emit Response
|
||||
$response = $app->handle($request);
|
||||
$responseEmitter = new ResponseEmitter();
|
||||
$responseEmitter->emit($response);
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Actions;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Domain\DomainException\DomainRecordNotFoundException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Slim\Exception\HttpBadRequestException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
|
||||
abstract class Action
|
||||
{
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
protected Request $request;
|
||||
|
||||
protected Response $response;
|
||||
|
||||
protected array $args;
|
||||
|
||||
public function __construct(LoggerInterface $logger)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws HttpNotFoundException
|
||||
* @throws HttpBadRequestException
|
||||
*/
|
||||
public function __invoke(Request $request, Response $response, array $args): Response
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->response = $response;
|
||||
$this->args = $args;
|
||||
|
||||
try {
|
||||
return $this->action();
|
||||
} catch (DomainRecordNotFoundException $e) {
|
||||
throw new HttpNotFoundException($this->request, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DomainRecordNotFoundException
|
||||
* @throws HttpBadRequestException
|
||||
*/
|
||||
abstract protected function action(): Response;
|
||||
|
||||
/**
|
||||
* @return array|object
|
||||
*/
|
||||
protected function getFormData()
|
||||
{
|
||||
return $this->request->getParsedBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
* @throws HttpBadRequestException
|
||||
*/
|
||||
protected function resolveArg(string $name)
|
||||
{
|
||||
if (!isset($this->args[$name])) {
|
||||
throw new HttpBadRequestException($this->request, "Could not resolve argument `{$name}`.");
|
||||
}
|
||||
|
||||
return $this->args[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|object|null $data
|
||||
*/
|
||||
protected function respondWithData($data = null, int $statusCode = 200): Response
|
||||
{
|
||||
$payload = new ActionPayload($statusCode, $data);
|
||||
|
||||
return $this->respond($payload);
|
||||
}
|
||||
|
||||
protected function respond(ActionPayload $payload): Response
|
||||
{
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT);
|
||||
$this->response->getBody()->write($json);
|
||||
|
||||
return $this->response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($payload->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Actions;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JsonSerializable;
|
||||
|
||||
class ActionError implements JsonSerializable
|
||||
{
|
||||
public const BAD_REQUEST = 'BAD_REQUEST';
|
||||
public const INSUFFICIENT_PRIVILEGES = 'INSUFFICIENT_PRIVILEGES';
|
||||
public const NOT_ALLOWED = 'NOT_ALLOWED';
|
||||
public const NOT_IMPLEMENTED = 'NOT_IMPLEMENTED';
|
||||
public const RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND';
|
||||
public const SERVER_ERROR = 'SERVER_ERROR';
|
||||
public const UNAUTHENTICATED = 'UNAUTHENTICATED';
|
||||
public const VALIDATION_ERROR = 'VALIDATION_ERROR';
|
||||
public const VERIFICATION_ERROR = 'VERIFICATION_ERROR';
|
||||
|
||||
private string $type;
|
||||
|
||||
private string $description;
|
||||
|
||||
public function __construct(string $type, ?string $description)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description = null): self
|
||||
{
|
||||
$this->description = $description;
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[ArrayShape(['type' => "string", 'description' => "null|string"])] #[\ReturnTypeWillChange]
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'description' => $this->description,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Actions;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JsonSerializable;
|
||||
|
||||
class ActionPayload implements JsonSerializable
|
||||
{
|
||||
private int $statusCode;
|
||||
|
||||
/**
|
||||
* @var array|object|null
|
||||
*/
|
||||
private $data;
|
||||
|
||||
private ?ActionError $error;
|
||||
|
||||
public function __construct(
|
||||
int $statusCode = 200,
|
||||
$data = null,
|
||||
?ActionError $error = null
|
||||
) {
|
||||
$this->statusCode = $statusCode;
|
||||
$this->data = $data;
|
||||
$this->error = $error;
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|null|object
|
||||
*/
|
||||
public function getData()
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function getError(): ?ActionError
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
#[ArrayShape(['statusCode' => "int", 'data' => "mixed", 'error' => "\App\Application\Actions\ActionError|null"])] #[\ReturnTypeWillChange]
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$payload = [
|
||||
'statusCode' => $this->statusCode,
|
||||
];
|
||||
|
||||
if ($this->data !== null) {
|
||||
$payload['data'] = $this->data;
|
||||
} elseif ($this->error !== null) {
|
||||
$payload['error'] = $this->error;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Actions\User;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
|
||||
class ListUsersAction extends UserAction
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function action(): Response
|
||||
{
|
||||
$users = $this->userRepository->findAll();
|
||||
|
||||
$this->logger->info("Users list was viewed.");
|
||||
|
||||
return $this->respondWithData($users);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Actions\User;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\Action;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
abstract class UserAction extends Action
|
||||
{
|
||||
protected UserRepository $userRepository;
|
||||
|
||||
public function __construct(LoggerInterface $logger, UserRepository $userRepository)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Actions\User;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
|
||||
class ViewUserAction extends UserAction
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function action(): Response
|
||||
{
|
||||
$userId = (int) $this->resolveArg('id');
|
||||
$user = $this->userRepository->findUserOfId($userId);
|
||||
|
||||
$this->logger->info("User of id `${userId}` was viewed.");
|
||||
|
||||
return $this->respondWithData($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Handlers;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\ActionError;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\ActionPayload;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Slim\Exception\HttpBadRequestException;
|
||||
use Slim\Exception\HttpException;
|
||||
use Slim\Exception\HttpForbiddenException;
|
||||
use Slim\Exception\HttpMethodNotAllowedException;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Exception\HttpNotImplementedException;
|
||||
use Slim\Exception\HttpUnauthorizedException;
|
||||
use Slim\Handlers\ErrorHandler as SlimErrorHandler;
|
||||
use Throwable;
|
||||
|
||||
class HttpErrorHandler extends SlimErrorHandler
|
||||
{
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function respond(): Response
|
||||
{
|
||||
$exception = $this->exception;
|
||||
$statusCode = 500;
|
||||
$error = new ActionError(
|
||||
ActionError::SERVER_ERROR,
|
||||
'An internal error has occurred while processing your request.'
|
||||
);
|
||||
|
||||
if ($exception instanceof HttpException) {
|
||||
$statusCode = $exception->getCode();
|
||||
$error->setDescription($exception->getMessage());
|
||||
|
||||
if ($exception instanceof HttpNotFoundException) {
|
||||
$error->setType(ActionError::RESOURCE_NOT_FOUND);
|
||||
} elseif ($exception instanceof HttpMethodNotAllowedException) {
|
||||
$error->setType(ActionError::NOT_ALLOWED);
|
||||
} elseif ($exception instanceof HttpUnauthorizedException) {
|
||||
$error->setType(ActionError::UNAUTHENTICATED);
|
||||
} elseif ($exception instanceof HttpForbiddenException) {
|
||||
$error->setType(ActionError::INSUFFICIENT_PRIVILEGES);
|
||||
} elseif ($exception instanceof HttpBadRequestException) {
|
||||
$error->setType(ActionError::BAD_REQUEST);
|
||||
} elseif ($exception instanceof HttpNotImplementedException) {
|
||||
$error->setType(ActionError::NOT_IMPLEMENTED);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!($exception instanceof HttpException)
|
||||
&& $exception instanceof Throwable
|
||||
&& $this->displayErrorDetails
|
||||
) {
|
||||
$error->setDescription($exception->getMessage());
|
||||
}
|
||||
|
||||
$payload = new ActionPayload($statusCode, null, $error);
|
||||
$encodedPayload = json_encode($payload, JSON_PRETTY_PRINT);
|
||||
|
||||
$response = $this->responseFactory->createResponse($statusCode);
|
||||
$response->getBody()->write($encodedPayload);
|
||||
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Handlers;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Application\ResponseEmitter\ResponseEmitter;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Exception\HttpInternalServerErrorException;
|
||||
|
||||
class ShutdownHandler
|
||||
{
|
||||
private Request $request;
|
||||
|
||||
private HttpErrorHandler $errorHandler;
|
||||
|
||||
private bool $displayErrorDetails;
|
||||
|
||||
public function __construct(
|
||||
Request $request,
|
||||
HttpErrorHandler $errorHandler,
|
||||
bool $displayErrorDetails
|
||||
) {
|
||||
$this->request = $request;
|
||||
$this->errorHandler = $errorHandler;
|
||||
$this->displayErrorDetails = $displayErrorDetails;
|
||||
}
|
||||
|
||||
public function __invoke()
|
||||
{
|
||||
$error = error_get_last();
|
||||
if ($error) {
|
||||
$errorFile = $error['file'];
|
||||
$errorLine = $error['line'];
|
||||
$errorMessage = $error['message'];
|
||||
$errorType = $error['type'];
|
||||
$message = 'An error while processing your request. Please try again later.';
|
||||
|
||||
if ($this->displayErrorDetails) {
|
||||
switch ($errorType) {
|
||||
case E_USER_ERROR:
|
||||
$message = "FATAL ERROR: {$errorMessage}. ";
|
||||
$message .= " on line {$errorLine} in file {$errorFile}.";
|
||||
break;
|
||||
|
||||
case E_USER_WARNING:
|
||||
$message = "WARNING: {$errorMessage}";
|
||||
break;
|
||||
|
||||
case E_USER_NOTICE:
|
||||
$message = "NOTICE: {$errorMessage}";
|
||||
break;
|
||||
|
||||
default:
|
||||
$message = "ERROR: {$errorMessage}";
|
||||
$message .= " on line {$errorLine} in file {$errorFile}.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$exception = new HttpInternalServerErrorException($this->request, $message);
|
||||
$response = $this->errorHandler->__invoke(
|
||||
$this->request,
|
||||
$exception,
|
||||
$this->displayErrorDetails,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
$responseEmitter = new ResponseEmitter();
|
||||
$responseEmitter->emit($response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
|
||||
class SessionMiddleware implements Middleware
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
session_start();
|
||||
$request = $request->withAttribute('session', $_SESSION);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\ResponseEmitter;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Slim\ResponseEmitter as SlimResponseEmitter;
|
||||
|
||||
class ResponseEmitter extends SlimResponseEmitter
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function emit(ResponseInterface $response): void
|
||||
{
|
||||
// This variable should be set to the allowed host from which your API can be accessed with
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
$response = $response
|
||||
->withHeader('Access-Control-Allow-Credentials', 'true')
|
||||
->withHeader('Access-Control-Allow-Origin', $origin)
|
||||
->withHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'X-Requested-With, Content-Type, Accept, Origin, Authorization',
|
||||
)
|
||||
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
||||
->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
->withAddedHeader('Cache-Control', 'post-check=0, pre-check=0')
|
||||
->withHeader('Pragma', 'no-cache');
|
||||
|
||||
if (ob_get_contents()) {
|
||||
ob_clean();
|
||||
}
|
||||
|
||||
parent::emit($response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Settings;
|
||||
|
||||
class Settings implements SettingsInterface
|
||||
{
|
||||
private array $settings;
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key = '')
|
||||
{
|
||||
return (empty($key)) ? $this->settings : $this->settings[$key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Application\Settings;
|
||||
|
||||
interface SettingsInterface
|
||||
{
|
||||
/**
|
||||
* @param string $key
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $key = '');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Domain\DomainException;
|
||||
|
||||
use Exception;
|
||||
|
||||
abstract class DomainException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Domain\DomainException;
|
||||
|
||||
class DomainRecordNotFoundException extends DomainException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Domain\User;
|
||||
|
||||
use JetBrains\PhpStorm\ArrayShape;
|
||||
use JsonSerializable;
|
||||
|
||||
class User implements JsonSerializable
|
||||
{
|
||||
private ?int $id;
|
||||
|
||||
private string $username;
|
||||
|
||||
private string $firstName;
|
||||
|
||||
private string $lastName;
|
||||
|
||||
public function __construct(?int $id, string $username, string $firstName, string $lastName)
|
||||
{
|
||||
$this->id = $id;
|
||||
$this->username = strtolower($username);
|
||||
$this->firstName = ucfirst($firstName);
|
||||
$this->lastName = ucfirst($lastName);
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUsername(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getFirstName(): string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function getLastName(): string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
#[ArrayShape(['id' => "int|null", 'username' => "string", 'firstName' => "string", 'lastName' => "string"])] #[\ReturnTypeWillChange]
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'username' => $this->username,
|
||||
'firstName' => $this->firstName,
|
||||
'lastName' => $this->lastName,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Domain\User;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Domain\DomainException\DomainRecordNotFoundException;
|
||||
|
||||
class UserNotFoundException extends DomainRecordNotFoundException
|
||||
{
|
||||
public $message = 'The user you requested does not exist.';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Domain\User;
|
||||
|
||||
interface UserRepository
|
||||
{
|
||||
/**
|
||||
* @return User[]
|
||||
*/
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* @param int $id
|
||||
* @return User
|
||||
* @throws UserNotFoundException
|
||||
*/
|
||||
public function findUserOfId(int $id): User;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Infrastructure\Persistence\User;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Domain\User\User;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserNotFoundException;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserRepository;
|
||||
|
||||
class InMemoryUserRepository implements UserRepository
|
||||
{
|
||||
/**
|
||||
* @var User[]
|
||||
*/
|
||||
private array $users;
|
||||
|
||||
/**
|
||||
* @param User[]|null $users
|
||||
*/
|
||||
public function __construct(array $users = null)
|
||||
{
|
||||
$this->users = $users ?? [
|
||||
1 => new User(1, 'bill.gates', 'Bill', 'Gates'),
|
||||
2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'),
|
||||
3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'),
|
||||
4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'),
|
||||
5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
return array_values($this->users);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function findUserOfId(int $id): User
|
||||
{
|
||||
if (!isset($this->users[$id])) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
return $this->users[$id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Helper;
|
||||
|
||||
// here you can define custom actions
|
||||
// all public methods declared in helper class will be available in $I
|
||||
|
||||
class Unit extends \Codeception\Module
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
|
||||
/**
|
||||
* Inherited Methods
|
||||
* @method void wantToTest($text)
|
||||
* @method void wantTo($text)
|
||||
* @method void execute($callable)
|
||||
* @method void expectTo($prediction)
|
||||
* @method void expect($prediction)
|
||||
* @method void amGoingTo($argumentation)
|
||||
* @method void am($role)
|
||||
* @method void lookForwardTo($achieveValue)
|
||||
* @method void comment($description)
|
||||
* @method void pause()
|
||||
*
|
||||
* @SuppressWarnings(PHPMD)
|
||||
*/
|
||||
class UnitTester extends \Codeception\Actor
|
||||
{
|
||||
use _generated\UnitTesterActions;
|
||||
|
||||
/**
|
||||
* Define custom actions here
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,10 @@
|
||||
# Codeception Test Suite Configuration
|
||||
#
|
||||
# Suite for unit or integration tests.
|
||||
|
||||
actor: UnitTester
|
||||
modules:
|
||||
enabled:
|
||||
- Asserts
|
||||
- \Helper\Unit
|
||||
step_decorators: ~
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Tests\unit\Application\Actions;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Psr\Container\ContainerExceptionInterface;
|
||||
use Psr\Container\NotFoundExceptionInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\Action;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\ActionPayload;
|
||||
use TorstenHettstedt\OptLogin\Tests\unit\TestCase;
|
||||
|
||||
class ActionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testActionSetsHttpCodeInRespond()
|
||||
{
|
||||
$app = $this->getAppInstance();
|
||||
$container = $app->getContainer();
|
||||
$logger = $container->get(LoggerInterface::class);
|
||||
|
||||
$testAction = new class ($logger) extends Action {
|
||||
public function __construct(
|
||||
LoggerInterface $loggerInterface
|
||||
) {
|
||||
parent::__construct($loggerInterface);
|
||||
}
|
||||
|
||||
public function action(): Response
|
||||
{
|
||||
return $this->respond(
|
||||
new ActionPayload(
|
||||
202,
|
||||
[
|
||||
'willBeDoneAt' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM)
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$app->get('/test-action-response-code', $testAction);
|
||||
$request = $this->createRequest('GET', '/test-action-response-code');
|
||||
$response = $app->handle($request);
|
||||
|
||||
$this->assertEquals(202, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ContainerExceptionInterface
|
||||
* @throws NotFoundExceptionInterface
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testActionSetsHttpCodeRespondData()
|
||||
{
|
||||
$app = $this->getAppInstance();
|
||||
$container = $app->getContainer();
|
||||
$logger = $container->get(LoggerInterface::class);
|
||||
|
||||
$testAction = new class ($logger) extends Action {
|
||||
public function __construct(
|
||||
LoggerInterface $loggerInterface
|
||||
) {
|
||||
parent::__construct($loggerInterface);
|
||||
}
|
||||
|
||||
public function action(): Response
|
||||
{
|
||||
return $this->respondWithData(
|
||||
[
|
||||
'willBeDoneAt' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM)
|
||||
],
|
||||
202
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
$app->get('/test-action-response-code', $testAction);
|
||||
$request = $this->createRequest('GET', '/test-action-response-code');
|
||||
$response = $app->handle($request);
|
||||
|
||||
$this->assertEquals(202, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Tests\unit\Application\Actions\User;
|
||||
|
||||
use Codeception\Stub\Expected;
|
||||
use DI\Container;
|
||||
use Exception;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\ActionPayload;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\User;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserRepository;
|
||||
use TorstenHettstedt\OptLogin\Tests\unit\TestCase;
|
||||
|
||||
class ListUserActionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testAction()
|
||||
{
|
||||
$app = $this->getAppInstance();
|
||||
|
||||
/** @var Container $container */
|
||||
$container = $app->getContainer();
|
||||
|
||||
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
|
||||
|
||||
$userRepository = $this->makeEmpty(UserRepository::class, [
|
||||
'findAll' => Expected::once([$user])
|
||||
]);
|
||||
|
||||
$container->set(UserRepository::class, $userRepository);
|
||||
|
||||
$request = $this->createRequest('GET', '/users');
|
||||
$response = $app->handle($request);
|
||||
|
||||
$payload = (string) $response->getBody();
|
||||
$expectedPayload = new ActionPayload(200, [$user]);
|
||||
$serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT);
|
||||
|
||||
$this->assertEquals($serializedPayload, $payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Tests\unit\Application\Actions\User;
|
||||
|
||||
use Codeception\Stub\Expected;
|
||||
use DI\Container;
|
||||
use Exception;
|
||||
use Slim\Middleware\ErrorMiddleware;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\ActionError;
|
||||
use TorstenHettstedt\OptLogin\Application\Actions\ActionPayload;
|
||||
use TorstenHettstedt\OptLogin\Application\Handlers\HttpErrorHandler;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\User;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserNotFoundException;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserRepository;
|
||||
use TorstenHettstedt\OptLogin\Tests\unit\TestCase;
|
||||
|
||||
class ViewUserActionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testAction()
|
||||
{
|
||||
$app = $this->getAppInstance();
|
||||
|
||||
/** @var Container $container */
|
||||
$container = $app->getContainer();
|
||||
|
||||
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
|
||||
|
||||
$userRepository = $this->makeEmpty(UserRepository::class, [
|
||||
'findUserOfId' => Expected::once($user)
|
||||
]);
|
||||
|
||||
$container->set(UserRepository::class, $userRepository);
|
||||
|
||||
$request = $this->createRequest('GET', '/users/1');
|
||||
$response = $app->handle($request);
|
||||
|
||||
$payload = (string) $response->getBody();
|
||||
$expectedPayload = new ActionPayload(200, $user);
|
||||
$serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT);
|
||||
|
||||
$this->assertEquals($serializedPayload, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testActionThrowsUserNotFoundException()
|
||||
{
|
||||
$app = $this->getAppInstance();
|
||||
|
||||
$callableResolver = $app->getCallableResolver();
|
||||
$responseFactory = $app->getResponseFactory();
|
||||
|
||||
$errorHandler = new HttpErrorHandler($callableResolver, $responseFactory);
|
||||
$errorMiddleware = new ErrorMiddleware($callableResolver, $responseFactory, true, false, false);
|
||||
$errorMiddleware->setDefaultErrorHandler($errorHandler);
|
||||
|
||||
$app->add($errorMiddleware);
|
||||
|
||||
/** @var Container $container */
|
||||
$container = $app->getContainer();
|
||||
|
||||
$userRepository = $this->makeEmpty(UserRepository::class, [
|
||||
'findUserOfId' => Expected::once(function() {throw new UserNotFoundException();})
|
||||
]);
|
||||
|
||||
$container->set(UserRepository::class, $userRepository);
|
||||
|
||||
$request = $this->createRequest('GET', '/users/1');
|
||||
$response = $app->handle($request);
|
||||
|
||||
$payload = (string) $response->getBody();
|
||||
$expectedError = new ActionError(ActionError::RESOURCE_NOT_FOUND, 'The user you requested does not exist.');
|
||||
$expectedPayload = new ActionPayload(404, null, $expectedError);
|
||||
$serializedPayload = json_encode($expectedPayload, JSON_PRETTY_PRINT);
|
||||
|
||||
$this->assertEquals($serializedPayload, $payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Tests\unit\Domain\User;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Domain\User\User;
|
||||
use TorstenHettstedt\OptLogin\Tests\unit\TestCase;
|
||||
|
||||
class UserTest extends TestCase
|
||||
{
|
||||
public function userProvider(): array
|
||||
{
|
||||
return [
|
||||
[1, 'bill.gates', 'Bill', 'Gates'],
|
||||
[2, 'steve.jobs', 'Steve', 'Jobs'],
|
||||
[3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'],
|
||||
[4, 'evan.spiegel', 'Evan', 'Spiegel'],
|
||||
[5, 'jack.dorsey', 'Jack', 'Dorsey'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider userProvider
|
||||
* @param int $id
|
||||
* @param string $username
|
||||
* @param string $firstName
|
||||
* @param string $lastName
|
||||
*/
|
||||
public function testGetters(int $id, string $username, string $firstName, string $lastName)
|
||||
{
|
||||
$user = new User($id, $username, $firstName, $lastName);
|
||||
|
||||
$this->assertEquals($id, $user->getId());
|
||||
$this->assertEquals($username, $user->getUsername());
|
||||
$this->assertEquals($firstName, $user->getFirstName());
|
||||
$this->assertEquals($lastName, $user->getLastName());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider userProvider
|
||||
* @param int $id
|
||||
* @param string $username
|
||||
* @param string $firstName
|
||||
* @param string $lastName
|
||||
*/
|
||||
public function testJsonSerialize(int $id, string $username, string $firstName, string $lastName)
|
||||
{
|
||||
$user = new User($id, $username, $firstName, $lastName);
|
||||
|
||||
$expectedPayload = json_encode([
|
||||
'id' => $id,
|
||||
'username' => $username,
|
||||
'firstName' => $firstName,
|
||||
'lastName' => $lastName,
|
||||
]);
|
||||
|
||||
$this->assertEquals($expectedPayload, json_encode($user));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Tests\unit\Infrastructure\Persistence\User;
|
||||
|
||||
use TorstenHettstedt\OptLogin\Domain\User\User;
|
||||
use TorstenHettstedt\OptLogin\Domain\User\UserNotFoundException;
|
||||
use TorstenHettstedt\OptLogin\Infrastructure\Persistence\User\InMemoryUserRepository;
|
||||
use TorstenHettstedt\OptLogin\Tests\unit\TestCase;
|
||||
|
||||
class InMemoryUserRepositoryTest extends TestCase
|
||||
{
|
||||
public function testFindAll()
|
||||
{
|
||||
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
|
||||
|
||||
$userRepository = new InMemoryUserRepository([1 => $user]);
|
||||
|
||||
$this->assertEquals([$user], $userRepository->findAll());
|
||||
}
|
||||
|
||||
public function testFindAllUsersByDefault()
|
||||
{
|
||||
$users = [
|
||||
1 => new User(1, 'bill.gates', 'Bill', 'Gates'),
|
||||
2 => new User(2, 'steve.jobs', 'Steve', 'Jobs'),
|
||||
3 => new User(3, 'mark.zuckerberg', 'Mark', 'Zuckerberg'),
|
||||
4 => new User(4, 'evan.spiegel', 'Evan', 'Spiegel'),
|
||||
5 => new User(5, 'jack.dorsey', 'Jack', 'Dorsey'),
|
||||
];
|
||||
|
||||
$userRepository = new InMemoryUserRepository();
|
||||
|
||||
$this->assertEquals(array_values($users), $userRepository->findAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws UserNotFoundException
|
||||
*/
|
||||
public function testFindUserOfId()
|
||||
{
|
||||
$user = new User(1, 'bill.gates', 'Bill', 'Gates');
|
||||
|
||||
$userRepository = new InMemoryUserRepository([1 => $user]);
|
||||
|
||||
$this->assertEquals($user, $userRepository->findUserOfId(1));
|
||||
}
|
||||
|
||||
public function testFindUserOfIdThrowsNotFoundException()
|
||||
{
|
||||
$userRepository = new InMemoryUserRepository([]);
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
$userRepository->findUserOfId(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace TorstenHettstedt\OptLogin\Tests\unit;
|
||||
|
||||
use Codeception\Test\Unit;
|
||||
use DI\ContainerBuilder;
|
||||
use Exception;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\App;
|
||||
use Slim\Factory\AppFactory;
|
||||
use Slim\Psr7\Factory\StreamFactory;
|
||||
use Slim\Psr7\Headers;
|
||||
use Slim\Psr7\Request as SlimRequest;
|
||||
use Slim\Psr7\Uri;
|
||||
|
||||
class TestCase extends Unit
|
||||
{
|
||||
/**
|
||||
* @return App
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getAppInstance(): App
|
||||
{
|
||||
// Instantiate PHP-DI ContainerBuilder
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
|
||||
$app_folder = __DIR__ . '/../../app';
|
||||
|
||||
// Container intentionally not compiled for tests.
|
||||
|
||||
// Set up settings
|
||||
$settings = require $app_folder . '/settings.php';
|
||||
$settings($containerBuilder);
|
||||
|
||||
// Set up dependencies
|
||||
$dependencies = require $app_folder . '/dependencies.php';
|
||||
$dependencies($containerBuilder);
|
||||
|
||||
// Set up repositories
|
||||
$repositories = require $app_folder . '/repositories.php';
|
||||
$repositories($containerBuilder);
|
||||
|
||||
// Build PHP-DI Container instance
|
||||
$container = $containerBuilder->build();
|
||||
|
||||
// Instantiate the app
|
||||
AppFactory::setContainer($container);
|
||||
$app = AppFactory::create();
|
||||
|
||||
// Register middleware
|
||||
$middleware = require $app_folder . '/middleware.php';
|
||||
$middleware($app);
|
||||
|
||||
// Register routes
|
||||
$routes = require $app_folder . '/routes.php';
|
||||
$routes($app);
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method
|
||||
* @param string $path
|
||||
* @param array $headers
|
||||
* @param array $cookies
|
||||
* @param array $serverParams
|
||||
* @return Request
|
||||
*/
|
||||
protected function createRequest(
|
||||
string $method,
|
||||
string $path,
|
||||
array $headers = ['HTTP_ACCEPT' => 'application/json'],
|
||||
array $cookies = [],
|
||||
array $serverParams = []
|
||||
): Request {
|
||||
$uri = new Uri('', '', 80, $path);
|
||||
$handle = fopen('php://temp', 'w+');
|
||||
$stream = (new StreamFactory())->createStreamFromResource($handle);
|
||||
|
||||
$h = new Headers();
|
||||
foreach ($headers as $name => $value) {
|
||||
$h->addHeader($name, $value);
|
||||
}
|
||||
|
||||
return new SlimRequest($method, $uri, $h, $cookies, $serverParams, $stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,21 @@
|
||||
@startuml
|
||||
'https://plantuml.com/sequence-diagram
|
||||
|
||||
actor Veranstalter
|
||||
actor IT
|
||||
boundary "Auth-Website" as AuthUI
|
||||
control "Auth-API" as AuthAPI
|
||||
database "Auth-Datenbank" as AuthDB
|
||||
|
||||
Veranstalter -> IT : anfrage Account
|
||||
activate AuthUI
|
||||
IT -> AuthUI : Login
|
||||
IT -> AuthUI : Neuer //Veranstalter//
|
||||
Veranstalter -> AuthUI : Angabe Benutzername
|
||||
AuthUI -> AuthAPI : Erstellung //Veranstalter//
|
||||
AuthAPI -> AuthAPI : Erstellung //OPT-Token//
|
||||
AuthAPI -> AuthDB : Speicherung der Daten des //Veranstalter//
|
||||
AuthUI <-- AuthAPI : OTP-Token für die //OTP-App//
|
||||
Veranstalter <--AuthUI : OTP-Token für die //OTP-App//
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,29 @@
|
||||
@startuml
|
||||
'https://plantuml.com/sequence-diagram
|
||||
|
||||
actor Veranstalter
|
||||
actor Client
|
||||
boundary "Auth-Website" as AuthUI
|
||||
control "Auth-API" as AuthAPI
|
||||
control "OTP-API" as OTP
|
||||
database "Auth-Datenbank" as AuthDB
|
||||
|
||||
Veranstalter -> Client : Anfrage mitarbeit?
|
||||
Client -> AuthUI : Aufruf Webseite Login
|
||||
Veranstalter -> AuthUI : Eintragung //Benutzername//-Client
|
||||
Veranstalter -> AuthUI : Eintragung //Benutzername//-Veranstalter
|
||||
Veranstalter -> AuthUI : Eintragung Rollen-Client
|
||||
Veranstalter -> AuthUI : Eintragung gültiges //OPT//
|
||||
AuthUI -> AuthAPI : Anfrage nach //Web-Token//
|
||||
AuthAPI -> AuthDB : Anfrage nach Veranstalter-Daten
|
||||
AuthAPI -> OTP : Prüfung OTP
|
||||
return
|
||||
alt OTP ist ok
|
||||
AuthAPI -> AuthDB : Speichere Daten
|
||||
AuthAPI -> AuthAPI : Erstelle //Web-Token//
|
||||
AuthUI <-- AuthAPI : //Web-Token//
|
||||
Client <-- AuthUI : //Web-Token//
|
||||
else OTP ist nicht ok
|
||||
AuthUI <-- AuthAPI : HTTP-Error 401
|
||||
end
|
||||
@enduml
|
||||
@@ -0,0 +1,36 @@
|
||||
@startuml
|
||||
'https://plantuml.com/activity-diagram-beta
|
||||
|
||||
start
|
||||
:Client möchte App-Server abfragen;
|
||||
:Client sendet Token>
|
||||
partition App-Server {
|
||||
if (Token nicht in der Datenbank) then (Ja)
|
||||
:Sende Token an Auth-Server>
|
||||
partition Auth-Server {
|
||||
:Überprüfe Token|
|
||||
:Sende Ergebnis<
|
||||
}
|
||||
if (Token ist gültig) then (Ja)
|
||||
:Speichere Token und Verfallsdatum
|
||||
in Datenbank/
|
||||
else
|
||||
:Sende HTTP-Code 401<
|
||||
stop
|
||||
endif
|
||||
else
|
||||
if (Token ist verfalle) then (Ja)
|
||||
:Sende HTTP-Code 401<
|
||||
stop
|
||||
endif
|
||||
endif
|
||||
:Rolle aus Token überprüfen]
|
||||
if (Rolle erlaubt Abfrage) then (Ja)
|
||||
:Abfrage ausführen|
|
||||
else
|
||||
:Sende HTTP-Code 403<
|
||||
endif
|
||||
}
|
||||
stop
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,28 @@
|
||||
@startuml
|
||||
'https://plantuml.com/sequence-diagram
|
||||
|
||||
actor Client
|
||||
boundary "App-Website" as AppUI
|
||||
control "App-Rest-API" as AppAPI
|
||||
|
||||
Client -> AppUI : Anfrage an Webseite
|
||||
AppUI -> AppAPI : Anfrage an Webseite
|
||||
Client --> AppUI : //Web-Token//
|
||||
AppUI --> AppAPI : //Web-Token//
|
||||
AppAPI -> AppAPI : //Web-Token// überprüfen
|
||||
alt //Web-Token// ist gültig
|
||||
AppAPI -> AppAPI : Client-Rolle überprüfen
|
||||
alt Rolle ist gültig
|
||||
AppAPI -> AppAPI : Anfrage auswerten
|
||||
AppUI <-- AppAPI :
|
||||
Client <-- AppUI :
|
||||
else
|
||||
AppUI <- AppAPI : HTTP-Code 403
|
||||
Client <- AppUI : HTTP-Code 403
|
||||
end
|
||||
else
|
||||
AppUI <- AppAPI : HTTP-Code 401
|
||||
Client <- AppUI : HTTP-Code 401
|
||||
end
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,30 @@
|
||||
@startuml
|
||||
'https://plantuml.com/component-diagram
|
||||
|
||||
|
||||
node Webseite {
|
||||
component UI
|
||||
folder Cookies
|
||||
UI <--> Cookies
|
||||
}
|
||||
node "App-API" {
|
||||
component API
|
||||
database "DB" as AppDB {
|
||||
folder "Clients" as BekannteClients
|
||||
}
|
||||
API <--> AppDB
|
||||
}
|
||||
node "Auth-API" {
|
||||
component "API" as AuthAPI
|
||||
database "DB" as AuthDB{
|
||||
folder "Administratoren"
|
||||
folder "Veranstalter"
|
||||
folder "Clients"
|
||||
}
|
||||
AuthAPI <--> AuthDB
|
||||
}
|
||||
|
||||
UI <-> API
|
||||
API <-> AuthAPI
|
||||
|
||||
@enduml
|
||||
Reference in New Issue
Block a user