Merged in feature/MAPG-115-guard-endpoints-based-on-user-g (pull request #89)

Feature/MAPG-115 guard endpoints based on user g
This commit is contained in:
Bence Pőcze 2020-06-09 19:05:23 +00:00
commit 2f2a7968a0
20 changed files with 614 additions and 52 deletions

View File

@ -0,0 +1,8 @@
CREATE TABLE `users` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(100) NOT NULL,
`password` varchar(60) NOT NULL,
`type` enum('user', 'admin') NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

1
mapg
View File

@ -6,5 +6,6 @@ require 'main.php';
$app = new Symfony\Component\Console\Application('MapGuesser Console', '');
$app->add(new MapGuesser\Cli\DatabaseMigration());
$app->add(new MapGuesser\Cli\AddUserCommand());
$app->run();

View File

@ -12,6 +12,9 @@ if (($pos = strpos($url, '?')) !== false) {
$url = rawurldecode($url);
Container::$routeCollection->get('index', '', [MapGuesser\Controller\HomeController::class, 'getIndex']);
Container::$routeCollection->get('login', 'login', [MapGuesser\Controller\LoginController::class, 'getLoginForm']);
Container::$routeCollection->post('login-action', 'login', [MapGuesser\Controller\LoginController::class, 'login']);
Container::$routeCollection->get('logout', 'logout', [MapGuesser\Controller\LoginController::class, 'logout']);
Container::$routeCollection->get('maps', 'maps', [MapGuesser\Controller\MapsController::class, 'getMaps']);
Container::$routeCollection->group('game', function (MapGuesser\Routing\RouteCollection $routeCollection) {
$routeCollection->get('game', '{mapId}', [MapGuesser\Controller\GameController::class, 'getGame']);
@ -30,15 +33,32 @@ $match = Container::$routeCollection->match($method, explode('/', $url));
if ($match !== null) {
list($route, $params) = $match;
$response = $route->callController($params);
$request = new MapGuesser\Request\Request($_GET, $params, $_POST, $_SESSION);
$handler = $route->getHandler();
$controller = new $handler[0]($request);
if ($controller instanceof MapGuesser\Interfaces\Authorization\ISecured) {
$authorized = $controller->authorize();
} else {
$authorized = true;
}
if ($authorized) {
$response = call_user_func([$controller, $handler[1]]);
if ($response instanceof MapGuesser\Interfaces\Response\IContent) {
header('Content-Type: ' . $response->getContentType() . '; charset=UTF-8');
echo $response->render();
return;
} elseif ($response instanceof MapGuesser\Interfaces\Response\IRedirect) {
header('Location: ' . $host . '/' . $response->getUrl(), true, $response->getHttpCode());
return;
}
}
} else {
header('Content-Type: text/html; charset=UTF-8', true, 404);
require ROOT . '/views/error/404.php';
}
header('Content-Type: text/html; charset=UTF-8', true, 404);
require ROOT . '/views/error/404.php';

View File

@ -17,7 +17,7 @@ button::-moz-focus-inner, input::-moz-focus-inner {
border: 0;
}
p, h1, h2, button, a {
p, h1, h2, input, textarea, select, button, a {
font-family: 'Roboto', sans-serif;
}
@ -93,6 +93,10 @@ sub {
margin-bottom: 10px;
}
.right {
text-align: right;
}
svg.inline, img.inline {
display: inline-block;
width: 1em;
@ -158,6 +162,54 @@ button.red:hover, button.red:focus, a.button.red:hover, a.button.red:focus {
background-color: #7f2929;
}
input, select, textarea {
background-color: #f9fafb;
border: solid #c8d2e1 1px;
border-radius: 2px;
padding: 4px;
box-sizing: border-box;
font-size: 15px;
font-weight: 300;
}
textarea {
font-size: 13px;
resize: none;
}
input.big, select.big, textarea.big {
padding: 5px;
font-size: 18px;
}
input.fullWidth, select.fullWidth, textarea.fullWidth {
display: block;
width: 100%;
}
input:disabled, select:disabled, textarea:disabled {
background-color: #dfdfdf;
border: solid #dfdfdf 1px;
color: #000000;
}
input:focus, select:focus, textarea:focus {
background-color: #ffffff;
border: solid #29457f 2px;
padding: 3px;
outline: none;
}
input.big:focus, select.big:focus, textarea.big:focus {
padding: 4px;
}
p.formError {
color: #7f2929;
font-weight: 500;
display: none;
}
div.header {
background-color: #333333;
height: 50px;
@ -200,6 +252,15 @@ div.buttonContainer>button {
visibility: hidden;
}
div.box {
width: 576px;
background-color: #eeeeee;
border-radius: 3px;
margin: 10px auto;
padding: 10px;
box-sizing: border-box;
}
@media screen and (max-width: 599px) {
div.header.small h1 span {
display: none;
@ -208,4 +269,7 @@ div.buttonContainer>button {
padding: 0;
width: 100%;
}
div.box {
width: initial;
}
}

42
public/static/js/login.js Normal file
View File

@ -0,0 +1,42 @@
(function () {
var form = document.getElementById('loginForm');
form.onsubmit = function (e) {
document.getElementById('loading').style.visibility = 'visible';
e.preventDefault();
var formData = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = function () {
document.getElementById('loading').style.visibility = 'hidden';
if (this.response.error) {
var errorText;
switch (this.response.error) {
case 'user_not_found':
errorText = 'No user found with the given email address.';
break;
case 'password_not_match':
errorText = 'The given password is wrong.'
break;
}
var loginFormError = document.getElementById('loginFormError');
loginFormError.style.display = 'block';
loginFormError.innerHTML = errorText;
form.elements.email.select();
return;
}
window.location.replace('/');
};
xhr.open('POST', form.action, true);
xhr.send(formData);
};
})();

View File

@ -0,0 +1,51 @@
<?php namespace MapGuesser\Cli;
use MapGuesser\Database\Query\Modify;
use MapGuesser\Model\User;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class AddUserCommand extends Command
{
public function configure()
{
$this->setName('user:add')
->setDescription('Adding of user.')
->addArgument('email', InputArgument::REQUIRED, 'Email of user')
->addArgument('password', InputArgument::REQUIRED, 'Password of user')
->addArgument('type', InputArgument::OPTIONAL, 'Type of user');;
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$user = new User([
'email' => $input->getArgument('email'),
]);
$user->setPlainPassword($input->getArgument('password'));
if ($input->hasArgument('type')) {
$user->setType($input->getArgument('type'));
}
try {
$modify = new Modify(\Container::$dbConnection, 'users');
$modify->fill($user->toArray());
$modify->save();
} catch (\Exception $e) {
$output->writeln('<error>Adding user failed!</error>');
$output->writeln('');
$output->writeln((string) $e);
$output->writeln('');
return 1;
}
$output->writeln('<info>User was successfully added!</info>');
return 0;
}
}

View File

@ -2,6 +2,7 @@
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Util\Geo\Bounds;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
@ -9,16 +10,23 @@ use MapGuesser\Interfaces\Response\IContent;
class GameController
{
public function getGame(array $parameters): IContent
private IRequest $request;
public function __construct(IRequest $request)
{
$mapId = (int) $parameters['mapId'];
$this->request = $request;
}
public function getGame(): IContent
{
$mapId = (int) $this->request->query('mapId');
$data = $this->prepareGame($mapId);
return new HtmlContent('game', $data);
}
public function getGameJson(array $parameters): IContent
public function getGameJson(): IContent
{
$mapId = (int) $parameters['mapId'];
$mapId = (int) $this->request->query('mapId');
$data = $this->prepareGame($mapId);
return new JsonContent($data);
}
@ -27,12 +35,14 @@ class GameController
{
$bounds = $this->getMapBounds($mapId);
if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) {
$_SESSION['state'] = [
$session = $this->request->session();
if (($state = $session->get('state')) && $state['mapId'] !== $mapId) {
$session->set('state', [
'mapId' => $mapId,
'area' => $bounds->calculateApproximateArea(),
'rounds' => []
];
]);
}
return ['mapId' => $mapId, 'bounds' => $bounds->toArray()];

View File

@ -0,0 +1,73 @@
<?php namespace MapGuesser\Controller;
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Interfaces\Response\IRedirect;
use MapGuesser\Model\User;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
use MapGuesser\Response\Redirect;
class LoginController
{
private IRequest $request;
public function __construct(IRequest $request)
{
$this->request = $request;
}
public function getLoginForm()
{
$session = $this->request->session();
if ($session->get('user')) {
return new Redirect([\Container::$routeCollection->getRoute('index'), []], IRedirect::TEMPORARY);
}
$data = [];
return new HtmlContent('login', $data);
}
public function login(): IContent
{
$session = $this->request->session();
if ($session->get('user')) {
$data = ['success' => true];
return new JsonContent($data);
}
$select = new Select(\Container::$dbConnection, 'users');
$select->columns(User::getFields());
$select->where('email', '=', $this->request->post('email'));
$userData = $select->execute()->fetch(IResultSet::FETCH_ASSOC);
if ($userData === null) {
$data = ['error' => 'user_not_found'];
return new JsonContent($data);
}
$user = new User($userData);
if (!$user->checkPassword($this->request->post('password'))) {
$data = ['error' => 'password_not_match'];
return new JsonContent($data);
}
$session->set('user', $user);
$data = ['success' => true];
return new JsonContent($data);
}
public function logout(): IRedirect
{
$this->request->session()->delete('user');
return new Redirect([\Container::$routeCollection->getRoute('login'), []], IRedirect::TEMPORARY);
}
}

View File

@ -1,22 +1,35 @@
<?php namespace MapGuesser\Controller;
use MapGuesser\Database\Query\Select;
use MapGuesser\Interfaces\Authentication\IUser;
use MapGuesser\Interfaces\Authorization\ISecured;
use MapGuesser\Interfaces\Database\IResultSet;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Response\IContent;
use MapGuesser\Repository\PlaceRepository;
use MapGuesser\Response\HtmlContent;
use MapGuesser\Response\JsonContent;
use MapGuesser\Util\Geo\Bounds;
class MapAdminController
class MapAdminController implements ISecured
{
private IRequest $request;
private PlaceRepository $placeRepository;
public function __construct()
public function __construct(IRequest $request)
{
$this->request = $request;
$this->placeRepository = new PlaceRepository();
}
public function authorize(): bool
{
$user = $this->request->user();
return $user !== null && $user->hasPermission(IUser::PERMISSION_ADMIN);
}
public function getMaps(): IContent
{
//TODO
@ -24,9 +37,9 @@ class MapAdminController
return new HtmlContent('admin/maps');
}
public function getMapEditor(array $parameters): IContent
public function getMapEditor(): IContent
{
$mapId = (int) $parameters['mapId'];
$mapId = (int) $this->request->query('mapId');
$bounds = $this->getMapBounds($mapId);
@ -36,9 +49,9 @@ class MapAdminController
return new HtmlContent('admin/map_editor', $data);
}
public function getPlace(array $parameters)
public function getPlace()
{
$placeId = (int) $parameters['placeId'];
$placeId = (int) $this->request->query('placeId');
$placeData = $this->placeRepository->getById($placeId);

View File

@ -1,5 +1,6 @@
<?php namespace MapGuesser\Controller;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Util\Geo\Position;
use MapGuesser\Response\JsonContent;
use MapGuesser\Interfaces\Response\IContent;
@ -10,34 +11,40 @@ class PositionController
const NUMBER_OF_ROUNDS = 5;
const MAX_SCORE = 1000;
private IRequest $request;
private PlaceRepository $placeRepository;
public function __construct()
public function __construct(IRequest $request)
{
$this->request = $request;
$this->placeRepository = new PlaceRepository();
}
public function getPosition(array $parameters): IContent
public function getPosition(): IContent
{
$mapId = (int) $parameters['mapId'];
$mapId = (int) $this->request->query('mapId');
if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) {
$session = $this->request->session();
if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) {
$data = ['error' => 'no_session_found'];
return new JsonContent($data);
}
if (count($_SESSION['state']['rounds']) === 0) {
if (count($state['rounds']) === 0) {
$newPosition = $this->placeRepository->getForMapWithValidPano($mapId);
$_SESSION['state']['rounds'][] = $newPosition;
$state['rounds'][] = $newPosition;
$session->set('state', $state);
$data = ['panoId' => $newPosition['panoId']];
} else {
$rounds = count($_SESSION['state']['rounds']);
$last = $_SESSION['state']['rounds'][$rounds - 1];
$rounds = count($state['rounds']);
$last = $state['rounds'][$rounds - 1];
$history = [];
for ($i = 0; $i < $rounds - 1; ++$i) {
$round = $_SESSION['state']['rounds'][$i];
$round = $state['rounds'][$i];
$history[] = [
'position' => $round['position']->toArray(),
'guessPosition' => $round['guessPosition']->toArray(),
@ -55,41 +62,45 @@ class PositionController
return new JsonContent($data);
}
public function evaluateGuess(array $parameters): IContent
public function evaluateGuess(): IContent
{
$mapId = (int) $parameters['mapId'];
$mapId = (int) $this->request->query('mapId');
if (!isset($_SESSION['state']) || $_SESSION['state']['mapId'] !== $mapId) {
$session = $this->request->session();
if (!($state = $session->get('state')) || $state['mapId'] !== $mapId) {
$data = ['error' => 'no_session_found'];
return new JsonContent($data);
}
$last = &$_SESSION['state']['rounds'][count($_SESSION['state']['rounds']) - 1];
$last = $state['rounds'][count($state['rounds']) - 1];
$position = $last['position'];
$guessPosition = new Position((float) $_POST['lat'], (float) $_POST['lng']);
$last['guessPosition'] = $guessPosition;
$guessPosition = new Position((float) $this->request->post('lat'), (float) $this->request->post('lng'));
$distance = $this->calculateDistance($position, $guessPosition);
$score = $this->calculateScore($distance, $_SESSION['state']['area']);
$score = $this->calculateScore($distance, $state['area']);
$last['guessPosition'] = $guessPosition;
$last['distance'] = $distance;
$last['score'] = $score;
$state['rounds'][count($state['rounds']) - 1] = $last;
if (count($_SESSION['state']['rounds']) < static::NUMBER_OF_ROUNDS) {
if (count($state['rounds']) < static::NUMBER_OF_ROUNDS) {
$exclude = [];
foreach ($_SESSION['state']['rounds'] as $round) {
foreach ($state['rounds'] as $round) {
$exclude = array_merge($exclude, $round['placesWithoutPano'], [$round['placeId']]);
}
$newPosition = $this->placeRepository->getForMapWithValidPano($mapId, $exclude);
$_SESSION['state']['rounds'][] = $newPosition;
$state['rounds'][] = $newPosition;
$session->set('state', $state);
$panoId = $newPosition['panoId'];
} else {
$_SESSION['state']['rounds'] = [];
$state['rounds'] = [];
$session->set('state', $state);
$panoId = null;
}

View File

@ -0,0 +1,10 @@
<?php namespace MapGuesser\Interfaces\Authentication;
interface IUser
{
const PERMISSION_NORMAL = 0;
const PERMISSION_ADMIN = 1;
public function hasPermission(int $permission): bool;
}

View File

@ -0,0 +1,6 @@
<?php namespace MapGuesser\Interfaces\Authorization;
interface ISecured
{
public function authorize(): bool;
}

View File

@ -0,0 +1,14 @@
<?php namespace MapGuesser\Interfaces\Request;
use MapGuesser\Interfaces\Authentication\IUser;
interface IRequest
{
public function query(string $key);
public function post(string $key);
public function session(): ISession;
public function user(): ?IUser;
}

View File

@ -0,0 +1,12 @@
<?php namespace MapGuesser\Interfaces\Request;
interface ISession
{
public function has(string $key): bool;
public function get(string $key);
public function set(string $key, $value): void;
public function delete(string $key): void;
}

49
src/Model/BaseModel.php Normal file
View File

@ -0,0 +1,49 @@
<?php namespace MapGuesser\Model;
abstract class BaseModel
{
protected static array $fields;
protected $id = null;
public static function getFields(): array
{
return array_merge(['id'], static::$fields);
}
public function __construct(array $data)
{
foreach ($data as $key => $value) {
$method = 'set' . str_replace('_', '', ucwords($key, '_'));
if (method_exists($this, $method)) {
$this->$method($value);
}
}
}
public function setId($id): void
{
$this->id = $id;
}
public function getId()
{
return $this->id;
}
function toArray(): array
{
$array = [];
foreach (self::getFields() as $key) {
$method = 'get' . str_replace('_', '', ucwords($key, '_'));
if (method_exists($this, $method)) {
$array[$key] = $this->$method();
}
}
return $array;
}
}

70
src/Model/User.php Normal file
View File

@ -0,0 +1,70 @@
<?php namespace MapGuesser\Model;
use MapGuesser\Interfaces\Authentication\IUser;
class User extends BaseModel implements IUser
{
private static array $types = ['user', 'admin'];
protected static array $fields = ['email', 'password', 'type'];
private string $email;
private string $password;
private string $type = 'user';
public function setEmail(string $email): void
{
$this->email = $email;
}
public function setPassword(string $hashedPassword): void
{
$this->password = $hashedPassword;
}
public function setPlainPassword(string $plainPassword): void
{
$this->password = password_hash($plainPassword, PASSWORD_BCRYPT);
}
public function setType(string $type): void
{
if (in_array($type, self::$types)) {
$this->type = $type;
}
}
public function getEmail(): string
{
return $this->email;
}
public function getPassword(): string
{
return $this->password;
}
public function getType(): string
{
return $this->type;
}
public function hasPermission(int $permission): bool
{
switch ($permission) {
case IUser::PERMISSION_NORMAL:
return true;
break;
case IUser::PERMISSION_ADMIN:
return $this->type === 'admin';
break;
}
}
public function checkPassword(string $password): bool
{
return password_verify($password, $this->password);
}
}

57
src/Request/Request.php Normal file
View File

@ -0,0 +1,57 @@
<?php namespace MapGuesser\Request;
use MapGuesser\Interfaces\Authentication\IUser;
use MapGuesser\Interfaces\Request\IRequest;
use MapGuesser\Interfaces\Request\ISession;
use MapGuesser\Model\User;
class Request implements IRequest
{
private array $get;
private array $routeParams;
private array $post;
private Session $session;
public function __construct(array &$get, array &$routeParams, array &$post, array &$session)
{
$this->get = &$get;
$this->routeParams = &$routeParams;
$this->post = &$post;
$this->session = new Session($session);
}
public function query($key)
{
if (isset($this->get[$key])) {
return $this->get[$key];
}
if (isset($this->routeParams[$key])) {
return $this->routeParams[$key];
}
return null;
}
public function post($key)
{
if (isset($this->post[$key])) {
return $this->post[$key];
}
return null;
}
public function session(): ISession
{
return $this->session;
}
public function user(): ?IUser
{
return $this->session->get('user');
}
}

37
src/Request/Session.php Normal file
View File

@ -0,0 +1,37 @@
<?php namespace MapGuesser\Request;
use MapGuesser\Interfaces\Request\ISession;
class Session implements ISession
{
private array $data;
public function __construct(array &$data)
{
$this->data = &$data;
}
public function has($key): bool
{
return isset($this->data[$key]);
}
public function get($key)
{
if (isset($this->data[$key])) {
return $this->data[$key];
}
return null;
}
public function set($key, $value): void
{
$this->data[$key] = $value;
}
public function delete($key): void
{
unset($this->data[$key]);
}
}

View File

@ -20,6 +20,11 @@ class Route
return $this->id;
}
public function getHandler(): array
{
return $this->handler;
}
public function generateLink(array $parameters = []): string
{
$link = [];
@ -51,14 +56,6 @@ class Route
return implode('/', $link) . $query;
}
public function callController(array $parameters)
{
$controllerName = $this->handler[0];
$controller = new $controllerName();
return call_user_func([$controller, $this->handler[1]], $parameters);
}
public function testAgainst(array $path): ?array
{
$parameters = [];

17
views/login.php Normal file
View File

@ -0,0 +1,17 @@
<?php require ROOT . '/views/templates/main_header.php'; ?>
<?php require ROOT . '/views/templates/header.php'; ?>
<div class="main">
<h2>Login</h2>
<div class="box">
<form id="loginForm" action="/login" method="post">
<input class="big fullWidth" type="email" name="email" placeholder="Email address" autofocus>
<input class="big fullWidth marginTop" type="password" name="password" placeholder="Password">
<p id="loginFormError" class="formError marginTop"></p>
<div class="right marginTop">
<button type="submit">Login</button>
</div>
</form>
</div>
</div>
<script src="/static/js/login.js"></script>
<?php require ROOT . '/views/templates/main_footer.php'; ?>