var request = require('request') , url = require('url') , crypto = require('crypto') , YEAR = (1000 * 60 * 60 * 24 * 365) module.exports = CouchLogin function CouchLogin (couch, tok) { if (!(this instanceof CouchLogin)) { return new CouchLogin(couch) } if (!couch) throw new Error( "Need to pass a couch url to CouchLogin constructor") // having auth completely defeats the purpose couch = url.parse(couch) delete couch.auth if (tok === 'anonymous') tok = NaN this.token = tok this.couch = url.format(couch) this.proxy = null this.maxAge = YEAR } CouchLogin.prototype = { get: makeReq('GET') , del: makeReq('DELETE') , put: makeReq('PUT', true) , post: makeReq('POST', true) , login: login , logout: logout , decorate: decorate , changePass: changePass , signup: signup , deleteAccount: deleteAccount , anon: anon , anonymous: anon , valid: valid } Object.defineProperty(CouchLogin.prototype, 'constructor', { value: CouchLogin, enumerable: false }) function decorate (req, res) { req.couch = res.couch = this // backed by some sort of set(k,v,cb), get(k,cb) session storage. var session = req.session || res.session || null if (session) { this.tokenGet = function (cb) { session.get('couch_token', cb) } // don't worry about it failing. it'll just mean a login next time. this.tokenSet = function (tok, cb) { session.set('couch_token', tok, cb || function () {}) } this.tokenDel = function (cb) { session.del('couch_token', cb || function () {}) } } return this } function anon () { return new CouchLogin(this.couch, NaN) } function makeReq (meth, body, f) { return function madeReq (p, d, cb) { f = f || (this.token !== this.token) if (!f && !valid(this.token)) { // lazily get the token. if (this.tokenGet) return this.tokenGet(function (er, tok) { if (er || !valid(tok)) { if (!body) cb = d, d = null return cb(new Error('auth token expired or invalid')) } this.token = tok return madeReq.call(this, p, d, cb) }.bind(this)) // no getter, no token, no business. return process.nextTick(function () { if (!body) cb = d, d = null cb(new Error('auth token expired or invalid')) }) } if (!body) cb = d, d = null var h = {} , u = url.resolve(this.couch, p) , req = { uri: u, headers: h, json: true, body: d, method: meth } if (this.token) { h.cookie = 'AuthSession=' + this.token.AuthSession } if (this.proxy) { req.proxy = this.proxy } request(req, function (er, res, data) { // update cookie. if (er || res.statusCode !== 200) return cb(er, res, data) addToken.call(this, res) return cb(er, res, data) }.bind(this)) }} function login (auth, cb) { var a = { name: auth.name, password: auth.password } makeReq('post', true, true).call(this, '/_session', a, cb) } function changePass (auth, cb) { if (!auth.name || !auth.password) return cb(new Error('invalid auth')) var u = '/_users/org.couchdb.user:' + auth.name this.get(u, function (er, res, data) { if (er || res.statusCode !== 200) return cb(er, res, data) // copy any other keys we're setting here. // note that name, password_sha, salt, and date // are all set explicitly below. Object.keys(auth).filter(function (k) { return k.charAt(0) !== '_' && k !== 'password' && k !== 'password_sha' && k !== 'salt' }).forEach(function (k) { data[k] = auth[k] }) var newSalt = crypto.randomBytes(30).toString('hex') , newPass = auth.password , newSha = sha(newPass + newSalt) data.password_sha = newSha data.salt = newSalt data.date = new Date().toISOString() this.put(u + '?rev=' + data._rev, data, function (er, res, data) { if (er || res.statusCode >= 400) return cb(er, res, data) this.login(auth, cb) }.bind(this)) }.bind(this)) } // They said that there should probably be a warning before // deleting the user's whole account, so here it is: // // WATCH OUT! function deleteAccount (name, cb) { var u = '/_users/org.couchdb.user:' + name this.get(u, thenPut.bind(this)) function thenPut (er, res, data) { if (er || res.statusCode !== 200) { return cb(er, res, data) } // user accts can't be just DELETE'd by non-admins // so we take the existing doc and just slap a _deleted // flag on it to fake it. Works the same either way // in couch. data._deleted = true this.put(u + '?rev=' + data._rev, data, cb) } } function signup (auth, cb) { if (this.token) return this.logout(function (er, res, data) { if (er || res.statusCode !== 200) { return cb(er, res, data) } if (this.token) { return cb(new Error('failed to delete token'), res, data) } this.signup(auth, cb) }.bind(this)) // make a new user record. var newSalt = crypto.randomBytes(30).toString('hex') , newSha = sha(auth.password + newSalt) , user = { _id: 'org.couchdb.user:' + auth.name , name: auth.name , roles: [] , type: 'user' , password_sha: newSha , salt: newSalt , date: new Date().toISOString() } Object.keys(auth).forEach(function (k) { if (k === 'name' || k === 'password') return user[k] = auth[k] }) var u = '/_users/' + user._id makeReq('put', true, true).call(this, u, user, function (er, res, data) { if (er || res.statusCode >= 400) { return cb(er, res, data) } // it worked! log in as that user and get their record this.login(auth, function (er, res, data) { if (er || (res && res.statusCode >= 400) || data && data.error) { return cb(er, res, data) } this.get(u, cb) }.bind(this)) }.bind(this)) } function addToken (res) { // attach the token, if a new one was provided. var sc = res.headers['set-cookie'] if (!sc) return if (!Array.isArray(sc)) sc = [sc] sc = sc.filter(function (c) { return c.match(/^AuthSession=/) })[0] if (!sc.length) return sc = sc.split(/\s*;\s*/).map(function (p) { return p.split('=') }).reduce(function (set, p) { var k = p[0] === 'AuthSession' ? p[0] : p[0].toLowerCase() , v = k === 'expires' ? Date.parse(p[1]) : p[1] === '' || p[1] === undefined ? true // HttpOnly : p[1] set[k] = v return set }, {}) if (sc.hasOwnProperty('max-age')) { var ma = sc['max-age'] sc.expires = (ma <= 0) ? 0 : Date.now() + (ma * 1000) delete sc['max-age'] } // expire the session after 1 year, even if couch won't. if (!sc.hasOwnProperty('expires')) { sc.expires = Date.now() + YEAR } if (!isNaN(this.maxAge)) { sc.expires = Math.min(sc.expires, Date.now() + this.maxAge) } this.token = sc if (this.tokenSet) this.tokenSet(this.token) } function logout (cb) { if (!this.token && this.tokenGet) { return this.tokenGet(function (er, tok) { if (er || !tok) return cb() this.token = tok this.logout(cb) }.bind(this)) } if (!valid(this.token)) { this.token = null if (this.tokenDel) this.tokenDel() return process.nextTick(cb) } var h = { cookie: 'AuthSession=' + this.token.AuthSession } , u = url.resolve(this.couch, '/_session') , req = { uri: u, headers: h, json: true } request.del(req, function (er, res, data) { if (er || (res.statusCode !== 200 && res.statusCode !== 404)) { return cb(er, res, data) } this.token = null if (this.tokenDel) this.tokenDel() cb(er, res, data) }.bind(this)) } function valid (token) { if (!token) return false if (!token.hasOwnProperty('expires')) return true return token.expires > Date.now() } function sha (s) { return crypto.createHash("sha1").update(s).digest("hex") }