Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
This commit is contained in:
Christoph Wurst 2018-10-19 10:33:27 +02:00
Родитель 4da04fd450
Коммит 0bb0a8e61b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: CC42AC2A7F0E56D8
34 изменённых файлов: 4358 добавлений и 2586 удалений

2
.gitignore поставляемый
Просмотреть файл

@ -1,5 +1,5 @@
/build
/js/build
/js
/coverage
/node_modules

Просмотреть файл

@ -8,41 +8,7 @@
* @copyright Christoph Wurst 2016
*/
.u2f-loading {
display: inline-block;
vertical-align: sub;
margin-left: -2px;
margin-right: 3px;
}
/** icons for personal page settings **/
.nav-icon-u2f-second-factor-auth, .icon-u2f-device {
background-image: url('../img/app-dark.svg?v=1');
}
.u2f-device {
line-height: 300%;
display: flex;
}
.u2f-device .more {
position: relative;
}
.u2f-device .more .icon-more {
display: inline-block;
width: 16px;
height: 16px;
padding-left: 20px;
vertical-align: middle;
opacity: .7;
}
.u2f-device .popovermenu {
right: -5px;
top: 42px;
}
.icon-u2f-device {
display: inline-block;
background-size: 100%;
padding: 3px;
margin: 3px;
}

Просмотреть файл

@ -1,52 +0,0 @@
/* global OCA */
define(function (require) {
'use strict';
var $ = require('jquery');
var u2f = require('u2f-api');
function toggleError(state) {
var $info = $('#u2f-info');
var $error = $('#u2f-error');
if (state) {
$info.hide();
$error.show();
} else {
$info.show();
$error.hide();
}
}
function signCallback(data) {
var $form = $('#u2f-form');
var $auth = $('#challenge');
if (data.errorCode) {
console.error('U2F auth failed: ' + data.errorCode);
toggleError(true);
setTimeout(sign, 5 * 1000);
return;
}
$auth.val(JSON.stringify(data));
$form.submit();
}
function checkHTTPS() {
if (document.location.protocol !== 'https:') {
$('#u2f-http-warning').show();
}
}
function sign() {
checkHTTPS();
var req = JSON.parse($('#u2f-auth').val());
toggleError(false);
u2f.sign(req).then(signCallback).catch(console.error.bind(this));
}
return {
sign: sign
};
});

Просмотреть файл

@ -1,9 +0,0 @@
define(function(require) {
'use strict';
var $ = require('jquery');
var challenge = require('./challenge');
$(challenge.sign);
});

Просмотреть файл

@ -1,14 +0,0 @@
define(function () {
'use strict';
var $ = require('jquery');
var SettingsView = require('./settingsview');
$(function () {
var view = new SettingsView({
el: $('#twofactor-u2f-settings')
});
view.load();
});
});

14
js/jquery.js поставляемый
Просмотреть файл

@ -1,14 +0,0 @@
/**
* Mail
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @copyright Christoph Wurst 2015
*/
define(function() {
'use strict';
return $;
});

Просмотреть файл

