...
 
Commits (2)
......@@ -2,10 +2,7 @@
namespace Minds\Api;
use Minds\Core\Di\Di;
use Minds\Core\Pro\Domain\Security as ProDomainSecurity;
use Minds\Interfaces;
use Minds\Helpers;
use Minds\Core\Security;
use Minds\Core\Session;
......@@ -111,11 +108,13 @@ class Factory
static::setCORSHeader();
$code = !Security\XSRF::validateRequest() ? 403 : 401;
header('Content-type: application/json');
header('HTTP/1.1 401 Unauthorized', true, 401);
http_response_code($code);
echo json_encode([
'error' => 'Sorry, you are not authenticated',
'code' => 401,
'code' => $code,
'loggedin' => false
]);
exit;
......
......@@ -28,8 +28,6 @@ class channel implements Interfaces\Api
*/
public function get($pages)
{
$currentUser = Session::getLoggedinUser();
$channel = new User(strtolower($pages[0]));
$channel->fullExport = true; //get counts
$channel->exportCounts = true;
......@@ -41,6 +39,8 @@ class channel implements Interfaces\Api
]);
}
$currentUser = Session::getLoggedinUser();
/** @var Manager $manager */
$manager = Di::_()->get('Pro\Manager');
$manager->setUser($channel);
......
<?php
/**
* authorize
* @author edgebal
*/
namespace Minds\Controllers\api\v2\sso;
use Exception;
use Minds\Api\Factory;
use Minds\Core\Di\Di;
use Minds\Core\Session;
use Minds\Core\SSO\Manager;
use Minds\Entities\User;
use Minds\Interfaces;
use Zend\Diactoros\ServerRequest;
class authorize implements Interfaces\Api, Interfaces\ApiIgnorePam
{
/** @var ServerRequest */
public $request;
/**
* Equivalent to HTTP GET method
* @param array $pages
* @return mixed|null
*/
public function get($pages)
{
return Factory::response([]);
}
/**
* Equivalent to HTTP POST method
* @param array $pages
* @return mixed|null
* @throws Exception
*/
public function post($pages)
{
$origin = $this->request->getServerParams()['HTTP_ORIGIN'] ?? '';
if (!$origin) {
return Factory::response([
'status' => 'error',
'message' => 'No HTTP Origin header'
]);
}
$domain = parse_url($origin, PHP_URL_HOST);
/** @var Manager $sso */
$sso = Di::_()->get('SSO');
$sso
->setDomain($domain);
try {
$sso
->authorize($_POST['token']);
} catch (Exception $e) {
error_log((string) $e);
return Factory::response([
'status' => 'error',
'message' => 'Cannot authorize',
]);
}
/** @var User $currentUser */
$currentUser = Session::getLoggedinUser();
return Factory::response([
'user' => $currentUser ? $currentUser->export() : null,
]);
}
/**
* Equivalent to HTTP PUT method
* @param array $pages
* @return mixed|null
*/
public function put($pages)
{
return Factory::response([]);
}
/**
* Equivalent to HTTP DELETE method
* @param array $pages
* @return mixed|null
*/
public function delete($pages)
{
return Factory::response([]);
}
}
<?php
/**
* connect
* @author edgebal
*/
namespace Minds\Controllers\api\v2\sso;
use Exception;
use Minds\Api\Factory;
use Minds\Core\Di\Di;
use Minds\Core\SSO\Manager;
use Minds\Interfaces;
use Zend\Diactoros\ServerRequest;
class connect implements Interfaces\Api, Interfaces\ApiIgnorePam
{
/** @var ServerRequest */
public $request;
/**
* Equivalent to HTTP GET method
* @param array $pages
* @return mixed|null
*/
public function get($pages)
{
return Factory::response([]);
}
/**
* Equivalent to HTTP POST method
* @param array $pages
* @return mixed|null
* @throws Exception
*/
public function post($pages)
{
$origin = $this->request->getServerParams()['HTTP_ORIGIN'] ?? '';
if (!$origin) {
return Factory::response([
'status' => 'error',
'message' => 'No HTTP Origin header'
]);
}
$domain = parse_url($origin, PHP_URL_HOST);
/** @var Manager $sso */
$sso = Di::_()->get('SSO');
$sso
->setDomain($domain);
try {
return Factory::response([
'token' => $sso->generateToken()
]);
} catch (Exception $e) {
error_log((string) $e);
return Factory::response([
'status' => 'error',
'message' => 'Cannot connect',
]);
}
}
/**
* Equivalent to HTTP PUT method
* @param array $pages
* @return mixed|null
*/
public function put($pages)
{
return Factory::response([]);
}
/**
* Equivalent to HTTP DELETE method
* @param array $pages
* @return mixed|null
*/
public function delete($pages)
{
return Factory::response([]);
}
}
......@@ -16,6 +16,7 @@ class Minds extends base
private $modules = [
Events\Module::class,
SSO\Module::class,
Email\Module::class,
Experiments\Module::class,
Helpdesk\Module::class,
......
......@@ -71,6 +71,16 @@ class Domain
return !$settings || ((string) $settings->getUserGuid() === $userGuid);
}
/**
* @param string $domain
* @return bool
*/
public function isRoot(string $domain): bool
{
$rootDomains = $this->config->get('pro')['root_domains'] ?? [];
return in_array(strtolower($domain), $rootDomains, true);
}
/**
* @param Settings $settings
* @param User|null $owner
......
<?php
/**
* Security
* @author edgebal
*/
namespace Minds\Core\Pro\Domain;
use Exception;
use Minds\Common\Cookie;
use Minds\Common\Jwt;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Zend\Diactoros\ServerRequest;
class Security
{
/** @var string */
const JWT_COOKIE_NAME = 'PRO-XSRF-JWT';
/** @var string */
const XSRF_COOKIE_NAME = 'XSRF-TOKEN';
/** @var Cookie */
protected $cookie;
/** @var Jwt */
protected $jwt;
/** @var Config */
protected $config;
/**
* Security constructor.
* @param Cookie $cookie
* @param Jwt $jwt
* @param Config $config
*/
public function __construct(
$cookie = null,
$jwt = null,
$config = null
) {
$this->cookie = $cookie ?: new Cookie();
$this->jwt = $jwt ?: new Jwt();
$this->config = $config ?: Di::_()->get('Config');
}
/**
* @param string $domain
* @return string
* @throws Exception
*/
public function setUp($domain): string
{
$nonce = $this->jwt->randomString();
$nbf = time();
$exp = $nbf + 60;
$jwt = $this->jwt
->setKey($this->getEncryptionKey())
->encode([
'nonce' => $nonce,
], $exp, $nbf);
$this->cookie
->setName(static::JWT_COOKIE_NAME)
->setValue($jwt)
->setExpire($exp)
->setPath('/')
->setHttpOnly(false)
->create();
$this->cookie
->setName(static::XSRF_COOKIE_NAME)
->setValue($nonce)
->setExpire(0)
->setPath('/')
->setHttpOnly(false)
->create();
return $jwt;
}
/**
* @param ServerRequest $request
*/
public function syncCookies(ServerRequest $request): void
{
$jwt = $request->getServerParams()['HTTP_X_PRO_XSRF_JWT'] ?? '';
if (!$jwt) {
return;
}
try {
$data = $this->jwt
->setKey($this->getEncryptionKey())
->decode($jwt);
if (($_COOKIE[static::XSRF_COOKIE_NAME] ?? null) === $data['nonce']) {
return;
}
$this->cookie
->setName(static::XSRF_COOKIE_NAME)
->setValue($data['nonce'])
->setExpire(0)
->setPath('/')
->setHttpOnly(false)
->create();
} catch (Exception $e) {
// Invalid or expired JWT
}
}
/**
* @return string
*/
protected function getEncryptionKey(): string
{
return $this->config->get('oauth')['encryption_key'] ?? '';
}
}
......@@ -24,10 +24,6 @@ class ProProvider extends Provider
return new Domain();
}, ['useFactory' => true]);
$this->di->bind('Pro\Domain\Security', function ($di) {
return new Domain\Security();
}, ['useFactory' => true]);
$this->di->bind('Pro\Domain\Subscription', function ($di) {
return new Domain\Subscription();
}, ['useFactory' => true]);
......
......@@ -20,9 +20,6 @@ class ProMiddleware implements RouterMiddleware
/** @var Domain */
protected $domain;
/** @var Domain\Security */
protected $domainSecurity;
/** @var Manager */
protected $manager;
......@@ -35,20 +32,17 @@ class ProMiddleware implements RouterMiddleware
/**
* ProMiddleware constructor.
* @param Domain $domain
* @param Domain\Security $domainSecurity
* @param Manager $manager
* @param SEO $seo
* @param EntitiesBuilder $entitiesBuilder
*/
public function __construct(
$domain = null,
$domainSecurity = null,
$manager = null,
$seo = null,
$entitiesBuilder = null
) {
$this->domain = $domain ?: Di::_()->get('Pro\Domain');
$this->domainSecurity = $domainSecurity ?: Di::_()->get('Pro\Domain\Security');
$this->manager = $manager ?: Di::_()->get('Pro\Manager');
$this->seo = $seo ?: Di::_()->get('Pro\SEO');
$this->entitiesBuilder = $entitiesBuilder ?: Di::_()->get('EntitiesBuilder');
......@@ -65,8 +59,8 @@ class ProMiddleware implements RouterMiddleware
$serverParams = $request->getServerParams() ?? [];
$originalHost = $serverParams['HTTP_HOST'];
$scheme = $request->getUri()->getScheme();
$host = parse_url($serverParams['HTTP_ORIGIN'] ?? '', PHP_URL_HOST) ?: $originalHost;
$scheme = parse_url($serverParams['HTTP_ORIGIN'] ?? '', PHP_URL_SCHEME) ?: $request->getUri()->getScheme();
if (!$host) {
return null;
......@@ -104,17 +98,6 @@ class ProMiddleware implements RouterMiddleware
->setUser($user)
->setup($settings);
// Initialize XRSF JWT cookie, only if we're on Pro domain's scope
// If not and within 1 minute, update XSRF cookie to match it
if ($originalHost === $settings->getDomain()) {
$this->domainSecurity
->setUp($settings->getDomain());
} else {
$this->domainSecurity
->syncCookies($request);
}
return null;
}
}
<?php
/**
* ProDelegate
* @author edgebal
*/
namespace Minds\Core\SSO\Delegates;
use Minds\Core\Di\Di;
use Minds\Core\Pro\Domain as ProDomain;
class ProDelegate
{
/** @var ProDomain */
protected $proDomain;
/**
* ProDelegate constructor.
* @param ProDomain $proDomain
*/
public function __construct(
$proDomain = null
) {
$this->proDomain = $proDomain ?: Di::_()->get('Pro\Domain');
}
/**
* @param string $domain
* @return bool
*/
public function isAllowed(string $domain): bool
{
return $this->proDomain->isRoot($domain)
|| (bool) $this->proDomain->lookup($domain);
}
}
<?php
/**
* Manager
* @author edgebal
*/
namespace Minds\Core\SSO;
use Exception;
use Minds\Common\Jwt;
use Minds\Core\Config;
use Minds\Core\Data\cache\abstractCacher;
use Minds\Core\Di\Di;
use Minds\Core\Sessions\Manager as SessionsManager;
class Manager
{
/** @var int */
const JWT_EXPIRE = 300;
/** @var Config */
protected $config;
/** @var abstractCacher */
protected $cache;
/** @var Jwt */
protected $jwt;
/** @var SessionsManager */
protected $sessions;
/** @var Delegates\ProDelegate */
protected $proDelegate;
/** @var string */
protected $domain;
/**
* Manager constructor.
* @param Config $config
* @param abstractCacher $cache
* @param Jwt $jwt
* @param SessionsManager $sessions
* @param Delegates\ProDelegate $proDelegate
*/
public function __construct(
$config = null,
$cache = null,
$jwt = null,
$sessions = null,
$proDelegate = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->cache = $cache ?: Di::_()->get('Cache');
$this->jwt = $jwt ?: new Jwt();
$this->sessions = $sessions ?: Di::_()->get('Sessions\Manager');
$this->proDelegate = $proDelegate ?: new Delegates\ProDelegate();
}
/**
* @param string $domain
* @return Manager
*/
public function setDomain(string $domain): Manager
{
$this->domain = $domain;
return $this;
}
/**
* @return bool
*/
protected function isAllowed(): bool
{
if ($this->proDelegate->isAllowed($this->domain)) {
return true;
}
return false;
}
/**
* @return string
* @throws Exception
*/
public function generateToken(): ?string
{
if (!$this->isAllowed()) {
throw new Exception('Invalid domain');
}
$now = time();
$session = $this->sessions->getSession();
if (!$session || !$session->getUserGuid()) {
return null;
}
$key = $this->config->get('oauth')['encryption_key'] ?? '';
if (!$key) {
throw new Exception('Invalid encryption key');
}
$sessionToken = (string) $session->getToken();
$sessionTokenHash = hash('sha256', $key . $sessionToken);
$ssoKey = implode(':', ['sso', $this->domain, $sessionTokenHash, $this->jwt->randomString()]);
$jwt = $this->jwt
->setKey($key)
->encode([
'key' => $ssoKey,
'domain' => $this->domain,
], $now, $now + static::JWT_EXPIRE);
$this->cache
->set($ssoKey, $sessionToken, static::JWT_EXPIRE * 2);
return $jwt;
}
/**
* @param string $jwt
* @return void
* @throws Exception
*/
public function authorize(string $jwt): void
{
if (!$jwt) {
throw new Exception('Invalid JTW');
}
if (!$this->isAllowed()) {
throw new Exception('Invalid domain');
}
$key = $this->config->get('oauth')['encryption_key'] ?? '';
if (!$key) {
throw new Exception('Invalid encryption key');
}
$data = $this->jwt
->setKey($key)
->decode($jwt);
if ($this->domain !== $data['domain']) {
throw new Exception('Domain mismatch');
}
$ssoKey = $data['key'];
$sessionToken = $this->cache
->get($ssoKey);
if ($sessionToken) {
$this->sessions
->withString($sessionToken)
->save();
$this->cache
->destroy($ssoKey);
}
}
}
<?php
/**
* Module
* @author edgebal
*/
namespace Minds\Core\SSO;
use Minds\Interfaces\ModuleInterface;
class Module implements ModuleInterface
{
/**
* Executed onInit
* @return void
*/
public function onInit()
{
(new Provider())->register();
}
}
<?php
/**
* Provider
* @author edgebal
*/
namespace Minds\Core\SSO;
use Minds\Core\Di\Provider as DiProvider;
class Provider extends DiProvider
{
public function register()
{
$this->di->bind('SSO', function () {
return new Manager();
});
}
}
......@@ -81,17 +81,27 @@ class Manager
/**
* Build session from jwt cookie
* @return $this
* @param $request
* @return Manager
*/
public function withRouterRequest($request)
public function withRouterRequest($request): Manager
{
$cookies = $request->getCookieParams();
if (!isset($cookies['minds_sess'])) {
return $this;
}
return $this->withString((string) $cookies['minds_sess']);
}
/**
* @param string $sessionToken
* @return Manager
*/
public function withString(string $sessionToken): Manager
{
try {
$token = $this->jwtParser->parse((string) $cookies['minds_sess']); // Collect from cookie
$token = $this->jwtParser->parse($sessionToken);
$token->getHeaders();
$token->getClaims();
} catch (\Exception $e) {
......
......@@ -2,10 +2,24 @@
/**
* Minds Session
*/
namespace Minds\Core\Sessions;
use Lcobucci\JWT\Token as JwtToken;
use Minds\Traits\MagicAttributes;
/**
* Class Session
* @package Minds\Core\Sessions
* @method string getId()
* @method Session setId(string $id)
* @method string|JwtToken getToken()
* @method Session setToken(string|JwtToken $token)
* @method int|string getUserGuid()
* @method Session setUserGuid(int|string $userGuid)
* @method int getExpires()
* @method Session setExpires(int $expires)
*/
class Session
{
use MagicAttributes;
......@@ -13,7 +27,7 @@ class Session
/** @var string $id */
private $id;
/** @var string $token */
/** @var string|JwtToken $token */
private $token;
/** @var int $userGuid */
......
<?php
namespace Spec\Minds\Core\Pro\Domain;
use Minds\Common\Cookie;
use Minds\Common\Jwt;
use Minds\Core\Config;
use Minds\Core\Pro\Domain\Security;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class SecuritySpec extends ObjectBehavior
{
/** @var Cookie */
protected $cookie;
/** @var Jwt */
protected $jwt;
/** @var Config */
protected $config;
public function let(
Cookie $cookie,
Jwt $jwt,
Config $config
) {
$this->cookie = $cookie;
$this->jwt = $jwt;
$this->config = $config;
$this->beConstructedWith($cookie, $jwt, $config);
}
public function it_is_initializable()
{
$this->shouldHaveType(Security::class);
}
public function it_should_set_up()
{
$this->jwt->randomString()
->shouldBeCalled()
->willReturn('~random~');
$this->config->get('oauth')
->shouldBeCalled()
->willReturn([
'encryption_key' => 'phpspec'
]);
$this->jwt->setKey('phpspec')
->shouldBeCalled()
->willReturn($this->jwt);
$this->jwt->encode(Argument::type('array'), Argument::type('int'), Argument::type('int'))
->shouldBeCalled()
->willReturn('~encoded~');
$this->cookie->setName(Security::JWT_COOKIE_NAME)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setName(Security::XSRF_COOKIE_NAME)
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setValue('~encoded~')
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setValue('~random~')
->shouldBeCalled()
->willReturn($this->cookie);
$this->cookie->setExpire(Argument::type('int'))
->shouldBeCalledTimes(2)
->willReturn($this->cookie);
$this->cookie->setPath('/')
->shouldBeCalledTimes(2)
->willReturn($this->cookie);
$this->cookie->setHttpOnly(false)
->shouldBeCalledTimes(2)
->willReturn($this->cookie);
$this->cookie->create()
->shouldBeCalledTimes(2)
->willReturn(true);
$this
->setUp('phpspec.test')
->shouldReturn('~encoded~');
}
}
......@@ -173,6 +173,23 @@ class DomainSpec extends ObjectBehavior
->shouldReturn(false);
}
public function it_should_check_if_root_domain()
{
$this->config->get('pro')
->shouldBeCalled()
->willReturn([
'root_domains' => ['phpspec.test']
]);
$this
->isRoot('phpspec.test')
->shouldReturn(true);
$this
->isRoot('not-a-root-phpspec.test')
->shouldReturn(false);
}
public function it_should_get_icon(
Settings $settings,
User $owner
......
<?php
namespace Spec\Minds\Core\SSO;
use Exception;
use Minds\Common\Jwt;
use Minds\Core\Config;
use Minds\Core\Data\cache\abstractCacher;
use Minds\Core\Sessions\Manager as SessionsManager;
use Minds\Core\Sessions\Session;
use Minds\Core\SSO\Delegates;
use Minds\Core\SSO\Manager;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class ManagerSpec extends ObjectBehavior
{
/** @var Config */
protected $config;
/** @var abstractCacher */
protected $cache;
/** @var Jwt */
protected $jwt;
/** @var SessionsManager */
protected $sessions;
/** @var Delegates\ProDelegate */
protected $proDelegate;
public function let(
Config $config,
abstractCacher $cache,
Jwt $jwt,
SessionsManager $sessions,
Delegates\ProDelegate $proDelegate
) {
$this->config = $config;
$this->cache = $cache;
$this->jwt = $jwt;
$this->sessions = $sessions;
$this->proDelegate = $proDelegate;
$this->config->get('oauth')
->willReturn([
'encryption_key' => '~key~'
]);
$this->beConstructedWith(
$config,
$cache,
$jwt,
$sessions,
$proDelegate
);
}
public function it_is_initializable()
{
$this->shouldHaveType(Manager::class);
}
public function it_should_generate_token(
Session $session
) {
$this->proDelegate->isAllowed('phpspec.test')
->shouldBeCalled()
->willReturn(true);
$this->sessions->getSession()
->shouldBeCalled()
->willReturn($session);
$session->getUserGuid()
->shouldBeCalled()
->willReturn(1000);
$session->getToken()
->shouldBeCalled()
->willReturn('~token~');
$this->jwt->randomString()
->shouldBeCalled()
->willReturn('~random~');
$this->jwt->setKey('~key~')
->shouldBeCalled()
->willReturn($this->jwt);
$ssoKey = sprintf(
"sso:%s:%s:%s",
'phpspec.test',
hash('sha256', '~key~~token~'),
'~random~'
);
$this->jwt->encode([
'key' => $ssoKey,
'domain' => 'phpspec.test'
], Argument::type('int'), Argument::type('int'))
->shouldBeCalled()
->willReturn('~jwt~');
$this->cache->set($ssoKey, '~token~', Argument::type('int'))
->shouldBeCalled()
->willReturn(true);
$this
->setDomain('phpspec.test')
->generateToken()
->shouldReturn('~jwt~');
}
public function it_should_not_generate_a_token_if_logged_out()
{
$this->proDelegate->isAllowed('phpspec.test')
->shouldBeCalled()
->willReturn(true);
$this->sessions->getSession()
->shouldBeCalled()
->willReturn(null);
$this
->setDomain('phpspec.test')
->generateToken()
->shouldReturn(null);
}
public function it_should_authorize()
{
$this->proDelegate->isAllowed('phpspec.test')
->shouldBeCalled()
->willReturn(true);
$this->jwt->setKey('~key~')
->shouldBeCalled()
->willReturn($this->jwt);
$this->jwt->decode('~jwt~')
->shouldBeCalled()
->willReturn([
'key' => 'sso:key',
'domain' => 'phpspec.test'
]);
$this->cache->get('sso:key')
->shouldBeCalled()
->willReturn('~token~');
$this->sessions->withString('~token~')
->shouldBeCalled()
->willReturn($this->sessions);
$this->sessions->save()
->shouldBeCalled()
->willReturn(true);
$this->cache->destroy('sso:key')
->shouldBeCalled()
->willReturn(true);
$this
->setDomain('phpspec.test')
->shouldNotThrow(Exception::class)
->duringAuthorize('~jwt~');
}
public function it_should_not_authorize_if_domain_mismatches()
{
$this->proDelegate->isAllowed('other-phpspec.test')
->shouldBeCalled()
->willReturn(true);
$this->jwt->setKey('~key~')
->shouldBeCalled()
->willReturn($this->jwt);
$this->jwt->decode('~jwt~')
->shouldBeCalled()
->willReturn([
'key' => 'sso:key',
'domain' => 'phpspec.test'
]);
$this->sessions->withString(Argument::cetera())
->shouldNotBeCalled();
$this
->setDomain('other-phpspec.test')
->shouldThrow(new Exception('Domain mismatch'))
->duringAuthorize('~jwt~');
}
}