From 12431aa3997154aaea4eec11c2dd65f9e5dbe179 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Wed, 18 May 2016 12:03:22 +0200 Subject: [PATCH 1/3] list user's auth tokens on the personal settings page --- .../Authentication/Token/DefaultToken.php | 12 ++- lib/private/Server.php | 3 +- settings/Application.php | 16 +++- .../Controller/AuthSettingsController.php | 71 ++++++++++++++ settings/css/settings.css | 15 +++ settings/js/authtoken.js | 33 +++++++ settings/js/authtoken_collection.js | 36 +++++++ settings/js/authtoken_view.js | 93 +++++++++++++++++++ settings/js/personal.js | 7 ++ settings/personal.php | 5 + settings/routes.php | 3 +- settings/templates/personal.php | 30 ++++++ .../controller/AuthSettingsControllerTest.php | 66 +++++++++++++ 13 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 settings/Controller/AuthSettingsController.php create mode 100644 settings/js/authtoken.js create mode 100644 settings/js/authtoken_collection.js create mode 100644 settings/js/authtoken_view.js create mode 100644 tests/settings/controller/AuthSettingsControllerTest.php diff --git a/lib/private/Authentication/Token/DefaultToken.php b/lib/private/Authentication/Token/DefaultToken.php index 08451a4615..ca4c723fba 100644 --- a/lib/private/Authentication/Token/DefaultToken.php +++ b/lib/private/Authentication/Token/DefaultToken.php @@ -22,6 +22,7 @@ namespace OC\Authentication\Token; +use JsonSerializable; use OCP\AppFramework\Db\Entity; /** @@ -38,7 +39,7 @@ use OCP\AppFramework\Db\Entity; * @method void setLastActivity(int $lastActivity) * @method int getLastActivity() */ -class DefaultToken extends Entity implements IToken { +class DefaultToken extends Entity implements IToken, JsonSerializable { /** * @var string user UID @@ -87,4 +88,13 @@ class DefaultToken extends Entity implements IToken { return parent::getPassword(); } + public function jsonSerialize() { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'lastActivity' => $this->lastActivity, + 'type' => $this->type, + ]; + } + } diff --git a/lib/private/Server.php b/lib/private/Server.php index 0b7b8f9e40..ea0c436d84 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -223,6 +223,7 @@ class Server extends ServerContainer implements IServerContainer { $timeFactory = new TimeFactory(); return new \OC\Authentication\Token\DefaultTokenProvider($mapper, $crypto, $config, $logger, $timeFactory); }); + $this->registerAlias('OC\Authentication\Token\IProvider', 'OC\Authentication\Token\DefaultTokenProvider'); $this->registerService('UserSession', function (Server $c) { $manager = $c->getUserManager(); $session = new \OC\Session\Memory(''); @@ -230,7 +231,7 @@ class Server extends ServerContainer implements IServerContainer { // Token providers might require a working database. This code // might however be called when ownCloud is not yet setup. if (\OC::$server->getSystemConfig()->getValue('installed', false)) { - $defaultTokenProvider = $c->query('OC\Authentication\Token\DefaultTokenProvider'); + $defaultTokenProvider = $c->query('OC\Authentication\Token\IProvider'); } else { $defaultTokenProvider = null; } diff --git a/settings/Application.php b/settings/Application.php index 5b84d028ab..7069fc9c35 100644 --- a/settings/Application.php +++ b/settings/Application.php @@ -29,7 +29,9 @@ namespace OC\Settings; use OC\Files\View; +use OC\Server; use OC\Settings\Controller\AppSettingsController; +use OC\Settings\Controller\AuthSettingsController; use OC\Settings\Controller\CertificateController; use OC\Settings\Controller\CheckSetupController; use OC\Settings\Controller\EncryptionController; @@ -39,10 +41,9 @@ use OC\Settings\Controller\MailSettingsController; use OC\Settings\Controller\SecuritySettingsController; use OC\Settings\Controller\UsersController; use OC\Settings\Middleware\SubadminMiddleware; -use \OCP\AppFramework\App; +use OCP\AppFramework\App; use OCP\IContainer; -use \OCP\Util; -use OC\Server; +use OCP\Util; /** * @package OC\Settings @@ -97,6 +98,15 @@ class Application extends App { $c->query('OcsClient') ); }); + $container->registerService('AuthSettingsController', function(IContainer $c) { + return new AuthSettingsController( + $c->query('AppName'), + $c->query('Request'), + $c->query('ServerContainer')->query('OC\Authentication\Token\IProvider'), + $c->query('UserManager'), + $c->query('UserId') + ); + }); $container->registerService('SecuritySettingsController', function(IContainer $c) { return new SecuritySettingsController( $c->query('AppName'), diff --git a/settings/Controller/AuthSettingsController.php b/settings/Controller/AuthSettingsController.php new file mode 100644 index 0000000000..1d874193d3 --- /dev/null +++ b/settings/Controller/AuthSettingsController.php @@ -0,0 +1,71 @@ + + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OC\Settings\Controller; + +use OC\Authentication\Token\IProvider; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserManager; + +class AuthSettingsController extends Controller { + + /** @var IProvider */ + private $tokenProvider; + + /** + * @var IUserManager + */ + private $userManager; + + /** @var string */ + private $uid; + + /** + * @param string $appName + * @param IRequest $request + * @param IProvider $tokenProvider + * @param IUserManager $userManager + * @param string $uid + */ + public function __construct($appName, IRequest $request, IProvider $tokenProvider, IUserManager $userManager, $uid) { + parent::__construct($appName, $request); + $this->tokenProvider = $tokenProvider; + $this->userManager = $userManager; + $this->uid = $uid; + } + + /** + * @NoAdminRequired + * + * @return JSONResponse + */ + public function index() { + $user = $this->userManager->get($this->uid); + if (is_null($user)) { + return []; + } + return $this->tokenProvider->getTokenByUser($user); + } + +} diff --git a/settings/css/settings.css b/settings/css/settings.css index edc4939d2d..be61265935 100644 --- a/settings/css/settings.css +++ b/settings/css/settings.css @@ -100,6 +100,21 @@ input#identity { table.nostyle label { margin-right: 2em; } table.nostyle td { padding: 0.2em 0; } +#sessions, +#devices { + min-height: 180px; +} +#sessions table, +#devices table { + width: 100%; + min-height: 150px; + padding-top: 25px; +} +#sessions table th, +#devices table th { + font-weight: 800; +} + /* USERS */ #newgroup-init a span { margin-left: 20px; } #newgroup-init a span:before { diff --git a/settings/js/authtoken.js b/settings/js/authtoken.js new file mode 100644 index 0000000000..215192d716 --- /dev/null +++ b/settings/js/authtoken.js @@ -0,0 +1,33 @@ +/* global Backbone */ + +/** + * @author Christoph Wurst + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +(function(OC, Backbone) { + 'use strict'; + + OC.Settings = OC.Settings || {}; + + var AuthToken = Backbone.Model.extend({ + }); + + OC.Settings.AuthToken = AuthToken; + +})(OC, Backbone); diff --git a/settings/js/authtoken_collection.js b/settings/js/authtoken_collection.js new file mode 100644 index 0000000000..dd964356d0 --- /dev/null +++ b/settings/js/authtoken_collection.js @@ -0,0 +1,36 @@ +/* global Backbone */ + +/** + * @author Christoph Wurst + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +(function(OC, Backbone) { + 'use strict'; + + OC.Settings = OC.Settings || {}; + + var AuthTokenCollection = Backbone.Collection.extend({ + model: OC.Settings.AuthToken, + tokenType: null, + url: OC.generateUrl('/settings/personal/authtokens'), + }); + + OC.Settings.AuthTokenCollection = AuthTokenCollection; + +})(OC, Backbone); diff --git a/settings/js/authtoken_view.js b/settings/js/authtoken_view.js new file mode 100644 index 0000000000..0ca1682123 --- /dev/null +++ b/settings/js/authtoken_view.js @@ -0,0 +1,93 @@ +/* global Backbone, Handlebars */ + +/** + * @author Christoph Wurst + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +(function(OC, _, Backbone, $, Handlebars) { + 'use strict'; + + OC.Settings = OC.Settings || {}; + + var TEMPLATE_TOKEN = + '' + + '{{name}}' + + '{{lastActivity}}' + + ''; + + var SubView = Backbone.View.extend({ + collection: null, + type: 0, + template: Handlebars.compile(TEMPLATE_TOKEN), + initialize: function(options) { + this.type = options.type; + this.collection = options.collection; + }, + render: function() { + var _this = this; + + var list = this.$el.find('.token-list'); + var tokens = this.collection.filter(function(token) { + return parseInt(token.get('type')) === _this.type; + }); + list.removeClass('icon-loading'); + list.html(''); + + tokens.forEach(function(token) { + var html = _this.template(token.toJSON()); + list.append(html); + }); + }, + }); + + var AuthTokenView = Backbone.View.extend({ + collection: null, + views + : [], + initialize: function(options) { + this.collection = options.collection; + + var tokenTypes = [0, 1]; + var _this = this; + _.each(tokenTypes, function(type) { + _this.views.push(new SubView({ + el: type === 0 ? '#sessions' : '#devices', + type: type, + collection: _this.collection + })); + }); + }, + render: function() { + _.each(this.views, function(view) { + view.render(); + }); + }, + reload: function() { + var loadingTokens = this.collection.fetch(); + + var _this = this; + $.when(loadingTokens).done(function() { + _this.render(); + }); + } + }); + + OC.Settings.AuthTokenView = AuthTokenView; + +})(OC, _, Backbone, $, Handlebars); diff --git a/settings/js/personal.js b/settings/js/personal.js index 09f63f3f6a..aea2400e99 100644 --- a/settings/js/personal.js +++ b/settings/js/personal.js @@ -361,6 +361,13 @@ $(document).ready(function () { if (oc_config.enable_avatars) { $('#avatar .avatardiv').avatar(OC.currentUser, 145); } + + // Show token views + var collection = new OC.Settings.AuthTokenCollection(); + var view = new OC.Settings.AuthTokenView({ + collection: collection + }); + view.reload(); }); if (!OC.Encryption) { diff --git a/settings/personal.php b/settings/personal.php index 6c2fccbec9..3b283fb2d3 100644 --- a/settings/personal.php +++ b/settings/personal.php @@ -42,6 +42,9 @@ $config = \OC::$server->getConfig(); $urlGenerator = \OC::$server->getURLGenerator(); // Highlight navigation entry +OC_Util::addScript('settings', 'authtoken'); +OC_Util::addScript('settings', 'authtoken_collection'); +OC_Util::addScript('settings', 'authtoken_view'); OC_Util::addScript( 'settings', 'personal' ); OC_Util::addScript('settings', 'certificates'); OC_Util::addStyle( 'settings', 'settings' ); @@ -171,6 +174,8 @@ $tmpl->assign('groups', $groups2); // add hardcoded forms from the template $formsAndMore = []; $formsAndMore[]= ['anchor' => 'avatar', 'section-name' => $l->t('Personal info')]; +$formsAndMore[]= ['anchor' => 'sessions', 'section-name' => $l->t('Sessions')]; +$formsAndMore[]= ['anchor' => 'devices', 'section-name' => $l->t('Devices')]; $formsAndMore[]= ['anchor' => 'clientsbox', 'section-name' => $l->t('Sync clients')]; $forms=OC_App::getForms('personal'); diff --git a/settings/routes.php b/settings/routes.php index 90e1d1e442..5c356e0173 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -36,7 +36,8 @@ $application = new Application(); $application->registerRoutes($this, [ 'resources' => [ 'groups' => ['url' => '/settings/users/groups'], - 'users' => ['url' => '/settings/users/users'] + 'users' => ['url' => '/settings/users/users'], + 'AuthSettings' => ['url' => '/settings/personal/authtokens'], ], 'routes' => [ ['name' => 'MailSettings#setMailSettings', 'url' => '/settings/admin/mailsettings', 'verb' => 'POST'], diff --git a/settings/templates/personal.php b/settings/templates/personal.php index 29bf240e7e..a7e86b50a5 100644 --- a/settings/templates/personal.php +++ b/settings/templates/personal.php @@ -139,6 +139,36 @@ if($_['passwordChangeSupported']) { } ?> +
+