@ -1,352 +0,0 @@
/* OC, t, Handlebars */
define(function () {
'use strict';
var _ = require('underscore');
var $ = require('jquery');
var Backbone = require('backbone');
var u2f = require('u2f-api');
var TEMPLATE = ''
+ '<div>'
+ ' {{#unless loading}}'
+ ' <div>'
+ ' {{#unless devices.length}}'
+ ' <p>' + t('twofactor_u2f', 'No U2F devices configured. You are not using U2F as second factor at the moment.') + '</p>'
+ ' {{else}}'
+ ' <p>' + t('twofactor_u2f', 'The following devices are configured for U2F second-factor authentication:') + '</p>'
+ ' {{/unless}}'
+ ' {{#each devices}}'
+ ' <div class="u2f-device" data-u2f-id="{{id}}">'
+ ' <span class="icon-u2f-device"></span>'
+ ' <span>{{#if name}}{{name}}{{else}}' + t('twofactor_u2f', 'Unnamed device') + '{{/if}}</span>'
+ ' <span class="more">'
+ ' <a class="icon icon-more"></a>'
+ ' <div class="popovermenu">'
+ ' <ul>'
+ ' <li class="remove-device">'
+ ' <a><span class="icon-delete"></span><span>' + t('twofactor_u2f', 'Remove') + '</span></a>'
+ ' </li>'
+ ' </ul>'
+ ' </div>'
+ ' </span>'
+ ' </div>'
+ ' {{/each}}'
+ ' </div>'
+ ' <input id="u2f-device-name" type="text" placeholder="Name your device">'
+ ' <button id="add-u2f-device">' + t('twofactor_u2f', 'Add U2F device') + '</button><br>'
+ ' <p><em>' + t('twofactor_u2f', 'You can add as many devices as you like. It is recommended to give each device a distinct name.') + '</em></p>'
+ ' {{else}}'
+ ' <span class="icon-loading-small u2f-loading"></span>'
+ ' <span>' + t('twofactor_u2f', 'Adding a new device …') + '</span>'
+ ' {{/unless}}'
+ '</div>';
/**
* @class
*/
var SettingsView = Backbone.View.extend(/** @lends Backbone.View */ {
/**
* @type {function|undefined}
*/
_template: undefined,
/**
* @type {boolean}
*/
_loading: false,
/**
* @type {Object[]}
*/
_devices: undefined,
/**
* @param {object} data
* @returns {string}
*/
template: function (data) {
if (!this._template) {
console.log('compiling u2f settings template');
this._template = Handlebars.compile(TEMPLATE);
}
return this._template(data);
},
events: {
'click #add-u2f-device': '_onAddU2FDevice',
'keydown #u2f-device-name': '_onInputKeyDown',
'click .u2f-device .remove-device': '_onRemoveDevice'
},
/**
* @returns {undefined}
*/
render: function () {
console.log('rendering u2f settings view');
this._checkHTTPS();
this._devices = _.sortBy(this._devices, function (device) {
// Underscore's stable sort requires a value for each item
return device.name || '';
});
this.$el.html(this.template({
loading: this._loading,
devices: this._devices
}));
_.each(this._devices, function (device) {
var $deviceEl = this.$('div[data-u2f-id="' + device.id + '"]');
OC.registerMenu($deviceEl.find('a.icon-more'), $deviceEl.find('.popovermenu'));
}, this);
console.log('u2f settings view rendered');
return this;
},
_checkHTTPS: function () {
if (document.location.protocol !== 'https:') {
console.error('u2f requires https');
$('#u2f-http-warning').show();
} else {
console.log('https connection detected');
}
},
/**
* @private
* @returns {Promise}
*/
_getServerState: function () {
var url = OC.generateUrl('/apps/twofactor_u2f/settings/state');
return Promise.resolve($.ajax(url, {
method: 'GET'
}));
},
/**
* @returns {Promise}
*/
load: function () {
return this._getServerState().then(function (data) {
this._devices = data.devices;
this.render();
}.bind(this), function () {
console.error('Could not load list of u2f devices');
OC.Notification.showTemporary('Could not load list of U2F devices.');
}).catch(console.error.bind(this));
},
/**
* @private
* @returns {Promise}
*/
_onAddU2FDevice: function () {
if (this._loading) {
console.log('view is loading, ignoring `_onAddU2FDevice` call');
// Ignore event
return Promise.resolve();
}
return this._onRegister();
},
/**
* @private
* @param {Event} e
*/
_onInputKeyDown: function (e) {
if (e.which === 13) {
return this._onAddU2FDevice();
}
return Promise.resolve();
},
/**
* @private
* @returns {Promise}
*/
_onRemoveDevice: function (e) {
var deviceId = $(e.target).closest('.u2f-device').data('u2f-id');
var device = _.find(this._devices, function (device) {
return device.id === deviceId;
}, this);
if (!device) {
console.error('Cannot remove u2f device: unkown');
return Promise.reject('Unknown u2f device');
}
return this._requirePasswordConfirmation().then(function () {
// Remove visually
this._devices.splice(this._devices.indexOf(device), 1);
this.render();
// Remove on server
return this._removeOnServer(device);
}.bind(this)).catch(function (e) {
this._devices.push(device);
this.render();
console.error(e);
OC.Notification.showTemporary(t('twofactor_u2f', 'Could not remove your U2F device'));
throw new Error('Could not remove u2f device on server');
}.bind(this)).catch(function (e) {
console.error('Unexpected error while removing the u2f device', e);
throw e;
});
},
/**
* @private
* @returns {Promise}
*/
_onRegister: function () {
console.log('starting u2f registration');
var name = this.$('#u2f-device-name').val();
// Show loading feedback
this._loading = true;
this.render();
var self = this;
return this._requirePasswordConfirmation()
.then(this._startRegistrationOnServer)
.then(function (data) {
console.log('got server u2f registration data');
return self._registerU2fDevice(data.req, data.sigs);
})
.then(function (data) {
console.log('finished client-side u2f registration');
data.name = name;
return self._finishRegisterOnServer(data);
})
.then(function (newDevice) {
console.log('finished server-side u2f registration');
self._devices.push(newDevice);
})
.catch(function (e) {
console.error(e);
OC.Notification.showTemporary('Error while registering u2f device: ' + e.message);
})
.then(function () {
console.log('finished u2f registration');
self._loading = false;
self.render();
});
},
/**
* @private
* @returns {Promise}
*/
_startRegistrationOnServer: function () {
var url = OC.generateUrl('/apps/twofactor_u2f/settings/startregister');
return Promise.resolve($.ajax(url, {
method: 'POST'
})).catch(function (e) {
console.error(e);
throw new Error(t('twofactor_u2f', 'Server error while trying to add U2F device'));
});
},
/**
* @private
* @returns {Promise}
*/
_requirePasswordConfirmation: function () {
if (!OC.PasswordConfirmation.requiresPasswordConfirmation()) {
return Promise.resolve();
}
return new Promise(function (resolve) {
OC.PasswordConfirmation.requirePasswordConfirmation(resolve);
});
},
/**
* @private
* @param {object} req
* @param {object} sigs
* @returns {Promise}
*/
_registerU2fDevice: function (req, sigs) {
$('.utf-register-info').slideDown();
return u2f.register([req], sigs).then(function (data) {
return new Promise(function (resolve, reject) {
if (data.errorCode && data.errorCode !== 0) {
$('.utf-register-info').slideUp();
// https://developers.yubico.com/U2F/Libraries/Client_error_codes.html
switch (data.errorCode) {
case 4:
// 4 - DEVICE_INELIGIBLE
reject(new Error(t('twofactor_u2f', 'U2F device is already registered (error code {errorCode})', {
errorCode: data.errorCode
})));
break;
case 5:
// 5 - TIMEOUT
reject(new Error(t('twofactor_u2f', 'U2F device registration timeout reached (error code {errorCode})', {
errorCode: data.errorCode
})));
break;
default:
// 1 - OTHER_ERROR
// 2 - BAD_REQUEST
// 3 - CONFIGURATION_UNSUPPORTED
reject(new Error(t('twofactor_u2f', 'U2F device registration failed (error code {errorCode})', {
errorCode: data.errorCode
})));
}
return;
}
resolve(data);
});
});
},
/**
* @private
* @param {Object} data
* @param {string} data.name device name (specified by the user)
* @returns {Promise}
*/
_finishRegisterOnServer: function (data) {
var url = OC.generateUrl('/apps/twofactor_u2f/settings/finishregister');
return Promise.resolve($.ajax(url, {
method: 'POST',
data: data
})).catch(function (e) {
console.error(e);
throw new Error(t('twofactor_u2f', 'Server error while trying to complete U2F device registration'));
}).then(function (data) {
$('.utf-register-info').slideUp();
return data;
});
},
/**
* @private
* @param {Object} device
* @returns {Promise}
*/
_removeOnServer: function (device) {
var url = OC.generateUrl('/apps/twofactor_u2f/settings/remove');
return Promise.resolve($.ajax(url, {
method: 'POST',
data: {
id: device.id
}
})).catch(function (e) {
console.error(e);
throw e;
});
}
});
return SettingsView;
});

