diff --git a/.eslintrc.js b/.eslintrc.js index e2a8ef3f6..5b985ff69 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,8 @@ module.exports = { }, 'extends': 'eslint:recommended', 'parserOptions': { - 'sourceType': 'module' + 'sourceType': 'module', + "ecmaVersion": 8 }, 'rules': { 'indent': [ diff --git a/gulpfile.js b/gulpfile.js index 9f6af992b..fce0cddaa 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -28,7 +28,7 @@ const compileJS = (src, dest) => { .pipe(sourcemaps.init()) .pipe(babel({ presets: [['@babel/env', { - targets: { node: 4 } + targets: { node: 8 } }]] })) .pipe(sourcemaps.write('.')) diff --git a/package.json b/package.json index 6f285c3ce..6d0930938 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "url": "https://min.io" }, "engines": { - "node": ">= 4" + "node": ">8 <16.8.0" }, "license": "Apache-2.0", "bugs": { diff --git a/src/main/AssumeRoleProvider.js b/src/main/AssumeRoleProvider.js new file mode 100644 index 000000000..70f29cd6d --- /dev/null +++ b/src/main/AssumeRoleProvider.js @@ -0,0 +1,216 @@ +import Http from 'http' +import Https from 'https' +import {makeDateLong, parseXml, toSha256} from "./helpers" +import {signV4ByServiceName} from "./signing" +import CredentialProvider from "./CredentialProvider" +import Credentials from "./Credentials" + +class AssumeRoleProvider extends CredentialProvider { + constructor({ + stsEndpoint, + accessKey, + secretKey, + durationSeconds = 900, + sessionToken, + policy, + region = '', + roleArn, + roleSessionName, + externalId, + token, + webIdentityToken, + action = "AssumeRole" + }) { + super({}) + + this.stsEndpoint = stsEndpoint + this.accessKey = accessKey + this.secretKey = secretKey + this.durationSeconds = durationSeconds + this.policy = policy + this.region = region + this.roleArn = roleArn + this.roleSessionName = roleSessionName + this.externalId = externalId + this.token = token + this.webIdentityToken = webIdentityToken + this.action = action + this.sessionToken = sessionToken + + /** + * Internal Tracking variables + */ + this.credentials = null + this.expirySeconds = null + this.accessExpiresAt = null + + } + + + getRequestConfig() { + const url = new URL(this.stsEndpoint) + const hostValue = url.hostname + const portValue = url.port + const isHttp = url.protocol.includes("http:") + const qryParams = new URLSearchParams() + qryParams.set("Action", this.action) + qryParams.set("Version", "2011-06-15") + + const defaultExpiry = 900 + let expirySeconds = parseInt(this.durationSeconds) + if (expirySeconds < defaultExpiry) { + expirySeconds = defaultExpiry + } + this.expirySeconds = expirySeconds // for calculating refresh of credentials. + + qryParams.set("DurationSeconds", this.expirySeconds) + + if (this.policy) { + qryParams.set("Policy", this.policy) + } + if (this.roleArn) { + qryParams.set("RoleArn", this.roleArn) + } + + if (this.roleSessionName != null) { + qryParams.set("RoleSessionName", this.roleSessionName) + } + if (this.token != null) { + qryParams.set("Token", this.token) + } + + if (this.webIdentityToken) { + qryParams.set("WebIdentityToken", this.webIdentityToken) + } + + if (this.externalId) { + qryParams.set("ExternalId", this.externalId) + } + + + const urlParams = qryParams.toString() + const contentSha256 = toSha256(urlParams) + + const date = new Date() + + /** + * Nodejs's Request Configuration. + */ + const requestOptions = { + hostname: hostValue, + port: portValue, + path: "/", + protocol: url.protocol, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "content-length": urlParams.length, + "host": hostValue, + "x-amz-date": makeDateLong(date), + 'x-amz-content-sha256': contentSha256 + } + } + + const authorization = signV4ByServiceName(requestOptions, this.accessKey, this.secretKey, this.region, date, "sts") + requestOptions.headers.authorization = authorization + + return { + requestOptions, + requestData: urlParams, + isHttp: isHttp + } + } + + async performRequest() { + const reqObj = this.getRequestConfig() + const requestOptions = reqObj.requestOptions + const requestData = reqObj.requestData + + const isHttp = reqObj.isHttp + const Transport = isHttp ? Http : Https + + const promise = new Promise((resolve, reject) => { + const requestObj = Transport.request(requestOptions, (resp) => { + let resChunks = [] + resp.on('data', rChunk => { + resChunks.push(rChunk) + }) + resp.on('end', () => { + let body = Buffer.concat(resChunks).toString() + const xmlobj = parseXml(body) + resolve(xmlobj) + }) + resp.on('error', (err) => { + reject(err) + }) + }) + requestObj.on('error', (e) => { + reject(e) + }) + requestObj.write(requestData) + requestObj.end() + }) + return promise + + } + + parseCredentials(respObj={}) { + + const { + AssumeRoleResponse: { + AssumeRoleResult: { + Credentials: { + AccessKeyId: accessKey, + SecretAccessKey: secretKey, + SessionToken: sessionToken, + Expiration: expiresAt + } = {} + } = {} + } = {} + } = respObj + + + this.accessExpiresAt = expiresAt + + const newCreds = new Credentials({ + accessKey, + secretKey, + sessionToken + }) + + this.setCredentials(newCreds) + return this.credentials + + } + + + async refreshCredentials() { + try { + const assumeRoleCredentials = await this.performRequest() + this.credentials = this.parseCredentials(assumeRoleCredentials) + } catch (err) { + this.credentials = null + throw new Error(`Error getting credentials:` + err) + } + return this.credentials + } + + async getCredentials() { + let credConfig + if (!this.credentials || (this.credentials && this.isAboutToExpire())) { + credConfig = await this.refreshCredentials() + } else { + credConfig = this.credentials + } + return credConfig + } + + isAboutToExpire() { + const expiresAt = new Date(this.accessExpiresAt) + const provisionalExpiry = new Date(Date.now() + 1000 * 10)//check before 10 seconds. + const isAboutToExpire = provisionalExpiry > expiresAt + return isAboutToExpire + } +} + +export default AssumeRoleProvider \ No newline at end of file diff --git a/src/main/CredentialProvider.js b/src/main/CredentialProvider.js new file mode 100644 index 000000000..c1b59becb --- /dev/null +++ b/src/main/CredentialProvider.js @@ -0,0 +1,56 @@ +import Credentials from "./Credentials" + +class CredentialProvider { + constructor({ + accessKey, + secretKey, + sessionToken + }) { + this.credentials = new Credentials({ + accessKey, + secretKey, + sessionToken + }) + } + + getCredentials() { + return this.credentials.get() + } + + setCredentials(credentials) { + if (credentials instanceof Credentials) { + this.credentials = credentials + } else { + throw new Error("Unable to set Credentials . it should be an instance of Credentials class") + } + } + + + setAccessKey(accessKey) { + this.credentials.setAccessKey(accessKey) + } + + getAccessKey() { + return this.credentials.getAccessKey() + } + + setSecretKey(secretKey) { + this.credentials.setSecretKey(secretKey) + } + + getSecretKey() { + return this.credentials.getSecretKey() + } + + setSessionToken(sessionToken) { + this.credentials.setSessionToken(sessionToken) + } + + getSessionToken() { + return this.credentials.getSessionToken() + } + + +} + +export default CredentialProvider \ No newline at end of file diff --git a/src/main/Credentials.js b/src/main/Credentials.js new file mode 100644 index 000000000..acd0d2470 --- /dev/null +++ b/src/main/Credentials.js @@ -0,0 +1,42 @@ +class Credentials{ + constructor({ + accessKey, + secretKey, + sessionToken + }) { + this.accessKey = accessKey + this.secretKey = secretKey + this.sessionToken=sessionToken + } + + + setAccessKey(accessKey){ + this.accessKey = accessKey + } + getAccessKey(){ + return this.accessKey + } + setSecretKey(secretKey){ + this.secretKey=secretKey + } + getSecretKey(){ + return this.secretKey + } + setSessionToken (sessionToken){ + this.sessionToken = sessionToken + } + getSessionToken (){ + return this.sessionToken + } + + get(){ + return { + accessKey:this.accessKey, + secretKey:this.secretKey, + sessionToken:this.sessionToken + } + } + +} + +export default Credentials \ No newline at end of file diff --git a/src/main/helpers.js b/src/main/helpers.js index ca4434a92..9a20a23e8 100644 --- a/src/main/helpers.js +++ b/src/main/helpers.js @@ -16,6 +16,7 @@ import stream from 'stream' import mime from 'mime-types' +import fxp from "fast-xml-parser" var Crypto = require('crypto') // Returns a wrapper function that will promisify a given callback function. @@ -81,8 +82,8 @@ export function uriResourceEscape(string) { return uriEscape(string).replace(/%2F/g, '/') } -export function getScope(region, date) { - return `${makeDateShort(date)}/${region}/s3/aws4_request` +export function getScope(region, date, serviceName="s3") { + return `${makeDateShort(date)}/${region}/${serviceName}/aws4_request` } // isAmazonEndpoint - true if endpoint is 's3.amazonaws.com' or 's3.cn-north-1.amazonaws.com.cn' @@ -396,4 +397,14 @@ export const toMd5=(payload)=>{ } export const toSha256=(payload)=>{ return Crypto.createHash('sha256').update(payload).digest('hex') +} + +export const parseXml = (xml) => { + let result = null + result = fxp.parse(xml) + if (result.Error) { + throw result.Error + } + + return result } \ No newline at end of file diff --git a/src/main/minio.js b/src/main/minio.js index c59333b80..6469dd9ff 100644 --- a/src/main/minio.js +++ b/src/main/minio.js @@ -52,6 +52,7 @@ import { getS3Endpoint } from './s3-endpoints.js' import { NotificationConfig, NotificationPoller } from './notification' import extensions from './extensions' +import CredentialProvider from "./CredentialProvider" var Package = require('../../package.json') @@ -137,6 +138,11 @@ export class Client { if (!this.secretKey) this.secretKey = '' this.anonymous = !this.accessKey || !this.secretKey + if(params.credentialsProvider) { + this.credentialsProvider = params.credentialsProvider + this.checkAndRefreshCreds() + } + this.regionMap = {} if (params.region) { this.region = params.region @@ -450,6 +456,7 @@ export class Client { reqOptions.headers['x-amz-security-token'] = this.sessionToken } + this.checkAndRefreshCreds() var authorization = signV4(reqOptions, this.accessKey, this.secretKey, region, date) reqOptions.headers.authorization = authorization } @@ -1775,6 +1782,8 @@ export class Client { bucketName, objectName, query}) + + this.checkAndRefreshCreds() try { url = presignSignatureV4(reqOptions, this.accessKey, this.secretKey, this.sessionToken, region, requestDate, expires) @@ -1856,6 +1865,8 @@ export class Client { var date = new Date() var dateStr = makeDateLong(date) + this.checkAndRefreshCreds() + if (!postPolicy.policy.expiration) { // 'expiration' is mandatory field for S3. // Set default expiration date of 7 days. @@ -3064,6 +3075,30 @@ export class Client { } + async setCredentialsProvider(credentialsProvider){ + if(!(credentialsProvider instanceof CredentialProvider)){ + throw new Error("Unable to get credentials. Expected instance of CredentialProvider") + } + this.credentialsProvider = credentialsProvider + await this.checkAndRefreshCreds() + } + + async checkAndRefreshCreds(){ + if(this.credentialsProvider ) { + return await this.fetchCredentials() + } + } + + async fetchCredentials(){ + if(this.credentialsProvider ){ + const credentialsConf = await this.credentialsProvider.getCredentials() + this.accessKey = credentialsConf.getAccessKey() + this.secretKey = credentialsConf.getSecretKey() + this.sessionToken = credentialsConf.getSessionToken() + }else{ + throw new Error("Unable to get credentials. Expected instance of BaseCredentialsProvider") + } + } get extensions() { if(!this.clientExtensions) diff --git a/src/main/signing.js b/src/main/signing.js index bd6625d93..ded424bc4 100644 --- a/src/main/signing.js +++ b/src/main/signing.js @@ -48,15 +48,15 @@ function getCanonicalRequest(method, path, headers, signedHeaders, hashedPayload if (!isString(hashedPayload)) { throw new TypeError('hashedPayload should be of type "string"') } - var headersArray = signedHeaders.reduce((acc, i) => { + const headersArray = signedHeaders.reduce((acc, i) => { // Trim spaces from the value (required by V4 spec) - var val = `${headers[i]}`.replace(/ +/g, " ") + const val = `${headers[i]}`.replace(/ +/g, " ") acc.push(`${i.toLowerCase()}:${val}`) return acc }, []) - var requestResource = path.split('?')[0] - var requestQuery = path.split('?')[1] + const requestResource = path.split('?')[0] + let requestQuery = path.split('?')[1] if (!requestQuery) requestQuery = '' if (requestQuery) { @@ -67,7 +67,7 @@ function getCanonicalRequest(method, path, headers, signedHeaders, hashedPayload .join('&') } - var canonical = [] + const canonical = [] canonical.push(method.toUpperCase()) canonical.push(requestResource) canonical.push(requestQuery) @@ -78,7 +78,7 @@ function getCanonicalRequest(method, path, headers, signedHeaders, hashedPayload } // generate a credential string -function getCredential(accessKey, region, requestDate) { +function getCredential(accessKey, region, requestDate,serviceName="s3") { if (!isString(accessKey)) { throw new TypeError('accessKey should be of type "string"') } @@ -88,7 +88,7 @@ function getCredential(accessKey, region, requestDate) { if (!isObject(requestDate)) { throw new TypeError('requestDate should be of type "object"') } - return `${accessKey}/${getScope(region, requestDate)}` + return `${accessKey}/${getScope(region, requestDate,serviceName)}` } // Returns signed headers array - alphabetically sorted @@ -123,14 +123,14 @@ function getSignedHeaders(headers) { // // Is skipped for obvious reasons - var ignoredHeaders = ['authorization', 'content-length', 'content-type', 'user-agent'] + const ignoredHeaders = ['authorization', 'content-length', 'content-type', 'user-agent'] return _.map(headers, (v, header) => header) .filter(header => ignoredHeaders.indexOf(header) === -1) .sort() } // returns the key used for calculating signature -function getSigningKey(date, region, secretKey) { +function getSigningKey(date, region, secretKey,serviceName="s3") { if (!isObject(date)) { throw new TypeError('date should be of type "object"') } @@ -140,15 +140,15 @@ function getSigningKey(date, region, secretKey) { if (!isString(secretKey)) { throw new TypeError('secretKey should be of type "string"') } - var dateLine = makeDateShort(date), - hmac1 = Crypto.createHmac('sha256', 'AWS4' + secretKey).update(dateLine).digest(), + const dateLine = makeDateShort(date) + let hmac1 = Crypto.createHmac('sha256', 'AWS4' + secretKey).update(dateLine).digest(), hmac2 = Crypto.createHmac('sha256', hmac1).update(region).digest(), - hmac3 = Crypto.createHmac('sha256', hmac2).update('s3').digest() + hmac3 = Crypto.createHmac('sha256', hmac2).update(serviceName).digest() return Crypto.createHmac('sha256', hmac3).update('aws4_request').digest() } // returns the string that needs to be signed -function getStringToSign(canonicalRequest, requestDate, region) { +function getStringToSign(canonicalRequest, requestDate, region,serviceName="s3") { if (!isString(canonicalRequest)) { throw new TypeError('canonicalRequest should be of type "string"') } @@ -158,14 +158,15 @@ function getStringToSign(canonicalRequest, requestDate, region) { if (!isString(region)) { throw new TypeError('region should be of type "string"') } - var hash = Crypto.createHash('sha256').update(canonicalRequest).digest('hex') - var scope = getScope(region, requestDate) - var stringToSign = [] + const hash = Crypto.createHash('sha256').update(canonicalRequest).digest('hex') + const scope = getScope(region, requestDate, serviceName) + const stringToSign = [] stringToSign.push(signV4Algorithm) stringToSign.push(makeDateLong(requestDate)) stringToSign.push(scope) stringToSign.push(hash) - return stringToSign.join('\n') + const signString = stringToSign.join('\n') + return signString } // calculate the signature of the POST policy @@ -182,12 +183,12 @@ export function postPresignSignatureV4(region, date, secretKey, policyBase64) { if (!isString(policyBase64)) { throw new TypeError('policyBase64 should be of type "string"') } - var signingKey = getSigningKey(date, region, secretKey) + const signingKey = getSigningKey(date, region, secretKey) return Crypto.createHmac('sha256', signingKey).update(policyBase64).digest('hex').toLowerCase() } // Returns the authorization header -export function signV4(request, accessKey, secretKey, region, requestDate) { +export function signV4(request, accessKey, secretKey, region, requestDate, serviceName="s3") { if (!isObject(request)) { throw new TypeError('request should be of type "object"') } @@ -208,19 +209,23 @@ export function signV4(request, accessKey, secretKey, region, requestDate) { throw new errors.SecretKeyRequiredError('secretKey is required for signing') } - var sha256sum = request.headers['x-amz-content-sha256'] + const sha256sum = request.headers['x-amz-content-sha256'] - var signedHeaders = getSignedHeaders(request.headers) - var canonicalRequest = getCanonicalRequest(request.method, request.path, request.headers, - signedHeaders, sha256sum) - var stringToSign = getStringToSign(canonicalRequest, requestDate, region) - var signingKey = getSigningKey(requestDate, region, secretKey) - var credential = getCredential(accessKey, region, requestDate) - var signature = Crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex').toLowerCase() + const signedHeaders = getSignedHeaders(request.headers) + const canonicalRequest = getCanonicalRequest(request.method, request.path, request.headers, + signedHeaders, sha256sum) + const serviceIdentifier = serviceName || "s3" + const stringToSign = getStringToSign(canonicalRequest, requestDate, region,serviceIdentifier) + const signingKey = getSigningKey(requestDate, region, secretKey,serviceIdentifier) + const credential = getCredential(accessKey, region, requestDate, serviceIdentifier) + const signature = Crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex').toLowerCase() return `${signV4Algorithm} Credential=${credential}, SignedHeaders=${signedHeaders.join(';').toLowerCase()}, Signature=${signature}` } +export function signV4ByServiceName( request, accessKey, secretKey, region, requestDate, serviceName="s3") { + return signV4(request, accessKey, secretKey, region,requestDate, serviceName) +} // returns a presigned URL string export function presignSignatureV4(request, accessKey, secretKey, sessionToken, region, requestDate, expires) { if (!isObject(request)) { @@ -253,12 +258,12 @@ export function presignSignatureV4(request, accessKey, secretKey, sessionToken, throw new errors.ExpiresParamError('expires param cannot be greater than 7 days') } - var iso8601Date = makeDateLong(requestDate) - var signedHeaders = getSignedHeaders(request.headers) - var credential = getCredential(accessKey, region, requestDate) - var hashedPayload = 'UNSIGNED-PAYLOAD' + const iso8601Date = makeDateLong(requestDate) + const signedHeaders = getSignedHeaders(request.headers) + const credential = getCredential(accessKey, region, requestDate) + const hashedPayload = 'UNSIGNED-PAYLOAD' - var requestQuery = [] + const requestQuery = [] requestQuery.push(`X-Amz-Algorithm=${signV4Algorithm}`) requestQuery.push(`X-Amz-Credential=${uriEscape(credential)}`) requestQuery.push(`X-Amz-Date=${iso8601Date}`) @@ -268,22 +273,22 @@ export function presignSignatureV4(request, accessKey, secretKey, sessionToken, requestQuery.push(`X-Amz-Security-Token=${uriEscape(sessionToken)}`) } - var resource = request.path.split('?')[0] - var query = request.path.split('?')[1] + const resource = request.path.split('?')[0] + let query = request.path.split('?')[1] if (query) { query = query + '&' + requestQuery.join('&') } else { query = requestQuery.join('&') } - var path = resource + '?' + query + const path = resource + '?' + query - var canonicalRequest = getCanonicalRequest(request.method, path, - request.headers, signedHeaders, hashedPayload) + const canonicalRequest = getCanonicalRequest(request.method, path, + request.headers, signedHeaders, hashedPayload) - var stringToSign = getStringToSign(canonicalRequest, requestDate, region) - var signingKey = getSigningKey(requestDate, region, secretKey) - var signature = Crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex').toLowerCase() - var presignedUrl = request.protocol + '//' + request.headers.host + path + `&X-Amz-Signature=${signature}` + const stringToSign = getStringToSign(canonicalRequest, requestDate, region) + const signingKey = getSigningKey(requestDate, region, secretKey) + const signature = Crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex').toLowerCase() + const presignedUrl = request.protocol + '//' + request.headers.host + path + `&X-Amz-Signature=${signature}` return presignedUrl } diff --git a/src/main/xml-parsers.js b/src/main/xml-parsers.js index ae303ce23..8f098945d 100644 --- a/src/main/xml-parsers.js +++ b/src/main/xml-parsers.js @@ -20,18 +20,10 @@ import * as errors from './errors.js' import { isObject, sanitizeETag, - RETENTION_VALIDITY_UNITS + RETENTION_VALIDITY_UNITS, + parseXml } from "./helpers" -var parseXml = (xml) => { - var result = null - result = fxp.parse(xml) - if (result.Error) { - throw result.Error - } - - return result -} // toArray returns a single element array with param being the element, // if param is just a string, and returns 'param' back if it is an array diff --git a/src/test/functional/functional-tests.js b/src/test/functional/functional-tests.js index 0d9a08c28..9910fddd2 100644 --- a/src/test/functional/functional-tests.js +++ b/src/test/functional/functional-tests.js @@ -29,6 +29,8 @@ const superagent = require('superagent') const uuid = require("uuid") const step = require("mocha-steps").step import { getVersionId, isArray } from "../../../dist/main/helpers" +import AssumeRoleProvider from "../../../dist/main/AssumeRoleProvider" + let minio try { @@ -2494,4 +2496,57 @@ describe('functional tests', function() { }) })}) + + + describe('Assume Role Tests', ()=>{ + const bucketName = "minio-js-test-assume-role" + uuid.v4() + before((done) => client.makeBucket(bucketName, '', done)) + after((done) => client.removeBucket(bucketName, done)) + + + const objName = 'datafile-to-encrypt-100-kB' + const objContent = Buffer.alloc(100 * 1024, 0) + + const isPlayInstance = playConfig.endPoint.includes("play.min.io") + const isLocalhost = playConfig.endPoint.includes("localhost") + const canRunAssumeRoleTest = isLocalhost || isPlayInstance + const stsEndPoint = isLocalhost ? "http://localhost:9000":"https://play.min.io:9000" + + if(canRunAssumeRoleTest) { + //Creates a new Client with assume role provider for testing. + const assumeRoleProvider = new AssumeRoleProvider({ + stsEndpoint: stsEndPoint, + accessKey: client.accessKey, + secretKey: client.secretKey + }) + + const aRoleConf = Object.assign({}, playConfig, {credentialsProvider: assumeRoleProvider}) + const asumeRoleClient = new minio.Client(aRoleConf) + + describe('Put an Object', function () { + step(`Put an object to check for default encryption bucket:_bucketName:${bucketName}, _objectName:${objName}`, done => { + const putObjPromise = asumeRoleClient.putObject(bucketName, objName, objContent) + putObjPromise.then(() => { + done() + }) + .catch(() => { + done() + }) + }) + + step(`Remove an Object :${bucketName}, _objectName:${objName}`, done => { + const putObjPromise = client.removeObject(bucketName, objName) + putObjPromise.then(() => { + done() + }) + .catch(() => { + done() + }) + }) + + }) + } + + + }) })