From 74277c25be2f3231e52a73a684bd14452a9ff2aa Mon Sep 17 00:00:00 2001
From: Christoph Wurst <christoph@owncloud.com>
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 ca4c723fba3..4a64eacb247 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 9f173571270..970c2242dbe 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 3527f4155a9..0f7c54dab57 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 b8648dda5b7..e4e4581e738 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 2a01ea75ea9..b741cd4ac22 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 71868b7688d..75311920d2a 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 418c5f95517..5fc96343502 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 dd964356d06..a78e053995f 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 8ca38d80d84..a165a465247 100644
--- a/settings/js/authtoken_view.js
+++ b/settings/js/authtoken_view.js
@@ -26,62 +26,110 @@
 	OC.Settings = OC.Settings || {};
 
 	var TEMPLATE_TOKEN =
-		'<tr>'
+		'<tr data-id="{{id}}">'
 		+ '<td>{{name}}</td>'
-		+ '<td>{{lastActivity}}</td>'
+		+ '<td><span class="last-activity" title="{{lastActivityTime}}">{{lastActivity}}</span></td>'
+		+ '<td><a class="icon-delete" title="' + t('core', 'Disconnect') + '"></a></td>'
 		+ '<tr>';
 
 	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 4f8d564f549..dcc83b3e99e 100644
--- a/settings/templates/personal.php
+++ b/settings/templates/personal.php
@@ -141,12 +141,12 @@ if($_['passwordChangeSupported']) {
 
 <div id="sessions" class="section">
 	<h2><?php p($l->t('Sessions'));?></h2>
-	<?php p($l->t('These are the web browsers currently logged in to your ownCloud.'));?>
+	<span class="hidden-when-empty"><?php p($l->t('These are the web browsers currently logged in to your ownCloud.'));?></span>
 	<table>
-		<thead>
+		<thead class="token-list-header">
 			<tr>
-				<th>Browser</th>
-				<th>Most recent activity</th>
+				<th><?php p($l->t('Browser'));?></th>
+				<th><?php p($l->t('Most recent activity'));?></th>
 				<th></th>
 			</tr>
 		</thead>
@@ -157,13 +157,13 @@ if($_['passwordChangeSupported']) {
 
 <div id="devices" class="section">
 	<h2><?php p($l->t('Devices'));?></h2>
-	<?php p($l->t("You've linked these devices."));?>
+	<span class="hidden-when-empty"><?php p($l->t("You've linked these devices."));?></span>
 	<table>
-		<thead>
+		<thead class="hidden-when-empty">
 			<tr>
-				<th>Name</th>
-				<th>Most recent activity</th>
-				<th><a class="icon-delete"></a></th>
+				<th><?php p($l->t('Name'));?></th>
+				<th><?php p($l->t('Most recent activity'));?></th>
+				<th></th>
 			</tr>
 		</thead>
 		<tbody class="token-list icon-loading">
@@ -175,7 +175,7 @@ if($_['passwordChangeSupported']) {
 		<button id="device-add-token" class="button">Create new device password</button>
 	</div>
 	<div id="device-token-result" class="hidden">
-		<span id="device-new-token"></span>
+		<input id="device-new-token" type="text" readonly="readonly"/>
 		<button id="device-token-hide" class="button">Done</button>
 	</div>
 </div>
diff --git a/tests/lib/Authentication/Token/DefaultTokenMapperTest.php b/tests/lib/Authentication/Token/DefaultTokenMapperTest.php
index e17149a5c1b..9179e23bfb2 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 eeb249cfa8a..8af5e1e933a 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 3b46a2caa2b..49491c8ff52 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));
+	}
+
 }
-- 
GitLab