diff --git a/.env b/.env index a3e9f84..110824e 100644 --- a/.env +++ b/.env @@ -58,12 +58,24 @@ APP_KEVACOIN_ROOM_NAMESPACES_READONLY= # Redirect from index page to default room APP_KEVACOIN_ROOM_NAMESPACE_DEFAULT=EDIT_ME +# Session expire +APP_SESSION_DEFAULT_TIMEOUT=2592000 + # Online session expiration timeout APP_SESSION_ONLINE_TIMEOUT=900 # Moderators IP with extra permissions, separated with | APP_MODERATOR_REMOTE_IP= +# User name regex condition +APP_ADD_USER_NAME_REGEX=/^[0-9A-z-]{2,16}$/ui + +# User name reserved (case insensitive) separated with | +APP_ADD_USER_NAME_BLACKLIST=KevaChat|admin|moderator|test + +# Delay before have ability to create new username again +APP_ADD_USER_REMOTE_IP_DELAY=86400 + # Allow remotes to create new rooms (namespaces) APP_ADD_ROOM_REMOTE_IP_REGEX=/.*/ diff --git a/README.md b/README.md index 3dfd38d..144fcee 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Application package contain settings preset, just few steps required to launch: * Copy `rpcuser` to `env`.`APP_KEVACOIN_USERNAME` and `rpcpassword` to `env`.`APP_KEVACOIN_PASSWORD` * Generate new address using CLI `kevacoin-cli getnewaddress` and copy to `env`.`APP_KEVACOIN_BOOST_ADDRESS` * Send few coins to this address and wait for new block to continue +* To allow users registration, create namespace `kevacoin-cli keva_namespace "_KEVACHAT_USERS_"` * Create at least one room namespace with Web UI or CLI `kevacoin-cli keva_namespace "sandbox"` * Provide at least one namespace for default chat room to `env`.`APP_KEVACOIN_ROOM_NAMESPACE_DEFAULT` (for homepage redirects) diff --git a/config/services.yaml b/config/services.yaml index 6744c66..1b6eb0c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,7 +22,11 @@ parameters: app.kevacoin.explorer.url: '%env(APP_KEVACOIN_EXPLORER_URL)%' app.kevacoin.mine.pool.url: '%env(APP_KEVACOIN_MINE_POOL_URL)%' app.kevacoin.mine.solo.url: '%env(APP_KEVACOIN_MINE_SOLO_URL)%' + app.session.default.timeout: '%env(APP_SESSION_DEFAULT_TIMEOUT)%' app.session.online.timeout: '%env(APP_SESSION_ONLINE_TIMEOUT)%' + app.add.user.name.regex: '%env(APP_ADD_USER_NAME_REGEX)%' + app.add.user.name.blacklist: '%env(APP_ADD_USER_NAME_BLACKLIST)%' + app.add.user.remote.ip.delay: '%env(APP_ADD_USER_REMOTE_IP_DELAY)%' app.add.room.remote.ip.regex: '%env(APP_ADD_ROOM_REMOTE_IP_REGEX)%' app.add.room.remote.ip.delay: '%env(APP_ADD_ROOM_REMOTE_IP_DELAY)%' app.add.room.remote.ip.denied: '%env(APP_ADD_ROOM_REMOTE_IP_DENIED)%' diff --git a/public/css/default.css b/public/css/default.css index bef1348..10f2123 100644 --- a/public/css/default.css +++ b/public/css/default.css @@ -44,6 +44,8 @@ body font-size: 12px; } +/* header */ + header { background-color: Canvas; @@ -95,6 +97,8 @@ header > form > input[type="text"] width: 100%; } +/* main */ + main { display: block; @@ -158,6 +162,50 @@ main ul li span > svg float: right; } +main > form +{ + box-sizing: border-box; + display: block; + margin: 16px 0; + overflow: hidden; + width: 100%; +} + +main > form > input[type="text"], +main > form > input[type="password"], +main > form > textarea +{ + box-sizing: border-box; + display: block; + margin-bottom: 10px; + padding: 4px; + width: 100%; +} + +main > form > label +{ + clear: both; + display: inline-block; + margin-bottom: 8px; +} + +main > form > button +{ + cursor: pointer; + float: right; + padding: 2px 8px; +} + +main > form > output +{ + color: var(--color-error); + display: block; + font-weight: bolder; + margin-bottom: 16px; +} + +/* footer */ + footer { background-color: Canvas; diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..2ec7c1a --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,646 @@ + 12 + ]; + + #[Route( + '/user', + name: 'user_index', + methods: + [ + 'GET' + ] + )] + public function index( + ?Request $request + ): Response + { + return $this->redirectToRoute( + 'user_login' + ); + } + + #[Route( + '/join', + name: 'user_join', + methods: + [ + 'GET' + ] + )] + public function join( + ?Request $request + ): Response + { + // Check user session does not exist to continue + if (!empty($request->cookies->get('KEVACHAT_SESSION'))) + { + // Redirect to logout + return $this->redirectToRoute( + 'user_logout' + ); + } + + return $this->render( + 'default/user/join.html.twig', + [ + 'request' => $request + ] + ); + } + + #[Route( + '/login', + name: 'user_login', + methods: + [ + 'GET' + ] + )] + public function login( + ?Request $request + ): Response + { + // Check user session does not exist to continue + if (!empty($request->cookies->get('KEVACHAT_SESSION'))) + { + // Redirect to logout + return $this->redirectToRoute( + 'user_logout' + ); + } + + return $this->render( + 'default/user/login.html.twig', + [ + 'request' => $request + ] + ); + } + + #[Route( + '/logout', + name: 'user_logout', + methods: + [ + 'GET' + ] + )] + public function logout( + ?Request $request + ): Response + { + // Connect memcached + $memcached = new \Memcached(); + $memcached->addServer( + $this->getParameter('app.memcached.host'), + $this->getParameter('app.memcached.port') + ); + + // Make sure cookies exist + if (!empty($request->cookies->get('KEVACHAT_SESSION')) && preg_match('/[A-z0-9]{32}/', $request->cookies->get('KEVACHAT_SESSION'))) + { + // Delete from memory + $memcached->delete($session); + + // Delete cookies + setcookie('KEVACHAT_SESSION', '', -1); + } + + // Redirect to main page + return $this->redirectToRoute( + 'user_login' + ); + } + + #[Route( + '/join', + name: 'user_add', + methods: + [ + 'POST' + ] + )] + public function add( + Request $request, + TranslatorInterface $translator + ): Response + { + // Check maintenance mode disabled + if ($this->getParameter('app.maintenance')) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Maintenance, please try again later!') + ] + ); + } + + // Check user session does not exist to continue + if (!empty($request->cookies->get('KEVACHAT_SESSION'))) + { + // Redirect to logout + return $this->redirectToRoute( + 'user_logout' + ); + } + + // Trim extra spaces from username + $username = trim( + $request->get('username') + ); + + // Connect memcached + $memcached = new \Memcached(); + $memcached->addServer( + $this->getParameter('app.memcached.host'), + $this->getParameter('app.memcached.port') + ); + + // Create IP delay record + $memory = md5( + sprintf( + '%s.UserController::add:add.user.remote.ip.delay:%s', + __DIR__, + $request->getClientIp(), + ), + ); + + // Validate remote IP limits + if ($delay = (int) $memcached->get($memory)) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => sprintf( + $translator->trans('Please wait %s seconds before register new username!'), + (int) $this->getParameter('app.add.user.remote.ip.delay') - (time() - $delay) + ) + ] + ); + } + + // Check client connection + if (!$client = $this->_client()) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not connect wallet database!') + ] + ); + } + + // Check users database accessible + if (!$namespace = $this->_namespace($client)) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not access user database, try again later!') + ] + ); + } + + // Validate kevacoin key requirements + if (mb_strlen($username) < 1 || mb_strlen($username) > 520) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username length out of KevaCoin protocol limits!') + ] + ); + } + + // Validate meta NS + if (str_starts_with($username, '_')) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username contain meta format!') + ] + ); + } + + // Validate username regex + if (!preg_match($this->getParameter('app.add.user.name.regex'), $username)) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => sprintf( + $translator->trans('Username does not match node requirements: %s!'), + $this->getParameter('app.add.user.name.regex') + ) + ] + ); + } + + // Validate username blacklist (reserved) + if (in_array($username, (array) explode('|', $this->getParameter('app.add.user.name.blacklist')))) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username reserved by node!') + ] + ); + } + + // Validate username exist + if ($this->_hash($client, $namespace, $username)) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username already taken!') + ] + ); + } + + // Validate password length + if (mb_strlen($request->get('password')) <= 12) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Please, provide stronger password!') + ] + ); + } + + // Validate passwords match + if ($request->get('password') !== $request->get('repeat')) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Password repeat incorrect!') + ] + ); + } + + // Generate password hash + if (!$hash = password_hash($request->get('password'), $this->_algorithm, $this->_options)) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not generate password hash!') + ] + ); + } + + // Auth success, add user to DB + if (!$this->_add($client, $namespace, $username, $hash)) + { + return $this->redirectToRoute( + 'user_add', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not create user in blockchain!') + ] + ); + } + + // Register event time + $memcached->set( + $memory, + time(), + (int) $this->getParameter('app.add.user.remote.ip.delay') // auto remove on cache expire + ); + + // Auth success, create user session + $session = md5( + sprintf( + '%s.%s.%s', + $request->get('username'), + $request->getClientIp(), + rand() + ) + ); + + // Save session to memory + if (!$memcached->set($session, $request->get('username'), (int) time() + $this->getParameter('app.session.default.timeout'))) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not save user session!') + ] + ); + } + + // Save session to user cookies + if (!setcookie('KEVACHAT_SESSION', $session, time() + $this->getParameter('app.session.default.timeout'))) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not create session cookie!') + ] + ); + } + + // Redirect to main page + return $this->redirectToRoute( + 'room_index' + ); + + // Redirect to login page + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username') + ] + ); + } + + #[Route( + '/login', + name: 'user_auth', + methods: + [ + 'POST' + ] + )] + public function auth( + Request $request, + TranslatorInterface $translator + ): Response + { + // Check maintenance mode disabled + if ($this->getParameter('app.maintenance')) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Maintenance, please try again later!') + ] + ); + } + + // Check user session does not exist to continue + if (!empty($request->cookies->get('KEVACHAT_SESSION'))) + { + // Redirect to logout + return $this->redirectToRoute( + 'user_logout' + ); + } + + // Connect memcached + $memcached = new \Memcached(); + $memcached->addServer( + $this->getParameter('app.memcached.host'), + $this->getParameter('app.memcached.port') + ); + + // Check client connection + if (!$client = $this->_client()) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not connect wallet database!') + ] + ); + } + + // Check username namespace accessible + if (!$namespace = $this->_namespace($client)) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not access user database, try again later!') + ] + ); + } + + // Trim extra spaces from username + $username = trim( + $request->get('username') + ); + + // Validate kevacoin key requirements + if (mb_strlen($username) < 1 || mb_strlen($username) > 520) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username length out of KevaCoin protocol limits!') + ] + ); + } + + // Validate meta NS + if (str_starts_with($username, '_')) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username contain meta format!') + ] + ); + } + + // Validate username regex + if (!preg_match($this->getParameter('app.add.user.name.regex'), $username)) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => sprintf( + $translator->trans('Username does not match node requirements: %s!'), + $this->getParameter('app.add.user.name.regex') + ) + ] + ); + } + + // Validate username blacklist + if (in_array($username, (array) explode('|', $this->getParameter('app.add.user.name.blacklist')))) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username reserved by node!') + ] + ); + } + + // Validate username exist + if (!$hash = $this->_hash($client, $namespace, $username)) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Username not found!') + ] + ); + } + + // Validate password + if (!password_verify($request->get('password'), $hash)) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Password invalid!') + ] + ); + } + + // Validate password algo + if (password_needs_rehash($hash, $this->_algorithm, $this->_options)) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Password needs rehash!') + ] + ); + } + + // Auth success, create user session + $session = md5( + sprintf( + '%s.%s.%s', + $request->get('username'), + $request->getClientIp(), + rand() + ) + ); + + // Save session to memory + if (!$memcached->set($session, $request->get('username'), (int) time() + $this->getParameter('app.session.default.timeout'))) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not save user session!') + ] + ); + } + + // Save session to user cookies + if (!setcookie('KEVACHAT_SESSION', $session, time() + $this->getParameter('app.session.default.timeout'))) + { + return $this->redirectToRoute( + 'user_login', + [ + 'username' => $request->get('username'), + 'error' => $translator->trans('Could not create session cookie!') + ] + ); + } + + // Redirect to main page + return $this->redirectToRoute( + 'room_index' + ); + } + + private function _client(): \Kevachat\Kevacoin\Client + { + $client = new \Kevachat\Kevacoin\Client( + $this->getParameter('app.kevacoin.protocol'), + $this->getParameter('app.kevacoin.host'), + $this->getParameter('app.kevacoin.port'), + $this->getParameter('app.kevacoin.username'), + $this->getParameter('app.kevacoin.password') + ); + + return $client; + } + + private function _namespace( + \Kevachat\Kevacoin\Client $client + ): ?string + { + foreach ((array) $client->kevaListNamespaces() as $value) + { + if ($value['displayName'] === '_KEVACHAT_USERS_') + { + return $value['namespaceId']; + } + } + + return null; + } + + private function _hash( + \Kevachat\Kevacoin\Client $client, + string $namespace, + string $username + ): ?string + { + if ($user = $client->kevaGet($namespace, $username)) + { + if (!empty($user['value'])) + { + return (string) $user['value']; + } + } + + return null; + } + + private function _add( + \Kevachat\Kevacoin\Client $client, + string $namespace, + string $username, + string $hash + ): ?string + { + return $client->kevaPut( + $namespace, + $username, + $hash + ); + } +} \ No newline at end of file diff --git a/templates/default/user/join.html.twig b/templates/default/user/join.html.twig new file mode 100644 index 0000000..ecae851 --- /dev/null +++ b/templates/default/user/join.html.twig @@ -0,0 +1,17 @@ +{% extends 'default/layout.html.twig' %} +{% block head_title_content %}{{ 'Join' | trans }} - {{ 'KevaChat' | trans }}{% endblock %} +{% block main_content %} +
+ {% if request.get('error') %} + {{ request.get('error') }} + {% endif %} + + + + + + + {{ 'I have account' | trans }} + +
+{% endblock %} \ No newline at end of file diff --git a/templates/default/user/login.html.twig b/templates/default/user/login.html.twig new file mode 100644 index 0000000..31fd9f9 --- /dev/null +++ b/templates/default/user/login.html.twig @@ -0,0 +1,15 @@ +{% extends 'default/layout.html.twig' %} +{% block head_title_content %}{{ 'Login' | trans }} - {{ 'KevaChat' | trans }}{% endblock %} +{% block main_content %} +
+ {% if request.get('error') %} + {{ request.get('error') }} + {% endif %} + + + + + {{ 'Create account' | trans }} + +
+{% endblock %} \ No newline at end of file