diff --git a/phpstan.neon b/phpstan.neon index bc6c041..715fe84 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -9,5 +9,7 @@ parameters: - sql/database.php - profiles/dev-config-profile.php - profiles/prod-config-profile.php + - public/api/index.php excludePaths: - src/react-display-file.php + - public/api/index.php diff --git a/public/api/index.php b/public/api/index.php index 3ed5caa..8cc7287 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -6,32 +6,134 @@ require "../../sql/database.php"; require "../utils.php"; use App\Connexion; +use App\Controller\Api\APIAuthController; use App\Controller\Api\APITacticController; +use App\Data\Account; +use App\Gateway\AccountGateway; use App\Gateway\TacticInfoGateway; +use App\Http\HttpResponse; use App\Http\JsonHttpResponse; use App\Http\ViewHttpResponse; +use App\Model\AuthModel; use App\Model\TacticModel; +use App\Session\SessionHandle; +use App\Validation\ValidationFail; -$con = new Connexion(get_database()); +function getTacticController(): APITacticController { + return new APITacticController(new TacticModel(new TacticInfoGateway(new Connexion(get_database())))); +} + +function getAuthController(): APIAuthController { + return new APIAuthController(new AuthModel(new AccountGateway(new Connexion(get_database())))); +} + +class Action { + /** + * @var callable(mixed[]): HttpResponse $action action to call + */ + private $action; + + private bool $isAuthRequired; + + /** + * @param callable(mixed[]): HttpResponse $action + */ + private function __construct(callable $action, bool $isAuthRequired) { + $this->action = $action; + $this->isAuthRequired = $isAuthRequired; + } + + public function isAuthRequired(): bool { + return $this->isAuthRequired; + } + + /** + * @param mixed[] $params + * @param ?Account $account + * @return HttpResponse + */ + public function run(array $params, ?Account $account): HttpResponse { + $params = array_values($params); + if ($this->isAuthRequired) { + if ($account == null) { + throw new Exception("action requires authorization."); + } + $params[] = $account; + } + + return call_user_func_array($this->action, $params); + } + + /** + * @param callable(mixed[]): HttpResponse $action + * @return Action an action that does not require to have an authorization. + */ + public static function noAuth(callable $action): Action { + return new Action($action, false); + } + + /** + * @param callable(mixed[]): HttpResponse $action + * @return Action an action that does require to have an authorization. + */ + public static function auth(callable $action): Action { + return new Action($action, true); + } +} + +/** + * @param mixed[] $match + * @return HttpResponse + * @throws Exception + */ +function handleMatch(array $match): HttpResponse { + if (!$match) { + return new JsonHttpResponse([ValidationFail::notFound("not found")]); + } + + $action = $match['target']; + if (!$action instanceof Action) { + throw new Exception("routed action is not an Action object."); + } + + $auth = null; + + if ($action->isAuthRequired()) { + $auth = tryGetAuthAccount(); + if ($auth == null) { + return new JsonHttpResponse([ValidationFail::unauthorized("Missing or invalid 'Authorization' header")]); + } + } + + return $action->run($match['params'], $auth); +} + +function tryGetAuthAccount(): ?Account { + $headers = getallheaders(); + + // If no authorization header is set, try fallback to php session. + if (!isset($headers['Authorization'])) { + $session = SessionHandle::init(); + return $session->getAccount(); + } + + $token = $headers['Authorization']; + $gateway = new AccountGateway(new Connexion(get_database())); + return $gateway->getAccountFromToken($token); +} $router = new AltoRouter(); $router->setBasePath(get_public_path() . "/api"); -$tacticEndpoint = new APITacticController(new TacticModel(new TacticInfoGateway($con))); -$router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->updateName($id)); -$router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->getTacticInfo($id)); -$router->map("POST", "/tactic/new", fn() => $tacticEndpoint->newTactic()); +$router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc))); +$router->map("GET", "/tactic/[i:id]", Action::auth(fn(int $id, Account $acc) => getTacticController()->getTacticInfo($id, $acc))); +$router->map("POST", "/tactic/new", Action::auth(fn(Account $acc) => getTacticController()->newTactic($acc))); +$router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize())); $match = $router->match(); -if ($match == null) { - echo "404 not found"; - header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); - exit(1); -} - -$response = call_user_func_array($match['target'], $match['params']); +$response = handleMatch($match); http_response_code($response->getCode()); if ($response instanceof JsonHttpResponse) { diff --git a/public/index.php b/public/index.php index ce1df33..7d42305 100644 --- a/public/index.php +++ b/public/index.php @@ -8,9 +8,9 @@ require "utils.php"; require "../src/react-display.php"; use App\Controller\FrontController; -use App\Session\PhpSessionHandle; +use App\Session\SessionHandle; $basePath = get_public_path(); $frontController = new FrontController($basePath); -$frontController->run(PhpSessionHandle::init()); +$frontController->run(SessionHandle::init()); diff --git a/src/Controller/Api/APIAuthController.php b/src/Controller/Api/APIAuthController.php new file mode 100644 index 0000000..d9e4c23 --- /dev/null +++ b/src/Controller/Api/APIAuthController.php @@ -0,0 +1,39 @@ +model = $model; + } + + + public function authorize(): HttpResponse { + return Control::runChecked([ + "email" => [Validators::regex("/^\\S+@\\S+\\.\\S+$/"), Validators::lenBetween(5, 256)], + "password" => [Validators::lenBetween(6, 256)], + ], function (HttpRequest $req) { + $failures = []; + $account = $this->model->login($req["email"], $req["password"], $failures); + + if (!empty($failures)) { + return new JsonHttpResponse($failures); + } + + return new JsonHttpResponse(["authorization" => $account->getToken()]); + }, true); + } + +} diff --git a/src/Controller/Api/APITacticController.php b/src/Controller/Api/APITacticController.php index ec0edc8..c1fcee3 100644 --- a/src/Controller/Api/APITacticController.php +++ b/src/Controller/Api/APITacticController.php @@ -3,6 +3,7 @@ namespace App\Controller\Api; use App\Controller\Control; +use App\Data\Account; use App\Http\HttpCodes; use App\Http\HttpRequest; use App\Http\HttpResponse; @@ -23,26 +24,33 @@ class APITacticController { $this->model = $model; } - public function updateName(int $tactic_id): HttpResponse { + public function updateName(int $tactic_id, Account $account): HttpResponse { return Control::runChecked([ "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()], - ], function (HttpRequest $request) use ($tactic_id) { - $this->model->updateName($tactic_id, $request["name"]); + ], function (HttpRequest $request) use ($tactic_id, $account) { + + $failures = $this->model->updateName($tactic_id, $request["name"], $account->getId()); + + if (!empty($failures)) { + //TODO find a system to handle Unauthorized error codes more easily from failures. + return new JsonHttpResponse($failures, HttpCodes::BAD_REQUEST); + } + return HttpResponse::fromCode(HttpCodes::OK); }, true); } - public function newTactic(): HttpResponse { + public function newTactic(Account $account): HttpResponse { return Control::runChecked([ "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()], - ], function (HttpRequest $request) { - $tactic = $this->model->makeNew($request["name"]); + ], function (HttpRequest $request) use ($account) { + $tactic = $this->model->makeNew($request["name"], $account->getId()); $id = $tactic->getId(); return new JsonHttpResponse(["id" => $id]); }, true); } - public function getTacticInfo(int $id): HttpResponse { + public function getTacticInfo(int $id, Account $account): HttpResponse { $tactic_info = $this->model->get($id); if ($tactic_info == null) { diff --git a/src/Controller/FrontController.php b/src/Controller/FrontController.php index 3d73d80..d1bbd6d 100644 --- a/src/Controller/FrontController.php +++ b/src/Controller/FrontController.php @@ -42,7 +42,7 @@ class FrontController { $this->handleMatch($match, $session); } else { $this->displayViewByKind(ViewHttpResponse::twig("error.html.twig", [ - 'failures' => [ValidationFail::notFound("Could not find page ${_SERVER['REQUEST_URI']}.")] + 'failures' => [ValidationFail::notFound("Could not find page ${_SERVER['REQUEST_URI']}.")], ], HttpCodes::NOT_FOUND)); } } @@ -108,7 +108,7 @@ class FrontController { return call_user_func_array([$controller, $action], $params); } else { return ViewHttpResponse::twig("error.html.twig", [ - 'failures' => [ValidationFail::notFound("Could not find page ${_SERVER['REQUEST_URI']}.")] + 'failures' => [ValidationFail::notFound("Could not find page ${_SERVER['REQUEST_URI']}.")], ], HttpCodes::NOT_FOUND); } } diff --git a/src/Controller/Sub/AuthController.php b/src/Controller/Sub/AuthController.php index fc72f67..fcf12d5 100644 --- a/src/Controller/Sub/AuthController.php +++ b/src/Controller/Sub/AuthController.php @@ -2,7 +2,6 @@ namespace App\Controller\Sub; -use App\Gateway\AccountGateway; use App\Http\HttpRequest; use App\Http\HttpResponse; use App\Http\ViewHttpResponse; diff --git a/src/Controller/Sub/EditorController.php b/src/Controller/Sub/EditorController.php index 1188bc9..dff6e8a 100644 --- a/src/Controller/Sub/EditorController.php +++ b/src/Controller/Sub/EditorController.php @@ -40,7 +40,7 @@ class EditorController { public function edit(int $id, SessionHandle $session): HttpResponse { $tactic = $this->model->get($id); - $failure = TacticValidator::validateAccess($tactic, $session->getAccount()->getId()); + $failure = TacticValidator::validateAccess($tactic, $id, $session->getAccount()->getId()); if ($failure != null) { return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND); diff --git a/src/Controller/Sub/VisualizerController.php b/src/Controller/Sub/VisualizerController.php index f70b18e..c7e5098 100644 --- a/src/Controller/Sub/VisualizerController.php +++ b/src/Controller/Sub/VisualizerController.php @@ -25,7 +25,7 @@ class VisualizerController { public function visualize(int $id, SessionHandle $session): HttpResponse { $tactic = $this->tacticModel->get($id); - $failure = TacticValidator::validateAccess($tactic, $session->getAccount()->getId()); + $failure = TacticValidator::validateAccess($tactic, $id, $session->getAccount()->getId()); if ($failure != null) { return ViewHttpResponse::twig('error.html.twig', ['failures' => [$failure]], HttpCodes::NOT_FOUND); diff --git a/src/Controller/VisitorController.php b/src/Controller/VisitorController.php index 56e0ec2..e74934a 100644 --- a/src/Controller/VisitorController.php +++ b/src/Controller/VisitorController.php @@ -9,9 +9,7 @@ use App\Model\AuthModel; use App\Session\MutableSessionHandle; class VisitorController { - - - public final function register(MutableSessionHandle $session): HttpResponse { + final public function register(MutableSessionHandle $session): HttpResponse { $model = new AuthModel(new AccountGateway(new Connexion(get_database()))); if ($_SERVER['REQUEST_METHOD'] === 'GET') { return (new Sub\AuthController($model))->displayRegister(); @@ -19,7 +17,7 @@ class VisitorController { return (new Sub\AuthController($model))->confirmRegister($_POST, $session); } - public final function login(MutableSessionHandle $session): HttpResponse { + final public function login(MutableSessionHandle $session): HttpResponse { $model = new AuthModel(new AccountGateway(new Connexion(get_database()))); if ($_SERVER['REQUEST_METHOD'] === 'GET') { return (new Sub\AuthController($model))->displayLogin(); @@ -27,4 +25,4 @@ class VisitorController { return (new Sub\AuthController($model))->confirmLogin($_POST, $session); } -} \ No newline at end of file +} diff --git a/src/Gateway/AccountGateway.php b/src/Gateway/AccountGateway.php index 8320f33..3a57abb 100644 --- a/src/Gateway/AccountGateway.php +++ b/src/Gateway/AccountGateway.php @@ -17,39 +17,56 @@ class AccountGateway { } - public function exists(string $email): bool { - return $this->getAccount($email) != null; - } - - public function insertAccount(string $name, string $email, string $token, string $hash): int { $this->con->exec("INSERT INTO Account(username, hash, email, token) VALUES (:username,:hash,:email,:token)", [ ':username' => [$name, PDO::PARAM_STR], ':hash' => [$hash, PDO::PARAM_STR], ':email' => [$email, PDO::PARAM_STR], - ':token' => [$token, PDO::PARAM_STR] + ':token' => [$token, PDO::PARAM_STR], ]); return intval($this->con->lastInsertId()); } - public function getHash(string $email): string { - $results = $this->con->fetch("SELECT hash FROM Account WHERE email = :email", [ - ':email' => [$email, PDO::PARAM_STR] - ]); - return $results[0]['hash']; + /** + * @param string $email + * @return array|null + */ + private function getRowsFromMail(string $email): ?array { + return $this->con->fetch("SELECT * FROM Account WHERE email = :email", [':email' => [$email, PDO::PARAM_STR]])[0] ?? null; + } + + + public function getHash(string $email): ?string { + $results = $this->getRowsFromMail($email); + if ($results == null) { + return null; + } + return $results['hash']; } + public function exists(string $email): bool { + return $this->getRowsFromMail($email) != null; + } /** * @param string $email * @return Account|null */ - public function getAccount(string $email): ?Account { - $results = $this->con->fetch("SELECT * FROM Account WHERE email = :email", [':email' => [$email, PDO::PARAM_STR]]); - if (empty($results)) + public function getAccountFromMail(string $email): ?Account { + $acc = $this->getRowsFromMail($email); + if (empty($acc)) { + return null; + } + + return new Account($email, $acc["username"], $acc["token"], $acc["id"]); + } + + public function getAccountFromToken(string $token): ?Account { + $acc = $this->con->fetch("SELECT * FROM Account WHERE token = :token", [':token' => [$token, PDO::PARAM_STR]])[0] ?? null; + if (empty($acc)) { return null; + } - $acc = $results[0]; return new Account($acc["email"], $acc["username"], $acc["token"], $acc["id"]); } diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php index 6aa2cca..efa0a7c 100644 --- a/src/Gateway/TacticInfoGateway.php +++ b/src/Gateway/TacticInfoGateway.php @@ -36,7 +36,7 @@ class TacticInfoGateway { "INSERT INTO TacticInfo(name, owner) VALUES(:name, :owner)", [ ":name" => [$name, PDO::PARAM_STR], - ":owner" => [$owner, PDO::PARAM_INT] + ":owner" => [$owner, PDO::PARAM_INT], ] ); $row = $this->con->fetch( diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php index d6d6a9a..e5db3e8 100644 --- a/src/Http/HttpResponse.php +++ b/src/Http/HttpResponse.php @@ -3,7 +3,6 @@ namespace App\Http; class HttpResponse { - /** * @var array */ diff --git a/src/Model/AuthModel.php b/src/Model/AuthModel.php index a12ce9e..1739ab5 100644 --- a/src/Model/AuthModel.php +++ b/src/Model/AuthModel.php @@ -69,7 +69,7 @@ class AuthModel { return null; } - return $this->gateway->getAccount($email); + return $this->gateway->getAccountFromMail($email); } diff --git a/src/Model/TacticModel.php b/src/Model/TacticModel.php index 73cfd91..8111963 100644 --- a/src/Model/TacticModel.php +++ b/src/Model/TacticModel.php @@ -2,8 +2,10 @@ namespace App\Model; +use App\Data\Account; use App\Data\TacticInfo; use App\Gateway\TacticInfoGateway; +use App\Validation\ValidationFail; class TacticModel { public const TACTIC_DEFAULT_NAME = "Nouvelle tactique"; @@ -39,15 +41,22 @@ class TacticModel { * Update the name of a tactic * @param int $id the tactic identifier * @param string $name the new name to set - * @return bool true if the update was done successfully + * @return ValidationFail[] failures, if any */ - public function updateName(int $id, string $name): bool { - if ($this->tactics->get($id) == null) { - return false; + public function updateName(int $id, string $name, int $authId): array { + + $tactic = $this->tactics->get($id); + + if ($tactic == null) { + return [ValidationFail::notFound("Could not find tactic")]; + } + + if ($tactic->getOwnerId() != $authId) { + return [ValidationFail::unauthorized()]; } $this->tactics->updateName($id, $name); - return true; + return []; } } diff --git a/src/Session/MutableSessionHandle.php b/src/Session/MutableSessionHandle.php index e142049..2bcb0fc 100644 --- a/src/Session/MutableSessionHandle.php +++ b/src/Session/MutableSessionHandle.php @@ -5,6 +5,5 @@ namespace App\Session; use App\Data\Account; interface MutableSessionHandle extends SessionHandle { - public function setAccount(Account $account): void; -} \ No newline at end of file +} diff --git a/src/Session/PhpSessionHandle.php b/src/Session/PhpSessionHandle.php index ea0e7c2..09d862c 100644 --- a/src/Session/PhpSessionHandle.php +++ b/src/Session/PhpSessionHandle.php @@ -5,8 +5,7 @@ namespace App\Session; use App\Data\Account; class PhpSessionHandle implements MutableSessionHandle { - - public static function init(): PhpSessionHandle { + public static function init(): SessionHandle { if (session_status() !== PHP_SESSION_NONE) { throw new \Exception("A php session is already started !"); } @@ -21,4 +20,4 @@ class PhpSessionHandle implements MutableSessionHandle { public function setAccount(Account $account): void { $_SESSION["account"] = $account; } -} \ No newline at end of file +} diff --git a/src/Session/SessionHandle.php b/src/Session/SessionHandle.php index 5a65794..6ffb9c0 100644 --- a/src/Session/SessionHandle.php +++ b/src/Session/SessionHandle.php @@ -5,7 +5,5 @@ namespace App\Session; use App\Data\Account; interface SessionHandle { - public function getAccount(): ?Account; - -} \ No newline at end of file +} diff --git a/src/Validation/ValidationFail.php b/src/Validation/ValidationFail.php index 83d6c76..2229a53 100644 --- a/src/Validation/ValidationFail.php +++ b/src/Validation/ValidationFail.php @@ -37,4 +37,8 @@ class ValidationFail implements JsonSerializable { return new ValidationFail("Not found", $message); } + public static function unauthorized(string $message = "Unauthorized"): ValidationFail { + return new ValidationFail("Unauthorized", $message); + } + } diff --git a/src/Validator/TacticValidator.php b/src/Validator/TacticValidator.php index 711fb72..e39cd51 100644 --- a/src/Validator/TacticValidator.php +++ b/src/Validator/TacticValidator.php @@ -6,17 +6,16 @@ use App\Data\TacticInfo; use App\Validation\ValidationFail; class TacticValidator { - - public static function validateAccess(?TacticInfo $tactic, int $ownerId): ?ValidationFail { + public static function validateAccess(?TacticInfo $tactic, int $tacticId, int $ownerId): ?ValidationFail { if ($tactic == null) { - return ValidationFail::notFound("La tactique " . $tactic->getId() . " n'existe pas"); + return ValidationFail::notFound("La tactique $tacticId n'existe pas"); } if ($tactic->getOwnerId() != $ownerId) { - return new ValidationFail("Unauthorized", "Vous ne pouvez pas accéder à cette tactique.",); + return ValidationFail::unauthorized("Vous ne pouvez pas accéder à cette tactique."); } return null; } -} \ No newline at end of file +}