Просмотреть файл

@ -1,4 +0,0 @@
(function (global) {
global.u2f = {};
})(window);

Просмотреть файл

@ -1,138 +0,0 @@
/* global expect, Promise, spyOn */
define(['settingsview'], function(SettingsView) {
describe('Settings view', function() {
var view;
beforeEach(function() {
jasmine.Ajax.install();
view = new SettingsView();
});
afterEach(function() {
jasmine.Ajax.uninstall();
});
it('fetches state from the server', function() {
spyOn(OC.Notification, 'showTemporary');
view.load();
expect(jasmine.Ajax.requests.mostRecent().url).toBe('/apps/twofactor_u2f/settings/state');
jasmine.Ajax.requests.mostRecent().respondWith({
status: 200,
contentType: 'application/json',
responseText: JSON.stringify({
enabled: false
})
});
expect(OC.Notification.showTemporary).not.toHaveBeenCalled();
});
it('shows a notification if the state cannot be loaded from the server', function(done) {
spyOn(OC.Notification, 'showTemporary');
var loading = view.load();
expect(jasmine.Ajax.requests.mostRecent().url).toBe('/apps/twofactor_u2f/settings/state');
jasmine.Ajax.requests.mostRecent().respondWith({
status: 500,
contentType: 'application/json'
});
loading.then(function() {
expect(OC.Notification.showTemporary).toHaveBeenCalled();
done();
}).catch(function(e) {
done.fail(e);
});
});
it('asks for password confirmation when the user enables u2f', function(done) {
spyOn(OC.Notification, 'showTemporary');
spyOn(view, '_getServerState').and.returnValue(Promise.resolve({
devices: []
}));
spyOn(view, '_requirePasswordConfirmation').and.returnValue(Promise.reject({
message: 'Wrong password'
}));
view.load().then(function() {
view._onAddU2FDevice().then(function() {
expect(OC.Notification.showTemporary).toHaveBeenCalledWith('Error while registering u2f device: Wrong password');
done();
}).catch(function(e) {
done.fail(e);
});
}, function(e) {
done.fail(e);
});
});
it('lets the user register a new device', function(done) {
spyOn(OC.Notification, 'showTemporary');
spyOn(view, '_getServerState').and.returnValue(Promise.resolve({
devices: []
}));
spyOn(view, '_registerU2fDevice').and.returnValue(Promise.resolve({}));
spyOn(view, '_requirePasswordConfirmation').and.returnValue(Promise.resolve());
jasmine.Ajax.stubRequest('/apps/twofactor_u2f/settings/startregister').andReturn({
contentType: 'application/json',
responseText: JSON.stringify({
req: 'reqdata',
sigs: 'sigsdata'
})
});
jasmine.Ajax.stubRequest('/apps/twofactor_u2f/settings/finishregister').andReturn({
contentType: 'application/json',
responseText: JSON.stringify({})
});
view.load().then(function() {
expect(view._getServerState).toHaveBeenCalled();
return view._onAddU2FDevice().then(function() {
expect(view._registerU2fDevice).toHaveBeenCalled();
expect(OC.Notification.showTemporary).not.toHaveBeenCalled();
done();
});
}).catch(function(e) {
done.fail(e);
});
});
it('lets the user remove a device', function(done) {
spyOn(OC.Notification, 'showTemporary');
spyOn(view, '_getServerState').and.returnValue(Promise.resolve({
devices: [
{
id: 13,
name: 'Yolokey'
}
]
}));
spyOn(view, '_requirePasswordConfirmation').and.returnValue(Promise.resolve());
jasmine.Ajax.stubRequest('/apps/twofactor_u2f/settings/remove').andReturn({
contentType: 'application/json',
responseText: JSON.stringify({})
});
view.load().then(function() {
expect(view._getServerState).toHaveBeenCalled();
var fakeEvent = {
target: view.$('.remove-device')
};
return view._onRemoveDevice(fakeEvent).then(function() {
expect(OC.Notification.showTemporary).not.toHaveBeenCalled();
done();
});
}).catch(function(e) {
done.fail(e);
});
});
});
});

