From 46aaf46619cab70fa37ddf5c4530dfa61e613fc2 Mon Sep 17 00:00:00 2001
From: JVT038 <47184046+JVT038@users.noreply.github.com>
Date: Fri, 26 Jan 2024 11:50:02 +0100
Subject: [PATCH 01/27] Add API route for retreiving auth token Switch the
login process from the web to api controller
---
docs/openapi.json | 83 +++++++++++++++++++
public/js/login.js | 36 ++++++++
settings/routes.php | 1 +
src/Domain/User/Exception/InvalidTotpCode.php | 11 +++
src/Domain/User/Service/Authentication.php | 34 ++++++--
.../Api/AuthenticationController.php | 66 +++++++++++++++
.../Web/AuthenticationController.php | 1 +
.../Web/LandingPageController.php | 17 ----
src/ValueObject/Http/Request.php | 14 +++-
templates/page/login.html.twig | 66 +++++++--------
10 files changed, 268 insertions(+), 61 deletions(-)
create mode 100644 public/js/login.js
create mode 100644 src/Domain/User/Exception/InvalidTotpCode.php
create mode 100644 src/HttpController/Api/AuthenticationController.php
diff --git a/docs/openapi.json b/docs/openapi.json
index 83a6e182..9a277de7 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -1065,6 +1065,89 @@
}
}
}
+ },
+ "/authentication/request-token": {
+ "post": {
+ "description": "Get an authentication token by submitting an email, password and optionally a time-based 6-digit code. This token can be stored and used for future requests to protected endpoints.",
+ "parameters": [{
+ "in": "header",
+ "name": "X-Movary-Client",
+ "schema": {
+ "type": "string"
+ },
+ "required": true,
+ "example": "Client Name"
+ }],
+ "requestBody": {
+ "description": "The credentials and optionally a two-factor Authentication code",
+ "required": true,
+ "content": {
+ "application/x-www-form-urlencoded": {
+ "schema": {
+ "type": "object",
+ "required": ["email", "password"],
+ "properties": {
+ "email": {
+ "type": "string",
+ "example": "user@email.com",
+ "description": "An email address"
+ },
+ "password": {
+ "type": "string",
+ "example": "mysecurepassword123",
+ "description": "A password"
+ },
+ "totpCode": {
+ "type": "integer",
+ "pattern": "/^[0-9]{6}$/gm",
+ "example": "123456",
+ "description": "A 6-digit two-factor authentication code",
+ "nullable": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "The authentication token to be used in future requests."
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "An authentication error. Either missing credentials or the credentials were invalid.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "The name of the error"
+ },
+ "message": {
+ "type": "string",
+ "description": "The message of the error"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
diff --git a/public/js/login.js b/public/js/login.js
new file mode 100644
index 00000000..1ce090e5
--- /dev/null
+++ b/public/js/login.js
@@ -0,0 +1,36 @@
+const MOVARY_CLIENT_IDENTIFIER = 'Movary Web';
+
+async function submitCredentials() {
+ const request = await fetch('/api/authentication/request-token', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json',
+ 'X-Movary-Client': MOVARY_CLIENT_IDENTIFIER
+ },
+ body: JSON.stringify({
+ 'email': document.getElementById('email').value,
+ 'password': document.getElementById('password').value,
+ 'rememberMe': document.getElementById('rememberMe').checked,
+ 'totpCode': document.getElementById('totpCode').value
+ })
+ }).then((response) => {
+ return response;
+ });
+
+ if(request.redirected === true) {
+ window.location.replace(request.url);
+ } else {
+ await request.json().then(error => {
+ if(error['error'] === 'NoVerificationCode') {
+ document.getElementById('LoginForm').classList.add('d-none');
+ document.getElementById('TotpForm').classList.remove('d-none');
+ } else if(error['error'] === 'InvalidTotpCode') {
+ addAlert('totpErrors', error['message'], 'danger', false);
+ } else {
+ addAlert('loginErrors', error['message'], 'danger', false);
+ }
+ }).catch(error => {
+ console.error(error);
+ });
+ }
+}
\ No newline at end of file
diff --git a/settings/routes.php b/settings/routes.php
index 7c49d23d..745d4ae9 100644
--- a/settings/routes.php
+++ b/settings/routes.php
@@ -203,6 +203,7 @@ function addApiRoutes(RouterService $routerService, FastRoute\RouteCollector $ro
$routes = RouteList::create();
$routes->add('GET', '/openapi', [Api\OpenApiController::class, 'getSchema']);
+ $routes->add('POST', '/authentication/request-token', [Api\AuthenticationController::class, 'requestToken']);
$routeUserHistory = '/users/{username:[a-zA-Z0-9]+}/history/movies';
$routes->add('GET', $routeUserHistory, [Api\HistoryController::class, 'getHistory'], [Api\Middleware\IsAuthorizedToReadUserData::class]);
diff --git a/src/Domain/User/Exception/InvalidTotpCode.php b/src/Domain/User/Exception/InvalidTotpCode.php
new file mode 100644
index 00000000..c355440b
--- /dev/null
+++ b/src/Domain/User/Exception/InvalidTotpCode.php
@@ -0,0 +1,11 @@
+getId() === $userId;
}
- public function login(string $email, string $password, bool $rememberMe) : void
+ public function login(string $email, string $password, bool $rememberMe, ?int $userTotpInput = null) : void
{
if ($this->isUserAuthenticated() === true) {
return;
@@ -127,9 +131,12 @@ public function login(string $email, string $password, bool $rememberMe) : void
$totpUri = $this->userApi->findTotpUri($user->getId());
if ($totpUri !== null) {
- $this->sessionWrapper->set('totpUserId', $user->getId());
- $this->sessionWrapper->set('rememberMe', $rememberMe);
- throw NoVerificationCode::create();
+ if(empty($userTotpInput)) {
+ throw NoVerificationCode::create();
+ }
+ if($this->verifyTotp($user->getId(), $userTotpInput) === false) {
+ throw InvalidTotpCode::create();
+ }
}
$this->createAuthenticationCookie($user->getId(), $rememberMe);
@@ -148,7 +155,8 @@ public function createAuthenticationCookie(int $userId, bool $rememberMe) : void
$token = $this->generateToken($userId, DateTime::createFromString((string)$authTokenExpirationDate));
session_regenerate_id();
- setcookie(self::AUTHENTICATION_COOKIE_NAME, $token, $cookieExpiration);
+ setcookie(self::AUTHENTICATION_COOKIE_NAME, $token, $cookieExpiration, '/');
+ // setcookie(self::AUTHENTICATION_COOKIE_NAME, $token, $cookieExpiration, '/', $this->serverSettings->getApplicationUrl(), true, true);
$this->sessionWrapper->set('userId', $userId);
}
@@ -191,7 +199,7 @@ private function generateToken(int $userId, ?DateTime $expirationDate = null) :
return $token;
}
- private function isValidToken(string $token) : bool
+ public function isValidToken(string $token) : bool
{
$tokenExpirationDate = $this->repository->findAuthTokenExpirationDate($token);
@@ -205,4 +213,18 @@ private function isValidToken(string $token) : bool
return true;
}
+
+ public function getToken() : ?string
+ {
+ return $_COOKIE[self::AUTHENTICATION_COOKIE_NAME];
+ }
+
+
+ private function verifyTotp($userId, $userTotpInput) : bool
+ {
+ if ($this->twoFactorAuthenticationApi->verifyTotpUri($userId, (int)$userTotpInput) === false) {
+ return false;
+ }
+ return true;
+ }
}
diff --git a/src/HttpController/Api/AuthenticationController.php b/src/HttpController/Api/AuthenticationController.php
new file mode 100644
index 00000000..ae1b125c
--- /dev/null
+++ b/src/HttpController/Api/AuthenticationController.php
@@ -0,0 +1,66 @@
+getBody());
+ $headers = $request->getHeaders();
+ if($postParameters['email'] === null || $postParameters['password'] === null) {
+ return Response::createBadRequest('Username or password has not been provided');
+ }
+ $totpCode = $postParameters['totpCode'] ?? 0;
+
+ if(isset($headers['X-Movary-Client']) === false) {
+ return Response::createBadRequest();
+ }
+
+ $client = $headers['X-Movary-Client'];
+
+ try {
+ $this->authenticationService->login($postParameters['email'], $postParameters['password'], false, (int)$totpCode);
+
+ if($client === 'Movary Web') {
+ $redirect = $postParameters['redirect'] ?? null;
+ $target = $redirect ?? $_SERVER['HTTP_REFERER'];
+
+ $urlParts = parse_url($target);
+ if (is_array($urlParts) === false) {
+ $urlParts = ['path' => '/'];
+ }
+ $query = $urlParts['query'] ?? '';
+
+ /* @phpstan-ignore-next-line */
+ $targetRelativeUrl = $urlParts['path'] . $query;
+
+ return Response::createSeeOther($targetRelativeUrl);
+ }
+
+ return Response::createJson(Json::encode(['token' => $this->authenticationService->getToken()]));
+ } catch (InvalidCredentials $e) {
+ return Response::createBadRequest(Json::encode([
+ 'error' => basename(str_replace('\\', '/', get_class($e))),
+ 'message' => $e->getMessage()
+ ]));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/HttpController/Web/AuthenticationController.php b/src/HttpController/Web/AuthenticationController.php
index faa2dd71..86977af9 100644
--- a/src/HttpController/Web/AuthenticationController.php
+++ b/src/HttpController/Web/AuthenticationController.php
@@ -4,6 +4,7 @@
use Movary\Domain\SessionService;
use Movary\Domain\User\Exception\InvalidCredentials;
+use Movary\Domain\User\Exception\InvalidTotpCode;
use Movary\Domain\User\Exception\NoVerificationCode;
use Movary\Domain\User\Service;
use Movary\Util\SessionWrapper;
diff --git a/src/HttpController/Web/LandingPageController.php b/src/HttpController/Web/LandingPageController.php
index 63fb1842..d5ef5c6e 100644
--- a/src/HttpController/Web/LandingPageController.php
+++ b/src/HttpController/Web/LandingPageController.php
@@ -22,30 +22,13 @@ public function __construct(
public function render() : Response
{
- $failedLogin = $this->sessionWrapper->has('failedLogin');
$deletedAccount = $this->sessionWrapper->has('deletedAccount');
- $invalidTotpCode = $this->sessionWrapper->has('invalidTotpCode');
- $useTwoFactorAuthentication = $this->sessionWrapper->has('useTwoFactorAuthentication');
-
- $this->sessionWrapper->unset('failedLogin', 'deletedAccount', 'invalidTotpCode', 'useTwoFactorAuthentication');
- if ($invalidTotpCode === true) {
- $useTwoFactorAuthentication = true;
- }
-
- if ($useTwoFactorAuthentication === false) {
- $this->sessionWrapper->unset('rememberMe');
- }
return Response::create(
StatusCode::createOk(),
$this->twig->render('page/login.html.twig', [
- 'failedLogin' => $failedLogin,
'deletedAccount' => $deletedAccount,
'registrationEnabled' => $this->registrationEnabled,
- 'defaultEmail' => $this->defaultEmail,
- 'defaultPassword' => $this->defaultPassword,
- 'useTwoFactorAuthentication' => $useTwoFactorAuthentication,
- 'invalidTotpCode' => $invalidTotpCode,
]),
);
}
diff --git a/src/ValueObject/Http/Request.php b/src/ValueObject/Http/Request.php
index b39ea9a8..75ec570d 100644
--- a/src/ValueObject/Http/Request.php
+++ b/src/ValueObject/Http/Request.php
@@ -15,6 +15,7 @@ private function __construct(
private readonly string $body,
private readonly array $filesParameters,
private readonly array $headers,
+ private readonly string $userAgent
) {
}
@@ -27,10 +28,11 @@ public static function createFromGlobals() : self
$postParameters = self::extractPostParameter();
$filesParameters = self::extractFilesParameter();
$headers = self::extractHeaders();
+ $userAgent = self::extractUserAgent();
$body = (string)file_get_contents('php://input');
- return new self($path, $getParameters, $postParameters, $body, $filesParameters, $headers);
+ return new self($path, $getParameters, $postParameters, $body, $filesParameters, $headers, $userAgent);
}
private static function extractFilesParameter() : array
@@ -87,6 +89,11 @@ private static function extractRequestUri() : string
return self::getServerSetting('REQUEST_URI') ?? '';
}
+ private static function extractUserAgent() : string
+ {
+ return self::getServerSetting('HTTP_USER_AGENT') ?? '';
+ }
+
private static function getServerSetting(string $key) : ?string
{
return empty($_SERVER[$key]) === false ? (string)$_SERVER[$key] : null;
@@ -131,4 +138,9 @@ public function getRouteParameters() : array
{
return $this->routeParameters;
}
+
+ public function getUserAgent() : string
+ {
+ return $this->userAgent;
+ }
}
diff --git a/templates/page/login.html.twig b/templates/page/login.html.twig
index 2b8ef1ab..ca86d15f 100644
--- a/templates/page/login.html.twig
+++ b/templates/page/login.html.twig
@@ -8,74 +8,66 @@
{% endblock %}
+{% block scripts %}
+
+{% endblock %}
+
{% block body %}
-
- {% if useTwoFactorAuthentication == false %}
-
{% endblock %}
From d4ee3ce6bcb7735708b66947b4a0b6d4a11c5c7e Mon Sep 17 00:00:00 2001
From: JVT038 <47184046+JVT038@users.noreply.github.com>
Date: Fri, 26 Jan 2024 11:58:13 +0100
Subject: [PATCH 02/27] Clean up
---
src/Domain/User/Service/Authentication.php | 3 --
.../Web/AuthenticationController.php | 33 -------------------
templates/page/login.html.twig | 7 ++--
3 files changed, 3 insertions(+), 40 deletions(-)
diff --git a/src/Domain/User/Service/Authentication.php b/src/Domain/User/Service/Authentication.php
index f272f2bb..086fba52 100644
--- a/src/Domain/User/Service/Authentication.php
+++ b/src/Domain/User/Service/Authentication.php
@@ -9,7 +9,6 @@
use Movary\Domain\User\UserApi;
use Movary\Domain\User\UserEntity;
use Movary\Domain\User\UserRepository;
-use Movary\Service\ServerSettings;
use Movary\Util\SessionWrapper;
use Movary\ValueObject\DateTime;
use Movary\ValueObject\Http\Request;
@@ -24,7 +23,6 @@ public function __construct(
private readonly UserRepository $repository,
private readonly UserApi $userApi,
private readonly SessionWrapper $sessionWrapper,
- private readonly ServerSettings $serverSettings,
private readonly TwoFactorAuthenticationApi $twoFactorAuthenticationApi,
) {
}
@@ -156,7 +154,6 @@ public function createAuthenticationCookie(int $userId, bool $rememberMe) : void
session_regenerate_id();
setcookie(self::AUTHENTICATION_COOKIE_NAME, $token, $cookieExpiration, '/');
- // setcookie(self::AUTHENTICATION_COOKIE_NAME, $token, $cookieExpiration, '/', $this->serverSettings->getApplicationUrl(), true, true);
$this->sessionWrapper->set('userId', $userId);
}
diff --git a/src/HttpController/Web/AuthenticationController.php b/src/HttpController/Web/AuthenticationController.php
index 86977af9..460fc2f4 100644
--- a/src/HttpController/Web/AuthenticationController.php
+++ b/src/HttpController/Web/AuthenticationController.php
@@ -23,39 +23,6 @@ public function __construct(
) {
}
- public function login(Request $request) : Response
- {
- $postParameters = $request->getPostParameters();
-
- try {
- $this->authenticationService->login(
- $postParameters['email'],
- $postParameters['password'],
- isset($postParameters['rememberMe']) === true,
- );
- } catch (NoVerificationCode) {
- $this->sessionWrapper->set('useTwoFactorAuthentication', true);
- } catch (InvalidCredentials) {
- $this->sessionWrapper->set('failedLogin', true);
- }
- $redirect = $postParameters['redirect'];
- $target = $redirect ?? $_SERVER['HTTP_REFERER'];
-
- $urlParts = parse_url($target);
- if (is_array($urlParts) === false) {
- $urlParts = ['path' => '/'];
- }
-
- /* @phpstan-ignore-next-line */
- $targetRelativeUrl = $urlParts['path'] . $urlParts['query'] ?? '';
-
- return Response::create(
- StatusCode::createSeeOther(),
- null,
- [Header::createLocation($targetRelativeUrl)],
- );
- }
-
public function logout() : Response
{
$this->authenticationService->logout();
diff --git a/templates/page/login.html.twig b/templates/page/login.html.twig
index ca86d15f..ba58081a 100644
--- a/templates/page/login.html.twig
+++ b/templates/page/login.html.twig
@@ -20,19 +20,19 @@