[MM-22648] basic auth external sites (#1295)

* fix not using babel

* wip

* added tests, moved to map, polifill-like to convert between object and map

* basic structure setup

* working, found new bug

* change buttons

* fix login issue

* remove logging code

* address CR comments

* remove custom function in favor of airbnb shim

* fix linting

* fix PM requested changes

* [MM-25323] fix basic auth cancelling

* fix crash when multiple request were made

* address UX comments, added external link for user
convenience
This commit is contained in:
Guillermo Vayá
2020-05-28 10:53:57 +02:00
committed by GitHub
parent 53f1f40774
commit 36c6106cad
15 changed files with 721 additions and 51 deletions

View File

@@ -8,23 +8,36 @@ import {Button, Col, ControlLabel, Form, FormGroup, FormControl, Modal} from 're
export default class LoginModal extends React.Component {
constructor(props) {
super(props);
this.username = '';
this.password = '';
this.state = {
username: '',
password: '',
};
}
handleSubmit = (event) => {
event.preventDefault();
this.props.onLogin(this.props.request, this.username, this.password);
this.username = '';
this.password = '';
this.props.onLogin(this.props.request, this.state.username, this.state.password);
this.setState({
username: '',
password: '',
});
}
handleCancel = (event) => {
event.preventDefault();
this.props.onCancel(this.props.request);
this.setState({
username: '',
password: '',
});
}
setUsername = (e) => {
this.username = e.target.value;
this.setState({username: e.target.value});
}
setPassword = (e) => {
this.password = e.target.value;
this.setState({password: e.target.value});
}
render() {
@@ -60,6 +73,7 @@ export default class LoginModal extends React.Component {
type='text'
placeholder='User Name'
onChange={this.setUsername}
value={this.state.username}
onClick={(e) => {
e.stopPropagation();
}}
@@ -76,6 +90,7 @@ export default class LoginModal extends React.Component {
type='password'
placeholder='Password'
onChange={this.setPassword}
value={this.state.password}
onClick={(e) => {
e.stopPropagation();
}}
@@ -90,7 +105,7 @@ export default class LoginModal extends React.Component {
bsStyle='primary'
>{'Login'}</Button>
{ ' ' }
<Button onClick={this.props.onCancel}>{'Cancel'}</Button>
<Button onClick={this.handleCancel}>{'Cancel'}</Button>
</div>
</Col>
</FormGroup>

View File

@@ -29,6 +29,7 @@ import HoveringURL from './HoveringURL.jsx';
import Finder from './Finder.jsx';
import NewTeamModal from './NewTeamModal.jsx';
import SelectCertificateModal from './SelectCertificateModal.jsx';
import PermissionModal from './PermissionModal.jsx';
import ExtraBar from './ExtraBar.jsx';
export default class MainPage extends React.Component {
@@ -126,17 +127,7 @@ export default class MainPage extends React.Component {
}
ipcRenderer.on('login-request', (event, request, authInfo) => {
self.setState({
loginRequired: true,
});
const loginQueue = self.state.loginQueue;
loginQueue.push({
request,
authInfo,
});
self.setState({
loginQueue,
});
this.loginRequest(event, request, authInfo);
});
ipcRenderer.on('select-user-certificate', (_, origin, certificateList) => {
@@ -367,6 +358,18 @@ export default class MainPage extends React.Component {
}
}
loginRequest = (event, request, authInfo) => {
const loginQueue = this.state.loginQueue;
loginQueue.push({
request,
authInfo,
});
this.setState({
loginRequired: true,
loginQueue,
});
};
componentDidUpdate(prevProps, prevState) {
if (prevState.key !== this.state.key) { // i.e. When tab has been changed
this.refs[`mattermostView${this.state.key}`].focusOnWebView();
@@ -502,7 +505,9 @@ export default class MainPage extends React.Component {
this.setState({loginQueue});
}
handleLoginCancel = () => {
handleLoginCancel = (request) => {
ipcRenderer.send('login-cancel', request);
const loginQueue = this.state.loginQueue;
loginQueue.shift();
this.setState({loginQueue});
@@ -836,6 +841,7 @@ export default class MainPage extends React.Component {
onLogin={this.handleLogin}
onCancel={this.handleLoginCancel}
/>
<PermissionModal/>
<SelectCertificateModal
certificateRequests={this.state.certificateRequests}
onSelect={this.handleSelectCertificate}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable react/no-set-state */
import React from 'react';
import {Modal, Button} from 'react-bootstrap';
import {ipcRenderer, remote} from 'electron';
import {log} from 'electron-log';
import {BASIC_AUTH_PERMISSION, REQUEST_PERMISSION_CHANNEL, DENY_PERMISSION_CHANNEL, GRANT_PERMISSION_CHANNEL, PERMISSION_DESCRIPTION} from '../../common/permissions';
import Util from '../../utils/util';
import ExternalLink from './externalLink.jsx';
function getKey(request, permission) {
return `${request.url}:${permission}`;
}
export default class PermissionModal extends React.Component {
constructor(props) {
super(props);
this.state = {
tracker: new Map(), // permission request order is not preserved, but we won't have repetition of requests.
current: null,
};
ipcRenderer.on(REQUEST_PERMISSION_CHANNEL, (event, request, authInfo, permission) => {
switch (permission) {
case BASIC_AUTH_PERMISSION:
this.requestBasicAuthPermission(event, request, authInfo, permission);
break;
default:
console.warn(`Unknown permission request: ${permission}`);
ipcRenderer.send(DENY_PERMISSION_CHANNEL, request, permission);
}
});
}
requestBasicAuthPermission(event, request, authInfo, permission) {
const key = getKey(request, permission);
this.requestPermission(key, request.url, permission).then(() => {
ipcRenderer.send(GRANT_PERMISSION_CHANNEL, request.url, permission);
ipcRenderer.sendTo(remote.getCurrentWindow().webContents.id, 'login-request', request, authInfo);
this.loadNext();
}).catch((err) => {
ipcRenderer.send(DENY_PERMISSION_CHANNEL, request.url, permission, err.message);
ipcRenderer.send('login-cancel', request);
this.loadNext();
});
}
requestPermission(key, url, permission) {
return new Promise((resolve, reject) => {
const tracker = new Map(this.state.tracker);
const permissionRequest = {
grant: resolve,
deny: () => reject(new Error(`User denied ${permission} to ${url}`)),
url,
permission,
};
tracker.set(key, permissionRequest);
const current = this.state.current ? this.state.current : key;
this.setState({
tracker,
current,
});
});
}
getCurrentData() {
if (this.state.current) {
return this.state.tracker.get(this.state.current);
}
return {
grant: () => {
const err = new Error();
log.error(`There isn't any permission to grant access to.\n Stack trace:\n${err.stack}`);
},
deny: () => {
const err = new Error();
log.error(`There isn't any permission to deny access to.\n Stack trace:\n${err.stack}`);
}
};
}
loadNext() {
const tracker = new Map(this.state.tracker);
tracker.delete(this.state.current);
const nextKey = tracker.keys().next();
const current = nextKey.done ? null : nextKey.value;
this.setState({
tracker,
current,
});
}
getModalTitle() {
const {permission} = this.getCurrentData();
return `${PERMISSION_DESCRIPTION[permission]} Required`;
}
getModalBody() {
const {url, permission} = this.getCurrentData();
const originDisplay = url ? Util.getHost(url) : 'unknown origin';
const originLink = url ? originDisplay : '';
return (
<div>
<p>
{`A site that's not included in your Mattermost server configuration requires access for ${PERMISSION_DESCRIPTION[permission]}.`}
</p>
<p>
<span>{'This request originated from '}</span>
<ExternalLink href={originLink}>{`${originDisplay}`}</ExternalLink>
</p>
</div>
);
}
render() {
const {grant, deny} = this.getCurrentData();
return (
<Modal
bsClass='modal'
className='permission-modal'
show={Boolean(this.state.current)}
id='requestPermissionModal'
enforceFocus={true}
>
<Modal.Header>
<Modal.Title>{this.getModalTitle()}</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.getModalBody()}
</Modal.Body>
<Modal.Footer className={'remove-border'}>
<div>
<Button
onClick={deny}
>{'Cancel'}</Button>
<Button
bsStyle='primary'
onClick={grant}
>{'Accept'}</Button>
</div>
</Modal.Footer>
</Modal>
);
}
}
/* eslint-enable react/no-set-state */

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {ipcRenderer} from 'electron';
// this component is used to override some checks from the UI, leaving only to trust the protocol in case it wasn't http/s
// it is used the same as an `a` JSX tag
export default function ExternalLink(props) {
const click = (e) => {
e.preventDefault();
let parseUrl;
try {
parseUrl = new URL(props.href);
ipcRenderer.send('confirm-protocol', parseUrl.protocol, props.href);
} catch (err) {
console.error(`invalid url ${props.href} supplied to externallink: ${err}`);
}
};
const options = {
onClick: click,
...props,
};
return (
<a {...options}/>
);
}
ExternalLink.propTypes = {
href: PropTypes.string.isRequired,
};

View File

@@ -21,7 +21,7 @@
.PermissionRequestDialog-content .PermissionRequestDialog-content-buttons {
margin-bottom: 0;
text-align: right;
}
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-buttons > * {
margin-right: 7px;
@@ -42,3 +42,10 @@
.PermissionRequestDialog-content .PermissionRequestDialog-content-close:hover {
color: black;
}
.permission-modal .modal-dialog {
max-width: 580px;
}
.permission-modal .remove-border {
border: none;
}

16
src/common/permissions.js Normal file
View File

@@ -0,0 +1,16 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// channel types for managing permissions
export const REQUEST_PERMISSION_CHANNEL = 'request-permission';
export const GRANT_PERMISSION_CHANNEL = 'grant-permission';
export const DENY_PERMISSION_CHANNEL = 'deny-permission';
// Permission types that can be requested
export const BASIC_AUTH_PERMISSION = 'canBasicAuth';
// Permission descriptions
export const PERMISSION_DESCRIPTION = {
[BASIC_AUTH_PERMISSION]: 'Web Authentication',
};

View File

@@ -10,6 +10,7 @@ import electron, {nativeTheme} from 'electron';
import isDev from 'electron-is-dev';
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
import log from 'electron-log';
import 'airbnb-js-shims/target/es2015';
import {protocols} from '../electron-builder.json';
@@ -20,6 +21,7 @@ import upgradeAutoLaunch from './main/autoLaunch';
import RegistryConfig from './common/config/RegistryConfig';
import Config from './common/config';
import CertificateStore from './main/certificateStore';
import TrustedOriginsStore from './main/trustedOrigins';
import createMainWindow from './main/mainWindow';
import appMenu from './main/menus/app';
import trayMenu from './main/menus/tray';
@@ -32,6 +34,7 @@ import SpellChecker from './main/SpellChecker';
import UserActivityMonitor from './main/UserActivityMonitor';
import Utils from './utils/util';
import parseArgs from './main/ParseArgs';
import {REQUEST_PERMISSION_CHANNEL, GRANT_PERMISSION_CHANNEL, DENY_PERMISSION_CHANNEL, BASIC_AUTH_PERMISSION} from './common/permissions';
// pull out required electron components like this
// as not all components can be referenced before the app is ready
@@ -59,6 +62,7 @@ let mainWindow = null;
let popupWindow = null;
let hideOnStartup = null;
let certificateStore = null;
let trustedOriginsStore = null;
let spellChecker = null;
let deeplinkingUrl = null;
let scheme = null;
@@ -180,11 +184,13 @@ function initializeAppEventListeners() {
function initializeBeforeAppReady() {
certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json'));
trustedOriginsStore = new TrustedOriginsStore(path.resolve(app.getPath('userData'), 'trustedOrigins.json'));
trustedOriginsStore.load();
// prevent using a different working directory, which happens on windows running after installation.
const expectedPath = path.dirname(process.execPath);
if (process.cwd() !== expectedPath && !isDev) {
console.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`);
log.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`);
process.chdir(expectedPath);
}
@@ -219,6 +225,7 @@ function initializeBeforeAppReady() {
function initializeInterCommunicationEventListeners() {
ipcMain.on('reload-config', handleReloadConfig);
ipcMain.on('login-credentials', handleLoginCredentialsEvent);
ipcMain.on('login-cancel', handleCancelLoginEvent);
ipcMain.on('download-url', handleDownloadURLEvent);
ipcMain.on('notified', handleNotifiedEvent);
ipcMain.on('update-title', handleUpdateTitleEvent);
@@ -229,6 +236,8 @@ function initializeInterCommunicationEventListeners() {
ipcMain.on('get-spellchecker-locale', handleGetSpellcheckerLocaleEvent);
ipcMain.on('reply-on-spellchecker-is-ready', handleReplyOnSpellcheckerIsReadyEvent);
ipcMain.on('selected-client-certificate', handleSelectedCertificate);
ipcMain.on(GRANT_PERMISSION_CHANNEL, handlePermissionGranted);
ipcMain.on(DENY_PERMISSION_CHANNEL, handlePermissionDenied);
if (shouldShowTrayIcon()) {
ipcMain.on('update-unread', handleUpdateUnreadEvent);
@@ -334,16 +343,16 @@ function handleSelectCertificate(event, webContents, url, list, callback) {
function handleSelectedCertificate(event, server, cert) {
const callback = certificateRequests.get(server);
if (!callback) {
console.error(`there was no callback associated with: ${server}`);
log.error(`there was no callback associated with: ${server}`);
return;
}
if (typeof cert === 'undefined') {
console.log('user canceled certificate selection');
log.info('user canceled certificate selection');
} else {
try {
callback(cert);
} catch (e) {
console.log(`There was a problem using the selected certificate: ${e}`);
log.error(`There was a problem using the selected certificate: ${e}`);
}
}
}
@@ -363,7 +372,7 @@ function handleAppCertificateError(event, webContents, url, error, certificate,
// if we are already showing that error, don't add more dialogs
if (certificateErrorCallbacks.has(errorID)) {
console.log(`Ignoring already shown dialog for ${errorID}`);
log.warn(`Ignoring already shown dialog for ${errorID}`);
certificateErrorCallbacks.set(errorID, callback);
return;
}
@@ -417,11 +426,24 @@ function handleAppGPUProcessCrashed(event, killed) {
function handleAppLogin(event, webContents, request, authInfo, callback) {
event.preventDefault();
if (!isTrustedURL(request.url)) {
return;
const parsedURL = new URL(request.url);
const server = Utils.getServer(parsedURL, config.teams);
loginCallbackMap.set(request.url, typeof callback === 'undefined' ? null : callback); // if callback is undefined set it to null instead so we know we have set it up with no value
if (isTrustedURL(request.url) || isCustomLoginURL(parsedURL, server) || trustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) {
mainWindow.webContents.send('login-request', request, authInfo);
} else {
mainWindow.webContents.send(REQUEST_PERMISSION_CHANNEL, request, authInfo, BASIC_AUTH_PERMISSION);
}
loginCallbackMap.set(JSON.stringify(request), callback);
mainWindow.webContents.send('login-request', request, authInfo);
}
function handlePermissionGranted(event, url, permission) {
trustedOriginsStore.addPermission(url, permission);
trustedOriginsStore.save();
}
function handlePermissionDenied(event, url, permission, reason) {
log.warn(`Permission request denied: ${reason}`);
}
function handleAppWillFinishLaunching() {
@@ -808,10 +830,20 @@ function initializeAfterAppReady() {
//
function handleLoginCredentialsEvent(event, request, user, password) {
const callback = loginCallbackMap.get(JSON.stringify(request));
const callback = loginCallbackMap.get(request.url);
if (typeof callback === 'undefined') {
log.error(`Failed to retrieve login callback for ${request.url}`);
return;
}
if (callback != null) {
callback(user, password);
}
loginCallbackMap.delete(request.url);
}
function handleCancelLoginEvent(event, request) {
log.info(`Cancelling request for ${request ? request.url : 'unknown'}`);
handleLoginCredentialsEvent(event, request); // we use undefined to cancel the request
}
function handleDownloadURLEvent(event, url) {
@@ -821,7 +853,7 @@ function handleDownloadURLEvent(event, url) {
type: 'error',
message: err.toString(),
});
console.log(err);
log.error(err);
}
});
}
@@ -924,11 +956,11 @@ function handleUpdateDictionaryEvent(_, localeSelected) {
path.resolve(app.getAppPath(), 'node_modules/simple-spellchecker/dict'),
(err) => {
if (err) {
console.error(err);
log.error(err);
}
});
} catch (e) {
console.error('couldn\'t load a spellchecker for locale');
log.error('couldn\'t load a spellchecker for locale');
}
}
}
@@ -989,7 +1021,6 @@ function handleMainWindowWebContentsCrashed() {
function isTrustedURL(url) {
const parsedURL = Utils.parseURL(url);
if (!parsedURL) {
console.log('not an url');
return false;
}
return Utils.getServer(parsedURL, config.teams) !== null;
@@ -1121,7 +1152,6 @@ function wasUpdated(lastAppVersion) {
function clearAppCache() {
if (mainWindow) {
console.log('Clear cache after update');
mainWindow.webContents.session.clearCache().then(mainWindow.reload);
} else {
//Wait for mainWindow

View File

@@ -91,6 +91,17 @@ const certificateStoreSchema = Joi.object().pattern(
})
);
const originPermissionsSchema = Joi.object().keys({
canBasicAuth: Joi.boolean().default(false), // we can add more permissions later if we want
});
const trustedOriginsSchema = Joi.object({}).pattern(
Joi.string().uri(),
Joi.object().keys({
canBasicAuth: Joi.boolean().default(false), // we can add more permissions later if we want
}),
);
const allowedProtocolsSchema = Joi.array().items(Joi.string().regex(/^[a-z-]+:$/i));
// validate bounds_info.json
@@ -165,6 +176,16 @@ export function validateAllowedProtocols(data) {
return validateAgainstSchema(data, allowedProtocolsSchema);
}
export function validateTrustedOriginsStore(data) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, trustedOriginsSchema);
}
export function validateOriginPermissions(data) {
const jsonData = (typeof data === 'object' ? data : JSON.parse(data));
return validateAgainstSchema(jsonData, originPermissionsSchema);
}
function validateAgainstSchema(data, schema) {
if (typeof data !== 'object') {
console.error(`Input 'data' is not an object we can validate: ${typeof data}`);

102
src/main/trustedOrigins.js Normal file
View File

@@ -0,0 +1,102 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
import fs from 'fs';
import log from 'electron-log';
import Utils from '../utils/util.js';
import * as Validator from './Validator';
export default class TrustedOriginsStore {
constructor(storeFile) {
this.storeFile = storeFile;
}
// don't use this, is for ease of mocking it on testing
readFromFile = () => {
let storeData;
try {
storeData = fs.readFileSync(this.storeFile, 'utf-8');
} catch (e) {
storeData = null;
}
return storeData;
}
load = () => {
const storeData = this.readFromFile();
let result = {};
if (storeData !== null) {
result = Validator.validateTrustedOriginsStore(storeData);
if (!result) {
throw new Error('Provided TrustedOrigins file does not validate, using defaults instead.');
}
}
this.data = new Map(Object.entries(result));
}
// don't use this, is for ease of mocking it on testing
saveToFile(stringMap) {
fs.writeFileSync(this.storeFile, stringMap);
}
save = () => {
this.saveToFile(JSON.stringify(Object.fromEntries((this.data.entries())), null, ' '));
};
// if permissions or targetUrl are invalid, this function will throw an error
// this function stablishes all the permissions at once, overwriting whatever was before
// to enable just one permission use addPermission instead.
set = (targetURL, permissions) => {
const validPermissions = Validator.validateOriginPermissions(permissions);
if (!validPermissions) {
throw new Error(`Invalid permissions set for trusting ${targetURL}`);
}
this.data.set(Utils.getHost(targetURL), validPermissions);
};
// enables usage of `targetURL` for `permission`
addPermission = (targetURL, permission) => {
const origin = Utils.getHost(targetURL);
const currentPermissions = this.data.get(origin) || {};
currentPermissions[permission] = true;
this.set(origin, currentPermissions);
}
delete = (targetURL) => {
let host;
try {
host = Utils.getHost(targetURL);
this.data.delete(host);
} catch {
return false;
}
return true;
}
isExisting = (targetURL) => {
return (typeof this.data.get(Utils.getHost(targetURL)) !== 'undefined');
};
// if user hasn't set his preferences, it will return null (falsy)
checkPermission = (targetURL, permission) => {
if (!permission) {
log.error(`Missing permission request on ${targetURL}`);
return null;
}
let origin;
try {
origin = Utils.getHost(targetURL);
} catch (e) {
log.error(`invalid host to retrieve permissions: ${targetURL}: ${e}`);
return null;
}
const urlPermissions = this.data.get(origin);
return urlPermissions ? urlPermissions[permission] : null;
}
}

View File

@@ -34,6 +34,14 @@ function parseURL(inputURL) {
}
}
function getHost(inputURL) {
const parsedURL = parseURL(inputURL);
if (parsedURL) {
return parsedURL.origin;
}
throw new Error(`Couldn't parse url: ${inputURL}`);
}
// isInternalURL determines if the target url is internal to the application.
// - currentURL is the current url inside the webview
// - basename is the global export from the Mattermost application defining the subpath, if any
@@ -183,4 +191,5 @@ export default {
isPluginUrl,
getDisplayBoundaries,
dispatchNotification,
getHost,
};