Просмотреть файл

@ -1,23 +0,0 @@
(function (global, Backbone) {
// Global variable stubs
global.OC = {};
global.OC.generateUrl = function (url) {
return url;
};
global.OC.Backbone = Backbone;
global.OC.Notification = {};
global.OC.Notification.showTemporary = function (txt) {
console.error('temporary notification', txt)
};
global.OC.registerMenu = function () {
};
global.OCA = {};
global.t = function (app, txt) {
if (app !== 'twofactor_u2f') {
throw Error('wrong app used for translatoin');
}
return txt;
};
})(window, Backbone);

Просмотреть файл

@ -1,29 +0,0 @@
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: {
challenge: './js/init_challenge.js',
settings: './js/init_settings.js'
},
output: {
filename: '[name].js',
path: __dirname + '/build'
},
resolve: {
modules: [path.resolve(__dirname), 'node_modules'],
alias: {
'handlebars': 'handlebars/runtime.js'
}
},
module: {
rules: [
{
test: /\.html$/, loader: "handlebars-loader", query: {
extensions: '.html',
helperDirs: __dirname + '/templatehelpers'
}
}
]
}
};

Просмотреть файл

@ -1,7 +0,0 @@
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.config.js');
module.exports = merge(baseConfig, {
devtool: 'inline-source-map',
mode: 'development'
});

Просмотреть файл

@ -1,13 +0,0 @@
var webpack = require('webpack');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.config.js');
module.exports = merge(baseConfig, {
plugins: [
new webpack.optimize.AggressiveMergingPlugin(), // Merge chunks
new webpack.LoaderOptionsPlugin({
minimize: true
})
],
mode: 'production'
});

Просмотреть файл

@ -1,77 +0,0 @@
// Karma configuration
var webpackConfig = require('./js/webpack.dev.config.js');
webpackConfig.entry = {
challenge: './js/challenge.js',
settings: './js/settingsview.js'
};
webpackConfig.module.rules.push({
test: /\.js$/,
exclude: /^init_/
});
module.exports = function(config) {
config.set({
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['jasmine-ajax', 'jasmine'],
files: [
{pattern: 'node_modules/es6-promise/dist/es6-promise.auto.js', included: true},
{pattern: 'node_modules/jquery/dist/jquery.js', included: true},
{pattern: 'node_modules/handlebars/dist/handlebars.js', included: true},
{pattern: 'node_modules/underscore/underscore.js', included: true},
{pattern: 'node_modules/backbone/backbone.js', included: true},
{pattern: 'js/tests/test-main.js', included: true},
// all files ending in "_test"
{pattern: 'js/tests/spec/*_spec.js', watched: false},
{pattern: 'js/build/*.js', included: false}
],
// list of files to exclude
exclude: [
'js/webpack.*.js',
'js/init*.js'
],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
'js/**[!vendor]/*[!spec].js': ['coverage', 'sourcemap'],
// add webpack as preprocessor
'js/tests/*_spec.js': ['webpack', 'sourcemap'],
'js/tests/**/*_spec.js': ['webpack', 'sourcemap']
},
webpackMiddleware: {
// webpack-dev-middleware configuration
// i. e.
stats: 'errors-only'
},
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress', 'coverage'],
coverageReporter: {
type: 'lcov',
dir: 'coverage/'
},
// web server port
port: 9876,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: true,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['PhantomJS'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: false,
webpack: webpackConfig
});
};

Просмотреть файл

