diff --git a/.travis.yml b/.travis.yml index 32e58f9eb..70eb05f5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,20 @@ sudo: false language: node_js node_js: - "6.0" +env: + - CXX=g++-4.8 -cache: - directories: - - node_modules addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 hosts: - nic.localhost - tim.localhost - nicola.localhost + +cache: + directories: + - node_modules diff --git a/bin/lib/options.js b/bin/lib/options.js index 1e882581c..96bd66732 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -75,6 +75,12 @@ module.exports = [ return answers.webid } }, + { + name: 'acceptCertificateHeader', + question: 'Accept client certificates through the X-SSL-Cert header (for reverse proxies)', + default: false, + prompt: false + }, { name: 'useOwner', question: 'Do you already have a WebID?', diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index c63012407..a8719115a 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -1,5 +1,8 @@ var webid = require('webid/tls') var debug = require('../../debug').authentication +var x509 // optional dependency, load lazily + +const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m function authenticate () { return handler @@ -13,10 +16,9 @@ function handler (req, res, next) { return next() } - var certificate = req.connection.getPeerCertificate() - // Certificate is empty? skip - if (certificate === null || Object.keys(certificate).length === 0) { - debug('No client certificate found in the request. Did the user click on a cert?') + // No certificate? skip + const certificate = getCertificateViaTLS(req) || getCertificateViaHeader(req) + if (!certificate) { setEmptySession(req) return next() } @@ -36,6 +38,50 @@ function handler (req, res, next) { }) } +// Tries to obtain a client certificate retrieved through the TLS handshake +function getCertificateViaTLS (req) { + const certificate = req.connection.getPeerCertificate && + req.connection.getPeerCertificate() + if (certificate && Object.keys(certificate).length > 0) { + return certificate + } + debug('No peer certificate received during TLS handshake.') +} + +// Tries to obtain a client certificate retrieved through the X-SSL-Cert header +function getCertificateViaHeader (req) { + // Only allow the X-SSL-Cert header if explicitly enabled + if (!req.app.locals.acceptCertificateHeader) return + + // Try to retrieve the certificate from the header + const header = req.headers['x-ssl-cert'] + if (!header) { + return debug('No certificate received through the X-SSL-Cert header.') + } + // The certificate's newlines have been replaced by tabs + // in order to fit in an HTTP header (NGINX does this automatically) + const rawCertificate = header.replace(/\t/g, '\n') + + // Ensure the header contains a valid certificate + // (x509 unsafely interprets it as a file path otherwise) + if (!CERTIFICATE_MATCHER.test(rawCertificate)) { + return debug('Invalid value for the X-SSL-Cert header.') + } + + // Parse and convert the certificate to the format the webid library expects + if (!x509) x509 = require('x509') + try { + const { publicKey, extensions } = x509.parseCert(rawCertificate) + return { + modulus: publicKey.n, + exponent: '0x' + parseInt(publicKey.e, 10).toString(16), + subjectaltname: extensions && extensions.subjectAlternativeName + } + } catch (error) { + debug('Invalid certificate received through the X-SSL-Cert header.') + } +} + function setEmptySession (req) { req.session.userId = '' req.session.identified = false diff --git a/lib/create-app.js b/lib/create-app.js index 50a06fdab..fea9d03a0 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -187,6 +187,7 @@ function initAuthentication (argv, app) { case 'tls': // Enforce authentication with WebID-TLS on all LDP routes app.use('/', API.tls.authenticate()) + app.locals.acceptCertificateHeader = argv.acceptCertificateHeader break case 'oidc': let oidc = OidcManager.fromServerConfig(argv) diff --git a/lib/create-server.js b/lib/create-server.js index 6e4c0b225..049b5f195 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -12,7 +12,7 @@ function createServer (argv, app) { argv = argv || {} app = app || express() var ldpApp = createApp(argv) - var ldp = ldpApp.locals.ldp + var ldp = ldpApp.locals.ldp || {} var mount = argv.mount || '/' // Removing ending '/' if (mount.length > 1 && @@ -21,9 +21,13 @@ function createServer (argv, app) { } app.use(mount, ldpApp) debug.settings('Base URL (--mount): ' + mount) - var server = http.createServer(app) - if (ldp && (ldp.webid || ldp.idp || argv.sslKey || argv.sslCert)) { + var server + var needsTLS = argv.sslKey || argv.sslCert || + (ldp.webid || ldp.idp) && !argv.acceptCertificateHeader + if (!needsTLS) { + server = http.createServer(app) + } else { debug.settings('SSL Private Key path: ' + argv.sslKey) debug.settings('SSL Certificate path: ' + argv.sslCert) diff --git a/package.json b/package.json index 62ba3a954..cbbcedfae 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,9 @@ "vhost": "^3.0.2", "webid": "^0.3.7" }, + "optionalDependencies": { + "x509": "^0.3.2" + }, "devDependencies": { "chai": "^3.5.0", "chai-as-promised": "^6.0.0", diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 4cf201707..2220c3a01 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -18,12 +18,43 @@ var rm = require('../test-utils').rm var ldnode = require('../../index') var ns = require('solid-namespace')($rdf) -describe('ACL HTTP', function () { +var address = 'https://localhost:3456/test/' +let rootPath = path.join(__dirname, '../resources') + +var aclExtension = '.acl' +var metaExtension = '.meta' + +var testDir = 'acl-tls/testDir' +var testDirAclFile = testDir + '/' + aclExtension +var testDirMetaFile = testDir + '/' + metaExtension + +var abcFile = testDir + '/abc.ttl' +var abcAclFile = abcFile + aclExtension + +var globFile = testDir + '/*' + +var groupFile = testDir + '/group' + +var origin1 = 'http://example.org/' +var origin2 = 'http://example.com/' + +var user1 = 'https://user1.databox.me/profile/card#me' +var user2 = 'https://user2.databox.me/profile/card#me' +var userCredentials = { + user1: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) + }, + user2: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) + } +} + +describe('ACL with WebID+TLS', function () { this.timeout(10000) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - var address = 'https://localhost:3456/test/' - let rootPath = path.join(__dirname, '../resources') var ldpHttpsServer var ldp = ldnode.createServer({ mount: '/test', @@ -45,36 +76,6 @@ describe('ACL HTTP', function () { fs.removeSync(path.join(rootPath, 'index.html.acl')) }) - var aclExtension = '.acl' - var metaExtension = '.meta' - - var testDir = 'acl-tls/testDir' - var testDirAclFile = testDir + '/' + aclExtension - var testDirMetaFile = testDir + '/' + metaExtension - - var abcFile = testDir + '/abc.ttl' - var abcAclFile = abcFile + aclExtension - - var globFile = testDir + '/*' - - var groupFile = testDir + '/group' - - var origin1 = 'http://example.org/' - var origin2 = 'http://example.com/' - - var user1 = 'https://user1.databox.me/profile/card#me' - var user2 = 'https://user2.databox.me/profile/card#me' - var userCredentials = { - user1: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) - }, - user2: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) - } - } - function createOptions (path, user) { var options = { url: address + path, @@ -971,3 +972,99 @@ describe('ACL HTTP', function () { }) }) }) + +describe('ACL with WebID through X-SSL-Cert', function () { + this.timeout(10000) + + var ldpHttpsServer + before(function (done) { + const ldp = ldnode.createServer({ + mount: '/test', + root: rootPath, + webid: true, + auth: 'tls', + acceptCertificateHeader: true + }) + ldpHttpsServer = ldp.listen(3456, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(rootPath, 'index.html')) + fs.removeSync(path.join(rootPath, 'index.html.acl')) + }) + + function prepareRequest (certHeader, setResponse) { + return done => { + const options = { + url: address.replace('https', 'http') + '/acl-tls/write-acl/.acl', + headers: { 'X-SSL-Cert': certHeader } + } + request(options, function (error, response) { + setResponse(response) + done(error) + }) + } + } + + describe('without certificate', function () { + var response + before(prepareRequest('', res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with a valid certificate', function () { + // Escape certificate for usage in HTTP header + const escapedCert = userCredentials.user1.cert.toString() + .replace(/\n/g, '\t') + + var response + before(prepareRequest(escapedCert, res => { response = res })) + + it('should return 200', function () { + assert.propertyVal(response, 'statusCode', 200) + }) + + it('should set the User header', function () { + assert.propertyVal(response.headers, 'user', 'https://user1.databox.me/profile/card#me') + }) + }) + + describe('with a local filename as certificate', function () { + const certFile = path.join(__dirname, '../keys/user1-cert.pem') + + var response + before(prepareRequest(certFile, res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with an invalid certificate value', function () { + var response + before(prepareRequest('xyz', res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with an invalid certificate', function () { + const invalidCert = +`-----BEGIN CERTIFICATE----- +ABCDEF +-----END CERTIFICATE-----` + .replace(/\n/g, '\t') + + var response + before(prepareRequest(invalidCert, res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) +})