[MM-21192] allow for certificate selection (#1148)

* wip

* first working version

* kinda show the cert

* fix lint

* wip

* wip

* css fixes, cleanup

* fix lint

* get back to normal op

* get back cert name

* wip, added cert info screen

* queue cert requests, move user to cert request team

* certifications queued

* remove unneded import

* remove log

* remove commented code

* cleanup

* remove trust certificate dialog for showing it

* fix CR comments
This commit is contained in:
Guillermo Vayá 2020-01-17 02:37:25 +01:00 committed by Dean Whillier
parent 9f2c1b6f86
commit 33e24030d1
6 changed files with 478 additions and 0 deletions

View file

@ -26,6 +26,7 @@ import TabBar from './TabBar.jsx';
import HoveringURL from './HoveringURL.jsx';
import Finder from './Finder.jsx';
import NewTeamModal from './NewTeamModal.jsx';
import SelectCertificateModal from './SelectCertificateModal.jsx';
export default class MainPage extends React.Component {
constructor(props) {
@ -50,6 +51,7 @@ export default class MainPage extends React.Component {
mentionAtActiveCounts: new Array(this.props.teams.length),
loginQueue: [],
targetURL: '',
certificateRequests: [],
maximized: false,
};
}
@ -136,6 +138,20 @@ export default class MainPage extends React.Component {
});
});
ipcRenderer.on('select-user-certificate', (_, origin, certificateList) => {
const certificateRequests = self.state.certificateRequests;
certificateRequests.push({
server: origin,
certificateList,
});
self.setState({
certificateRequests,
});
if (certificateRequests.length === 1) {
self.switchToTabForCertificateRequest(origin);
}
});
// can't switch tabs sequentially for some reason...
ipcRenderer.on('switch-tab', (event, key) => {
this.handleSelect(key);
@ -341,6 +357,18 @@ export default class MainPage extends React.Component {
}
}
switchToTabForCertificateRequest = (origin) => {
// origin is server name + port, if the port doesn't match the protocol, it is kept by URL
const originURL = new URL(`http://${origin.split(':')[0]}`);
const secureOriginURL = new URL(`https://${origin.split(':')[0]}`);
const key = this.props.teams.findIndex((team) => {
const parsedURL = new URL(team.url);
return (parsedURL.origin === originURL.origin) || (parsedURL.origin === secureOriginURL.origin);
});
this.handleSelect(key);
};
handleMaximizeState = () => {
const win = remote.getCurrentWindow();
this.setState({maximized: win.isMaximized()});
@ -534,6 +562,24 @@ export default class MainPage extends React.Component {
});
}
handleSelectCertificate = (certificate) => {
const certificateRequests = this.state.certificateRequests;
const current = certificateRequests.shift();
this.setState({certificateRequests});
ipcRenderer.send('selected-client-certificate', current.server, certificate);
if (certificateRequests.length > 0) {
this.switchToTabForCertificateRequest(certificateRequests[0].server);
}
}
handleCancelCertificate = () => {
const certificateRequests = this.state.certificateRequests;
const current = certificateRequests.shift();
this.setState({certificateRequests});
ipcRenderer.send('selected-client-certificate', current.server);
if (certificateRequests.length > 0) {
this.switchToTabForCertificateRequest(certificateRequests[0].server);
}
};
setDarkMode() {
this.setState({
isDarkMode: this.props.setDarkMode(),
@ -727,6 +773,11 @@ export default class MainPage extends React.Component {
onLogin={this.handleLogin}
onCancel={this.handleLoginCancel}
/>
<SelectCertificateModal
certificateRequests={this.state.certificateRequests}
onSelect={this.handleSelectCertificate}
onCancel={this.handleCancelCertificate}
/>
<Grid fluid={true}>
{ topRow }
{ viewsRow }

View file

@ -0,0 +1,177 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import {Modal, Button, Table, Row, Col} from 'react-bootstrap';
import ShowCertificateModal from './showCertificateModal.jsx';
const CELL_SIZE = 23;
const ELIPSIS_SIZE = 3;
export default class SelectCertificateModal extends React.Component {
static propTypes = {
onSelect: PropTypes.func.isRequired,
onCancel: PropTypes.func,
certificateRequests: PropTypes.arrayOf(PropTypes.shape({
server: PropTypes.string,
certificateList: PropTypes.array,
})),
}
constructor(props) {
super(props);
this.state = {
selectedIndex: null,
showCertificate: null,
};
}
maxSize = (item, max) => {
if (!item || item.length <= max) {
return item;
}
const sub = item.substring(0, max - ELIPSIS_SIZE);
return `${sub}...`;
}
selectfn = (index) => {
return (() => {
this.setState({selectedIndex: index});
});
};
renderCert = (cert, index) => {
const issuer = cert.issuer && cert.issuer.commonName ? cert.issuer.commonName : '';
const subject = cert.subject && cert.subject.commonName ? cert.subject.commonName : '';
const serial = cert.serialNumber || '';
const issuerShort = this.maxSize(cert.issuer.commonName, CELL_SIZE);
const subjectShort = this.maxSize(cert.subject.commonName, CELL_SIZE);
const serialShort = this.maxSize(cert.serialNumber, CELL_SIZE);
const style = this.state.selectedIndex === index ? {background: '#457AB2', color: '#FFFFFF'} : {};
return (
<tr
key={`cert-${index}`}
onClick={this.selectfn(index)}
style={style}
>
<td
style={style}
title={issuer}
>{issuerShort}</td>
<td
style={style}
title={subject}
>{subjectShort}</td>
<td
style={style}
title={serial}
>{serialShort}</td>
</tr>);
};
renderCerts = (certificateList) => {
if (certificateList) {
const certs = certificateList.map(this.renderCert);
return (
<Fragment>
{certs}
</Fragment>
);
}
return (<Fragment><tr/><tr><td/><td>{'No certificates available'}</td><td/></tr></Fragment>);
}
getSelectedCert = () => {
return this.state.selectedIndex === null ? null : this.props.certificateRequests[0].certificateList[this.state.selectedIndex];
};
handleOk = () => {
const cert = this.getSelectedCert();
if (cert !== null) {
this.props.onSelect(cert);
}
}
handleCertificateInfo = () => {
const certificate = this.getSelectedCert();
this.setState({showCertificate: certificate});
}
certificateInfoClose = () => {
this.setState({showCertificate: null});
}
render() {
const certList = this.props.certificateRequests.length ? this.props.certificateRequests[0].certificateList : [];
const server = this.props.certificateRequests.length ? this.props.certificateRequests[0].server : '';
if (this.state.showCertificate) {
return (
<ShowCertificateModal
certificate={this.state.showCertificate}
onOk={this.certificateInfoClose}
/>
);
}
return (
<Modal
bsClass='modal'
className='certificateModal'
show={this.props.certificateRequests.length}
>
<Modal.Header className={'noBorder'}>
<Modal.Title className={'bottomBorder'}>{'Select a certificate'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className={'subtitle'}>{`Select a certificate to authenticate yourself to ${server}`}</p>
<Table
stripped={'true'}
hover={true}
size={'sm'}
className='certificateList'
>
<thead>
<tr>
<th><span className={'divider'}>{'Subject'}</span></th>
<th><span className={'divider'}>{'Issuer'}</span></th>
<th>{'Serial'}</th>
</tr>
</thead>
<tbody>
{this.renderCerts(certList)}
<tr/* this is to correct table height without affecting real rows *//>
</tbody>
</Table>
</Modal.Body>
<Modal.Footer className={'noBorder'}>
<Row className={'topBorder'}>
<Col sm={4}>
<Button
variant={'info'}
disabled={this.state.selectedIndex === null}
onClick={this.handleCertificateInfo}
className={'info'}
>{'Certificate Information'}</Button>
</Col>
<Col sm={8}>
<Button
onClick={this.props.onCancel}
variant={'secondary'}
className={'secondary'}
>{'Cancel'}</Button>
<Button
variant={'primary'}
onClick={this.handleOk}
disabled={this.state.selectedIndex === null}
className={'primary'}
>{'Ok'}</Button>
</Col>
</Row>
</Modal.Footer>
</Modal>
);
}
}

View file

@ -0,0 +1,97 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import {Modal, Button, Row} from 'react-bootstrap';
export default class ShowCertificateModal extends React.Component {
static propTypes = {
certificate: PropTypes.object,
onOk: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
certificate: props.certificate,
};
}
handleOk = () => {
this.setState({certificate: null});
this.props.onOk();
}
render() {
const certificateItem = (descriptor, value) => {
const ddclass = value ? '' : 'emtpyDescriptor';
const val = value ? `${value}` : <span/>;
return (
<Fragment>
<dt>{descriptor}</dt>
<dd className={ddclass}>{val}</dd>
</Fragment>
);
};
if (this.state.certificate === null) {
return (
<Modal>
<Modal.Body>
{'No certificate Selected'}
</Modal.Body>
</Modal>
);
}
const utcSeconds = (date) => {
const d = new Date(0);
d.setUTCSeconds(date);
return d;
};
const expiration = utcSeconds(this.state.certificate.validExpiry);
const creation = utcSeconds(this.state.certificate.validStart);
const dateDisplayOptions = {dateStyle: 'full', timeStyle: 'full'};
const dateLocale = 'en-US';
return (
<Modal
bsClass='modal'
className='certificateModal'
show={this.state.certificate !== null}
scrollable={true}
>
<Modal.Header className={'noBorder'}>
<Modal.Title className={'bottomBorder'}>{'Certificate Information'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<h3 className={'certificateKey'}>{`${this.state.certificate.subject.commonName}`}</h3>
<p className={'certInfo'}>{`Issued by: ${this.state.certificate.issuer.commonName}`}</p>
<p className={'certInfo'}>{`Expires: ${expiration.toLocaleString(dateLocale, dateDisplayOptions)}`}</p>
<p>{'Details'}</p>
<dl>
{certificateItem('Subject Name')}
{certificateItem('Common Name', this.state.certificate.subject.commonName)}
{certificateItem('Issuer Name')}
{certificateItem('Common Name', this.state.certificate.issuer.commonName)}
{certificateItem('Serial Number', this.state.certificate.serialNumber)}
{certificateItem('Not Valid Before', creation.toLocaleString(dateLocale, dateDisplayOptions))}
{certificateItem('Not Valid After', expiration.toLocaleString(dateLocale, dateDisplayOptions))}
{certificateItem('Public Key Info')}
{certificateItem('Algorithm', this.state.certificate.fingerprint.split('/')[0])}
</dl>
</Modal.Body>
<Modal.Footer className={'noBorder'}>
<Row className={'topBorder'}>
<Button
variant={'primary'}
onClick={this.handleOk}
className={'primary'}
>{'Close'}</Button>
</Row>
</Modal.Footer>
</Modal>
);
}
}

View file

@ -0,0 +1,121 @@
.certificateModal dialog {
background-color: aliceblue;
}
.certificateList thead {
width: 557.89px;
height: 22px;
}
.certificateList thead>tr>th {
font-family: Helvetica Neue;
font-style: normal;
font-weight: normal;
font-size: 12px;
line-height: 14px;
padding: 2px;
border-bottom: 1px solid #CCCCCC;
color: #333333;
}
.certificateList tbody>tr>td {
width: 227.17px;
height: 47px;
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 17px;
color: #555555;
}
table.certificateList {
background: #FFFFFF;
border: 1px solid #CCCCCC;
box-sizing: border-box;
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.0008);
border-radius: 4px;
border-collapse: unset;
height: 150px;
}
.certificateModal button {
background: #FFFFFF;
border: 1px solid #CCCCCC;
box-sizing: border-box;
border-radius: 4px;
padding: 9px 12px;
}
.certificateModal button:disabled {
opacity: 0.5;
}
.certificateModal button.primary {
background: #457AB2;
color: #FFFFFF;
border: 1px solid #2E6DA4;
}
.certificateModal button.info {
color: #457AB2;
}
.certificateModal button.primary:hover {
background: #659AD2;
}
.certificateModal .subtitle {
color: #737373;
margin: 0px 15px 10px;
}
.certificateModal .bottomBorder {
padding-bottom: 10px;
border-bottom: 1px solid #CCCCCC;
}
.certificateModal .topBorder {
border-top: 1px solid #CCCCCC;
margin: 0 1px 0 1px;
padding-top: 15px;
}
.certificateModal .noBorder {
border: none;
}
.divider {
border-right: 1px solid #CCCCCC;
display: block;
}
.certificateModal dt, dd {
float: left;
margin: 5px;
}
.certificateModal dt { clear:both }
.certificateModal dl {
overflow-y: auto;
}
.certificateKey {
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 17px;
color: #737373;
}
.certInfo {
font-family: Helvetica Neue;
font-style: normal;
font-weight: normal;
font-size: 12px;
line-height: 18px;
color: #333333;
}
.emtpyDescriptor {
border-bottom: 1px solid #CCCCCC;
}

View file

@ -8,3 +8,4 @@
@import url("TeamListItem.css");
@import url("Finder.css");
@import url("UpdaterPage.css");
@import url("CertificateModal.css");

View file

@ -50,6 +50,7 @@ const {
const criticalErrorHandler = new CriticalErrorHandler();
const assetsDir = path.resolve(app.getAppPath(), 'assets');
const loginCallbackMap = new Map();
const certificateRequests = new Map();
const userActivityMonitor = new UserActivityMonitor();
// Keep a global reference of the window object, if you don't, the window will
@ -158,6 +159,7 @@ function initializeAppEventListeners() {
app.on('activate', handleAppActivate);
app.on('before-quit', handleAppBeforeQuit);
app.on('certificate-error', handleAppCertificateError);
app.on('select-client-certificate', handleSelectCertificate);
app.on('gpu-process-crashed', handleAppGPUProcessCrashed);
app.on('login', handleAppLogin);
app.on('will-finish-launching', handleAppWillFinishLaunching);
@ -214,6 +216,8 @@ function initializeInterCommunicationEventListeners() {
ipcMain.on('get-spelling-suggestions', handleGetSpellingSuggestionsEvent);
ipcMain.on('get-spellchecker-locale', handleGetSpellcheckerLocaleEvent);
ipcMain.on('reply-on-spellchecker-is-ready', handleReplyOnSpellcheckerIsReadyEvent);
ipcMain.on('selected-client-certificate', handleSelectedCertificate);
if (shouldShowTrayIcon()) {
ipcMain.on('update-unread', handleUpdateUnreadEvent);
}
@ -307,6 +311,32 @@ function handleAppBeforeQuit() {
global.willAppQuit = true;
}
function handleSelectCertificate(event, webContents, url, list, callback) {
event.preventDefault(); // prevent the app from getting the first certificate available
// store callback so it can be called with selected certificate
certificateRequests.set(url, callback);
// open modal for selecting certificate
mainWindow.webContents.send('select-user-certificate', url, list);
}
function handleSelectedCertificate(event, server, cert) {
const callback = certificateRequests.get(server);
if (!callback) {
console.error(`there was no callback associated with: ${server}`);
return;
}
try {
if (typeof cert === 'undefined') {
callback(); //user cancelled, so we use the callback without certificate.
} else {
callback(cert);
}
} catch (e) {
console.log(`There was a problem using the selected certificate: ${e}`);
}
}
function handleAppCertificateError(event, webContents, url, error, certificate, callback) {
if (certificateStore.isTrusted(url, certificate)) {
event.preventDefault();
@ -912,6 +942,7 @@ function isTrustedURL(url) {
if (!parsedURL) {
return false;
}
const teamURLs = config.teams.reduce((urls, team) => {
const parsedTeamURL = parseURL(team.url);
if (parsedTeamURL) {