@ -11,8 +11,6 @@ exclude = [
"composer.json",
"composer.lock",
"composer.phar",
"js/tests",
"js/*.js",
"karma.conf.js",
"krankerl.toml",
"l10n/no-php",
@ -22,6 +20,7 @@ exclude = [
"package.json",
"package-lock.json",
"screenshots",
"src",
"tests",
"vendor/bin",
]

Просмотреть файл

@ -84,7 +84,7 @@ class U2FProvider implements IProvider, IProvidesIcons, IProvidesPersonalSetting
}
public function getPersonalSettings(IUser $user): IPersonalProviderSettings {
return new Personal();
return new Personal($this->manager->getDevices($user));
}
public function getLightIcon(): String {

Просмотреть файл

@ -23,12 +23,22 @@ declare(strict_types=1);
namespace OCA\TwoFactorU2F\Settings;
use function json_encode;
use OCP\Authentication\TwoFactorAuth\IPersonalProviderSettings;
use OCP\Template;
class Personal implements IPersonalProviderSettings {
/** @var array */
private $devices;
public function __construct(array $devices) {
$this->devices = $devices;
}
public function getBody(): Template {
return new Template('twofactor_u2f', 'personal');
$template = new Template('twofactor_u2f', 'personal');
$template->assign('state', json_encode($this->devices));
return $template;
}
}

5221
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -1,38 +1,52 @@
{
"name": "twofactor_u2f",
"version": "1.6.1",
"description": "![Downloads](https://img.shields.io/github/downloads/nextcloud/twofactor_u2f/total.svg) [![Build Status](https://api.travis-ci.org/nextcloud/twofactor_u2f.svg?branch=master)](https://travis-ci.org/nextcloud/twofactor_u2f)",
"main": "js/challenge.js",
"directories": {
"test": "tests"
},
"description": "U2F second factor provider for Nextcloud",
"private": true,
"dependencies": {
"es6-promise": "^4.2.5",
"handlebars": "^4.0.12",
"u2f-api": "^1.0.6"
"nextcloud-axios": "^0.1.2",
"nextcloud-password-confirmation": "^0.3.1",
"nextcloud-server": "^0.15.7",
"nextcloud-vue": "^0.2.0",
"u2f-api": "^1.0.6",
"vue": "^2.5.17",
"vue-click-outside": "^1.0.7",
"vuex": "^3.0.1"
},
"devDependencies": {
"backbone": "^1.3.3",
"jasmine": "^3.2.0",
"jasmine-ajax": "^3.4.0",
"jasmine-core": "^3.2.1",
"jquery": "^3.3.1",
"karma": "^3.0.0",
"karma-coverage": "^1.1.2",
"karma-jasmine": "^1.1.2",
"karma-jasmine-ajax": "^0.1.13",
"karma-phantomjs-launcher": "^1.0.4",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^3.0.5",
"underscore": "^1.9.1",
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"@vue/babel-preset-app": "^3.0.5",
"@vue/test-utils": "^1.0.0-beta.25",
"babel-loader": "^8.0.4",
"chai": "^4.2.0",
"css-loader": "^1.0.0",
"file-loader": "^2.0.0",
"jsdom": "^12.2.0",
"jsdom-global": "^3.0.2",
"mocha": "^5.2.0",
"mocha-webpack": "^2.0.0-beta.0",
"sinon": "^7.0.0",
"url-loader": "^1.1.2",
"vue-loader": "^15.4.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-merge": "^4.1.4"
"webpack-merge": "^4.1.4",
"webpack-node-externals": "^1.7.2"
},
"scripts": {
"build": "./node_modules/webpack-cli/bin/cli.js --config js/webpack.prod.config.js",
"dev": " ./node_modules/webpack-cli/bin/cli.js --config js/webpack.dev.config.js --watch",
"test": "./node_modules/karma/bin/karma start karma.conf.js --single-run"
"build": "webpack --progress --config src/webpack.prod.js",
"dev": "webpack --progress --watch --config src/webpack.dev.js",
"test": "mocha-webpack --webpack-config src/webpack.test.js --require src/tests/setup.js src/tests/**/*.spec.js",
"test:watch": "mocha-webpack -w --webpack-config src/webpack.test.js --require src/tests/setup.js src/tests/**/*.spec.js"
},
"browserslist": [
"last 2 versions",
"ie >= 11"
],
"jshintConfig": {
"esversion": 6
},
"repository": {
"type": "git",

Просмотреть файл

@ -0,0 +1,193 @@
<!--
- @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- 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
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div v-if="step === RegistrationSteps.READY">
<button
v-on:click="start">{{ t('twofactor_u2f', 'Add U2F device') }}
</button>
</div>
<div v-else-if="step === RegistrationSteps.U2F_REGISTRATION"
class="new-u2f-device">
<span class="icon-loading-small u2f-loading"></span>
{{ t('twofactor_u2f', 'Please plug in your U2F device and press the device button to authorize.') }}
</div>
<div v-else-if="step === RegistrationSteps.NAMING"
class="new-u2f-device">
<span class="icon-loading-small u2f-loading"></span>
<input type="text"
:placeholder="t('twofactor_u2f', 'Name your device')"
v-model="name">
<button v-on:click="submit">{{ t('twofactor_u2f', 'Add') }}</button>
</div>
<div v-else-if="step === RegistrationSteps.PERSIST"
class="new-u2f-device">
<span class="icon-loading-small u2f-loading"></span>
{{ t('twofactor_u2f', 'Adding your device …') }}
</div>
<div v-else>
Invalid registration step. This should not have happened.
</div>
</template>
<script>
import confirmPassword from 'nextcloud-password-confirmation'
import u2f from 'u2f-api'
import {
startRegistration,
finishRegistration
} from '../services/RegistrationService'
const logAndPass = (text) => (data) => {
console.debug(text)
return data
}
const RegistrationSteps = Object.freeze({
READY: 1,
U2F_REGISTRATION: 2,
NAMING: 3,
PERSIST: 4,
})
export default {
name: 'AddDeviceDialog',
props: {
httpWarning: Boolean
},
data () {
return {
name: '',
registrationData: {},
RegistrationSteps,
step: RegistrationSteps.READY,
}
},
methods: {
start () {
this.step = RegistrationSteps.U2F_REGISTRATION
return confirmPassword()
.then(this.getRegistrationData)
.then(this.register)
.then(() => this.step = RegistrationSteps.NAMING)
.catch(console.error.bind(this))
},
getRegistrationData () {
return startRegistration()
.catch(err => {
console.error('Error getting u2f registration data from server', err)
throw new Error(t('twofactor_u2f', 'Server error while trying to add U2F device'))
})
},
register ({req, sigs}) {
console.debug('starting u2f registration')
return u2f.register([req], sigs)
.then(data => {
console.debug('u2f registration was successful', data)
if (data.errorCode && data.errorCode !== 0) {
return this.rejectRegistration(data)
}
this.registrationData = data
})
},
rejectRegistration (data) {
// https://developers.yubico.com/U2F/Libraries/Client_error_codes.html
switch (data.errorCode) {
case 4:
// 4 - DEVICE_INELIGIBLE
Promise.reject(new Error(t('twofactor_u2f', 'U2F device is already registered (error code {errorCode})', {
errorCode: data.errorCode
})));
break;
case 5:
// 5 - TIMEOUT
Promise.reject(new Error(t('twofactor_u2f', 'U2F device registration timeout reached (error code {errorCode})', {
errorCode: data.errorCode
})));
break;
default:
// 1 - OTHER_ERROR
// 2 - BAD_REQUEST
// 3 - CONFIGURATION_UNSUPPORTED
Promise.reject(new Error(t('twofactor_u2f', 'U2F device registration failed (error code {errorCode})', {
errorCode: data.errorCode
})));
}
},
submit () {
this.step = RegistrationSteps.PERSIST
return confirmPassword()
.then(logAndPass('confirmed password'))
.then(this.saveRegistrationData)
.then(logAndPass('registration data saved'))
.then(() => this.reset())
.then(logAndPass('app reset'))
.catch(console.error.bind(this))
},
saveRegistrationData () {
const data = this.registrationData
data.name = this.name
return finishRegistration(data)
.then(device => this.$store.commit('addDevice', device))
.then(logAndPass('new device added to store'))
.catch(err => {
console.error('Error persisting u2f registration', err)
throw new Error(t('twofactor_u2f', 'Server error while trying to complete U2F device registration'))
})
},
reset () {
this.name = ''
this.registrationData = {}
this.step = RegistrationSteps.READY
}
}
}
</script>
<style scoped>
.u2f-loading {
display: inline-block;
vertical-align: sub;
margin-left: 2px;
margin-right: 2px;
}
.new-u2f-device {
line-height: 300%;
}
</style>

112
src/components/Device.vue Normal file
Просмотреть файл

@ -0,0 +1,112 @@
<!--
- @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- 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
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="u2f-device" :data-u2f-id="id">
<span class="icon-u2f-device"></span>
{{name || t('twofactor_u2f', 'Unnamed device') }}
<span class="more">
<a class="icon icon-more"
v-on:click.stop="togglePopover"></a>
<div class="popovermenu"
:class="{open: showPopover}"
v-click-outside="hidePopover">
<PopoverMenu :menu="menu"/>
</div>
</span>
</div>
</template>
<script>
import ClickOutside from 'vue-click-outside'
import confirmPassword from 'nextcloud-password-confirmation'
import {PopoverMenu} from 'nextcloud-vue'
export default {
name: 'Device',
props: {
id: Number,
name: String,
},
components: {
PopoverMenu
},
directives: {
ClickOutside
},
data () {
return {
showPopover: false,
menu: [
{
text: t('twofactor_u2f', 'Remove'),
icon: 'icon-delete',
action: () => {
confirmPassword()
.then(() => this.$store.dispatch('removeDevice', this.id))
}
}
]
}
},
methods: {
togglePopover () {
this.showPopover = !this.showPopover
},
hidePopover () {
this.showPopover = false
}
}
}
</script>
<style scoped>
.u2f-device {
line-height: 300%;
display: flex;
}
.u2f-device .more {
position: relative;
}
.u2f-device .more .icon-more {
display: inline-block;
width: 16px;
height: 16px;
padding-left: 20px;
vertical-align: middle;
opacity: .7;
}
.u2f-device .popovermenu {
right: -12px;
top: 42px;
}
.icon-u2f-device {
display: inline-block;
background-size: 100%;
padding: 3px;
margin: 3px;
}
</style>

Просмотреть файл

@ -0,0 +1,73 @@
<!--
- @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- 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
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div>
<p v-if="devices.length === 0">{{ t('twofactor_u2f', 'No U2F devices configured. You are not using U2F as second factor at the moment.') }}</p>
<p v-else>{{ t('twofactor_u2f', 'The following devices are configured for U2F second-factor authentication:') }}</p>
<Device v-for="device in devices"
:key="device.id"
:id="device.id"
:name="device.name" />
<AddDeviceDialog :httpWarning="httpWarning" />
<p v-if="notSupported">
{{ t('twofactor_u2f', 'Your browser does not support u2f.') }}
{{ t('twofactor_u2f', 'Chrome is the only browser that supports U2F devices. You need to install the "U2F Support Add-on" on Firefox to use U2F.') }}
</p>
<p v-if="httpWarning"
id="u2f-http-warning">
{{ t('twofactor_u2f', 'You are accessing this site via an insecure connection. Browsers might therefore refuse the U2F authentication.') }}
</p>
</div>
</template>
<script>
import u2f from 'u2f-api'
import AddDeviceDialog from './AddDeviceDialog'
import Device from './Device'
export default {
name: 'PersonalSettings',
props: {
httpWarning: Boolean
},
components: {
AddDeviceDialog,
Device
},
data() {
return {
notSupported: !u2f.isSupported()
}
},
computed: {
devices() {
return this.$store.state.devices
}
}
}
</script>
<style scoped>
</style>

46
src/main-settings.js Normal file
Просмотреть файл

@ -0,0 +1,46 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import store from './store'
import Vue from 'vue'
import Nextcloud from './mixins/Nextcloud'
Vue.mixin(Nextcloud)
const initialStateElement = document.getElementById('twofactor-u2f-initial-state')
if (initialStateElement) {
const devices = JSON.parse(initialStateElement.value)
devices.sort((d1, d2) => d1.name.localeCompare(d2.name))
store.replaceState({
devices
})
}
import PersonalSettings from './components/PersonalSettings'
const View = Vue.extend(PersonalSettings)
new View({
propsData: {
httpWarning: document.location.protocol !== 'https:',
},
store,
}).$mount('#twofactor-u2f-settings')

26
src/mixins/Nextcloud.js Normal file
Просмотреть файл

@ -0,0 +1,26 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export default {
methods: {
t,
}
}

Просмотреть файл

@ -0,0 +1,47 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Axios from 'nextcloud-axios'
import {generateUrl} from 'nextcloud-server/dist/router'
export function startRegistration () {
const url = generateUrl('/apps/twofactor_u2f/settings/startregister')
return Axios.post(url)
.then(resp => resp.data)
}
export function finishRegistration (data) {
const url = generateUrl('/apps/twofactor_u2f/settings/finishregister')
return Axios.post(url, data)
.then(resp => resp.data)
}
export function removeRegistration (id) {
const url = generateUrl('/apps/twofactor_u2f/settings/remove')
const data = {
id
}
return Axios.post(url, data)
.then(resp => resp.data)
}

45
src/store.js Normal file
Просмотреть файл

@ -0,0 +1,45 @@
import Vue from 'vue'
import Vuex from 'vuex'
import {removeRegistration} from './services/RegistrationService'
Vue.use(Vuex)
export const mutations = {
addDevice (state, device) {
state.devices.push(device)
state.devices.sort((d1, d2) => d1.name.localeCompare(d2.name))
},
removeDevice (state, id) {
state.devices = state.devices.filter(device => device.id !== id)
}
}
export const actions = {
removeDevice ({state, commit}, id) {
const device = state.devices[id]
commit('removeDevice', id)
removeRegistration(id)
.catch(err => {
// Rollback
commit('addDevice', device)
throw err
})
}
}
export const getters = {}
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
devices: []
},
getters,
mutations,
actions
})

