Add support for AWS authentication (#347)

* Implements #344
* Multifactor authentication (MFA) not yet supported
This commit is contained in:
Peter Lyons 2017-07-12 15:01:14 -06:00 committed by Gregory Schier
parent 5891414d0b
commit d2c677c0fd
8 changed files with 184 additions and 6 deletions

View File

@ -139,6 +139,7 @@ export const AUTH_BASIC = 'basic';
export const AUTH_DIGEST = 'digest';
export const AUTH_BEARER = 'bearer';
export const AUTH_NTLM = 'ntlm';
export const AUTH_AWS_IAM = 'iam';
const authTypesMap = {
[AUTH_BASIC]: ['Basic', 'Basic Auth'],
@ -146,7 +147,8 @@ const authTypesMap = {
[AUTH_NTLM]: ['NTLM', 'Microsoft NTLM'],
[AUTH_BEARER]: ['Bearer', 'Bearer Token'],
[AUTH_OAUTH_1]: ['OAuth 1', 'OAuth 1.0'],
[AUTH_OAUTH_2]: ['OAuth 2', 'OAuth 2.0']
[AUTH_OAUTH_2]: ['OAuth 2', 'OAuth 2.0'],
[AUTH_AWS_IAM]: ['AWS', 'AWS IAM v4']
};
export function getPreviewModeName (previewMode, useLong = false) {

View File

@ -1,4 +1,4 @@
import {AUTH_BASIC, AUTH_DIGEST, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_2, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_OTHER, getContentTypeFromHeaders, METHOD_GET} from '../common/constants';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_2, AUTH_AWS_IAM, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, CONTENT_TYPE_OTHER, getContentTypeFromHeaders, METHOD_GET} from '../common/constants';
import * as db from '../common/database';
import {getContentTypeHeader} from '../common/misc';
import {buildFromParams, deconstructToParams} from '../common/querystring';
@ -50,6 +50,14 @@ export function newAuth (type, oldAuth = {}) {
case AUTH_OAUTH_2:
return {type, grantType: GRANT_TYPE_AUTHORIZATION_CODE};
case AUTH_AWS_IAM:
return {
type,
disabled: oldAuth.disabled || false,
accessKeyId: oldAuth.accessKeyId || '',
secretAccessKey: oldAuth.secretAccessKey || ''
};
// Types needing no defaults
default:
return {type};

View File

@ -3,7 +3,8 @@ import * as db from '../../common/database';
import {join as pathJoin, resolve as pathResolve} from 'path';
import {getRenderedRequest} from '../../common/render';
import * as models from '../../models';
import {AUTH_BASIC, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../../common/constants';
import {AUTH_BASIC, AUTH_AWS_IAM, CONTENT_TYPE_FILE, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../../common/constants';
import {filterHeaders} from '../../common/misc';
describe('actuallySend()', () => {
beforeEach(() => db.init(models.types(), {inMemoryOnly: true}, true));
@ -400,3 +401,46 @@ describe('actuallySend()', () => {
});
});
});
describe('_getAwsAuthHeaders', () => {
it('should generate expected headers', () => {
const req = {
authentication: {
type: AUTH_AWS_IAM,
accessKeyId: 'AKIA99999999',
secretAccessKey: 'SAK9999999999999'
},
headers: [{name: 'content-type', value: 'application/json'}],
body: {text: '{}'}
};
const url = 'https://example.com/path?query=q1';
const headers = networkUtils._getAwsAuthHeaders(req, url);
expect(filterHeaders(headers, 'x-amz-date')[0].value)
.toMatch(/^\d{8}T\d{6}Z$/);
expect(filterHeaders(headers, 'host')[0].value).toEqual('example.com');
expect(filterHeaders(headers, 'authorization')[0].value)
.toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA99999999/);
expect(filterHeaders(headers, 'content-type'))
.toHaveLength(0);
});
it('should handle sparse request', () => {
const req = {
authentication: {
type: AUTH_AWS_IAM,
accessKeyId: 'AKIA99999999',
secretAccessKey: 'SAK9999999999999'
},
headers: []
};
const url = 'https://example.com';
const headers = networkUtils._getAwsAuthHeaders(req, url);
expect(filterHeaders(headers, 'x-amz-date')[0].value)
.toMatch(/^\d{8}T\d{6}Z$/);
expect(filterHeaders(headers, 'host')[0].value).toEqual('example.com');
expect(filterHeaders(headers, 'authorization')[0].value)
.toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA99999999/);
expect(filterHeaders(headers, 'content-type'))
.toHaveLength(0);
});
});

View File

@ -7,7 +7,7 @@ import {join as pathJoin} from 'path';
import * as models from '../models';
import * as querystring from '../common/querystring';
import * as util from '../common/misc.js';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_NTLM, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../common/constants';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_NTLM, AUTH_AWS_IAM, CONTENT_TYPE_FORM_DATA, CONTENT_TYPE_FORM_URLENCODED, getAppVersion} from '../common/constants';
import {describeByteSize, hasAuthHeader, hasContentTypeHeader, hasUserAgentHeader, setDefaultProtocol} from '../common/misc';
import {getRenderedRequest} from '../common/render';
import fs from 'fs';
@ -16,6 +16,7 @@ import * as CACerts from './cacert';
import {getAuthHeader} from './authentication';
import {cookiesFromJar, jarFromCookies} from '../common/cookies';
import urlMatchesCertHost from './url-matches-cert-host';
import aws4 from 'aws4';
// Time since user's last keypress to wait before making the request
const MAX_DELAY_TIME = 1000;
@ -383,6 +384,13 @@ export function _actuallySend (renderedRequest, workspace, settings) {
setOpt(Curl.option.HTTPAUTH, Curl.auth.NTLM);
setOpt(Curl.option.USERNAME, username || '');
setOpt(Curl.option.PASSWORD, password || '');
} else if (renderedRequest.authentication.type === AUTH_AWS_IAM) {
if (!renderedRequest.body.text) {
throw new Error('AWS authentication not supported for provided body type');
}
_getAwsAuthHeaders(renderedRequest, finalUrl).forEach((header) => {
headers.push(header);
});
} else {
const authHeader = await getAuthHeader(
renderedRequest._id,
@ -562,6 +570,33 @@ function _getCurlHeader (curlHeadersObj, name, fallback) {
}
}
// exported for unit tests only
export function _getAwsAuthHeaders (req, url) {
const credentials = {
accessKeyId: req.authentication.accessKeyId,
secretAccessKey: req.authentication.secretAccessKey
};
const parsedUrl = urlParse(url);
const contentTypeHeader = util.getContentTypeHeader(req.headers);
const options = {
path: parsedUrl.path,
// hostname is what we want here whether your URL has port or not
host: parsedUrl.hostname,
headers: {
'content-type': contentTypeHeader ? contentTypeHeader.value : ''
}
};
const body = req.body && req.body.text;
if (body) {
options.body = body;
}
const signature = aws4.sign(options, credentials);
return Object.keys(signature.headers)
// Don't use the inferred content type aws4 provides
.filter((name) => name !== 'content-type')
.map((name) => ({name, value: signature.headers[name]}));
}
document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) {
return;

View File

@ -5,7 +5,7 @@ import {trackEvent} from '../../../analytics';
import {showModal} from '../modals';
import AlertModal from '../modals/alert-modal';
import * as models from '../../../models';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2, getAuthTypeName} from '../../../common/constants';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NONE, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2, AUTH_AWS_IAM, getAuthTypeName} from '../../../common/constants';
@autobind
class AuthDropdown extends PureComponent {
@ -67,6 +67,7 @@ class AuthDropdown extends PureComponent {
{this.renderAuthType(AUTH_DIGEST)}
{this.renderAuthType(AUTH_BEARER)}
{this.renderAuthType(AUTH_NTLM)}
{this.renderAuthType(AUTH_AWS_IAM)}
<DropdownDivider>Other</DropdownDivider>
{this.renderAuthType(AUTH_NONE, 'No Authentication')}
</Dropdown>

View File

@ -1,10 +1,11 @@
import React, {PropTypes, PureComponent} from 'react';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2} from '../../../../common/constants';
import {AUTH_BASIC, AUTH_DIGEST, AUTH_BEARER, AUTH_NTLM, AUTH_OAUTH_1, AUTH_OAUTH_2, AUTH_AWS_IAM} from '../../../../common/constants';
import BasicAuth from './basic-auth';
import DigestAuth from './digest-auth';
import BearerAuth from './bearer-auth';
import NTLMAuth from './ntlm-auth';
import OAuth2 from './o-auth-2';
import AWSAuth from './aws-auth';
import autobind from 'autobind-decorator';
import Link from '../../base/link';
@ -92,6 +93,17 @@ class AuthWrapper extends PureComponent {
onChange={onChange}
/>
);
} else if (authentication.type === AUTH_AWS_IAM) {
return (
<AWSAuth
authentication={authentication}
handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext}
handleUpdateSettingsShowPasswords={handleUpdateSettingsShowPasswords}
onChange={onChange}
showPasswords={showPasswords}
/>
);
} else {
return (
<div className="vertically-center text-center">

View File

@ -0,0 +1,75 @@
import React, {PropTypes, PureComponent} from 'react';
import autobind from 'autobind-decorator';
import KeyValueEditor from '../../key-value-editor/editor';
import {trackEvent} from '../../../../analytics/index';
import {AUTH_AWS_IAM} from '../../../../common/constants';
@autobind
class AWSAuth extends PureComponent {
_handleOnCreate () {
trackEvent('AWS Auth Editor', 'Create');
}
_handleOnDelete () {
trackEvent('AWS Auth Editor', 'Delete');
}
_handleToggleDisable (pair) {
const label = pair.disabled ? 'Disable' : 'Enable';
trackEvent('AWS Auth Editor', 'Toggle', label);
}
_handleChange (pairs) {
const pair = {
type: AUTH_AWS_IAM,
accessKeyId: pairs.length ? pairs[0].name : '',
secretAccessKey: pairs.length ? pairs[0].value : '',
disabled: pairs.length ? pairs[0].disabled : false
};
this.props.onChange(pair);
}
render () {
const {
authentication,
showPasswords,
handleRender,
handleGetRenderContext
} = this.props;
const pairs = [{
name: authentication.accessKeyId || '',
value: authentication.secretAccessKey || '',
disabled: authentication.disabled || false
}];
return (
<KeyValueEditor
pairs={pairs}
maxPairs={1}
disableDelete
handleRender={handleRender}
handleGetRenderContext={handleGetRenderContext}
namePlaceholder="AWS_ACCESS_KEY_ID"
valuePlaceholder="AWS_SECRET_ACCESS_KEY"
valueInputType={showPasswords ? 'text' : 'password'}
onToggleDisable={this._handleToggleDisable}
onCreate={this._handleOnCreate}
onDelete={this._handleOnDelete}
onChange={this._handleChange}
/>
);
}
}
AWSAuth.propTypes = {
handleRender: PropTypes.func.isRequired,
handleGetRenderContext: PropTypes.func.isRequired,
handleUpdateSettingsShowPasswords: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
authentication: PropTypes.object.isRequired,
showPasswords: PropTypes.bool.isRequired
};
export default AWSAuth;

View File

@ -109,6 +109,7 @@
},
"dependencies": {
"autobind-decorator": "^1.3.4",
"aws4": "1.6.0",
"classnames": "^2.2.5",
"clone": "^2.1.0",
"codemirror": "^5.24.2",