Page MenuHomePhabricator

No OneTemporary

diff --git a/bin/controllers/app.php b/bin/controllers/app.php
index 905cb0a..04caf2a 100644
--- a/bin/controllers/app.php
+++ b/bin/controllers/app.php
@@ -1,113 +1,114 @@
<?php
use spitfire\exceptions\PublicException;
use spitfire\io\Upload;
use spitfire\storage\database\pagination\Paginator;
/**
* This controller allows administrators (and only those) to manage the applications
* that can connect to the server and manage their preferences and default
* access level settings.
*
* Only admins receive access since this is the strongest vector for a malicious
* application to raise it's privileges and access data it's not supposed to have.
*/
class AppController extends BaseController
{
public function _onload()
{
parent::_onload();
#Get the user model
if (!$this->user) { throw new PublicException('Not logged in', 403); }
}
public function index()
{
$query = db()->table('authapp')->get('owner', $this->user);
$pag = new Paginator($query);
$this->view->set('pagination', $pag);
}
public function create()
{
if ($this->request->isPost()) {
$app = db()->table('authapp')->newRecord();
$app->owner = $this->user;
$app->name = $_POST['name'];
#TODO: Replace with the proper app secret generation
$app->appSecret = preg_replace('/[^a-z\d]/i', '', base64_encode(random_bytes(35)));
+ $app->twofactor = false;
if ($_POST['icon'] instanceof Upload) {
$app->icon = $_POST['icon']->validate()->store()->uri();
}
do {
$id = $app->appID = mt_rand();
$count = db()->table('authapp')->get('appID', $id)->count();
} while ($count !== 0);
$app->store();
$this->response->getHeaders()->redirect(url('app', 'index', Array('message' => 'success')));
return;
}
}
public function detail(AuthAppModel$app)
{
if ($app->owner->_id != $this->user->_id) {
throw new PublicException('Not allowed', 403);
}
if ($this->request->isPost()) {
#The name of the application
if (isset($_POST['name'])) {
$app->name = trim($_POST['name']);
}
if ($_POST['icon'] instanceof Upload) {
$app->icon = $_POST['icon']->store()->uri();
}
$app->store();
}
$this->view->set('app', $app);
try {
$hookapp = db()->table('authapp')->get('_id', SysSettingModel::getValue('cptn.h00k'))->first(true)->appID;
//$this->view->set('webhooks', $this->hook->on($hookapp, $app->appID)->listeners);
} catch (Exception $ex) {
$this->view->set('webhooks', []);
}
}
public function delete($appID)
{
$xsrf = new \spitfire\io\XSSToken();
$app = db()->table('authapp')->get('_id', $appID)->fetch();
if ($app->owner->_id != $this->user->_id) {
throw new PublicException('Not allowed', 403);
}
if (isset($_GET['confirm']) && $xsrf->verify($_GET['confirm'])) {
$app->delete();
$this->response->getHeaders()->redirect(url('app', 'index', Array('message' => 'deleted')));
return;
}
$this->view->set('confirm', url('app', 'delete', $appID, Array('confirm' => $xsrf->getValue())));
}
}
diff --git a/bin/controllers/auth.php b/bin/controllers/auth.php
index b4ba735..0c7c9d1 100644
--- a/bin/controllers/auth.php
+++ b/bin/controllers/auth.php
@@ -1,476 +1,520 @@
<?php
use app\AuthLock;
+use client\LocationModel;
+use client\ScopeModel;
use connection\AuthModel;
+use exceptions\suspension\LoginDisabledException;
+use magic3w\http\url\reflection\URLReflection;
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)
+ * @validate GET#code_challenge (string required)
+ * @validate GET#code_challenge_method (string required)
*
* @param type $tokenid
* @return void
* @layout minimal.php
* @throws PublicException
*/
public function oauth() {
+ $code_challenge = $_GET['code_challenge'];
+ $code_challenge_method = $_GET['code_challenge_method']?? 'plain';
+ $audience = $_GET['audience']?
+ db()->table(AuthAppModel::class)->get('appID', $_GET['audience'])->first(true) :
+ db()->table(AuthAppModel::class)->get('_id', SysSettingModel::getValue('app.self'))->first(true);
+
+ /*
+ * In order to ensure that the client can be given appropriate access, the
+ * server needs to make sure that the application requests the appropriate
+ * scopes for this application.
+ *
+ * An application must never request access to a scope that doesn't exist,
+ * granting access to undefined scopes may lead to dangerous behavior.
+ *
+ * Scopes are defined by the audience that receives the token, to make sure
+ * you request the right scopes, refer to the documentation of the application
+ * you wish to read data from.
+ */
+ $scopes = collect(explode(' ', $_GET['scope']))
+ ->filter()
+ ->each(function ($e) use ($audience) {
+ return db()->table(ScopeModel::class)->get('identifier', sprintf('%s.%s', $audience->appID, $e))->first(true);
+ });
+
/*
* 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);
}
/*
* 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
+ /*
+ * Our implementation does not accept anything but S256, plain code_challenges
+ * are going to be rejected. These could be intercepted easily.
+ */
+ if ($code_challenge_method != 'S256') {
+ throw new PublicException('Invalid code_challenge_method', 400);
+ }
/*
* 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
+ $redirect = URLReflection::fromURL($_GET['redirect']);
+
+ /*
+ * In order to validate the redirect we make sure that the protocol, hostname
+ * and paths for the redirect match.
+ */
+ $valid = db()->table(LocationModel::class)->get('client', $client)->all()->reduce(function ($valid, LocationModel $e) use ($redirect) {
+ if ($e->protocol !== $redirect->getProtocol()) { return $valid; }
+ if ($e->hostname !== $redirect->getServer()) { return $valid; }
+ if (!Strings::startsWith($redirect->getPath(), $e->path)) { return $valid; }
+
+ return true;
+ }, false);
+
+ if (!$valid) {
+ throw new PublicException(sprintf('Redirect to %s is invalid', __($redirect)), 401);
+ }
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->challenge = sprintf('%s:%s', $code_challenge_method, $code_challenge);
$challenge->scope = $_GET['scope'];
- $challenge->redirect = $_GET['redirect'];
+ $challenge->redirect = (string)$redirect;
$challenge->created = time();
$challenge->expires = time() + 180;
$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]));
+ return $this->response->setBody('Redirect')
+ ->getHeaders()->redirect((clone $redirect)->setQueryString(['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
-
+ $this->view->set('audience', $audience);
+ $this->view->set('redirect', (string)$redirect);
+ $this->view->set('cancel', (string)(clone $redirect)->setQueryString(['error' => 'denied', 'description' => 'Authentication request was denied']));
}
/**
* 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->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 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 = [
1 => $primary,
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->session->candidate)
->setOrder('preferred', 'DESC')
->all();
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 {
$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/EmailController.php b/bin/controllers/mfa/EmailController.php
index 8f8ef19..ed50563 100644
--- a/bin/controllers/mfa/EmailController.php
+++ b/bin/controllers/mfa/EmailController.php
@@ -1,213 +1,212 @@
<?php namespace mfa;
use authentication\ProviderModel;
use BaseController;
use passport\PhoneUtils;
use PassportModel;
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 EmailController extends BaseController
{
/**
*
* @validate >> POST#phone(string required)
* @throws HTTPMethodException
*/
public function create() {
if (!$this->user) {
throw new PublicException('Login required', 401);
}
try {
if (!$this->request->isPost()) { throw new HTTPMethodException('Not Posted'); }
if (!$this->validation->isEmpty()) { throw new ValidationException('Validation failed', 2005121355, $this->validation->toArray()); }
/*
* For the sake of validation, we canonicalize phone numbers before searching
* for duplicates. This prevents users from abusing the system.
*/
$canonical = \mail\MailUtils::canonicalize($_POST['email'], true);
/*
* If somebody is already using this phone number as a login mechanism,
* we need to tell the user that we cannot accept another login with this
* number.
*/
if (db()->table('passport')->get('canonical', $canonical)->where('type', PassportModel::TYPE_EMAIL)->where('expires', null)->first()) {
throw new ValidationException('Validation failed', 2005121355, ['Email is already registered']);
}
/*
* Register the email as a passport to log into the system. This allows
* the user to enter their email address instead of their username.
*/
$passport = db()->table('passport')->newRecord();
$passport->user = $this->user;
$passport->type = PassportModel::TYPE_EMAIL;
$passport->content = $_POST['email'];
$passport->canonical = $canonical;
$passport->login = true;
/*
* If the user does not verify the number within 30 days, we will remove
* the record.
*/
$passport->expires = time() + 86400 * 30;
$passport->store();
$auth = db()->table('authentication\provider')->newRecord();
$auth->user = $this->user;
$auth->type = ProviderModel::TYPE_EMAIL;
$auth->passport = $passport;
$auth->content = $canonical;
$auth->preferred = false;
$auth->expires = time() + 86400 * 30;
$auth->store();
$this->response->setBody('Redirection...')->getHeaders()->redirect(url(['mfa', 'phone'], 'challenge', $auth->_id));
}
catch (HTTPMethodException $ex) {
# Do nothing, just show the form to the user
}
}
/**
* Removes an authentication provider, this prevents the provider from being
* used in the future. This won't work for passwords.
*
* @todo This method can be used to remove any provider, maybe merge?
* @param ProviderModel $provider
* @throws PublicException
*/
public function remove(ProviderModel$provider) {
if (!$this->user) {
throw new PublicException('Login required to remove email addresses', 401);
}
- #TODO: Replace that OIDC Session placeholder
- $strength = db()->table('authentication\challenge')->get('session', '[OIDC Session goes here]')->where('cleared', '>', time() - 1200)->all();
+ $strength = $this->level;
$expected = $this->user->mfa? 2 : 1;
if ($strength->count() < $expected) {
return $this->response->setBody('Redirect...')->getHeaders()
->redirect(url('auth', 'threshold', $expected, ['returnto' => strval(url(['mfa', 'email'], 'remove', $provider->_id))]));
}
if ($provider->user->_id != $this->user->_id) {
throw new PublicException('You cannot remove email addresses for other users', 403);
}
if ($provider->type != ProviderModel::TYPE_EMAIL) {
throw new PublicException('You can only use this endpoint to remove email addresses', 403);
}
$passport = $provider->passport;
if ($passport) {
$passport->expires = time();
$passport->store();
}
$provider->expires = time();
$provider->store();
$this->view->set('provider', $provider);
$this->view->set('passport', $passport);
}
/**
* Executes a challenge against the selected phone. The phone will be sent an
* SMS message that contains a code which the user has to put back into the
* system to unlock the provider.
*
* @param ProviderModel $email
* @throws PublicException
* @throws PrivateException
*/
public function challenge(ProviderModel$email)
{
/*
* Whenever a user is able to select their provider, the system must make
* sure that the provider type we have is the right one.
*
* Otherwise a user might be able to exploit a provider by passing the wrong
* type to the challenge method.
*/
if ($email->type != ProviderModel::TYPE_EMAIL) {
throw new PublicException('Invalid provider', 400);
}
/*
* To avoid a user spamming the "send another email" option in case they're
* having trouble logging in, we stop the system from generating any further
* codes.
*/
if (db()->table('authentication\challenge')->get('provider', $email)->where('expires', '>', time())->count() > 2) {
throw new PublicException('Retry limit reached, please wait...', 400);
}
/*
* Create a challenge, the challenge will have a secret that the user needs
* to type into their browser.
*/
#TODO: Add redirect location to the challenge so the user can be forwarded to the appropriate location
#Sending it with the email potentially leaks codes, tokens, etc that should not get into the wrong hands
$twofactor = \authentication\ChallengeModel::make($email);
$twofactor->secret = str_replace(['/', '+'], ['_', '-'], base64_encode(random_bytes(16)));
/**
*
* @todo Generate a token that can be used to authenticate Auth against orbital station
* @todo Build orbital station SDK and use it to send a message
* @todo Define a payload for the email to be sent
*/
$token = null;
$stat = new \magic3w\orbitalstation\SDK(\spitfire\core\Environment::get('phpas.orbital-station.credentials'), $token);
$payload = [];
/**
* Send the user to a location where they can verify their challenge
*/
if ($stat->deliver($payload)) {
$this->response->setBody('Redirect...')->getHeaders()->redirect(url(['mfa', 'email'], 'verify'));
}
else {
throw new PublicException('Email Delivery error', 500);
}
}
}
diff --git a/bin/controllers/mfa/PhoneController.php b/bin/controllers/mfa/PhoneController.php
index ae5f996..5ce00cd 100644
--- a/bin/controllers/mfa/PhoneController.php
+++ b/bin/controllers/mfa/PhoneController.php
@@ -1,219 +1,218 @@
<?php namespace mfa;
use authentication\ProviderModel;
use BaseController;
use passport\PhoneUtils;
use PassportModel;
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 PhoneController extends BaseController
{
/**
*
* @validate >> POST#phone(string required)
* @throws HTTPMethodException
*/
public function create() {
if (!$this->user) {
throw new PublicException('Login required', 401);
}
try {
if (!$this->request->isPost()) { throw new HTTPMethodException('Not Posted'); }
if (!$this->validation->isEmpty()) { throw new ValidationException('Validation failed', 2005121355, $this->validation->toArray()); }
/*
* For the sake of validation, we canonicalize phone numbers before searching
* for duplicates. This prevents users from abusing the system.
*/
$canonical = PhoneUtils::canonicalize($_POST['phone'], true);
/*
* If somebody is already using this phone number as a login mechanism,
* we need to tell the user that we cannot accept another login with this
* number.
*/
if (db()->table('passport')->get('canonical', $canonical)->where('type', PassportModel::TYPE_PHONE)->where('expires', null)->first()) {
throw new ValidationException('Validation failed', 2005121355, ['Phone is already registered']);
}
/*
* Only one user can register a certain telephone number as a passport
* (meaning they can log into the system using the phone number and email, and SMS
* that are sent to other applications identify the user).
*/
if (isset($_POST['login']) && $_POST['login'] !== false) {
/*
* Register the phone as a passport to log into the system. This allows
* the user to enter their phone number instead of their username if
* wanted.
*/
$passport = db()->table('passport')->newRecord();
$passport->user = $this->user;
$passport->type = PassportModel::TYPE_PHONE;
$passport->content = $_POST['phone'];
$passport->canonical = $canonical;
$passport->login = true;
/*
* If the user does not verify the number within 30 days, we will remove
* the record.
*/
$passport->expires = time() + 86400 * 30;
$passport->store();
}
$auth = db()->table('authentication\provider')->newRecord();
$auth->user = $this->user;
$auth->type = ProviderModel::TYPE_PHONE;
$auth->passport = $passport;
$auth->content = $canonical;
$auth->preferred = false;
$auth->expires = time() + 86400 * 30;
$auth->store();
$this->response->setBody('Redirection...')->getHeaders()->redirect(url(['mfa', 'phone'], 'challenge', $auth->_id));
}
catch (HTTPMethodException $ex) {
# Do nothing, just show the form to the user
}
}
/**
* Removes an authentication provider, this prevents the provider from being
* used in the future. This won't work for passwords.
*
* @todo This method can be used to remove any provider, maybe merge?
* @param ProviderModel $provider
* @throws PublicException
*/
public function remove(ProviderModel$provider) {
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();
+ $strength = $this->level;
$expected = $this->user->mfa? 2 : 1;
if ($strength->count() < $expected) {
return $this->response->setBody('Redirect...')->getHeaders()
->redirect(url('auth', 'threshold', $expected, ['returnto' => strval(url(['mfa', 'phone'], 'remove', $provider->_id))]));
}
if ($provider->user->_id != $this->user->_id) {
throw new PublicException('You cannot remove phone numbers for other users', 403);
}
if ($provider->type != ProviderModel::TYPE_PHONE) {
throw new PublicException('You can only use this endpoint to remove phone numbers', 403);
}
$passport = $provider->passport;
if ($passport) {
$passport->expires = time();
$passport->store();
}
$provider->expires = time();
$provider->store();
$this->view->set('provider', $provider);
$this->view->set('passport', $passport);
}
/**
* Executes a challenge against the selected phone. The phone will be sent an
* SMS message that contains a code which the user has to put back into the
* system to unlock the provider.
*
* @param ProviderModel $phone
* @throws PublicException
* @throws PrivateException
*/
public function challenge(ProviderModel$phone) {
/*
* Whenever a user is able to select their provider, the system must make
* sure that the provider type we have is the right one.
*
* Otherwise a user might be able to exploit a provider by passing the wrong
* type to the challenge method.
*/
if ($phone->type != ProviderModel::TYPE_PHONE) {
throw new PublicException('Invalid provider', 400);
}
/*
* To avoid a user spamming the "send another SMS" button and draining the
* balance on the messaging provider's end, we rate limit to sending up to
* 3 SMS before stopping the user.
*/
if (db()->table('authentication\challenge')->get('provider', $phone)->where('expires', '>', time())->count() > 2) {
throw new PublicException('Retry limit reached, please wait...', 400);
}
/*
* Create a challenge, the challenge will have a secret that the user needs
* to type into their browser.
*/
$twofactor = \authentication\ChallengeModel::make($phone);
$e = Environment::get('twofactor.sms.provider');
if (!$e) { throw new PrivateException('Invalid sms provider defined', 2006101058); }
$config = explode(':', $e, 2);
list($provider, $settings) = $config;
$reflection = new ReflectionClass($provider);
if (!$reflection->implementsInterface(\twofactor\sms\TransportInterface::class)) {
throw new PrivateException('Invalid sms provider defined', 2006101059);
}
$instance = new $provider($settings);
$payload = new twofactor\sms\Message($phone->content, 'Your authentication code is: ' . $twofactor->secret);
if ($instance->deliver($payload)) {
$this->response->setBody('Redirect...')->getHeaders()->redirect(url('twofactor', 'check', $phone->_id, ['returnto' => $_GET['returnto']?? '/']));
}
else {
throw new PublicException('SMS Delivery error', 500);
}
}
}
diff --git a/bin/controllers/mfa/TOTPController.php b/bin/controllers/mfa/TOTPController.php
index a21ab71..754ad1a 100644
--- a/bin/controllers/mfa/TOTPController.php
+++ b/bin/controllers/mfa/TOTPController.php
@@ -1,216 +1,215 @@
<?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();
$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();
+ $strength = $this->level;
$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/token.php b/bin/controllers/token.php
index 6fb51df..ea5f48b 100644
--- a/bin/controllers/token.php
+++ b/bin/controllers/token.php
@@ -1,150 +1,203 @@
<?php
use access\RefreshModel;
use access\TokenModel;
use spitfire\core\Environment;
use spitfire\exceptions\PublicException;
use spitfire\storage\database\pagination\Paginator;
class TokenController extends BaseController
{
public function index() {
$query = db()->table('token')->getAll();
if (!$this->isAdmin) { $query->addRestriction('user', $this->user); }
$query->group()
->addRestriction('expires', null, 'IS')
->addRestriction('expires', time(), '>');
$pages = new Paginator($query);
$this->view->set('pagination', $pages);
$this->view->set('records', $pages->records());
}
/**
* @todo Allow trading refresh tokens for fresh tokens
*
* @throws PublicException
*/
public function create()
{
$type = $_POST['grant_type']?? 'code';
$appid = isset($_POST['client'])? $_POST['client'] : $_GET['client'];
$secret = $_POST['secret']?? null;
- $expires = Environment::get('phpas.token.expiration')?: 14400;
+
+ /*
+ * We define the algorithms that the server understands. Since the oauth
+ * spec defines a different name for sha256, we will make our server translates
+ * the hashing algo back.
+ *
+ * We currently do not service anything but sha256.
+ */
+ $hash_algos = [
+ 's256' => 'sha256',
+ 'sha256' => 'sha256'
+ ];
/*
* Check if an app with the provided ID does indeed exist.
*/
$app = db()->table('authapp')->get('appID', $appid)->first();
if (!$app) { throw new PublicException('No application found', 403); }
/*
* In order to search for the application, we need to make sure that we're
* querying the secrets to find whether the application has an appropriate
* secret available.
*
* While I originally had a much leaner version that would just run a search
* for this:
*
* $app = db()->table('authapp')->get('appID', $appid)
* ->addRestriction('credentials', db()->table('client\credential')->get('secret', $secret)->group()->where('expires', null)->where('expires', '<', time()))->fetch();
*
* Which would run in a single query, the security of it was severely compromised
* by the fact that database searches are rather lenient. While this only meant a
* cost in enthropy, it still makes more sense to separate the queries and
* test the result in PHP.
*/
$credentials = db()->table('client\credential')
->get('secret', $secret)
->where('client', $app)
->group()->where('expires', null)->where('expires', '>', time())->endGroup()
->all();
/*
* If the application was issued credentials, it MUST provide a valid credential.
* In case the application is not issued credentials, because it runs on a
* user controlled device only, we can accept an "unauthenticated" request.
*/
if ($credentials && !$credentials->extract('secret')->contains($secret)) {
throw new PublicException('Invalid credentials', 403);
}
if ($type === 'code')
{
/*
* Read the code the client sent
*/
$code = db()->table('access\code')->get('code', $_POST['code']?? null)->where('expires', '>', time())->first(true);
/*
* Verify that the code the client sent, is actually the client's code
*/
if ($code->client->_id !== $app->_id) {
throw new PublicException('Code is for another client', 403);
}
/*
- * Check the code verifier
+ * Check the code verifier. We need to make sure that the algorhithm
+ * exists, so we don't pass invalid data into the hasing function.
*/
list($algo, $hash) = explode(':', $code->challenge);
-
+
+ if (isset($hash_algos[strtolower($algo)])) {
+ $algo = $hash_algos[strtolower($algo)];
+ }
+ else {
+ throw new PublicException('Invalid hashing algorhithm specified.', 400);
+ }
+
if (hash($algo, $_POST['verifier']) !== $hash) {
throw new PublicException('Hash failed', 403);
}
/*
*
*/
$code->expires = time();
$code->store();
-
- $token = TokenModel::create($code->session, $app, null, $code->user, $expires);
- $refresh = RefreshModel::create($app, null, $code->user, time() + 86400 * 365 * 5);
+
+ #TODO: This code could be extracted into an helper that could be pulled
+ #in via service providers to reduce the amount of code duplication.
+ /*
+ * Instance a token that can be sent to the client to provide them access
+ * to the resources of the owner.
+ */
+ $token = db()->table(TokenModel::class)->newRecord();
+ $token->session = $code->session;
+ $token->owner = $code->user;
+ $token->audience = $code->audience;
+ $token->client = $app;
+ $token->store();
+
+ $refresh = db()->table(RefreshModel::class)->newRecord();
+ $refresh->session = $code->session;
+ $refresh->owner = $code->user;
+ $refresh->audience = $code->audience;
+ $refresh->client = $app;
+ $refresh->store();
}
elseif ($type === 'refresh_token') {
/**
* The provided refresh token. The application MUST use this to validate
* the client's claims.
*
* @var RefreshModel
*/
$provided = $_POST['refresh_token']?? null;
if ($provided->client->_id !== $app->appID) {
throw new PublicException('Tried refreshing a token owned by a different client', 403);
}
- $token = TokenModel::create($provided->session, $app, null, $provided->owner, $expires);
- $refresh = RefreshModel::create($app, null, $provided->owner, time() + 86400 * 365 * 5);
+ #TODO: This code could be extracted into an helper that could be pulled
+ #in via service providers to reduce the amount of code duplication.
+ /*
+ * Instance a token that can be sent to the client to provide them access
+ * to the resources of the owner.
+ */
+ $token = db()->table(TokenModel::class)->newRecord();
+ $token->session = $provided->session;
+ $token->owner = $provided->owner;
+ $token->audience = $provided->audience;
+ $token->client = $provided->client;
+ $token->store();
+
+ $refresh = db()->table(RefreshModel::class)->newRecord();
+ $refresh->session = $provided->session;
+ $refresh->owner = $provided->owner;
+ $refresh->audience = $provided->audience;
+ $refresh->client = $provided->client;
+ $refresh->store();
}
else {
throw new PublicException('Invalid grant_type selected', 400);
}
//Send the token to the view so it can render it
$this->view->set('token', $token);
$this->view->set('refresh', $refresh);
}
/**
*
* @template none
* @param string $tokenid
*/
public function end($tokenid) {
$token = db()->table('token')->get('token', $tokenid)->fetch();
if (!$token) { throw new PublicException('No token found', 404); }
if ($token->expires && $token->expires < time()) { throw new PublicException('Token already expired', 403); }
$token->expires = time();
$token->store();
$this->response->getHeaders()->redirect(new URL('token', Array('message' => 'ended')));
}
}
diff --git a/bin/models/access/refresh.php b/bin/models/access/refresh.php
index 5f6cd21..8b15e9a 100644
--- a/bin/models/access/refresh.php
+++ b/bin/models/access/refresh.php
@@ -1,103 +1,95 @@
<?php namespace access;
use AuthAppModel;
use IntegerField;
use Reference;
use SessionModel;
use spitfire\exceptions\PrivateException;
use spitfire\Model;
use spitfire\storage\database\Schema;
use StringField;
use UserModel;
use function db;
/**
* While structurally extremely similar to regular access tokens, the refresh token
* has no ability to provide direct access to a resource, instead it needs to be
* traded for an access token.
*
* Since a refresh token must never be used in a context where the regular access
* token is accepted, and vice-versa, the system is way more stable whenever we
* use two separate models, making it easier for the DBMS and providing powerful
* isolation between the access and refresh tokens.
*
* @property string $type Either access or refresh
* @property string $token The token identifier
*
* @property UserModel $owner The resource owner
* @property AuthAppModel $client The application requesting access to the owner's information
* @property AuthAppModel $server The application containing the application owner's information
* @property string $scopes A comma separated list of contexts the client wishes to have access to
*
* @property int $created The time the token was created
* @property int $expires The time the token is no longer valid
* @property int $ttl The amount of seconds this token was set to be valid
*
* @property SessionModel $session The session that spawned this token
*
* @todo Make an array adapter for contexts so they get automatically separated
*/
class RefreshModel extends Model
{
const TOKEN_PREFIX = 'r_';
const TOKEN_LENGTH = 50;
+ const TOKEN_TTL = 63072000;
public function definitions(Schema $schema) {
$schema->token = new StringField(self::TOKEN_LENGTH);
$schema->owner = new Reference('user');
$schema->client = new Reference('authapp');
- $schema->host = new Reference('authapp');
+ $schema->audience = new Reference('authapp');
$schema->scopes = new StringField(255);
$schema->created = new IntegerField(true);
$schema->expires = new IntegerField(true);
$schema->ttl = new IntegerField(true);
$schema->session = new Reference(SessionModel::class);
$schema->token->setUnique(true);
}
- /**
- * There's only one endpoint generating tokens, and the function's signature is
- * getting very unwieldy. So we're moving best this to the controller.
- *
- * @deprecated since version 0.2-dev
- * @param SessionModel $session
- * @param type $client
- * @param type $server
- * @param type $owner
- * @param type $expires
- * @return type
- * @throws PrivateException
- */
- public static function create($client, $server = null, $owner = null, $expires = 63072000) {
- $record = db()->table('access\refresh')->newRecord();
- $record->created = time();
- $record->owner = $owner;
- $record->host = $server?: null;
- $record->client = $client;
- $record->expires = time() + $expires;
- $record->ttl = $expires;
- $record->store();
- return $record;
- }
-
public function onbeforesave(): void {
parent::onbeforesave();
/*
* If the token happened to be new, and therefore had no token-id assigned,
* we generate a new, unique token identifier for this one.
*/
if (!$this->token) {
do { $this->token = substr(self::TOKEN_PREFIX . bin2hex(random_bytes(25)), 0, self::TOKEN_LENGTH); }
while (db()->table('access\token')->get('token', $this->token)->first());
}
+
+ /*
+ * If the token has no creation date we assume that it has never been stored
+ * before and record the creation time.
+ */
+ if (!$this->created) {
+ $this->created = time();
+ }
+
+ /*
+ * Set the expiration time to a timestamp in the future (by default 30 minutes)
+ * if the expiration was not explicitly set before.
+ */
+ if (!$this->expires) {
+ $this->expires = time() + self::TOKEN_TTL;
+ }
}
-}
\ No newline at end of file
+}
diff --git a/bin/models/access/token.php b/bin/models/access/token.php
index 1faf7cb..c77461b 100644
--- a/bin/models/access/token.php
+++ b/bin/models/access/token.php
@@ -1,115 +1,106 @@
<?php namespace access;
use AuthAppModel;
use IntegerField;
use Reference;
use SessionModel;
use spitfire\exceptions\PrivateException;
use spitfire\Model;
use spitfire\storage\database\Schema;
use StringField;
use UserModel;
use function db;
/**
* An access token connects up to three parties in a relationship that authenticates
* the following:
*
* * A resource owner, who owns the resources on the server, and wishes to grant access to the client. This is generally a human.
* * A client, an application that wishes to retrieve data or issue commands to the server.
* * A server. An application that holds the owner's information and wishes to authenticate the client's requests.
*
* These tokens can be of two kinds, access tokens or refresh tokens. When a "public"
* application issues an access token, no refresh token is generated. A private
* application may request a refresh token to be issued.
*
* Depending on which fields are populated, the token may be used for different
* scenarios.
*
* * A token may have no owner, which means that the owner is implied to be the server.
* * A token may have no client nor owner, making it a client-credential so that an application can rate limit clients
* * If the client and server are the same app, the token is a session token and used to log the user into the application.
*
*
*
* @property string $type Either access or refresh
* @property string $token The token identifier
*
* @property UserModel $owner The resource owner
* @property AuthAppModel $client The application requesting access to the owner's information
* @property AuthAppModel $server The application containing the application owner's information
* @property string $scopes A comma separated list of contexts the client wishes to have access to
*
* @property int $created The time the token was created
* @property int $expires The time the token is no longer valid
* @property int $ttl The amount of seconds this token was set to be valid
*
* @property SessionModel $session The session that spawned this token
*
* @todo Make an array adapter for contexts so they get automatically separated
*/
class TokenModel extends Model
{
const TOKEN_PREFIX = 't_';
const TOKEN_LENGTH = 50;
+ const TOKEN_TTL = 1800;
public function definitions(Schema $schema) {
$schema->token = new StringField(self::TOKEN_LENGTH);
$schema->owner = new Reference('user');
$schema->client = new Reference('authapp');
- $schema->host = new Reference('authapp');
+ $schema->audience = new Reference('authapp');
$schema->scopes = new StringField(255);
$schema->created = new IntegerField(true);
$schema->expires = new IntegerField(true);
$schema->ttl = new IntegerField(true);
$schema->session = new Reference(SessionModel::class);
$schema->token->setUnique(true);
}
- /**
- * There's only one endpoint generating tokens, and the function's signature is
- * getting very unwieldy. So we're moving best this to the controller.
- *
- * @deprecated since version 0.2-dev
- * @param SessionModel $session
- * @param type $client
- * @param type $server
- * @param type $owner
- * @param type $expires
- * @return type
- * @throws PrivateException
- */
- public static function create(SessionModel $session, $client, $server = null, $owner = null, $expires = 14400) {
- $record = db()->table('access\token')->newRecord();
- $record->created = time();
- $record->owner = $owner;
- $record->host = $server?: $client;
- $record->client = $client;
- $record->session = $session;
- $record->expires = time() + $expires;
- $record->ttl = $expires;
- $record->store();
- return $record;
- }
-
public function onbeforesave(): void {
parent::onbeforesave();
/*
* If the token happened to be new, and therefore had no token-id assigned,
* we generate a new, unique token identifier for this one.
*/
if (!$this->token) {
do { $this->token = substr(self::TOKEN_PREFIX . bin2hex(random_bytes(25)), 0, self::TOKEN_LENGTH); }
while (db()->table('access\token')->get('token', $this->token)->first());
}
+
+ /*
+ * If the token has no creation date we assume that it has never been stored
+ * before and record the creation time.
+ */
+ if (!$this->created) {
+ $this->created = time();
+ }
+
+ /*
+ * Set the expiration time to a timestamp in the future (by default 30 minutes)
+ * if the expiration was not explicitly set before.
+ */
+ if (!$this->expires) {
+ $this->expires = time() + self::TOKEN_TTL;
+ }
}
-}
\ No newline at end of file
+}
diff --git a/bin/templates/auth/oauth.php b/bin/templates/auth/oauth.php
index d510b7b..d4ce067 100644
--- a/bin/templates/auth/oauth.php
+++ b/bin/templates/auth/oauth.php
@@ -1,35 +1,71 @@
-<div class="spacer" style="height: 30px"></div>
+<div class="spacer small"></div>
-<div class="row5">
- <div class="span1">
+<div class="row l5">
+ <div class="span l1">
</div>
- <div class="span3">
+ <div class="span l3">
<form method="POST" action="">
- <h1>Access <?= $client->name ?>?</h1>
-
- <div class="material">
- <p style="text-align: center">
- <img src="<?= url('image', 'app', $client->_id, 128) ?>" width="128" style="border-radius: 3px; border: solid 1px #777;">
- <img src="<?= \spitfire\core\http\URL::asset('img/right-arrow.png') ?>" style="margin: 4px 20px;">
- <img src="<?= url('image', 'user', $authUser->_id, 128) ?>" width="128" style="border-radius: 3px; border: solid 1px #777;">
- </p>
+ <div class="align-center text:green-600">
+ <img src="<?= url('image', 'app', $client->_id, 128) ?>" width="128" style="border-radius: 50%; border: solid 1px #777; vertical-align: middle;">
+ <div style="display: inline-block; width: 50px; border-top: solid 1px #CCC; vertical-align: middle;"></div>
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" style="height: 40px; vertical-align: middle;">
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
+ </svg>
+ <div style="display: inline-block; width: 50px; border-top: solid 1px #CCC; vertical-align: middle;"></div>
+ <img src="<?= url('image', 'app', $audience->_id, 128) ?>" width="128" style="border-radius: 50%; border: solid 1px #777; vertical-align: middle;">
+ </div>
- <p>
- If you wish to proceed and log into the application using your account,
- click "Continue". If you do not trust the application, just cancel the
- log in process - your account details won't be shared
- </p>
+ <div class="align-center">
+ <div class="spacer large"></div>
+ <h1 class="text:grey-700" style="font-size: 1.8rem">Authorize <?= $client->name ?></h1>
+ <div class="spacer small"></div>
+ </div>
+
+ <div class="box box-soft">
+ <div class="padded">
+ <div class="spacer medium"></div>
+
+ <div class="row s7">
+ <div class="span s1">
+ <img src="<?= url('image', 'user', $client->owner->_id, 128) ?>" width="128" style="border-radius: 50%; vertical-align: middle;">
+ </div>
+ <div class="span s6 text:grey-700">
+ <div class="spacer minuscule"></div>
+ <div>
+ <strong class="text:grey-800"><?= $client->name ?></strong>
+ by <strong class="text:grey-800"><?= $client->owner->usernames->getQuery()->first()->name ?></strong>
+ </div>
+ <div>
+ is requesting access your data on <strong class="text:grey-800"><?= $audience->name ?></strong>
+ </div>
+ </div>
+ </div>
- <p style="text-align: center">
- <a href="<?= $cancel ?>">Cancel</a>
- <span style="display: inline-block; width: 20px"></span>
- <input type="hidden" name="grant" value="grant">
- <!-- TODO: Add XSRF -->
- <input type="submit" value="Grant access to your account" class="button">
- </p>
- </div>
+ <p class="">
+ </p>
+
+ <div class="spacer large"></div>
+
+ <div style="text-align: center">
+ <input type="hidden" name="grant" value="grant">
+ <input type="submit" value="Grant access to your account" class="button full-width" style="width: 100%">
+ <div class="spacer small"></div>
+ <a href="<?= $cancel ?>">Cancel</a>
+ </div>
+
+
+ <div class="spacer medium"></div>
+ </div>
+ </div>
+ </form>
+
+ <div class="spacer large"></div>
+
+ <p class="text:grey-600 align-center" style="font-size: .8rem">
+ Authorizing redirects to <strong class="text:grey-700"><?= $redirect ?></strong>
+ </p>
</div>
</div>
diff --git a/composer.lock b/composer.lock
index b4da9f2..dbb03e3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -1,292 +1,298 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ef491d6233eb2a80b02d0e06371c031b",
"packages": [
{
"name": "magic3w/cptn-h00k-sdk-php",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://phabricator.magic3w.com/source/CptnH00k-SDK-PHP.git",
"reference": "a2b3d899eb4ab753e7ca617eb213a5db70d71cce"
},
"require": {
"magic3w/url-reflection": "dev-master",
"spitfire/request": "dev-master"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"magic3w\\hook\\sdk\\": "/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "César de la Cal Bretschneider",
"email": "cesar@magic3w.com"
}
],
"description": "PHP SDK for magic3w/cptn-hook, a webhook processing server",
"time": "2020-12-08T12:50:26+00:00"
},
{
"name": "magic3w/permission-php-sdk",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://phabricator.magic3w.com/source/permission-PHP-SDK.git",
- "reference": "872b399182d83caafdc7c142ac089551356974e1"
+ "reference": "b457f030ac81e8b613b8275f23efc3475f440471"
},
+ "require": {
+ "magic3w/url-reflection": "dev-master",
+ "spitfire/request": "dev-master"
+ },
+ "default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"magic3w\\permission\\sdk\\": "/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "César de la Cal Bretschneider",
"email": "cesar@magic3w.com"
}
],
"description": "Allows your application to communicate with a permission server through a simple API",
- "time": "2020-08-26T15:10:23+00:00"
+ "time": "2020-12-17T09:06:38+00:00"
},
{
"name": "magic3w/url-reflection",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://phabricator.magic3w.com/source/url-reflection.git",
- "reference": "6d7529af43c2cb1ed264e9b88d257ac4066b641c"
+ "reference": "341d1ede12d28159101b77c8ae12b544dba15a8c"
},
"require-dev": {
"phpunit/phpunit": "^9.3"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"magic3w\\http\\url\\reflection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "César de la Cal Bretschneider",
"email": "cesar@magic3w.com"
}
],
"description": "Allows applications to have a URL parsed and retrieve information about it's components",
- "time": "2020-11-04T12:01:53+00:00"
+ "time": "2021-02-11T13:17:34+00:00"
},
{
"name": "spitfire/defer",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://phabricator.magic3w.com/source/spitfire-defer.git",
"reference": "f005ec23a818129e813b543a51ab8d8cdc44cb9c"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"spitfire\\defer\\": "./src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "César de la Cal Bretschneider",
"email": "cesar@magic3w.com"
}
],
"description": "Allozs spitfire apps to defer tasks to be executed at a later point",
"time": "2020-12-09T09:50:10+00:00"
},
{
"name": "spitfire/request",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://phabricator.magic3w.com/source/spitfire-request.git",
- "reference": "2567db6a18f287cf33ffc93de1c8aaf7b7c7c3a6"
+ "reference": "cc2a34540bf114d97d269b9c61f37ee983dcc9a5"
},
"require": {
+ "ext-curl": "*",
"magic3w/url-reflection": "dev-master"
},
"require-dev": {
"phpunit/phpunit": "^9.4"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"spitfire\\io\\request\\": "./src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "César de la Cal Bretschneider",
"email": "cesar@magic3w.com"
}
],
"description": "Spitfire request mechanism",
- "time": "2020-12-28T14:18:45+00:00"
+ "time": "2020-12-28T15:14:13+00:00"
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.5.8",
"source": {
"type": "git",
"url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
"reference": "9d583721a7157ee997f235f327de038e7ea6dac4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4",
"reference": "9d583721a7157ee997f235f327de038e7ea6dac4",
"shasum": ""
},
"require": {
"ext-simplexml": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
"php": ">=5.4.0"
},
"require-dev": {
"phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"bin": [
"bin/phpcs",
"bin/phpcbf"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Greg Sherwood",
"role": "lead"
}
],
"description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
"homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
"keywords": [
"phpcs",
"standards"
],
"support": {
"issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
"source": "https://github.com/squizlabs/PHP_CodeSniffer",
"wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
},
"time": "2020-10-23T02:01:07+00:00"
}
],
"packages-dev": [
{
"name": "phpstan/phpstan",
- "version": "0.12.64",
+ "version": "0.12.79",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
- "reference": "23eb1cb7ae125f45f1d0e48051bcf67a9a9b08aa"
+ "reference": "dd7769915648b704b9bd12925994457f1c2c8442"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/23eb1cb7ae125f45f1d0e48051bcf67a9a9b08aa",
- "reference": "23eb1cb7ae125f45f1d0e48051bcf67a9a9b08aa",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dd7769915648b704b9bd12925994457f1c2c8442",
+ "reference": "dd7769915648b704b9bd12925994457f1c2c8442",
"shasum": ""
},
"require": {
"php": "^7.1|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "0.12-dev"
}
},
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"support": {
"issues": "https://github.com/phpstan/phpstan/issues",
- "source": "https://github.com/phpstan/phpstan/tree/0.12.64"
+ "source": "https://github.com/phpstan/phpstan/tree/0.12.79"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://www.patreon.com/phpstan",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan",
"type": "tidelift"
}
],
- "time": "2020-12-21T11:59:02+00:00"
+ "time": "2021-02-25T16:44:57+00:00"
}
],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"magic3w/permission-php-sdk": 20,
"spitfire/defer": 20,
"magic3w/cptn-h00k-sdk-php": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.0.0"
}

File Metadata

Mime Type
text/x-diff
Expires
Wed, Apr 14, 1:38 PM (3 w, 6 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
7072
Default Alt Text
(77 KB)

Event Timeline