Page MenuHomePhabricator

No OneTemporary

diff --git a/bin/classes/BaseController.php b/bin/classes/BaseController.php
index 683a264..df047ce 100644
--- a/bin/classes/BaseController.php
+++ b/bin/classes/BaseController.php
@@ -1,102 +1,111 @@
<?php
use magic3w\hook\sdk\Hook;
use signature\Helper;
use spitfire\core\Collection;
use spitfire\exceptions\PrivateException;
use spitfire\exceptions\PublicException;
use spitfire\io\session\Session;
abstract class BaseController extends Controller
{
/**
* @var UserModel|null
*/
protected $user = null;
- protected $token = null;
protected $isAdmin = false;
+ protected $session;
/**
*
* @var Helper
*/
protected $signature;
protected $authapp;
/**
*
* @var Collection
*/
protected $level;
/**
*
* @var Hook
*/
protected $hook;
public function _onload() {
#Get the user session, if no session is given - we skip all of the processing
#The user could also check the token
$s = Session::getInstance();
$u = $s->getUser();
- $t = isset($_GET['token'])? db()->table('token')->get('token', $_GET['token'])->fetch() : null;
try {
#Check if the user is an administrator
$admingroupid = SysSettingModel::getValue('admin.group');
}
catch (PrivateException$e) {
$admingroupid = null;
}
- if ($u || $t) {
+ #Find the application for the SSO Server
+ $self = db()->table(AuthAppModel::class)->get('_id', SysSettingModel::getValue('app.self'))->first();
+
+ try {
#Export the user to the controllers that may need it.
- $user = $u? db()->table('user')->get('_id', $u)->fetch() : $t->user;
+ $sess = db()->table('session')->get('_id', $u)->fetch(true);
+ $user = $sess->user;
$this->user = $user;
- $this->token = $t;
#Retrieve the user's authentication level
$this->level = db()->table('authentication\challenge')
- ->get('session', db()->table('session')->get('_id', $s->sessionId())->first())
+ ->get('session', $sess)
->where('cleared', '!=', null)
->where('expires', '>', time())
->all();
$isAdmin = !!db()->table('user\group')->get('group__id', $admingroupid)->addRestriction('user', $user)->fetch();
}
+ catch (\spitfire\exceptions\PrivateException $ex) {
+ #There was a problem loading the session
+ $this->level = collect();
+ $this->user = null;
+ $isAdmin = false;
+ }
$this->signature = new Helper(db());
- if (isset($_GET['signature']) && is_string($_GET['signature'])) {
- list($signature, $src, $target) = $this->signature->verify();
-
- if ($target) {
- throw new PublicException('_GET[signature] must not have remotes', 401);
- }
-
- $this->authapp = $src;
+ /*
+ * Check if the request is being sent by an application that wishes to
+ * directly interact with the SSO Server.
+ */
+ $t = isset($_GET['token'])? db()->table('token')->get('token', $_GET['token'])->fetch() : null;
+
+ if ($t && $self && $t->owner === null && $t->audience->_id === $self->_id) {
+ $this->authapp = $t->client;
$this->level = collect();
}
/*
* Webhook initialization
*/
if (null !== $hookapp = SysSettingModel::getValue('cptn.h00k')) {
$hook = db()->table('authapp')->get('_id', $hookapp)->first();
#TODO: Add a token to the webhook
//$this->hook = new Hook($hook->url, null);
}
$this->isAdmin = $isAdmin?? false;
+ $this->session = $sess?? null;
$this->view->set('level', $this->level);
$this->view->set('authUser', $this->user);
$this->view->set('authApp', $this->app);
$this->view->set('userIsAdmin', $isAdmin ?? false);
$this->view->set('administrativeGroup', $admingroupid);
}
}
diff --git a/bin/classes/MySQLSessionHandler.php b/bin/classes/MySQLSessionHandler.php
deleted file mode 100644
index 039cf10..0000000
--- a/bin/classes/MySQLSessionHandler.php
+++ /dev/null
@@ -1,81 +0,0 @@
-<?php
-
-use spitfire\exceptions\FileNotFoundException;
-use spitfire\exceptions\PrivateException;
-use spitfire\io\session\Session;
-
-class MySQLSessionHandler extends spitfire\io\session\SessionHandler
-{
-
- /**
- *
- * @var spitfire\storage\database\Table
- */
- private $table;
-
- private $handle;
-
- public function __construct($table, $timeout = null) {
- $this->table = $table;
- parent::__construct($timeout);
- }
-
- public function close() {
- return true;
- }
-
- public function destroy($id) {
- $record = $this->table->get('_id', $id)->first();
- $record && $record->delete();
- return true;
- }
-
- public function gc($maxlifetime) {
- if ($this->getTimeout()) { $maxlifetime = $this->getTimeout(); }
-
- $records = $this->table->get('expires', time() - $maxlifetime, '<')->all();
- $records->each(function ($e) { $e->delete(); });
-
- return true;
- }
-
- public function getHandle() {
- if ($this->handle) { return $this->handle; }
- if (!Session::sessionId()) { return false; }
-
-
- #Initialize the session itself
- $id = Session::sessionId(false);
-
- $this->handle = $this->table->get('_id', $id)->first();
-
- if (!$this->handle) {
- $this->handle = $this->table->newRecord();
- $this->handle->_id = $id;
- $this->handle->expires = time() + 90 * 86400;
- $this->handle->store();
- }
-
- return $this->handle;
- }
-
- public function open($savePath, $sessionName) {
- return true;
- }
-
- public function read($__garbage) {
- //The system can only read the first 8MB of the session.
- //We do hardcode to improve the performance since PHP will stop at EOF
- $_ret = $this->getHandle()->payload;
- return (string)$_ret;
- }
-
- public function write($__garbage, $data) {
- $this->getHandle()->payload = $data;
- $this->getHandle()->expires = time() + 90 * 86400;
- $this->getHandle()->store();
-
- return true;
- }
-
-}
diff --git a/bin/controllers/auth.php b/bin/controllers/auth.php
index 955eb17..b4ba735 100644
--- a/bin/controllers/auth.php
+++ b/bin/controllers/auth.php
@@ -1,486 +1,476 @@
<?php
use app\AuthLock;
use connection\AuthModel;
use signature\Signature;
use spitfire\core\Environment;
use spitfire\core\http\URL;
use spitfire\exceptions\PublicException;
use spitfire\io\session\Session;
use spitfire\io\XSSToken;
class AuthController extends BaseController
{
/**
* The auth/index endpoint provides an application with the means to retrieve
* information about a token they authorized.
*
* If the token is expired it will act as if there was no token and return an
* unathorized as result.
*
* @param string $tokenid
*/
public function index($tokenid = null) {
if ($this->token) { $token = $this->token; }
elseif ($tokenid) { $token = db()->table('access\token')->get('token', $tokenid)->where('expires', '>', time())->first(); }
else { $token = null; }
#Check if the user has been either banned or suspended
$suspension = $token? db()->table('user\suspension')->get('user', $token->user)->addRestriction('expires', time(), '>')->fetch() : null;
$this->view->set('token', $token);
$this->view->set('suspension', $suspension);
}
/**
*
* @validate GET#response_type (string required)
* @validate GET#client (string required)
* @validate GET#state (string required)
* @validate GET#redirect (string required)
* @validate GET#challenge (string required)
*
* @param type $tokenid
* @return void
* @layout minimal.php
* @throws PublicException
*/
public function oauth() {
- /*
- * We're going to need the session to provide it's ID to the code challenge
- * model.
- */
- $session = Session::getInstance();
-
/*
* The response type used to be code or token for applications implementing
* oAuth2 whenever the server and/or client does not support PKCE. Since our
* server is implemented right from the start with PKCE in mind, we can
* enforce the use of the response_type of code and deny any requests with
* token.
*/
if ($_GET['response_type'] !== 'code') {
throw new PublicException('This server does only accept a response_type of code. Please refer to the manual', 400);
}
- if (!$session->getUser()) {
+ /*
+ * When generating an oAuth session we do require the user to be fully
+ * authenticated.
+ */
+ if (!$this->user) {
$this->response->setBody('Redirecting...');
return $this->response->getHeaders()->redirect(url('user', 'login', Array('returnto' => (string) URL::current())));
}
/*
* Check whether the user was banned. If the account is disabled due to administrative
* action, we inform the user that the account was disabled and why.
*/
$banned = db()->table('user\suspension')->get('user', $this->user)->addRestriction('expires', time(), '>')->addRestriction('preventLogin', 1)->first();
if ($banned) {
throw new LoginDisabledException($banned);
}
#Check whether the user was disabled
if ($this->user->disabled) { throw new PublicException('Your account was disabled', 401); }
/*
* Find the application intending to authenticate this request.
*/
$client = db()->table('authapp')->get('appID', $_GET['client'])->first(true);
/*
* Check if the user needs to be strongly authenticated for this app
*/
if ($client->twofactor && $this->level->count() < 2) {
$this->response->setBody('Redirect...')->getHeaders()->redirect(url('auth', 'add', 2, ['returnto' => strval(URL::current())]));
return;
}
/*
* Start of by assuming that the client is not intended to be given the application's
* data. We will later check whether the application was granted access and
* will then flip this flag.
*/
$grant = false;
#TODO: verify the challenge is not malformed
#TODO: Verify the scopes exist
/*
* Extract the redirect, and make sure that it points to a URL that the client
* is authorized to send the user to.
*/
$redirect = $_GET['redirect'];
#TODO: Check the redirect is pointing to the application we intent to authenticate
if (false) {
#TODO: Check whether policy or permission disables the login for this application
}
/*
* Check if the user is approving the request to provide the application access
* to their account, given the information they have.
*/
elseif ($this->request->isPost()) {
#TODO: Verify that the resource owner is not being tricked into allowing
# the client access using XSRF
#TODO: Check if permission allows this user to authenticate codes for this
# application. Important for elevated privileges apps
$grant = $_POST['grant'] === 'grant';
}
/*
* Check if the client is granted access to the application by policy, this
* would allow the application to bypass the authentication flow.
*/
elseif (false) {
#TODO: Check whether the application is granted access by policy
}
if ($grant) {
/*
* Record the code challenge, and the user approving this challenge, together
* with the state the application passed.
*/
$challenge = db()->table('access\code')->newRecord();
$challenge->code = str_replace(['-', '/', '='], '', base64_encode(random_bytes(64)));
$challenge->client = $client;
$challenge->user = $this->user;
$challenge->state = $_GET['state'];
$challenge->challenge = $_GET['challenge'];
$challenge->scope = $_GET['scope'];
$challenge->redirect = $_GET['redirect'];
$challenge->created = time();
$challenge->expires = time() + 180;
- $challenge->session = db()->table('session')->get('_id', $session->sessionId())->first();
+ $challenge->session = $this->session;
$challenge->store();
#TODO: Use url-reflection to create the URL instead of jsut appending the params
$this->response->setBody('Redirect')->getHeaders()->redirect($challenge->redirect . '?' . http_build_query(['code' => $challenge->code, 'state' => $challenge->state]));
}
/*
* If the request was posted, the user selected to deny the application access
*/
elseif ($this->request->isPost()) {
$this->response->setBody('Redirect')->getHeaders()->redirect($challenge->redirect . '?' . http_build_query(['error' => 'denied', 'description' => 'Authentication request was denied']));
}
/*
* If the user has not been able to allow or deny the request, the server
* should request their permission.
*/
$this->view->set('client', $client);
$this->view->set('cancel', $_GET['redirect'] . '?' . http_build_query(['error' => 'denied', 'description' => 'Authentication request was denied']));
#TODO: If a target was defined, we would like to know that
}
/**
* Allows third party applications to test whether a certain application
* exists within PHPAS. It expects the application to provide a series of
* _GET parameters that need to be properly provided for it to return a
* positive match.
*
* * Application id
* * A signature that authorizes the application.
*
* The signature is composed of the application's id, the target application's
* id, a random salt and a hash composed of these and the application's secret.
*
* The signature should therefore prevent the application from forging requests
* on behalf of third parties.
*
* @todo For legacy purposes, this will accept an app id and secret combo
* which is no longer supported and will be removed in future versions.
*/
public function app() {
if ($this->token && $this->token->expires < time()) {
throw new PublicException('Invalid token: ' . __($_GET['token']), 400);
}
$remote = isset($_GET['remote'])? $this->signature->verify($_GET['remote']) : null;
$context = isset($_GET['context'])? $_GET->toArray('context') : [];
if ($remote) {
list($sig, $src, $tgt) = $remote;
if (!$tgt || $tgt->appID != $this->authapp->appID) {
throw new PublicException('Invalid remote signature. Target did not authorize itself properly', 401);
}
if ($sig->getContext()) {
throw new PublicException('Invalid signature. Context should be provided via _GET', 400);
}
$contexts = [];
$grant = [];
foreach ($context as $ctx) {
$contexts[] = $tgt->getContext($ctx);
$grant[$ctx] = $tgt->canAccess($src, $this->token? $this->token->user : null, $ctx);
}
$this->view->set('context', $contexts);
$this->view->set('grant', $grant);
}
else {
$this->view->set('context', null);
$this->view->set('grant', null);
}
$this->view->set('authenticated', !!$this->authapp);
$this->view->set('src', $this->authapp);
$this->view->set('remote', $src);
$this->view->set('token', $this->token);
}
/**
*
* @validate GET#signatures (required)
* @param string $confirm
* @throws PublicException
* @layout minimal.php
*/
public function connect($confirm = null) {
#Make the confirmation signature
$xsrf = new XSSToken();
/*
* First and foremost, this cannot be executed from the token context, this
* means that the user requesting this needs to be a user who is logged in
* via session instead of an application acting on behalf of a user.
*/
if ($this->token) {
throw new PublicException('This method cannot be called from token context', 400);
}
/**
* If the user is not logged in, we need to send them to the log-in screen
* first to ensure that they can create the connection.
*/
if (!$this->user) {
$this->response->setBody('Redirecting...')->getHeaders()->redirect(url('user', 'login', Array('returnto' => (string) URL::current())));
}
if (!isset($_GET['signatures'])) {
throw new PublicException('Invalid signature', 400);
}
/*
* Extract all the signatures the system received. The idea is to provide
* one signature per context piece needed. While this requires the system
* to provide several signatures, it also makes it way more flexible for
* the receiving application to select which permissions it wishes to request.
*/
$signatures = collect($_GET->toArray('signatures'))->each(function ($e) {
list($signature, $src, $tgt) = $this->signature->verify($e);
/*
* Check if the application was already granted access This may report that
* the target was already blocked by the system from accessing data
* on the source application.
*/
$lock = new AuthLock($src, $this->user, $signature->getContext()[0]);
$granted = $lock->unlock($tgt);
/*
* If the target was already denied access, either by the user or by policy,
* then we throw an exception and prevent the user from continuing.
*/
if ($granted === AuthModel::STATE_DENIED) {
throw new PublicException('Application was already denied access', 400);
}
/*
* If the target was already approved access, either by the user or by
* policy, then we skip asking for permission to this context.
*/
if ($granted === AuthModel::STATE_AUTHORIZED) {
return null;
}
return $signature;
})->filter();
$src = db()->table('authapp')->get('appID', $signatures->rewind()->getSrc())->first(true);
$tgt = db()->table('authapp')->get('appID', $signatures->rewind()->getTarget())->first(true);
/*
* To prevent applications from sneaking in requests to permissions that
* do belong to third parties (by requesting seemingly innocuous requests
* mixed with requests from potentially malicious software), the system
* will verify that there is only a single source signing all the signatures.
*/
$singlesource = $signatures->reduce(function ($c, Signature$e) use($src, $tgt) {
return $c && $e->getTarget() === $tgt->appID && $e->getSrc() === $src->appID;
}, true);
if (!$singlesource) {
throw new PublicException('All signatures must belong to a single source', 401);
}
/*
* If the user is already confirming the application request, we check whether
* the signature they used to do so is valid. This is generally to protect
* the user from any illegitimate requests to provide data by XSRF.
*/
if ($confirm) {
if (!$xsrf->verify($confirm)) {
throw new PublicException('Invalid confirmation hash', 403);
}
foreach ($signatures as $c) {
/*
* Create the authorizations. There's no need to check whether the
* connection already exists, since it would have been filtered from
* the signatures list at about line 242
*/
$connection = db()->table('connection\auth')->newRecord();
$connection->target = $tgt;
$connection->source = $src;
$connection->user = $this->user;
$connection->context = $c->getContext();
$connection->state = AuthModel::STATE_AUTHORIZED;
$connection->expires = isset($_POST['remember'])? null : time() + (86400 * 30);
$connection->store();
}
return $this->response->setBody('Redirecting...')->getHeaders()->redirect($_GET['returnto']?: url(/*Grant success page or w/e*/));
}
$this->view->set('ctx', $signatures->each(function (Signature$e) {
return $e->getContext();
})->each(function ($e) use ($src) {
return $e? $src->getContext($e) : null;
})->filter());
$this->view->set('src', $tgt);
$this->view->set('tgt', $src);
$this->view->set('signatures', $signatures);
$this->view->set('confirm', $xsrf->getValue());
}
public function threshold($expect = 0)
{
- if (!$this->user) {
+ if (!$this->session) {
$this->response->setBody('Redirecting')->getHeaders()->redirect(url('user', 'login', ['returnto' => strval(URL::current())]));
return;
}
/*
* Create a list of the available authentication providers for each level.
* Depending on the expected threshold, the application will require a different
* combination of providers:
*
- * For level 0, the application will check whether the user has a properly
- * authenticated session, if this is not the case, the user will have to
- * provide a primary authentication provider.
- *
* For level 1, the application will require the user to either confirm their
* password, if the password wasn't confirmed in a given amount of time,
* or use any other primary provider.
*
* For level 2, the application will require a secondary provider to be
* available.
*
* Subsequent levels will only ask for additional providers to be given.
*/
$primary = Environment::get('phpauth.mfa.providers.primary')? explode(',', Environment::get('phpauth.mfa.providers.primary')) : ['email', 'password'];
$secondary = Environment::get('phpauth.mfa.providers.secondary')? explode(',', Environment::get('phpauth.mfa.providers.secondary')) : ['phone', 'rfc6238', 'backup', 'webauthn'];
$levels = [
- 0 => [],
1 => $primary,
- 1 => $secondary,
+ 2 => $secondary,
3 => array_merge($primary, $secondary),
4 => array_merge($primary, $secondary),
5 => array_merge($primary, $secondary)
];
/*
* Create list of challenges that the user has already passed.
*/
$passed = db()->table('authentication\challenge')->get('session', $this->session)->where('cleared', '!=', null)->where('expires', '>', time())->all();
/*
* Fetch a list of all the authentication providers this user has available,
* these can then be used to challenge the user
*/
$providers = db()->table('authentication\provider')
->get('expires', null)
- ->where('user', $this->user)
+ ->where('user', $this->session->candidate)
+ ->setOrder('preferred', 'DESC')
->all();
- /*
- * Check whether the sessin has been locked to this user and we're certain
- * that they've logged in (even though their verification may have expired)
- */
- if (!$this->user) {
- #TODO: The user is not authenticated at all, this means we need to verify
- #their password or some other provider for sure.
- }
-
foreach ($levels as $level => $required)
{
/*
* We do not need to perform verification that is stronger than the app
* requested us to do.
*/
if ($level > $expect) { continue; }
/*
* Create a list of accepted providers for this level.
*/
$accepted = collect($providers)->filter(function ($e) use ($required) {
return array_search($e->type, $required);
});
+ if ($accepted->isEmpty()) {
+ throw new PrivateException('Authentication provider for level ' . $level . ' is unavailable');
+ }
+
/*
* Check if any of the providers the user has passed recently was a
* primary provider
*/
$success = $passed->filter(function ($challenge) use ($accepted) {
return ($accepted->extract('_id')->contains($challenge->provider->_id));
})->rewind();
/*
* When successfully authenticating using a certain provider, the provider
* cannot be used again. Otherwise a properly typed password could let
* the user authenticate themselves to level 5 without any issue.
*/
if ($success) {
$providers = $providers->filter(function ($e) use ($success) { return $success->_id != $e->_id; });
}
else {
- return $this->response->setBody('Redirect')->getHeaders()->redirect(url(['mfa', $accepted[0]->type], 'challenge', $accepted[0]->_id));
+ $provider = $accepted->rewind();
+ return $this->response->setBody('Redirect')->getHeaders()->redirect(url('mfa', $provider->type, 'challenge', $provider->_id, ['returnto' => (string)URL::current()]));
}
}
/*
* Redirect to where the user was headed since they passed all the trials
* in order to access this resource.
*
* TODO: Check the user is trying to get redirected to a URL within this
* application and not somewhere else.
*/
$this->response->setBody('Redirecting')->getHeaders()->redirect($_GET['returnto']);
}
}
diff --git a/bin/controllers/mfa/PasswordController.php b/bin/controllers/mfa/PasswordController.php
index 14b1411..4a76da6 100644
--- a/bin/controllers/mfa/PasswordController.php
+++ b/bin/controllers/mfa/PasswordController.php
@@ -1,189 +1,203 @@
<?php namespace mfa;
use authentication\ProviderModel;
use BaseController;
+use spitfire\core\http\URL;
use spitfire\exceptions\HTTPMethodException;
use spitfire\exceptions\PrivateException;
use spitfire\validation\ValidationException;
+use Strings;
use function db;
use function url;
/*
* The MIT License
*
* Copyright 2021 César de la Cal Bretschneider <cesar@magic3w.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
class PasswordController extends BaseController
{
/**
*
* @validate >> POST#password(length[8, 40] required string)
* @throws PrivateException
* @throws HTTPMethodException
* @throws ValidationException
*/
public function set()
{
/*
* Setting the password requires the user to be properly authenticated so
* a stolen session cannot hijack an account.
*/
if (!$this->user) {
- #TODO: Redirect to login
+ $this->response->setBody('Redirect')->getHeaders()->redirect(url('user', 'login', ['returnto' => (string) URL::current()]));
}
- #TODO: Require the user to be strongly authenticated to perform this action
+ /*
+ * If the user has multi factor authentication enabled, we check that they
+ * are indeed strongly authenticated before continuing.
+ */
+ if ($this->level->count() < ($this->user->mfa? 2 : 1)) {
+ $this->response->setBody('Redirect')->getHeaders()->redirect(url('auth', 'threshold', ($this->user->mfa? 2 : 1), ['returnto' => (string)URL::current()]));
+ }
/*
* Fetch the authentication provider for the password. The user can only
* have one password on their account, so there's no need to qualify it.
*
* In case the user had no password (which is a very weird condition, but
* could be given if the administrator created an account for a user on
* a server that has no sign-up method)
*/
$provider = db()->table('authentication\provider')->get('user', $this->user)->where('type', ProviderModel::TYPE_PASSWORD)->first();
if (!$provider) {
$provider = db()->table('authentication\provider')->newRecord();
$provider->user = $this->user;
$provider->type = ProviderModel::TYPE_PASSWORD;
$provider->preferred = true;
$provider->created = time();
$provider->store();
}
/*
* If there is no password hashing mechanism, we should abort ASAP. Since
* nothing the application could do then would make any sense.
*/
if (!function_exists('password_hash'))
{ throw new PrivateException('Password hashing algorithm is missing. Please check your PHP version', 1604251501); }
try {
if (!$this->request->isPost()) { throw new HTTPMethodException(); }
if (!$this->validation->isEmpty()) { throw new ValidationException('Password is invalid', 0, $this->validation->toArray()); }
/*
* Hash and set the new password. Please note that this function does not
* invoke the store() function. This prevents the method from being called
* by accident.
*/
$provider->content = password_hash($_POST['password'], PASSWORD_DEFAULT);
$provider->store();
/*
* Once the password has been properly set, redirect the user to a success
* page.
*/
$this->response->setBody('Redirect...')->getHeaders()->redirect(url('twofactor'));
}
catch (HTTPMethodException $ex) {
/*Show the form*/
}
catch (ValidationException $e) {
$this->view->set('messages', $e->getResult());
}
}
/**
*
* @validate >> POST#password(length[8, 40] required string)
* @throws PrivateException
* @throws HTTPMethodException
* @throws ValidationException
*/
public function challenge()
{
- #TODO: This needs to work with session candidates
- $user = $this->user;
+ if (!$this->session) {
+ $this->response->setBody('Redirecting')->getHeaders()->redirect(url('user', 'login', ['returnto' => strval(URL2::current())]));
+ return;
+ }
+
+ if (isset($_GET['returnto']) && Strings::startsWith($_GET['returnto'], '/')) { $returnto = $_GET['returnto']; }
+ else { $returnto = (string)url('twofactor'); }
+
+ $user = $this->session->candidate;
/*
* Fetch the authentication provider for the password. The user can only
* have one password on their account, so there's no need to qualify it.
*
* In case the user had no password (which is a very weird condition, but
* could be given if the administrator created an account for a user on
* a server that has no sign-up method)
*/
- $provider = db()->table('authentication\provider')->get('user', $user)->where('type', ProviderModel::TYPE_PASSWORD)->first();
+ $provider = db()->table('authentication\provider')->get('user', $user)->where('expires', null)->where('type', ProviderModel::TYPE_PASSWORD)->first();
/*
* If there is no password hashing mechanism, we should abort ASAP. Since
* nothing the application could do then would make any sense.
*/
if (!function_exists('password_hash'))
{ throw new PrivateException('Password hashing algorithm is missing. Please check your PHP version', 1604251501); }
try {
if (!$this->request->isPost()) { throw new HTTPMethodException(); }
if (!$this->validation->isEmpty()) { throw new ValidationException('Password is invalid', 0, $this->validation->toArray()); }
/*
* Hash and set the new password. Please note that this function does not
* invoke the store() function. This prevents the method from being called
* by accident.
*/
/*
* If the password doesn't match, then we need to tell the user that whatever
* he wrote into the form was not acceptable.
*/
if (!password_verify($_POST['password'], $provider->content)) { throw new ValidationException('Password is invalid', 0, ['Bad password']); }
/*
* Getting here means the password was correct, we can now ensure that it's
* up to speed with the latest encryption and rehash it in case it's needed.
*/
if (password_needs_rehash($provider->content, PASSWORD_DEFAULT)) {
$provider->content = password_hash($_POST['password'], PASSWORD_DEFAULT);
$this->store();
}
$challenge = db()->table('authentication\challenge')->newRecord();
- $challenge->user = $user;
- $challenge->type = ProviderModel::TYPE_PASSWORD;
- #TODO: The challenge needs to be locked to the session authenticating the user
- //$challenge->session = ;
+ $challenge->provider = $provider;
+ $challenge->session = $this->session;
+ $challenge->expires = time() + 1200;
$challenge->cleared = time();
$challenge->store();
/*
* Once the password has been properly set, redirect the user to a success
* page.
*/
- $this->response->setBody('Redirect...')->getHeaders()->redirect(url('twofactor'));
+ $this->response->setBody('Redirect...')->getHeaders()->redirect($returnto);
}
catch (HTTPMethodException $ex) {
/*Show the form*/
}
catch (ValidationException $e) {
$this->view->set('messages', $e->getResult());
}
$this->view->set('user', $user);
}
}
diff --git a/bin/controllers/mfa/TOTPController.php b/bin/controllers/mfa/TOTPController.php
index af9878b..a21ab71 100644
--- a/bin/controllers/mfa/TOTPController.php
+++ b/bin/controllers/mfa/TOTPController.php
@@ -1,215 +1,216 @@
<?php
namespace mfa;
use authentication\ProviderModel;
use BaseController;
use spitfire\exceptions\HTTPMethodException;
use spitfire\exceptions\PublicException;
use spitfire\validation\ValidationException;
use function db;
use function url;
/*
* The MIT License
*
* Copyright 2021 César de la Cal Bretschneider <cesar@magic3w.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
class TOTPController extends BaseController
{
public function pair()
{
/**
* @todo This code needs to be moved somewhere outside the method body
*/
$base32encode = function ($data) {
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$mask = 0b11111;
$dataSize = strlen($data);
$res = '';
$remainder = 0;
$remainderSize = 0;
for ($i = 0; $i < $dataSize; $i++) {
$b = ord($data[$i]);
$remainder = ($remainder << 8) | $b;
$remainderSize += 8;
while ($remainderSize > 4) {
$remainderSize -= 5;
$c = $remainder & ($mask << $remainderSize);
$c >>= $remainderSize;
$res .= $chars[$c];
}
}
if ($remainderSize > 0) {
$remainder <<= (5 - $remainderSize);
$c = $remainder & $mask;
$res .= $chars[$c];
}
return $res;
};
#TODO: Require the user to be strongly authenticated to perform this action
/*
* If there is already a TOTP provider for this account, the user needs to
* remove the old one.
*/
$provider = db()->table('authentication\provider')->get('user', $this->user)->where('type', ProviderModel::TYPE_TOTP)->first();
if (!$provider) {
$secret = random_bytes(18);
$provider = db()->table('authentication\provider')->newRecord();
$provider->user = $this->user;
$provider->type = ProviderModel::TYPE_TOTP;
$provider->content = base64_encode($secret);
$provider->preferred = true;
$provider->created = time();
$provider->expires = time() + 86400 * 30;
$provider->store();
}
elseif ($provider->expires === null) {
throw new PublicException('Only one TOTP provider may be registered per account', 403);
}
else {
$secret = base64_decode($provider->content);
}
$this->view->set('secret', $base32encode($secret));
}
public function challenge()
{
/*
* If there is already a TOTP provider for this account, the user needs to
* remove the old one.
*/
$provider = db()->table('authentication\provider')->get('user', $this->user)->where('type', ProviderModel::TYPE_TOTP)->first();
if (!$provider) {
throw new PublicException('No TOTP pairing found');
}
#TODO: Extract TOTP mechanism so a bunch of valid codes can be generated for the user. So, users on the fence have no issues logging in
/*
* The epoch is the amount of 30 second slots that passed since the unix timestamp
* value of 0.
*/
$secret = base64_decode($provider->content);
$epoch = intval(time() / 30);
$packed = [0, 0, 0, 0, 0, 0, 0, 0];
/*
* The integer of the epoch gets converted into an array of byte strings.
* But for some reason it gets packed backwards.
*/
for ($i = 0; $i < 8; $i++) {
$packed[7 - $i] = pack('C*', $epoch);
$epoch = $epoch >> 8;
}
/*
* Calculate the sha1 hmac on the result of our packing.
*/
$sha1 = hash_hmac('sha1', implode($packed), $secret, true);
/*
* Get the offset to be used from our hmac. We use the last 4 bit of the
* hmac to determine where to truncate from.
*/
$length = strlen($sha1);
$offset = ord($sha1[$length - 1]) & 0xF;
/*
* Extract the bytes at the offset, and omit the first bit so it fits into
* 31 bits. This prevents the code from causing issues with 4byte signed and
* unsigned integers.
*/
$truncated = (ord($sha1[$offset++]) & 0x7F) << 24 |
(ord($sha1[$offset++]) & 0xFF) << 16 |
(ord($sha1[$offset++]) & 0xFF) << 8 |
(ord($sha1[$offset++]) & 0xFF);
$totp = $truncated % pow(10, 6);
try {
if (!$this->request->isPost()) { throw new HTTPMethodException();}
if ( intval($_POST['challenge']) !== intval($totp)) { throw new ValidationException('Code failed', 0, []); }
$challenge = db()->table('authentication\challenge')->newRecord();
- #TODO: $challenge->session = ;
+ $challenge->session = $this->session;
$challenge->provider = $provider;
$challenge->cleared = time();
+ $challenge->expires = time() + 1200;
$challenge->store();
#TODO: Add flag for whether the provider is active
$provider->expires = null;
$provider->store();
$this->response->setBody('Redirect')->getHeaders()->redirect(url('twofactor'));
}
catch (HTTPMethodException $ex) {
}
catch (ValidationException $ex) {
$this->view->set('messages', [$ex->getMessage()]);
}
$this->view->set('challenge', $totp);
}
public function remove()
{
if (!$this->user) {
throw new PublicException('Login required to remove phone numbers', 401);
}
#TODO: Replace that OIDC Session placeholder
$strength = db()->table('authentication\challenge')->get('session', '[OIDC Session goes here]')->where('cleared', '>', time() - 1200)->all();
$expected = $this->user->mfa? 2 : 1;
/*
* Our system only allows one TOTP pairing per account for now, this prevents
* the system from being fuzzy about which providers it offers.
*/
$provider = db()->table('authentication\provider')->get('user', $this->user)->where('type', ProviderModel::TYPE_TOTP)->first();
if ($strength->count() < $expected) {
return $this->response->setBody('Redirect...')->getHeaders()
->redirect(url('auth', 'threshold', $expected, ['returnto' => strval(url(['mfa', 'totp'], 'remove', $provider->_id))]));
}
$provider->delete();
$this->response->setBody('Redirect...')->getHeaders()->redirect(url('twofactor'));
}
}
diff --git a/bin/controllers/user.php b/bin/controllers/user.php
index 431a85a..d291088 100644
--- a/bin/controllers/user.php
+++ b/bin/controllers/user.php
@@ -1,372 +1,387 @@
<?php
use client\LocationModel;
use defer\IncinerateSessionTask;
use exceptions\suspension\LoginDisabledException;
use mail\MailUtils;
use mail\spam\domain\implementation\SpamDomainModelReader;
use mail\spam\domain\SpamDomainValidationRule;
use spitfire\core\Environment;
use spitfire\exceptions\HTTPMethodException;
use spitfire\exceptions\PublicException;
use spitfire\io\curl\URLReflection;
use spitfire\io\session\Session;
use spitfire\storage\database\pagination\Paginator;
use spitfire\validation\rules\FilterValidationRule;
use spitfire\validation\rules\MaxLengthValidationRule;
use spitfire\validation\rules\MinLengthValidationRule;
use spitfire\validation\rules\RegexValidationRule;
use spitfire\validation\ValidationException;
class UserController extends BaseController
{
public function index() {
$query = db()->table('user')->get('disabled', null, 'IS');
$paginator = new Paginator($query);
if (isset($_GET['q'])) {
$query->where('usernames', db()->table('username')->getAll()->where('name', 'LIKE', $_GET['q'] . '%'));
}
$this->view->set('page.title', 'User list');
$this->view->set('users', $paginator->records());
$this->view->set('pagination', $paginator);
}
/**
*
* @layout minimal.php
* @return type
* @throws HTTPMethodException
* @throws ValidationException
*/
public function register() {
if (isset($_GET['returnto']) && (Strings::startsWith($_GET['returnto'], '/') || filter_var($_GET['input'], FILTER_VALIDATE_EMAIL))) {
$returnto = $_GET['returnto'];
}
else {
$returnto = (string)url();
}
try {
if (!$this->request->isPost()) { throw new HTTPMethodException(); }
/*
* We need to validate the data the user sends. This is a delicate process
* and therefore requires quite a lot of attention
*/
$validatorUsername = validate()->addRule(new MinLengthValidationRule(4, 'Username must be more than 3 characters'));
$validatorUsername->addRule(new MaxLengthValidationRule(20, 'Username must be shorter than 20 characters'));
$validatorUsername->addRule(new RegexValidationRule('/^[a-zA-Z][a-zA-Z0-9\-\_]+$/', 'Username must only contain characters, numbers, underscores and hyphens'));
$validatorEmail = validate()->addRule(new FilterValidationRule(FILTER_VALIDATE_EMAIL, 'Invalid email found'));
$validatorEmail->addRule(new MaxLengthValidationRule(50, 'Email cannot be longer than 50 characters'));
$validatorEmail->addRule(new SpamDomainValidationRule(new SpamDomainModelReader(db())));
$validatorPassword = validate()->addRule(new MinLengthValidationRule(8, 'Password must have 8 or more characters'));
validate(
$validatorEmail->setValue(_def($_POST['email'], '')),
$validatorUsername->setValue(_def($_POST['username'], '')),
$validatorPassword->setValue(_def($_POST['password'], '')));
$exists = db()->table('username')
->get('name', $_POST['username'])
->group()
->addRestriction('expires', null, 'IS')
->addRestriction('expires', time(), '>')
->endGroup()
->fetch();
if ($exists) {
throw new ValidationException('Username is taken', 0, Array('Username is taken'));
}
if (db()->table('user')->get('email', $_POST['email'])->fetch()) {
throw new ValidationException('Email is taken', 0, Array('Email is already in use'));
}
/**
* Once we validated the data, let's move onto the next step, store the
* data.
*
* @var $user UserModel
*/
$user = db()->table('user')->newRecord();
$user->email = $_POST['email'];
$user->verified = false;
$user->created = time();
$user->setPassword($_POST['password']);
$user->store();
//TODO: Email needs to be properly stored as a contact
$username = db()->table('username')->newRecord();
$username->user = $user;
$username->name = $_POST['username'];
$username->store();
$s = Session::getInstance();
$s->lock($user->_id);
return $this->response->getHeaders()->redirect($returnto);
}
catch(HTTPMethodException$e) { /*Do nothing, we'll show the form*/}
catch(ValidationException$e) { $this->view->set('messages', $e->getResult()); }
$this->view->set('returnto', $returnto);
}
/**
*
* @layout minimal.php
* @throws PublicException
*/
public function login() {
if (isset($_GET['returnto']) && Strings::startsWith($_GET['returnto'], '/')) {
$returnto = $_GET['returnto'];
}
else {
$returnto = (string)url();
}
+
+ if ($this->session && $this->level->count() < 1) {
+ return $this->response->setBody('Redirect')->getHeaders()->redirect(url('auth', 'threshold', 1, ['returnto' => strval(\spitfire\core\http\URL::current())]));
+ }
+
+ if ($this->session && $this->level->count() > 0) {
+ $this->session->user = $this->session->candidate;
+ $this->session->store();
+
+ return $this->response->setBody('Redirect')->getHeaders()->redirect($returnto);
+ }
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$query = db()->table('user')->getAll();
$query->group()
->addRestriction('email', $_POST['username'])
->addRestriction('usernames', db()->table('username')->get('name', $_POST['username'])->addRestriction('expires', NULL, 'IS'))
->endGroup();
$user = $query->fetch();
#Check whether the user was banned
$banned = $user? db()->table('user\suspension')->get('user', $user)->addRestriction('expires', time(), '>')->addRestriction('preventLogin', 1)->fetch() : false;
if ($banned) { throw new LoginDisabledException($banned); }
if ($user && $user->disabled !== null) {
throw new PublicException('This account has been disabled permanently.', 401);
}
- elseif ($user && $user->checkPassword($_POST['password'])) {
- $session = Session::getInstance();
- $session->lock($user->_id);
-
- $dbsession = db()->table('session')->get('_id', $session->sessionId(false))->first(true);
- $dbsession->user = $user;
+ elseif ($user) {
+ $dbsession = db()->table('session')->newRecord();
+ $dbsession->user = null;
+ $dbsession->candidate = $user;
$dbsession->device = DeviceModel::makeFromRequest();
$dbsession->ip = isset($_SERVER['HTTP_X_FORWARDED_FOR'])? $_SERVER['HTTP_X_FORWARDED_FOR']: $_SERVER['REMOTE_ADDR'];
/*
* Retrieve the IP information from the client. This should allow the
* application to provide the user with data where they connected from.
*/
$ip = IP::makeLocation();
if ($ip->country_code) {
$dbsession->location = LocationModel::getLocation($ip->country_code, substr($ip->city, 0, 50));
}
$dbsession->store();
- async()->defer(time() + 86400 * 90, new IncinerateSessionTask($dbsession->_id));
- return $this->response->getHeaders()->redirect($returnto);
+ $session = Session::getInstance();
+ $session->lock($dbsession->_id);
+
+ //async()->defer(time() + 86400 * 90, new IncinerateSessionTask($dbsession->_id));
+
+ return $this->response->setBody('Redirect')->getHeaders()->redirect(url('auth', 'threshold', 1, ['returnto' => strval(\spitfire\core\http\URL::current())]));
+
} else {
$this->view->set('message', 'Username or password did not match');
}
}
$this->view->set('returnto', $returnto);
}
public function logout() {
$s = Session::getInstance();
- $s->destroy();
$dbsession = db()->table('session')->get('_id', $s->sessionId())->first();
$token = isset($_GET['token'])? db()->table('access\token')->get('_id', $_GET['token'])->first() : null;
- $rtt = new URLReflection($_GET['returnto']?? null);
+ $rtt = URLReflection::fromURL($_GET['returnto']?? null);
+
+ $s->destroy();
if ($dbsession) {
$dbsession->expires = time();
$dbsession->store();
/*
* When a session is terminated, we clean it up after about twenty minutes,
* this gives the system more than enough time to perform some administrative
* tasks while maintaining the reference.
*/
async()->defer(time() + 1200, new IncinerateSessionTask($dbsession->_id));
/*
* It's absolutely imperative for a good user experience that the server
* sends a logout command for the session to all clients that depend on
* this session. Otherwise they will keep logged into the application and
* the sessions will get fractured (some clients maintain an old session).
*/
async()->defer(time(), new defer\notify\EndSessionTask($dbsession->_id));
}
if ($token) {
$locations = db()->table('client\location')->get('client', $token->client)->all();
#TODO: This should be extracted to a function with a proper name
$accept = $locations->filter(function (LocationModel $e) use ($rtt) {
if ($rtt->getPassword() || $rtt->getUser()) { return false; }
if ($rtt->getProtocol() !== $e->protocol) { return false; }
if ($rtt->getServer() !== $e->hostname) { return false; }
if (!Strings::startsWith($rtt->getPath(), $e->path)) { return false; }
return true;
});
if (!$accept) { $rtt = false; }
}
else {
$rtt = false;
}
$this->response->setBody('Redirect...');
#TODO: Actually the system should be redirecting to a location that waits for the
#session to be properly terminated before continuing.
#At that stage leaking the OIDC session id would be irrelevant, since it's already
#been terminated and therefore cannot be used for anything but checking whether
#the response was successful.
return $this->response->getHeaders()->redirect($rtt? strval($rtt) : url());
}
public function detail($userid) {
#Check whether the request is from either an admin account or an application
#All other profiles do have no access to this information
if (!$this->authapp && !$this->isAdmin) {
throw new PublicException('You have no privileges to access this data.', 403);
}
#Get the affected profile
$profile = db()->table('user')->get('_id', $userid)->fetch()? :
db()->table('user')->get('usernames', db()->table('username')->get('name', $userid)->
group()->addRestriction('expires', NULL, 'IS')->addRestriction('expires', time(), '>')->endGroup())->first();
#If there was no profile. Throw an error
if (!$profile) { throw new PublicException('No user found', 404); }
#Get the list of attributes
#TODO: Remove, deprecated
$attributes = db()->table('attribute')->getAll()->all();
$userAttr = collect();
foreach ($attributes as $attr) {
$userAttr[$attr->_id] = db()->table('user\attribute')->get('user', $profile)->where('attr', $attr)->first();
}
#Get the currently active moderative issue
#Check if the user has been either banned or suspended
$suspension = db()->table('user\suspension')->get('user', $profile)->addRestriction('expires', time(), '>')->first();
$this->view->set('user', $profile);
$this->view->set('profile', $userAttr);
$this->view->set('attributes', $attributes);
$this->view->set('suspension', $suspension);
$this->view->set('email', $this->authapp->email);
}
/**
*
* @layout minimal.php
* @return type
* @throws PublicException
*/
public function recover($tokenid = null) {
$token = $tokenid? db()->table('token')->get('token', $tokenid)->fetch() : null;
$returnto = isset($_GET['returnto'])? $_GET['returnto'] : url();
if ($token && $token->app !== null) {
throw new PublicException('Token level insufficient', 403);
}
if ($token && $this->request->isPost() && $_POST['password'][0] === $_POST['password'][1] ) {
#Store the new password
$token->user->setPassword($_POST['password'][0])->store();
return $this->response->getHeaders()->redirect($returnto);
}
elseif ($token) { //The user clicked on the recovery email
#Let the user enter a new password
$this->view->set('action', 'passwordform');
$this->view->set('user', $token->user);
}
elseif ($this->request->isPost()) {
#Tell the user the email was dispatched
$user = isset($_POST['email'])? db()->table('user')->get('email', $_POST['email'])->fetch() : null;
if ($user) {
$token = TokenModel::create(null, 1800, false);
$token->user = $user;
$token->store();
$url = url('user', 'recover', $token->token, ['returnto' => $returnto])->absolute();
EmailModel::queue($user->email, 'Recover your password', sprintf('Click here to recover your password: <a href="%s">%s</a>', $url, $url));
$this->view->set('success', 'An email with the link to recover your account was sent to you.');
}
else {
$this->view->set('error', 'That email address is not attached to any account');
}
$this->view->set('action', 'emailform');
$this->view->set('user', $user);
}
else {
#Show instructions to recover your password
$this->view->set('action', 'emailform');
}
$this->view->set('returnto', $returnto);
}
public function activate($tokenid = null) {
$token = $tokenid? db()->table('token')->get('token', $tokenid)->fetch() : null;
#The token should have been created by the Auth Server
if ($token && $token->app !== null) {
throw new PublicException('Token level insufficient', 403);
}
if ($token) {
$token->user->verified = 1;
$token->user->store();
}
elseif($this->user || $token->user) {
$token = TokenModel::create(null, 1800, false);
$token->user = $this->user? : $token->user;
$token->store();
$url = url('user', 'activate', $token->token)->absolute();
/*
* If the email the user is using to activate this account is not the
* canonical for their account, we need to make sure that they're not
* using a provider thatallows them to bypass verification systems.
*
* This is a common behavior for temporary email providers that generate
* gmail addresses (they take advantage of the fact that gmail allows
* incoming emails to be routed to the same account by just adding stops,
* making email@gmail.com exactly equal to e.mail@gmail.com and ema.il@gmail.com)
*/
$canonical = MailUtils::canonicalize($this->user->email, true) !== $this->user->email;
$delay = $canonical? (Environment::get('phpas.email.delaynoncanonical')?: 45 * 60) : 0;
EmailModel::queue($this->user->email, 'Activate your account',
sprintf('Click here to activate your account: <a href="%s">%s</a>', $url, $url), time() + $delay);
}
else {
throw new PublicException('Not logged in', 403);
}
#We need to redirect the user back to the home page
$this->response->setBody('Redirect...')->getHeaders()->redirect(url(Array('message' => 'success')));
}
}
diff --git a/bin/models/session.php b/bin/models/session.php
index 6d2b5b9..237db9e 100644
--- a/bin/models/session.php
+++ b/bin/models/session.php
@@ -1,93 +1,101 @@
<?php
use spitfire\Model;
use spitfire\storage\database\Schema;
/*
* The MIT License
*
* Copyright 2020 César de la Cal Bretschneider <cesar@magic3w.com>.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* A session connects a user with a device from which they logged in, when the user
* logs in, a session is started for them. When a user ends a session, all related
* tokens that have not been granted as offline will be terminated.
*
* @property UserModel $user Session owner
+ * @property UserModel $claim When a user is logging in, they can claim to be a certain person. This is not a valid user authentication.
* @property LocationModel $location The location from where the session was authorized
* @property DeviceModel $device The device from which the session was authorized
*
* @property int $created The timestamp of creation
* @property int $expires The timestamp this record expires
*
* @author César de la Cal Bretschneider <cesar@magic3w.com>
*/
class SessionModel extends Model
{
+ protected const TOKEN_PREFIX = 's_';
+ const TOKEN_LENGTH = 50;
+
/**
*
* @param Schema $schema
* @return Schema
*/
public function definitions(Schema $schema) {
#The session ID will be retrieved from the Session
unset($schema->_id);
- $schema->_id = new StringField(70);
+ $schema->_id = new StringField(self::TOKEN_LENGTH);
+ $schema->candidate= new Reference(UserModel::class);
$schema->user = new Reference(UserModel::class);
$schema->location = new Reference(LocationModel::class);
$schema->device = new Reference(DeviceModel::class);
/*
* Applications can use the IP address of the device to prevent an attacker
* generating a token from a certain IP address and sending it to an unsuspecting
* victim that may authorize this token from a different IP address.
*/
$schema->ip = new StringField(128);
/*
* This flag gets set to true whenever the user managed to authenticate
* themselves as the user they claim to be.
*
* Users with a session that is not marked as authenticated MUST NOT be able
* to issue codes, tokens or anything along those lines. This is obviously
* only relevant to users and not clients generating tokens.
*/
$schema->authenticated = new BooleanField();
$schema->created = new IntegerField(true);
$schema->expires = new IntegerField(true);
- $schema->payload = new TextField();
-
$schema->index($schema->_id)->setPrimary(true);
$schema->index($schema->expires);
}
public function onbeforesave(): void {
parent::onbeforesave();
+ if (!$this->_id) {
+ do { $this->_id = substr(self::TOKEN_PREFIX . bin2hex(random_bytes(25)), 0, self::TOKEN_LENGTH); }
+ while (db()->table('session')->get('_id', $this->_id)->first());
+ }
+
if (!$this->created) { $this->created = time(); }
$this->expires = time() + 86400 * 90;
}
}
diff --git a/bin/settings/routes.php b/bin/settings/routes.php
index d0451b2..6b29308 100644
--- a/bin/settings/routes.php
+++ b/bin/settings/routes.php
@@ -1,23 +1,24 @@
<?php
/* Use this file to add routes to your Spitfire app. Just use them like this
*
* router::route('/old/url', '/new/url');
*
* Or like this
*
* router:route('old/url/*', 'new/$2/url');
*
* Remember that routes are blocking. If one matches it'll stop the execution
* of the following rules. So add them wisely.
* It's really easy and fun!
*/
spitfire\core\router\Router::getInstance()->request('/app/permissions/:action?', ['controller' => ['permissions'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/client/credential/:action?', ['controller' => ['client', 'credential'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/mfa/backup-code/:action?', ['controller' => ['mfa', 'BackUpCode'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/mfa/password/:action?', ['controller' => ['mfa', 'Password'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/mfa/totp/:action?', ['controller' => ['mfa', 'TOTP'], 'action' => ':action']);
+spitfire\core\router\Router::getInstance()->request('/mfa/rfc6238/:action?', ['controller' => ['mfa', 'TOTP'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/mfa/phone/:action?', ['controller' => ['mfa', 'Phone'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/mfa/email/:action?', ['controller' => ['mfa', 'Email'], 'action' => ':action']);
spitfire\core\router\Router::getInstance()->request('/session/:action?', ['controller' => ['Session'], 'action' => ':action']);
\ No newline at end of file
diff --git a/bin/templates/layout.php b/bin/templates/layout.php
index a3e758f..92789c9 100644
--- a/bin/templates/layout.php
+++ b/bin/templates/layout.php
@@ -1,240 +1,240 @@
<!DOCTYPE html>
<html>
<head>
<title><?= isset(${'page.title'}) && ${'page.title'}? ${'page.title'} : 'Account server' ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="_scss" content="<?= \spitfire\SpitFire::baseUrl() ?>/assets/scss/_/js/">
<link rel="stylesheet" type="text/css" href="<?= spitfire\core\http\URL::asset('css/app.css') ?>">
<link rel="stylesheet" type="text/css" href="<?= spitfire\core\http\URL::asset('css/ui-layout.css') ?>">
<script>
window.baseURL = <?= json_encode(strval(url())); ?>
</script>
</head>
<body>
<script>
/*
* This little script prevents an annoying flickering effect when the layout
* is being composited. Basically, since we layout part of the page with JS,
* when the browser gets to the JS part it will discard everything it rendered
* to this point and reflow.
*
* Since the reflow MUST happen in order to render the layout, we can tell
* the browser to not render the layout at all. This will prevent the layout
* from shift around before the user had the opportunity to click on it.
*
* If, for some reason the layout was unable to start up within 500ms, we
* let the browser render the page. Risking that the browser may need to
* reflow once the layout is ready
*/
(function() {
document.body.style.display = 'none';
document.addEventListener('DOMContentLoaded', function () { document.body.style.display = null; }, false);
setTimeout(function () { document.body.style.display = null; }, 500);
}());
</script>
<div class="navbar">
<div class="left">
<div style="line-height: 32px">
<span class="toggle-button dark"></span>
</div>
</div>
<div class="right">
<?php if(isset($authUser) && $authUser): ?>
<div class="has-dropdown" style="display: inline-block">
<a href="<?= url('user', $authUser->username) ?>" class="app-switcher" data-toggle="app-drawer">
<img src="<?= url('image', 'user', $authUser->_id, 64) ?>" width="32" height="32" style="border-radius: 50%;" >
</a>
<div class="dropdown right-bound unpadded" data-dropdown="app-drawer">
<div class="app-drawer">
<div class="navigation vertical">
<!-- Todo: Once a dedicated profile hosting server is available, the link to editing the user profile there could be included here-->
<a class="navigation-item" href="<?= url('user', 'logout') ?>">Logout</a>
</div>
</div>
</div>
</div>
<?php else: ?>
- <a class="menu-item" href="<?= url('account', 'login') ?>">Login</a>
+ <a class="menu-item" href="<?= url('user', 'login') ?>">Login</a>
<?php endif; ?>
</div>
<div class="center align-center">
<form class="search-input">
<input type="hidden" data-placeholder="Search..." id="search-input">
</form>
</div>
</div>
<div class="auto-extend">
<div class="content">
<?php if (isset($authUser) && $authUser && !$authUser->verified): ?>
<!--
You haven't verified your account yet, that is quite a big deal for some
applications, which may rely on you activating your account to make sure
you didn't make up your address.
-->
<div class="spacer" style="height: 30px;"></div>
<div class="row l1">
<div class="span l1">
<div class="message error">
Your account has not yet been activated. <a href="<?= url('user', 'activate') ?>">Resend activation mail</a>
</div>
</div>
</div>
<?php endif; ?>
<div class="spacer" style="height: 30px"></div>
<div data-sticky-context>
<?= $this->content() ?>
</div>
</div>
</div>
<footer>
<div class="row1">
<div class="span1">
<span style="font-size: .8em; color: #777">
&copy; <?= date('Y') ?> Magic3W - This software is licensed under MIT License
</span>
</div>
</div>
</footer>
<div class="contains-sidebar">
<div class="sidebar">
<div class="spacer" style="height: 20px"></div>
<?php if(isset($authUser)): ?>
<div class="menu-title"> Account</div>
<div class="menu-entry"><a href="<?= url() ?>" >Edit profile</a></div>
<div class="menu-entry"><a href="<?= url('edit', 'email') ?>">Change email address</a></div>
<div class="menu-entry"><a href="<?= url('edit', 'password') ?>">Change password</a></div>
<div class="menu-entry"><a href="<?= url('edit', 'avatar') ?>" >Upload avatar</a></div>
<div class="menu-entry"><a href="<?= url('permissions') ?>" >Application permissions</a></div>
<?php if(isset($userIsAdmin) && $userIsAdmin): ?>
<div class="spacer" style="height: 30px"></div>
<div class="menu-title">Administration</div>
<div class="menu-entry"><a href="<?= url('user') ?>">Users</a></div>
<div class="menu-entry"><a href="<?= url('group') ?>">Groups</a></div>
<div class="menu-entry"><a href="<?= url('admin') ?>">System settings</a></div>
<div class="menu-entry"><a href="<?= url('token') ?>">Active sessions</a></div>
<!--APPLICATIONS-->
<div class="menu-entry"><a href="<?= url('app') ?>" >App administration</a></div>
<?php endif; ?>
<?php endif; ?>
<div class="menu-title">Our network</div>
<div id="appdrawer"></div>
</div>
</div>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function () {
var ae = document.querySelector('.auto-extend');
var wh = window.innerheight || document.documentElement.clientHeight;
var dh = document.body.clientHeight;
ae.style.minHeight = Math.max(ae.clientHeight + (wh - dh), 0) + 'px';
});
</script>
<!--Import depend.js and the router it uses to load locations -->
<script src="<?= spitfire\core\http\URL::asset('js/depend.js') ?>" type="text/javascript"></script>
<script src="<?= spitfire\core\http\URL::asset('js/m3/depend/router.js') ?>" type="text/javascript"></script>
<script type="text/javascript">
(function () {
depend(['m3/depend/router'], function(router) {
router.all().to(function(e) { return '<?= \spitfire\SpitFire::baseUrl() . '/assets/js/' ?>' + e + '.js'; });
router.equals('phpas/app/drawer').to( function() { return '<?= url('appdrawer')->setExtension('js') ?>'; });
router.equals('_scss').to( function() { return '<?= \spitfire\SpitFire::baseUrl() ?>/assets/scss/_/js/_.scss.js'; });
});
depend(['ui/dropdown'], function (dropdown) {
dropdown('.app-switcher');
});
depend(['_scss'], function() {
//Loaded
});
}());
</script>
<script type="text/javascript">
depend(['m3/core/request'], function (Request) {
var request = new Request('<?= url('appdrawer')->setExtension('json') ?>');
request
.then(JSON.parse)
.then(function (e) {
e.forEach(function (i) {
console.log(i)
var entry = document.createElement('div');
var link = entry.appendChild(document.createElement('a'));
var icon = link.appendChild(document.createElement('img'));
entry.className = 'menu-entry';
link.href = i.url;
link.appendChild(document.createTextNode(i.name));
icon.src = i.icon.m;
document.getElementById('appdrawer').appendChild(entry);
});
})
.catch(console.log);
});
</script>
<script type="text/javascript" src="<?= spitfire\core\http\URL::asset('js/dials.js') ?>" async="true"></script>
<script type="text/javascript" src="<?= spitfire\core\http\URL::asset('js/ui/form/styledElements.js') ?>" async="true"></script>
<script type="text/javascript">
depend(['sticky'], function (sticky) {
/*
* Create elements for all the elements defined via HTML
*/
var els = document.querySelectorAll('*[data-sticky]');
for (var i = 0; i < els.length; i++) {
sticky.stick(els[i], sticky.context(els[i]), els[i].getAttribute('data-sticky'));
}
});
</script>
<script type="text/javascript">
depend(['autocomplete'], function (autocomplete) {
var ac = autocomplete(document.getElementById('search-input'), function (input, output, entry) {
var index = [
entry('Change your avatar', 'edit/avatar', {}),
entry('Login history', 'edit/avatar', {}),
entry('Devices history', 'edit/avatar', {}),
entry('Applications connected', 'edit/avatar', {}),
];
var result = [];
if (!input) { output(index.slice(0, 10)); return; }
for (var i = 0; i < index.length; i++) {
if (index[i].value.substr(0, input.length) === input) {
result.push(index[i]);
}
}
output(result);
});
ac.allowUndefined = true;
});
</script>
</body>
</html>
\ No newline at end of file
diff --git a/bin/templates/user/login.php b/bin/templates/user/login.php
index d93eba6..116b2e4 100644
--- a/bin/templates/user/login.php
+++ b/bin/templates/user/login.php
@@ -1,159 +1,159 @@
<div class="spacer" style="height: 30px;"></div>
<div class="row l2">
<div class="span l1 login-logo">
<img src="<?= url('image', 'hero') ?>">
</div>
</div>
<div class="spacer" style="height: 30px;"></div>
<div class="row l2">
<div class="span l1 login-logo">
<form method="post" action="" enctype="multipart/form-data" id="login-form">
<input type="hidden" name="device[js]" id="device-js" value="false">
<input type="hidden" name="device[platform]" id="device-platform" value="unknown">
<input type="hidden" name="device[touch]" id="device-touch" value="false">
<input type="hidden" name="device[wide]" id="device-wide" value="false">
<?php if (isset($message) && $message): ?>
<div class="message error"><?= $message ?></div>
<?php else: ?>
<div class="message info">
Authenticating you requires our application to provide your browser with a
cookie and to record your IP. This is required to secure your account.
</div>
<?php endif; ?>
<div class="spacer medium"></div>
<div class="frm-ctrl-outer"><!-- Soon to be frm-ctrl-outer-->
<input class="frm-ctrl" type="text" name="username" id="username" autofocus="true" autocomplete="off" spellcheck="false" required minlength="3" placeholder="">
<label for="username">Username</label>
</div>
<div class="spacer small"></div>
- <div class="frm-ctrl-grp">
+ <!--<div class="frm-ctrl-grp">
<div class="frm-ctrl-outer">
<input class="frm-ctrl" placeholder="" type="password" name="password" id="password" autocomplete="current-password" required aria-describedby="password-constraints" minlength="8" style="height: 3.275rem">
<label for="password">Password</label>
</div>
<div class="frm-ctrl-outer fixed-width align-center" style=" width: 3.3rem">
<span class="frm-ctrl-ro">
<button type="button" id="toggle-password" aria-label="Show password. Others might be able to see your password." style="margin: 0; padding: 0; border: none; background: none">
<span id="show-password" >
<svg style="height: 1.5rem; color: #444" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</span>
<span id="hide-password" style="display: none">
<svg style="height: 1.5rem; color: #444" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</span>
</button>
</span>
</div>
- </div>
+ </div>-->
<div class="spacer small"></div>
<div id="password-constraints">Your password must be at least 8 characters long.</div>
<div class="spacer small"></div>
<input type="submit" class="button button-color-purple-700" style="width: 100%" id="login-submit" value="Log in">
</form>
<div class="spacer" style="height: 10px;"></div>
<p style="text-align: center">
<a href="<?= url('user', 'recover') ?>" >Forgot password?</a> ·
<a href="<?= url('user', 'register', Array('returnto' => $returnto)) ?>">Create account</a>
</p>
</div>
</div>
<script type="text/javascript">
(function () {
/*
* This script allows PHPAS to perform feature detection for your device.
* Your information is not stored past the lifetime of your sesstion, this
* information is not processed, only used to allow you to identify your own
* devices if you so desire.
*
* Your devices specific data is never sent to the server and only processed
* localy, here's a list of features we record about it:
*
* - Whether it is touch enabled
* - Whether is has a big screen
* - The operating system it runs on (Windows / Linux / Mac OS / Android / iOS)
* - Whether it has javascript enabled
*
* This allows us to print a little device icon telling you whether your account
* was accessed from your phone, your computer or a bot your may have created.
*/
function width() {
return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
}
document.getElementById('device-js').value = 'true';
document.getElementById('device-wide').value = width() > 1440? 'true' : 'false';
document.getElementById('device-platform').value = window.navigator.platform;
var touch = function () {
//Source: http://www.stucox.com/blog/you-cant-detect-a-touchscreen/
document.getElementById('device-wide').value = width() > 750? 'true' : 'false';
document.getElementById('device-touch').value = 'true';
// Remove event listener once fired, otherwise it'll kill scrolling
// performance
window.removeEventListener('touchstart', touch);
};
window.addEventListener('touchstart', touch, false);
}());
</script>
<script>
(function () {
/*
* Whenever the user submits the form, we're disabling the input so it can be
* pressed again. Please note that, if we're introducing validation here that
* needs to be performed before the form is submitted, we need to re-enable the
* input if the validation failed.
*/
var submit = document.getElementById('login-submit');
var form = document.getElementById('login-form');
form.addEventListener('submit', function (e) { submit.disabled = 'disabled'; })
/*
* Toggle the password. Source for this is: https://www.youtube.com/watch?v=alGcULGtiv8&app=desktop
*/
var toggle = document.getElementById('toggle-password');
var password = document.getElementById('password');
toggle.addEventListener('click', function (e) {
if (password.type === 'password') {
password.type = 'text';
toggle.querySelector('#hide-password').style.display = 'block';
toggle.querySelector('#show-password').style.display = 'none';
toggle.setAttribute('aria-label', 'Hide password');
}
else {
password.type = 'password';
toggle.querySelector('#hide-password').style.display = 'none';
toggle.querySelector('#show-password').style.display = 'block';
toggle.setAttribute('aria-label', 'Show password. Others might be able to see your password.');
}
e.preventDefault();
});
}());
</script>

File Metadata

Mime Type
text/x-diff
Expires
Apr 12 2021, 7:15 PM (9 w, 1 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
7068
Default Alt Text
(75 KB)

Event Timeline