diff --git a/lib/auth.js b/lib/auth.js index e1c722001..d840b7093 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,5 +1,6 @@ var assert = require('assert') var Crypto = require('crypto') +var jju = require('jju') var Error = require('http-errors') var Path = require('path') var Logger = require('./logger') @@ -10,6 +11,7 @@ function Auth(config) { var self = Object.create(Auth.prototype) self.config = config self.logger = Logger.logger.child({ sub: 'auth' }) + self.secret = config.secret || Crypto.pseudoRandomBytes(32) var stuff = { config: config, @@ -92,13 +94,16 @@ Auth.prototype.authenticate = function(user, password, cb) { ;(function next() { var p = plugins.shift() p.authenticate(user, password, function(err, groups) { - if (err || groups) return cb(err, groups) + if (err) return cb(err) + if (groups != null && groups != false) + return cb(err, AuthenticatedUser(user, groups)) next() }) })() } Auth.prototype.add_user = function(user, password, cb) { + var self = this var plugins = this.plugins.slice(0) ;(function next() { @@ -111,14 +116,15 @@ Auth.prototype.add_user = function(user, password, cb) { next() } else { p[n](user, password, function(err, ok) { - if (err || ok) return cb(err, ok) + if (err) return cb(err) + if (ok) return self.authenticate(user, password, cb) next() }) } })() } -Auth.prototype.auth_middleware = function() { +Auth.prototype.basic_middleware = function() { var self = this return function(req, res, _next) { req.pause() @@ -150,14 +156,14 @@ Auth.prototype.auth_middleware = function() { var index = credentials.indexOf(':') if (scheme !== 'Basic' || index < 0) - return next( Error[400]('bad authorization header') ) + return next() var user = credentials.slice(0, index) var pass = credentials.slice(index + 1) - self.authenticate(user, pass, function(err, groups) { - if (!err && groups != null && groups != false) { - req.remote_user = AuthenticatedUser(user, groups) + self.authenticate(user, pass, function(err, user) { + if (!err) { + req.remote_user = user next() } else { req.remote_user = AnonymousUser() @@ -167,9 +173,47 @@ Auth.prototype.auth_middleware = function() { } } +Auth.prototype.bearer_middleware = function() { + var self = this + return function(req, res, _next) { + req.pause() + function next(err) { + req.resume() + return _next.apply(null, arguments) + } + + if (req.remote_user != null && req.remote_user.name !== undefined) + return next() + req.remote_user = AnonymousUser() + + var authorization = req.headers.authorization + if (authorization == null) return next() + + var parts = authorization.split(' ') + + if (parts.length !== 2) + return next( Error[400]('bad authorization header') ) + + var scheme = parts[0] + var token = parts[1] + + if (scheme !== 'Bearer') + return next() + + try { + var user = self.decode_token(token) + } catch(err) { + return next(err) + } + + req.remote_user = AuthenticatedUser(user.u, user.g) + req.remote_user.token = token + next() + } +} + Auth.prototype.cookie_middleware = function() { var self = this - return function(req, res, _next) { req.pause() function next(err) { @@ -182,40 +226,71 @@ Auth.prototype.cookie_middleware = function() { req.remote_user = AnonymousUser() - var cookie = req.cookies.get('token') - if (cookie == null) return next() + var token = req.cookies.get('token') + if (token == null) return next() - var credentials = Buffer(cookie, 'base64').toString() - var index = credentials.indexOf(':') + try { + var user = self.decode_token(token, 60*60 /* 1 hour */) + } catch(err) { + return next() + } - var user = credentials.slice(0, index) - var pass = credentials.slice(index + 1) - - self.authenticate(user, pass, function(err, groups) { - if (!err && groups != null && groups != false) { - req.remote_user = AuthenticatedUser(user, groups) - next() - } else { - req.remote_user = AnonymousUser() - next(err) - } - }) + req.remote_user = AuthenticatedUser(user.u, user.g) + req.remote_user.token = token + next() } } +Auth.prototype.issue_token = function(user) { + var data = jju.stringify({ + u: user.name, + g: user.real_groups && user.real_groups.length ? user.real_groups : undefined, + t: ~~(Date.now()/1000), + }, { indent: false }) + + data = Buffer(data, 'utf8') + var mac = Crypto.createHmac('sha256', this.secret).update(data).digest() + return Buffer.concat([ data, mac ]).toString('base64') +} + +Auth.prototype.decode_token = function(str, expire_time) { + var buf = Buffer(str, 'base64') + if (buf.length <= 32) throw Error[401]('invalid token') + + var data = buf.slice(0, buf.length - 32) + var their_mac = buf.slice(buf.length - 32) + var good_mac = Crypto.createHmac('sha256', this.secret).update(data).digest() + + their_mac = Crypto.createHash('sha512').update(their_mac).digest('hex') + good_mac = Crypto.createHash('sha512').update(good_mac).digest('hex') + if (their_mac !== good_mac) throw Error[401]('bad signature') + + // make token expire in 24 hours + // TODO: make configurable? + expire_time = expire_time || 24*60*60 + + data = jju.parse(data.toString('utf8')) + if (Math.abs(data.t - ~~(Date.now()/1000)) > expire_time) + throw Error[401]('token expired') + + return data +} + function AnonymousUser() { return { name: undefined, - // groups without '@' are going to be deprecated eventually + // groups without '$' are going to be deprecated eventually groups: [ '$all', '$anonymous', '@all', '@anonymous', 'all', 'undefined', 'anonymous' ], + real_groups: [], } } function AuthenticatedUser(name, groups) { - groups = groups.concat([ '$all', '$authenticated', '@all', '@authenticated', 'all' ]) + var _groups = (groups || []).concat([ '$all', '$authenticated', '@all', '@authenticated', 'all' ]) return { name: name, - groups: groups, + groups: _groups, + real_groups: groups, } } diff --git a/lib/index-api.js b/lib/index-api.js index 707663536..b3faf01bc 100644 --- a/lib/index-api.js +++ b/lib/index-api.js @@ -26,10 +26,20 @@ module.exports = function(config, auth, storage) { app.param('org_couchdb_user', match(/^org\.couchdb\.user:/)) app.param('anything', match(/.*/)) - app.use(auth.auth_middleware()) + app.use(auth.basic_middleware()) + app.use(auth.bearer_middleware()) app.use(expressJson5({ strict: false, limit: config.max_body_size || '10mb' })) app.use(Middleware.anti_loop(config)) + // for "npm whoami" + app.get('/whoami', function(req, res, next) { + if (req.headers.referer === 'whoami') { + next({ username: req.remote_user.name }) + } else { + next('route') + } + }) + // TODO: anonymous user? app.get('/:package/:version?', can('access'), function(req, res, next) { storage.get_package(req.params.package, { req: req }, function(err, info) { @@ -104,7 +114,8 @@ module.exports = function(config, auth, storage) { if (req.remote_user.name != null) { res.status(201) return next({ - ok: 'you are authenticated as "' + req.remote_user.name + '"', + ok: "you are authenticated as '" + req.remote_user.name + "'", + token: auth.issue_token(req.remote_user), }) } else { if (typeof(req.body.name) !== 'string' || typeof(req.body.password) !== 'string') { @@ -114,7 +125,7 @@ module.exports = function(config, auth, storage) { return next( Error[422]('user/password is not found in request (npm issue?)') ) } } - auth.add_user(req.body.name, req.body.password, function(err) { + auth.add_user(req.body.name, req.body.password, function(err, user) { if (err) { if (err.status < 500 && err.message === 'this user already exists') { // with npm registering is the same as logging in @@ -124,8 +135,12 @@ module.exports = function(config, auth, storage) { return next(err) } + req.remote_user = user res.status(201) - return next({ ok: 'user "' + req.body.name + '" created' }) + return next({ + ok: "user '" + req.body.name + "' created", + token: auth.issue_token(req.remote_user), + }) }) } }) diff --git a/lib/index-web.js b/lib/index-web.js index f8013e3c6..db2f0327c 100644 --- a/lib/index-web.js +++ b/lib/index-web.js @@ -76,9 +76,15 @@ module.exports = function(config, auth, storage) { }) app.post('/-/login', function(req, res, next) { - var base = config.url_prefix || req.protocol + '://' + req.get('host') - res.cookies.set('token', Buffer(req.body.user + ':' + req.body.pass).toString('base64')) - res.redirect(base) + auth.authenticate(req.body.user, req.body.pass, function(err, user) { + if (!err) { + req.remote_user = user + res.cookies.set('token', auth.issue_token(req.remote_user)) + } + + var base = config.url_prefix || req.protocol + '://' + req.get('host') + res.redirect(base) + }) }) app.post('/-/logout', function(req, res, next) { diff --git a/lib/middleware.js b/lib/middleware.js index 6180f2773..3ba771452 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -88,6 +88,11 @@ module.exports.allow = function(config) { } module.exports.final = function(body, req, res, next) { + if (res.statusCode === 401 && !res.getHeader('WWW-Authenticate')) { + // they say it's required for 401, so... + res.header('WWW-Authenticate', 'Basic, Bearer') + } + try { if (typeof(body) === 'string' || typeof(body) === 'object') { if (!res.getHeader('Content-type')) { diff --git a/package.yaml b/package.yaml index 4b9813805..863a11d6c 100644 --- a/package.yaml +++ b/package.yaml @@ -41,6 +41,7 @@ dependencies: highlight.js: '8.x' lunr: '>=0.5.2 <1.0.0-0' marked: '>=0.3.2 <1.0.0-0' + jju: '1.x' # bundled deps, see below mkdirp: '>=0.3.5 <1.0.0-0'