t('Sessions'));?>

+ t('These are the web browsers currently logged in to your ownCloud.'));?> + + + + + + + + + +
BrowserMost recent activity
+
+ +
+

t('Devices'));?>

+ t("You've linked these devices."));?> + + + + + + + + + +
NameMost recent activity
+
+

diff --git a/tests/settings/controller/AuthSettingsControllerTest.php b/tests/settings/controller/AuthSettingsControllerTest.php new file mode 100644 index 0000000000..d236f9f5eb --- /dev/null +++ b/tests/settings/controller/AuthSettingsControllerTest.php @@ -0,0 +1,66 @@ + + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace Test\Settings\Controller; + +use OC\Settings\Controller\AuthSettingsController; +use Test\TestCase; + +class AuthSettingsControllerTest extends TestCase { + + /** @var AuthSettingsController */ + private $controller; + private $request; + private $tokenProvider; + private $userManager; + private $uid; + + protected function setUp() { + parent::setUp(); + + $this->request = $this->getMock('\OCP\IRequest'); + $this->tokenProvider = $this->getMock('\OC\Authentication\Token\IProvider'); + $this->userManager = $this->getMock('\OCP\IUserManager'); + $this->uid = 'jane'; + $this->user = $this->getMock('\OCP\IUser'); + + $this->controller = new AuthSettingsController('core', $this->request, $this->tokenProvider, $this->userManager, $this->uid); + } + + public function testIndex() { + $result = [ + 'token1', + 'token2', + ]; + $this->userManager->expects($this->once()) + ->method('get') + ->with($this->uid) + ->will($this->returnValue($this->user)); + $this->tokenProvider->expects($this->once()) + ->method('getTokenByUser') + ->with($this->user) + ->will($this->returnValue($result)); + + $this->assertEquals($result, $this->controller->index()); + } + +} From 6495534bcdbbda8aa2748cc9f5d94dcb2bc7a04a Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Wed, 18 May 2016 18:25:05 +0200 Subject: [PATCH 2/3] add button to add new device tokens --- .../Token/DefaultTokenProvider.php | 2 + .../Authentication/Token/IProvider.php | 3 +- settings/Application.php | 2 + .../Controller/AuthSettingsController.php | 71 +++++++++++++- settings/css/settings.css | 22 ++++- settings/js/authtoken_view.js | 95 ++++++++++++++++--- settings/templates/personal.php | 11 +++ .../controller/AuthSettingsControllerTest.php | 77 ++++++++++++++- 8 files changed, 259 insertions(+), 24 deletions(-) diff --git a/lib/private/Authentication/Token/DefaultTokenProvider.php b/lib/private/Authentication/Token/DefaultTokenProvider.php index 6c69d852d7..3527f4155a 100644 --- a/lib/private/Authentication/Token/DefaultTokenProvider.php +++ b/lib/private/Authentication/Token/DefaultTokenProvider.php @@ -134,6 +134,7 @@ class DefaultTokenProvider implements IProvider { /** * @param IToken $savedToken * @param string $tokenId session token + * @throws InvalidTokenException * @return string */ public function getPassword(IToken $savedToken, $tokenId) { @@ -203,6 +204,7 @@ class DefaultTokenProvider implements IProvider { * * @param string $password * @param string $token + * @throws InvalidTokenException * @return string the decrypted key */ private function decryptPassword($password, $token) { diff --git a/lib/private/Authentication/Token/IProvider.php b/lib/private/Authentication/Token/IProvider.php index a5c5faa563..b8648dda5b 100644 --- a/lib/private/Authentication/Token/IProvider.php +++ b/lib/private/Authentication/Token/IProvider.php @@ -35,7 +35,7 @@ interface IProvider { * @param string $password * @param string $name * @param int $type token type - * @return DefaultToken + * @return IToken */ public function generateToken($token, $uid, $password, $name, $type = IToken::TEMPORARY_TOKEN); @@ -85,6 +85,7 @@ interface IProvider { * * @param IToken $token * @param string $tokenId + * @throws InvalidTokenException * @return string */ public function getPassword(IToken $token, $tokenId); diff --git a/settings/Application.php b/settings/Application.php index 7069fc9c35..728c2bf9de 100644 --- a/settings/Application.php +++ b/settings/Application.php @@ -104,6 +104,8 @@ class Application extends App { $c->query('Request'), $c->query('ServerContainer')->query('OC\Authentication\Token\IProvider'), $c->query('UserManager'), + $c->query('ServerContainer')->getSession(), + $c->query('ServerContainer')->getSecureRandom(), $c->query('UserId') ); }); diff --git a/settings/Controller/AuthSettingsController.php b/settings/Controller/AuthSettingsController.php index 1d874193d3..71868b7688 100644 --- a/settings/Controller/AuthSettingsController.php +++ b/settings/Controller/AuthSettingsController.php @@ -22,41 +22,56 @@ namespace OC\Settings\Controller; +use OC\AppFramework\Http; +use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\ISession; use OCP\IUserManager; +use OCP\Security\ISecureRandom; +use OCP\Session\Exceptions\SessionNotAvailableException; class AuthSettingsController extends Controller { /** @var IProvider */ private $tokenProvider; - /** - * @var IUserManager - */ + /** @var IUserManager */ private $userManager; + /** @var ISession */ + private $session; + /** @var string */ private $uid; + /** @var ISecureRandom */ + private $random; + /** * @param string $appName * @param IRequest $request * @param IProvider $tokenProvider * @param IUserManager $userManager + * @param ISession $session + * @param ISecureRandom $random * @param string $uid */ - public function __construct($appName, IRequest $request, IProvider $tokenProvider, IUserManager $userManager, $uid) { + public function __construct($appName, IRequest $request, IProvider $tokenProvider, IUserManager $userManager, ISession $session, ISecureRandom $random, $uid) { parent::__construct($appName, $request); $this->tokenProvider = $tokenProvider; $this->userManager = $userManager; $this->uid = $uid; + $this->session = $session; + $this->random = $random; } /** * @NoAdminRequired + * @NoSubadminRequired * * @return JSONResponse */ @@ -68,4 +83,52 @@ class AuthSettingsController extends Controller { return $this->tokenProvider->getTokenByUser($user); } + /** + * @NoAdminRequired + * @NoSubadminRequired + * + * @return JSONResponse + */ + public function create($name) { + try { + $sessionId = $this->session->getId(); + } catch (SessionNotAvailableException $ex) { + $resp = new JSONResponse(); + $resp->setStatus(Http::STATUS_SERVICE_UNAVAILABLE); + return $resp; + } + + try { + $sessionToken = $this->tokenProvider->getToken($sessionId); + $password = $this->tokenProvider->getPassword($sessionToken, $sessionId); + } catch (InvalidTokenException $ex) { + $resp = new JSONResponse(); + $resp->setStatus(Http::STATUS_SERVICE_UNAVAILABLE); + return $resp; + } + + $token = $this->generateRandomDeviceToken(); + $deviceToken = $this->tokenProvider->generateToken($token, $this->uid, $password, $name, IToken::PERMANENT_TOKEN); + + return [ + 'token' => $token, + 'deviceToken' => $deviceToken + ]; + } + + /** + * Return a 20 digit device password + * + * Example: ABCDE-FGHIJ-KLMNO-PQRST + * + * @return string + */ + private function generateRandomDeviceToken() { + $groups = []; + for ($i = 0; $i < 4; $i++) { + $groups[] = $this->random->generate(5, implode('', range('A', 'Z'))); + } + return implode('-', $groups); + } + } diff --git a/settings/css/settings.css b/settings/css/settings.css index be61265935..418c5f9551 100644 --- a/settings/css/settings.css +++ b/settings/css/settings.css @@ -100,10 +100,6 @@ input#identity { table.nostyle label { margin-right: 2em; } table.nostyle td { padding: 0.2em 0; } -#sessions, -#devices { - min-height: 180px; -} #sessions table, #devices table { width: 100%; @@ -114,6 +110,24 @@ table.nostyle td { padding: 0.2em 0; } #devices table th { font-weight: 800; } +#sessions table th, +#sessions table td, +#devices table th, +#devices table td { + padding: 10px; +} + +#sessions .token-list td, +#devices .token-list td { + border-top: 1px solid #DDD; +} + +#device-new-token { + padding: 10px; + font-family: monospace; + font-size: 1.4em; + background-color: lightyellow; +} /* USERS */ #newgroup-init a span { margin-left: 20px; } diff --git a/settings/js/authtoken_view.js b/settings/js/authtoken_view.js index 0ca1682123..8ca38d80d8 100644 --- a/settings/js/authtoken_view.js +++ b/settings/js/authtoken_view.js @@ -1,4 +1,4 @@ -/* global Backbone, Handlebars */ +/* global Backbone, Handlebars, moment */ /** * @author Christoph Wurst @@ -20,16 +20,16 @@ * */ -(function(OC, _, Backbone, $, Handlebars) { +(function(OC, _, Backbone, $, Handlebars, moment) { 'use strict'; OC.Settings = OC.Settings || {}; var TEMPLATE_TOKEN = - '' - + '{{name}}' - + '{{lastActivity}}' - + ''; + '' + + '{{name}}' + + '{{lastActivity}}' + + ''; var SubView = Backbone.View.extend({ collection: null, @@ -46,48 +46,115 @@ var tokens = this.collection.filter(function(token) { return parseInt(token.get('type')) === _this.type; }); - list.removeClass('icon-loading'); list.html(''); tokens.forEach(function(token) { - var html = _this.template(token.toJSON()); + var viewData = token.toJSON(); + viewData.lastActivity = moment(viewData.lastActivity, 'X'). + format('LLL'); + var html = _this.template(viewData); list.append(html); }); }, + toggleLoading: function(state) { + this.$el.find('.token-list').toggleClass('icon-loading', state); + } }); var AuthTokenView = Backbone.View.extend({ collection: null, - views - : [], + _views: [], + _form: undefined, + _tokenName: undefined, + _addTokenBtn: undefined, + _result: undefined, + _newToken: undefined, + _hideTokenBtn: undefined, + _addingToken: false, initialize: function(options) { this.collection = options.collection; var tokenTypes = [0, 1]; var _this = this; _.each(tokenTypes, function(type) { - _this.views.push(new SubView({ + _this._views.push(new SubView({ el: type === 0 ? '#sessions' : '#devices', type: type, collection: _this.collection })); }); + + this._form = $('#device-token-form'); + this._tokenName = $('#device-token-name'); + this._addTokenBtn = $('#device-add-token'); + this._addTokenBtn.click(_.bind(this._addDeviceToken, this)); + + this._result = $('#device-token-result'); + this._newToken = $('#device-new-token'); + this._hideTokenBtn = $('#device-token-hide'); + this._hideTokenBtn.click(_.bind(this._hideToken, this)); }, render: function() { - _.each(this.views, function(view) { + _.each(this._views, function(view) { view.render(); + view.toggleLoading(false); }); }, reload: function() { + var _this = this; + + _.each(this._views, function(view) { + view.toggleLoading(true); + }); + var loadingTokens = this.collection.fetch(); - var _this = this; $.when(loadingTokens).done(function() { _this.render(); }); + $.when(loadingTokens).fail(function() { + OC.Notification.showTemporary(t('core', 'Error while loading browser sessions and device tokens')); + }); + }, + _addDeviceToken: function() { + var _this = this; + this._toggleAddingToken(true); + + var deviceName = this._tokenName.val(); + var creatingToken = $.ajax(OC.generateUrl('/settings/personal/authtokens'), { + method: 'POST', + data: { + name: deviceName + } + }); + + $.when(creatingToken).done(function(resp) { + _this.collection.add(resp.deviceToken); + _this.render(); + _this._newToken.text(resp.token); + _this._toggleFormResult(false); + _this._tokenName.val(''); + }); + $.when(creatingToken).fail(function() { + OC.Notification.showTemporary(t('core', 'Error while creating device token')); + }); + $.when(creatingToken).always(function() { + _this._toggleAddingToken(false); + }); + }, + _hideToken: function() { + this._toggleFormResult(true); + }, + _toggleAddingToken: function(state) { + this._addingToken = state; + this._addTokenBtn.toggleClass('icon-loading-small', state); + }, + _toggleFormResult: function(showForm) { + this._form.toggleClass('hidden', !showForm); + this._result.toggleClass('hidden', showForm); } }); OC.Settings.AuthTokenView = AuthTokenView; -})(OC, _, Backbone, $, Handlebars); +})(OC, _, Backbone, $, Handlebars, moment); diff --git a/settings/templates/personal.php b/settings/templates/personal.php index a7e86b50a5..4f8d564f54 100644 --- a/settings/templates/personal.php +++ b/settings/templates/personal.php @@ -147,6 +147,7 @@ if($_['passwordChangeSupported']) { Browser Most recent activity + @@ -162,11 +163,21 @@ if($_['passwordChangeSupported']) { Name Most recent activity + +

t('A device password is a passcode that gives an app or device permissions to access your ownCloud account.'));?>

+
+ + +
+ diff --git a/tests/settings/controller/AuthSettingsControllerTest.php b/tests/settings/controller/AuthSettingsControllerTest.php index d236f9f5eb..3b46a2caa2 100644 --- a/tests/settings/controller/AuthSettingsControllerTest.php +++ b/tests/settings/controller/AuthSettingsControllerTest.php @@ -22,7 +22,12 @@ namespace Test\Settings\Controller; +use OC\AppFramework\Http; +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\IToken; use OC\Settings\Controller\AuthSettingsController; +use OCP\AppFramework\Http\JSONResponse; +use OCP\Session\Exceptions\SessionNotAvailableException; use Test\TestCase; class AuthSettingsControllerTest extends TestCase { @@ -32,6 +37,8 @@ class AuthSettingsControllerTest extends TestCase { private $request; private $tokenProvider; private $userManager; + private $session; + private $secureRandom; private $uid; protected function setUp() { @@ -40,10 +47,12 @@ class AuthSettingsControllerTest extends TestCase { $this->request = $this->getMock('\OCP\IRequest'); $this->tokenProvider = $this->getMock('\OC\Authentication\Token\IProvider'); $this->userManager = $this->getMock('\OCP\IUserManager'); + $this->session = $this->getMock('\OCP\ISession'); + $this->secureRandom = $this->getMock('\OCP\Security\ISecureRandom'); $this->uid = 'jane'; $this->user = $this->getMock('\OCP\IUser'); - $this->controller = new AuthSettingsController('core', $this->request, $this->tokenProvider, $this->userManager, $this->uid); + $this->controller = new AuthSettingsController('core', $this->request, $this->tokenProvider, $this->userManager, $this->session, $this->secureRandom, $this->uid); } public function testIndex() { @@ -63,4 +72,70 @@ class AuthSettingsControllerTest extends TestCase { $this->assertEquals($result, $this->controller->index()); } + public function testCreate() { + $name = 'Nexus 4'; + $sessionToken = $this->getMock('\OC\Authentication\Token\IToken'); + $deviceToken = $this->getMock('\OC\Authentication\Token\IToken'); + $password = '123456'; + + $this->session->expects($this->once()) + ->method('getId') + ->will($this->returnValue('sessionid')); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->with('sessionid') + ->will($this->returnValue($sessionToken)); + $this->tokenProvider->expects($this->once()) + ->method('getPassword') + ->with($sessionToken, 'sessionid') + ->will($this->returnValue($password)); + + $this->secureRandom->expects($this->exactly(4)) + ->method('generate') + ->with(5, implode('', range('A', 'Z'))) + ->will($this->returnValue('XXXXX')); + $newToken = 'XXXXX-XXXXX-XXXXX-XXXXX'; + + $this->tokenProvider->expects($this->once()) + ->method('generateToken') + ->with($newToken, $this->uid, $password, $name, IToken::PERMANENT_TOKEN) + ->will($this->returnValue($deviceToken)); + + $expected = [ + 'token' => $newToken, + 'deviceToken' => $deviceToken, + ]; + $this->assertEquals($expected, $this->controller->create($name)); + } + + public function testCreateSessionNotAvailable() { + $name = 'personal phone'; + + $this->session->expects($this->once()) + ->method('getId') + ->will($this->throwException(new SessionNotAvailableException())); + + $expected = new JSONResponse(); + $expected->setStatus(Http::STATUS_SERVICE_UNAVAILABLE); + + $this->assertEquals($expected, $this->controller->create($name)); + } + + public function testCreateInvalidToken() { + $name = 'Company IPhone'; + + $this->session->expects($this->once()) + ->method('getId') + ->will($this->returnValue('sessionid')); + $this->tokenProvider->expects($this->once()) + ->method('getToken') + ->with('sessionid') + ->will($this->throwException(new InvalidTokenException())); + + $expected = new JSONResponse(); + $expected->setStatus(Http::STATUS_SERVICE_UNAVAILABLE); + + $this->assertEquals($expected, $this->controller->create($name)); + } + } From 74277c25be2f3231e52a73a684bd14452a9ff2aa Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Thu, 19 May 2016 11:20:22 +0200 Subject: [PATCH 3/3] add button to invalidate browser sessions/device tokens --- .../Authentication/Token/DefaultToken.php | 4 +- .../Token/DefaultTokenMapper.php | 13 +++ .../Token/DefaultTokenProvider.php | 10 ++ .../Authentication/Token/IProvider.php | 10 +- lib/private/Authentication/Token/IToken.php | 6 +- .../Controller/AuthSettingsController.php | 19 +++- settings/css/settings.css | 12 +- settings/js/authtoken_collection.js | 18 ++- settings/js/authtoken_view.js | 104 ++++++++++++++++-- settings/templates/personal.php | 20 ++-- .../Token/DefaultTokenMapperTest.php | 27 +++++ .../Token/DefaultTokenProviderTest.php | 11 ++ .../controller/AuthSettingsControllerTest.php | 15 +++ 13 files changed, 236 insertions(+), 33 deletions(-) diff --git a/lib/private/Authentication/Token/DefaultToken.php b/lib/private/Authentication/Token/DefaultToken.php index ca4c723fba..4a64eacb24 100644 --- a/lib/private/Authentication/Token/DefaultToken.php +++ b/lib/private/Authentication/Token/DefaultToken.php @@ -22,14 +22,12 @@ namespace OC\Authentication\Token; -use JsonSerializable; use OCP\AppFramework\Db\Entity; /** * @method void setId(int $id) * @method void setUid(string $uid); * @method void setPassword(string $password) - * @method string getPassword() * @method void setName(string $name) * @method string getName() * @method void setToken(string $token) @@ -39,7 +37,7 @@ use OCP\AppFramework\Db\Entity; * @method void setLastActivity(int $lastActivity) * @method int getLastActivity() */ -class DefaultToken extends Entity implements IToken, JsonSerializable { +class DefaultToken extends Entity implements IToken { /** * @var string user UID diff --git a/lib/private/Authentication/Token/DefaultTokenMapper.php b/lib/private/Authentication/Token/DefaultTokenMapper.php index 9f17357127..970c2242db 100644 --- a/lib/private/Authentication/Token/DefaultTokenMapper.php +++ b/lib/private/Authentication/Token/DefaultTokenMapper.php @@ -111,4 +111,17 @@ class DefaultTokenMapper extends Mapper { return $entities; } + /** + * @param IUser $user + * @param int $id + */ + public function deleteById(IUser $user, $id) { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->delete('authtoken') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($user->getUID()))); + $qb->execute(); + } + } diff --git a/lib/private/Authentication/Token/DefaultTokenProvider.php b/lib/private/Authentication/Token/DefaultTokenProvider.php index 3527f4155a..0f7c54dab5 100644 --- a/lib/private/Authentication/Token/DefaultTokenProvider.php +++ b/lib/private/Authentication/Token/DefaultTokenProvider.php @@ -150,6 +150,16 @@ class DefaultTokenProvider implements IProvider { $this->mapper->invalidate($this->hashToken($token)); } + /** + * Invalidate (delete) the given token + * + * @param IUser $user + * @param int $id + */ + public function invalidateTokenById(IUser $user, $id) { + $this->mapper->deleteById($user, $id); + } + /** * Invalidate (delete) old session tokens */ diff --git a/lib/private/Authentication/Token/IProvider.php b/lib/private/Authentication/Token/IProvider.php index b8648dda5b..e4e4581e73 100644 --- a/lib/private/Authentication/Token/IProvider.php +++ b/lib/private/Authentication/Token/IProvider.php @@ -47,7 +47,7 @@ interface IProvider { * @return IToken */ public function getToken($tokenId) ; - + /** * @param string $token * @throws InvalidTokenException @@ -62,6 +62,14 @@ interface IProvider { */ public function invalidateToken($token); + /** + * Invalidate (delete) the given token + * + * @param IUser $user + * @param int $id + */ + public function invalidateTokenById(IUser $user, $id); + /** * Update token activity timestamp * diff --git a/lib/private/Authentication/Token/IToken.php b/lib/private/Authentication/Token/IToken.php index 2a01ea75ea..b741cd4ac2 100644 --- a/lib/private/Authentication/Token/IToken.php +++ b/lib/private/Authentication/Token/IToken.php @@ -22,7 +22,9 @@ namespace OC\Authentication\Token; -interface IToken { +use JsonSerializable; + +interface IToken extends JsonSerializable { const TEMPORARY_TOKEN = 0; const PERMANENT_TOKEN = 1; @@ -30,7 +32,7 @@ interface IToken { /** * Get the token ID * - * @return string + * @return int */ public function getId(); diff --git a/settings/Controller/AuthSettingsController.php b/settings/Controller/AuthSettingsController.php index 71868b7688..75311920d2 100644 --- a/settings/Controller/AuthSettingsController.php +++ b/settings/Controller/AuthSettingsController.php @@ -60,7 +60,8 @@ class AuthSettingsController extends Controller { * @param ISecureRandom $random * @param string $uid */ - public function __construct($appName, IRequest $request, IProvider $tokenProvider, IUserManager $userManager, ISession $session, ISecureRandom $random, $uid) { + public function __construct($appName, IRequest $request, IProvider $tokenProvider, IUserManager $userManager, + ISession $session, ISecureRandom $random, $uid) { parent::__construct($appName, $request); $this->tokenProvider = $tokenProvider; $this->userManager = $userManager; @@ -131,4 +132,20 @@ class AuthSettingsController extends Controller { return implode('-', $groups); } + /** + * @NoAdminRequired + * @NoSubadminRequired + * + * @return JSONResponse + */ + public function destroy($id) { + $user = $this->userManager->get($this->uid); + if (is_null($user)) { + return []; + } + + $this->tokenProvider->invalidateTokenById($user, $id); + return []; + } + } diff --git a/settings/css/settings.css b/settings/css/settings.css index 418c5f9551..5fc9634350 100644 --- a/settings/css/settings.css +++ b/settings/css/settings.css @@ -114,18 +114,22 @@ table.nostyle td { padding: 0.2em 0; } #sessions table td, #devices table th, #devices table td { - padding: 10px; + padding: 10px; } #sessions .token-list td, #devices .token-list td { - border-top: 1px solid #DDD; + border-top: 1px solid #DDD; +} +#sessions .token-list td a.icon-delete, +#devices .token-list td a.icon-delete { + display: block; + opacity: 0.6; } #device-new-token { - padding: 10px; + width: 186px; font-family: monospace; - font-size: 1.4em; background-color: lightyellow; } diff --git a/settings/js/authtoken_collection.js b/settings/js/authtoken_collection.js index dd964356d0..a78e053995 100644 --- a/settings/js/authtoken_collection.js +++ b/settings/js/authtoken_collection.js @@ -26,9 +26,25 @@ OC.Settings = OC.Settings || {}; var AuthTokenCollection = Backbone.Collection.extend({ + model: OC.Settings.AuthToken, + + /** + * Show recently used sessions/devices first + * + * @param {OC.Settigns.AuthToken} t1 + * @param {OC.Settigns.AuthToken} t2 + * @returns {Boolean} + */ + comparator: function (t1, t2) { + var ts1 = parseInt(t1.get('lastActivity'), 10); + var ts2 = parseInt(t2.get('lastActivity'), 10); + return ts1 < ts2; + }, + tokenType: null, - url: OC.generateUrl('/settings/personal/authtokens'), + + url: OC.generateUrl('/settings/personal/authtokens') }); OC.Settings.AuthTokenCollection = AuthTokenCollection; diff --git a/settings/js/authtoken_view.js b/settings/js/authtoken_view.js index 8ca38d80d8..a165a46524 100644 --- a/settings/js/authtoken_view.js +++ b/settings/js/authtoken_view.js @@ -26,62 +26,110 @@ OC.Settings = OC.Settings || {}; var TEMPLATE_TOKEN = - '' + '' + '{{name}}' - + '{{lastActivity}}' + + '{{lastActivity}}' + + '' + ''; var SubView = Backbone.View.extend({ collection: null, + + /** + * token type + * - 0: browser + * - 1: device + * + * @see OC\Authentication\Token\IToken + */ type: 0, - template: Handlebars.compile(TEMPLATE_TOKEN), + + _template: undefined, + + template: function(data) { + if (_.isUndefined(this._template)) { + this._template = Handlebars.compile(TEMPLATE_TOKEN); + } + + return this._template(data); + }, + initialize: function(options) { this.type = options.type; this.collection = options.collection; + + this.on(this.collection, 'change', this.render); }, + render: function() { var _this = this; - var list = this.$el.find('.token-list'); + var list = this.$('.token-list'); var tokens = this.collection.filter(function(token) { - return parseInt(token.get('type')) === _this.type; + return parseInt(token.get('type'), 10) === _this.type; }); list.html(''); + // Show header only if there are tokens to show + console.log(tokens.length > 0); + this._toggleHeader(tokens.length > 0); + tokens.forEach(function(token) { var viewData = token.toJSON(); - viewData.lastActivity = moment(viewData.lastActivity, 'X'). - format('LLL'); + var ts = viewData.lastActivity * 1000; + viewData.lastActivity = OC.Util.relativeModifiedDate(ts); + viewData.lastActivityTime = OC.Util.formatDate(ts, 'LLL'); var html = _this.template(viewData); - list.append(html); + var $html = $(html); + $html.find('.last-activity').tooltip(); + $html.find('.icon-delete').tooltip(); + list.append($html); }); }, + toggleLoading: function(state) { - this.$el.find('.token-list').toggleClass('icon-loading', state); + this.$('.token-list').toggleClass('icon-loading', state); + }, + + _toggleHeader: function(show) { + this.$('.hidden-when-empty').toggleClass('hidden', !show); } }); var AuthTokenView = Backbone.View.extend({ collection: null, + _views: [], + _form: undefined, + _tokenName: undefined, + _addTokenBtn: undefined, + _result: undefined, + _newToken: undefined, + _hideTokenBtn: undefined, + _addingToken: false, + initialize: function(options) { this.collection = options.collection; var tokenTypes = [0, 1]; var _this = this; _.each(tokenTypes, function(type) { + var el = type === 0 ? '#sessions' : '#devices'; _this._views.push(new SubView({ - el: type === 0 ? '#sessions' : '#devices', + el: el, type: type, collection: _this.collection })); + + var $el = $(el); + $el.on('click', 'a.icon-delete', _.bind(_this._onDeleteToken, _this)); }); this._form = $('#device-token-form'); @@ -91,15 +139,18 @@ this._result = $('#device-token-result'); this._newToken = $('#device-new-token'); + this._newToken.on('focus', _.bind(this._onNewTokenFocus, this)); this._hideTokenBtn = $('#device-token-hide'); this._hideTokenBtn.click(_.bind(this._hideToken, this)); }, + render: function() { _.each(this._views, function(view) { view.render(); view.toggleLoading(false); }); }, + reload: function() { var _this = this; @@ -116,6 +167,7 @@ OC.Notification.showTemporary(t('core', 'Error while loading browser sessions and device tokens')); }); }, + _addDeviceToken: function() { var _this = this; this._toggleAddingToken(true); @@ -131,8 +183,9 @@ $.when(creatingToken).done(function(resp) { _this.collection.add(resp.deviceToken); _this.render(); - _this._newToken.text(resp.token); + _this._newToken.val(resp.token); _this._toggleFormResult(false); + _this._newToken.select(); _this._tokenName.val(''); }); $.when(creatingToken).fail(function() { @@ -142,13 +195,42 @@ _this._toggleAddingToken(false); }); }, + + _onNewTokenFocus: function() { + this._newToken.select(); + }, + _hideToken: function() { this._toggleFormResult(true); }, + _toggleAddingToken: function(state) { this._addingToken = state; this._addTokenBtn.toggleClass('icon-loading-small', state); }, + + _onDeleteToken: function(event) { + var $target = $(event.target); + var $row = $target.closest('tr'); + var id = $row.data('id'); + + var token = this.collection.get(id); + if (_.isUndefined(token)) { + // Ignore event + return; + } + + var destroyingToken = token.destroy(); + + var _this = this; + $.when(destroyingToken).fail(function() { + OC.Notification.showTemporary(t('core', 'Error while deleting the token')); + }); + $.when(destroyingToken).always(function() { + _this.render(); + }); + }, + _toggleFormResult: function(showForm) { this._form.toggleClass('hidden', !showForm); this._result.toggleClass('hidden', showForm); diff --git a/settings/templates/personal.php b/settings/templates/personal.php index 4f8d564f54..dcc83b3e99 100644 --- a/settings/templates/personal.php +++ b/settings/templates/personal.php @@ -141,12 +141,12 @@ if($_['passwordChangeSupported']) {

t('Sessions'));?>

- t('These are the web browsers currently logged in to your ownCloud.'));?> + t('These are the web browsers currently logged in to your ownCloud.'));?> - + - - + + @@ -157,13 +157,13 @@ if($_['passwordChangeSupported']) {

t('Devices'));?>

- t("You've linked these devices."));?> + t("You've linked these devices."));?>
BrowserMost recent activityt('Browser'));?>t('Most recent activity'));?>
- + - - - + + + @@ -175,7 +175,7 @@ if($_['passwordChangeSupported']) { diff --git a/tests/lib/Authentication/Token/DefaultTokenMapperTest.php b/tests/lib/Authentication/Token/DefaultTokenMapperTest.php index e17149a5c1..9179e23bfb 100644 --- a/tests/lib/Authentication/Token/DefaultTokenMapperTest.php +++ b/tests/lib/Authentication/Token/DefaultTokenMapperTest.php @@ -159,4 +159,31 @@ class DefaultTokenMapperTest extends TestCase { $this->assertCount(0, $this->mapper->getTokenByUser($user)); } + public function testDeleteById() { + $user = $this->getMock('\OCP\IUser'); + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select('id') + ->from('authtoken') + ->where($qb->expr()->eq('token', $qb->createNamedParameter('9c5a2e661482b65597408a6bb6c4a3d1af36337381872ac56e445a06cdb7fea2b1039db707545c11027a4966919918b19d875a8b774840b18c6cbb7ae56fe206'))); + $result = $qb->execute(); + $id = $result->fetch()['id']; + $user->expects($this->once()) + ->method('getUID') + ->will($this->returnValue('user1')); + + $this->mapper->deleteById($user, $id); + $this->assertEquals(2, $this->getNumberOfTokens()); + } + + public function testDeleteByIdWrongUser() { + $user = $this->getMock('\OCP\IUser'); + $id = 33; + $user->expects($this->once()) + ->method('getUID') + ->will($this->returnValue('user10000')); + + $this->mapper->deleteById($user, $id); + $this->assertEquals(3, $this->getNumberOfTokens()); + } + } diff --git a/tests/lib/Authentication/Token/DefaultTokenProviderTest.php b/tests/lib/Authentication/Token/DefaultTokenProviderTest.php index eeb249cfa8..8af5e1e933 100644 --- a/tests/lib/Authentication/Token/DefaultTokenProviderTest.php +++ b/tests/lib/Authentication/Token/DefaultTokenProviderTest.php @@ -170,6 +170,17 @@ class DefaultTokenProviderTest extends TestCase { $this->tokenProvider->invalidateToken('token7'); } + public function testInvaildateTokenById() { + $id = 123; + $user = $this->getMock('\OCP\IUser'); + + $this->mapper->expects($this->once()) + ->method('deleteById') + ->with($user, $id); + + $this->tokenProvider->invalidateTokenById($user, $id); + } + public function testInvalidateOldTokens() { $defaultSessionLifetime = 60 * 60 * 24; $this->config->expects($this->once()) diff --git a/tests/settings/controller/AuthSettingsControllerTest.php b/tests/settings/controller/AuthSettingsControllerTest.php index 3b46a2caa2..49491c8ff5 100644 --- a/tests/settings/controller/AuthSettingsControllerTest.php +++ b/tests/settings/controller/AuthSettingsControllerTest.php @@ -138,4 +138,19 @@ class AuthSettingsControllerTest extends TestCase { $this->assertEquals($expected, $this->controller->create($name)); } + public function testDestroy() { + $id = 123; + $user = $this->getMock('\OCP\IUser'); + + $this->userManager->expects($this->once()) + ->method('get') + ->with($this->uid) + ->will($this->returnValue($user)); + $this->tokenProvider->expects($this->once()) + ->method('invalidateTokenById') + ->with($user, $id); + + $this->assertEquals([], $this->controller->destroy($id)); + } + }
NameMost recent activityt('Name'));?>t('Most recent activity'));?>