From 74277c25be2f3231e52a73a684bd14452a9ff2aa Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Thu, 19 May 2016 11:20:22 +0200 Subject: [PATCH] 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'));?>