Просмотреть файл

@ -0,0 +1,98 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {shallowMount, createLocalVue} from '@vue/test-utils'
import Vuex from 'vuex'
import Nextcloud from '../../mixins/Nextcloud'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.mixin(Nextcloud)
import Device from '../../components/Device'
describe('Device component', () => {
var actions
var store
beforeEach(() => {
actions = {}
store = new Vuex.Store({
state: {
devices: []
},
actions
})
})
it('renders devices without a name', () => {
store.state.devices.push({
id: 1,
name: undefined
})
const device = shallowMount(Device, {
store,
localVue
})
expect(device.text()).to.have.string('Unnamed device')
})
it('has a closed popover menu by default', () => {
const device = shallowMount(Device, {
store,
localVue
})
expect(device.contains('.popovermenu.open')).to.be.false
})
it('opens popover menu on click', done => {
const device = shallowMount(Device, {
store,
localVue
})
device.find('.icon-more').trigger('click')
localVue.nextTick(() => {
expect(device.vm.showPopover).to.be.true
done()
})
})
it('closed popover menu on second click', done => {
const device = shallowMount(Device, {
store,
localVue
})
device.vm.showPopover = true
device.find('.icon-more').trigger('click')
localVue.nextTick(() => {
expect(device.vm.showPopover).to.be.false
done()
})
})
})

Просмотреть файл

@ -0,0 +1,81 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {shallowMount, createLocalVue} from '@vue/test-utils'
import Vuex from 'vuex'
import Nextcloud from '../../mixins/Nextcloud'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.mixin(Nextcloud)
import PersonalSettings from '../../components/PersonalSettings'
describe('Device component', () => {
var actions
var store
beforeEach(() => {
actions = {}
store = new Vuex.Store({
state: {
devices: []
},
actions
})
})
it('shows text if no devices are configured', () => {
const settings = shallowMount(PersonalSettings, {
store,
localVue
})
expect(settings.text()).to.contain('No U2F devices configured')
})
it('shows no info text if devices are configured', () => {
const settings = shallowMount(PersonalSettings, {
store,
localVue
})
store.state.devices.push({
id: 1,
name: 'a'
})
expect(settings.text()).to.not.contain('No U2F devices configured')
})
it('shows a HTTP warning', () => {
const settings = shallowMount(PersonalSettings, {
store,
localVue,
propsData: {
httpWarning: true
}
})
expect(settings.text()).to.contain('You are accessing this site via an')
})
})

34
src/tests/setup.js Normal file
Просмотреть файл

@ -0,0 +1,34 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
require('jsdom-global')()
const t = (app, str) => str
require('vue').mixin({
methods: {
t
}
})
global.expect = require('chai').expect
global.OC = {}
global.t = t

51
src/webpack.common.js Normal file
Просмотреть файл

@ -0,0 +1,51 @@
const path = require('path');
const { VueLoaderPlugin } = require('vue-loader');
module.exports = {
entry: path.join(__dirname, 'main-settings.js'),
output: {
path: path.resolve(__dirname, '../js'),
publicPath: '/js/',
filename: 'settings.js'
},
module: {
rules: [
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader']
},
{
test: /\.scss$/,
use: ['vue-style-loader', 'css-loader', 'sass-loader']
},
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
},
{
test: /\.(svg)$/i,
use: [
{
loader: 'url-loader'
}
]
}
]
},
plugins: [new VueLoaderPlugin()],
resolve: {
extensions: ['*', '.js', '.vue']
}
};

7
src/webpack.dev.js Normal file
Просмотреть файл

@ -0,0 +1,7 @@
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
devtool: 'source-map',
})

7
src/webpack.prod.js Normal file
Просмотреть файл

@ -0,0 +1,7 @@
const merge = require('webpack-merge')
const common = require('./webpack.common.js')
module.exports = merge(common, {
mode: 'production',
devtool: 'source-map'
})

30
src/webpack.test.js Normal file
Просмотреть файл

@ -0,0 +1,30 @@
/*
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
const nodeExternals = require('webpack-node-externals')
module.exports = merge(common, {
mode: 'development',
devtool: 'inline-cheap-module-source-map',
externals: [nodeExternals()]
})

Просмотреть файл

@ -1,26 +1,8 @@
<?php
script('twofactor_u2f', 'build/settings');
script('twofactor_u2f', 'settings');
style('twofactor_u2f', 'style');
?>
<div id="twofactor-u2f-settings">
<span class="icon-loading-small u2f-loading"></span>
<span><?php p($l->t('Loading your devices …')); ?></span>
<input type="hidden" id="twofactor-u2f-initial-state" value="<?php p($_['state']); ?>">
<p class="utf-register-info" style="display: none;">
<?php p($l->t('Please plug in your U2F device and press the device button to authorize.')) ?>
</p>
<p class="utf-register-info" style="display: none;">
<em>
<?php p($l->t('Chrome is the only browser that supports U2F devices. You need to install the "U2F Support Add-on" on Firefox to use U2F.')) ?>
<p id="u2f-http-warning"
style="display: none">
<?php p($l->t('You are accessing this site via an insecure connection. Browsers might therefore refuse the U2F authentication.')) ?>
</p>
</em>
</p>
<p class="utf-register-success" style="display: none;">
<span class="icon-checkmark-color"
style="width: 16px;"></span><?php p($l->t('U2F device successfully registered.')) ?>
</p>
</div>
<div id="twofactor-u2f-settings"></div>