From 2d182fdb9ac43cebd1b2100d0cb28be4b5d7ef86 Mon Sep 17 00:00:00 2001 From: Fynn Leitow <19202953+LyteFM@users.noreply.github.com> Date: Sat, 25 Jul 2020 15:14:39 +0200 Subject: [PATCH] :construction: Simper, leaner version of superlogin No more session caching --- .github/workflows/ci-workflow.yml | 1 + package-lock.json | 75 +---------- package.json | 7 +- src/dbauth/couchdb.ts | 13 +- src/dbauth/index.ts | 2 +- src/index.ts | 6 +- src/session.ts | 89 +------------ src/sessionAdapters/FileAdapter.ts | 62 ---------- src/sessionAdapters/MemoryAdapter.ts | 54 -------- src/sessionAdapters/RedisAdapter.ts | 63 ---------- src/types/typings.d.ts | 11 +- src/user.ts | 179 ++++++++++++--------------- test/session.spec.js | 157 +++++++---------------- test/test-server.js | 1 - test/user.spec.js | 8 +- 15 files changed, 154 insertions(+), 574 deletions(-) delete mode 100644 src/sessionAdapters/FileAdapter.ts delete mode 100644 src/sessionAdapters/MemoryAdapter.ts delete mode 100644 src/sessionAdapters/RedisAdapter.ts diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index f6b3554..f092133 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -7,6 +7,7 @@ on: - master - release - dev + - minimal jobs: test: diff --git a/package-lock.json b/package-lock.json index 772ef20..d2a67ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -681,11 +681,6 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, - "at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" - }, "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -812,11 +807,6 @@ "file-uri-to-path": "1.0.0" } }, - "bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, "body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", @@ -1491,11 +1481,6 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, - "denque": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", - "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" - }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -2389,17 +2374,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" }, - "fs-extra": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", - "integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==", - "requires": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^1.0.0" - } - }, "fs-mkdirp-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz", @@ -2624,7 +2598,8 @@ "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true }, "growl": { "version": "1.10.5", @@ -3810,15 +3785,6 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, - "jsonfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz", - "integrity": "sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==", - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^1.0.0" - } - }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -4665,7 +4631,8 @@ "nodemailer-stub-transport": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/nodemailer-stub-transport/-/nodemailer-stub-transport-1.1.0.tgz", - "integrity": "sha1-EUIdLWa07m9AU1T5FMH0ZB6ySw0=" + "integrity": "sha1-EUIdLWa07m9AU1T5FMH0ZB6ySw0=", + "optional": true }, "normalize-package-data": { "version": "2.5.0", @@ -5418,35 +5385,6 @@ "resolve": "^1.1.6" } }, - "redis": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.0.2.tgz", - "integrity": "sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==", - "requires": { - "denque": "^1.4.1", - "redis-commands": "^1.5.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" - } - }, - "redis-commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz", - "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg==" - }, - "redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" - }, - "redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", - "requires": { - "redis-errors": "^1.0.0" - } - }, "regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -6577,11 +6515,6 @@ "through2-filter": "^3.0.0" } }, - "universalify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", - "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==" - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 36ea937..b53d7b9 100644 --- a/package.json +++ b/package.json @@ -50,20 +50,19 @@ "@types/express": "^4.17.6", "@types/nodemailer": "^6.4.0", "@types/passport": "^1.0.3", - "bluebird": "^3.7.2", "deepmerge": "^4.2.2", "ejs": "^3.1.3", "express": "^4.17.1", - "fs-extra": "^9.0.0", "nodemailer": "^6.4.6", - "nodemailer-stub-transport": "^1.1.0", "passport": "^0.4.1", "passport-http-bearer-sl": "^1.0.1", "passport-local": "^1.0.0", - "redis": "^3.0.2", "urlsafe-base64": "1.0.0", "uuid": "^8.2.0" }, + "optionalDependencies": { + "nodemailer-stub-transport": "^1.1.0" + }, "devDependencies": { "@types/ejs": "^3.0.4", "@types/passport-local": "^1.0.33", diff --git a/src/dbauth/couchdb.ts b/src/dbauth/couchdb.ts index 5588cd2..4aec0f4 100644 --- a/src/dbauth/couchdb.ts +++ b/src/dbauth/couchdb.ts @@ -52,14 +52,11 @@ export class CouchAdapter implements DBAdapter { roles: roles, provider: provider }; - if (this.couchAuthOnCloudant) { - // PWs need to be hashed manually when using pbkdf2 - newKey.password_scheme = 'pbkdf2'; - newKey.iterations = 10; - newKey = { ...newKey, ...(await hashPassword(password)) }; - } else { - newKey.password = password; - } + // required when using Cloudant or other db than `_users` + newKey.password_scheme = 'pbkdf2'; + newKey.iterations = 10; + newKey = { ...newKey, ...(await hashPassword(password)) }; + await this.#couchAuthDB.insert(newKey); newKey._id = key; return newKey; diff --git a/src/dbauth/index.ts b/src/dbauth/index.ts index 79e855c..80461be 100644 --- a/src/dbauth/index.ts +++ b/src/dbauth/index.ts @@ -67,7 +67,7 @@ export class DBAuth { } retrieveKey(key: string) { - return this.#adapter.retrieveKey(key); + return this.#adapter.retrieveKey(key) as Promise; } /** generates a random token and password (CouchDB) or retrieves from Cloudant */ diff --git a/src/index.ts b/src/index.ts index e3aefb1..ca567dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,15 @@ 'use strict'; import { addProvidersToDesignDoc, - getCloudantURL, - getDBURL, hashPassword, loadCouchServer, verifyPassword } from './util'; -import cloudant, { ServerScope as CloudantServer } from '@cloudant/cloudant'; import { CouchDbAuthDoc, SlUserDoc } from './types/typings'; +import { DocumentScope, ServerScope as NanoServer } from 'nano'; import express, { Router } from 'express'; -import nano, { DocumentScope, ServerScope as NanoServer } from 'nano'; import { Authenticator } from 'passport'; +import { ServerScope as CloudantServer } from '@cloudant/cloudant'; import { Config } from './types/config'; import { ConfigHelper } from './config/configure'; import events from 'events'; diff --git a/src/session.ts b/src/session.ts index 10f6ab7..4b011d0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,98 +1,21 @@ 'use strict'; - -import { hashPassword, verifyPassword } from './util'; -import { FileAdapter } from './sessionAdapters/FileAdapter'; -import { MemoryAdapter } from './sessionAdapters/MemoryAdapter'; -import { RedisAdapter } from './sessionAdapters/RedisAdapter'; - -const extend = require('util')._extend; - -const tokenPrefix = 'token'; +import { LocalHashObj } from './types/typings'; +import { verifyPassword } from './util'; export class Session { - #adapter; static invalidMsg = 'invalid token'; - constructor(config) { - let adapter; - const sessionAdapter = config.getItem('session.adapter'); - if (sessionAdapter === 'redis') { - adapter = new RedisAdapter(config); - } else if (sessionAdapter === 'file') { - adapter = new FileAdapter(config); - } else { - adapter = new MemoryAdapter(); - } - this.#adapter = adapter; - } + constructor(config?) {} - storeToken(token) { - token = extend({}, token); - if (!token.password && token.salt && token.derived_key) { - return this.#adapter - .storeKey( - tokenPrefix + ':' + token.key, - token.expires - Date.now(), - JSON.stringify(token) - ) - .then(() => { - delete token.salt; - delete token.derived_key; - return Promise.resolve(token); - }); - } - return hashPassword(token.password) - .then(hash => { - token.salt = hash.salt; - token.derived_key = hash.derived_key; - delete token.password; - return this.#adapter.storeKey( - tokenPrefix + ':' + token.key, - token.expires - Date.now(), - JSON.stringify(token) - ); - }) - .then(() => { - delete token.salt; - delete token.derived_key; - return Promise.resolve(token); - }); - } - - deleteTokens(keys) { - const entries = []; - if (!(keys instanceof Array)) { - keys = [keys]; - } - keys.forEach(key => { - entries.push(tokenPrefix + ':' + key); - }); - return this.#adapter.deleteKeys(entries); - } - - async confirmToken(key: string, password: string) { + /** Confirms the token and removes the information that should not be sent to the client */ + async confirmToken(token: LocalHashObj, password: string) { try { - const result = await this.#adapter.getKey(tokenPrefix + ':' + key); - if (!result) { - throw Session.invalidMsg; - } - const token = JSON.parse(result); await verifyPassword(token, password); delete token.salt; delete token.derived_key; + delete token.iterations; return token; } catch (error) { throw Session.invalidMsg; } } - /** - * retrieved the key from the session adapter - */ - fetchToken(key: string) { - return this.#adapter.getKey(tokenPrefix + ':' + key).then(result => { - return Promise.resolve(JSON.parse(result)); - }); - } - quit() { - return this.#adapter.quit(); - } } diff --git a/src/sessionAdapters/FileAdapter.ts b/src/sessionAdapters/FileAdapter.ts deleted file mode 100644 index fd04c6f..0000000 --- a/src/sessionAdapters/FileAdapter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ConfigHelper } from '../config/configure'; -import { SessionAdapter } from '../types/adapters'; - -const fs = require('fs-extra'); -const path = require('path'); - -export class FileAdapter implements SessionAdapter { - #sessionFolder; - constructor(config: ConfigHelper) { - const sessionsRoot = config.getItem('session.file.sessionsRoot'); - this.#sessionFolder = path.join(process.env.PWD, sessionsRoot); - console.log('File Adapter loaded'); - } - - private getFilepath(key: string) { - return path.format({ - dir: this.#sessionFolder, - base: key + '.json' - }); - } - - storeKey(key: string, life: number, data: string) { - const now = Date.now(); - return fs.outputJson(this.getFilepath(key), { - data: data, - expire: now + life - }); - } - - getKey(key: string) { - const now = Date.now(); - return fs - .readJson(this.getFilepath(key)) - .then(session => { - if (session.expire > now) { - return session.data; - } - return false; - }) - .catch(() => { - return false; - }); - } - - deleteKeys(keys: string[]) { - if (!(keys instanceof Array)) { - keys = [keys]; - } - const deleteQueue = keys.map(key => { - return fs.remove(this.getFilepath(key)); - }); - - return Promise.all(deleteQueue).then(done => { - // this._removeExpired(); todo maybe? - return done.length; - }); - } - - quit() { - return Promise.resolve(); - } -} diff --git a/src/sessionAdapters/MemoryAdapter.ts b/src/sessionAdapters/MemoryAdapter.ts deleted file mode 100644 index 2aab74f..0000000 --- a/src/sessionAdapters/MemoryAdapter.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { SessionAdapter } from '../types/adapters'; - -export class MemoryAdapter implements SessionAdapter { - #keys: Record; - #expires: Record; - constructor(config?: any) { - this.#keys = {}; - this.#expires = {}; - console.log('Memory Adapter loaded'); - } - - storeKey(key: string, life: number, data: string) { - const now = Date.now(); - this.#keys[key] = data; - this.#expires[key] = now + life; - this.removeExpired(); - return Promise.resolve(); - } - - getKey(key: string) { - const now = Date.now(); - if (this.#keys[key] && this.#expires[key] > now) { - return Promise.resolve(this.#keys[key]); - } else { - return Promise.resolve(false); - } - } - - deleteKeys(keys: string[]) { - if (!(keys instanceof Array)) { - keys = [keys]; - } - keys.forEach(key => { - delete this.#keys[key]; - delete this.#expires[key]; - }); - this.removeExpired(); - return Promise.resolve(keys.length); - } - - quit() { - return Promise.resolve(); - } - - private removeExpired() { - const now = Date.now(); - Object.keys(this.#expires).forEach(key => { - if (this.#expires[key] < now) { - delete this.#keys[key]; - delete this.#expires[key]; - } - }); - } -} diff --git a/src/sessionAdapters/RedisAdapter.ts b/src/sessionAdapters/RedisAdapter.ts deleted file mode 100644 index c28fd04..0000000 --- a/src/sessionAdapters/RedisAdapter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ConfigHelper } from '../config/configure'; - -import BPromise = require('bluebird'); -import { SessionAdapter } from '../types/adapters'; -const redis = BPromise.promisifyAll(require('redis')); - -export class RedisAdapter implements SessionAdapter { - #redisClient; - - constructor(config: ConfigHelper) { - if (!config.getItem('session.redis.unix_socket')) { - if (config.getItem('session.redis.url')) { - this.#redisClient = redis.createClient( - config.getItem('session.redis.url'), - config.getItem('session.redis.options') - ); - } else { - this.#redisClient = redis.createClient( - config.getItem('session.redis.port') || 6379, - config.getItem('session.redis.host') || '127.0.0.1', - config.getItem('session.redis.options') - ); - } - } else { - this.#redisClient = redis.createClient( - config.getItem('session.redis.unix_socket'), - config.getItem('session.redis.options') - ); - } - - // Authenticate with Redis if necessary - if (config.getItem('session.redis.password')) { - this.#redisClient - .authAsync(config.getItem('session.redis.password')) - .catch(err => { - throw new Error(err); - }); - } - - this.#redisClient.on('error', err => { - console.error('Redis error: ' + err); - }); - - this.#redisClient.on('connect', () => { - console.log('Redis is ready'); - }); - } - storeKey(key: string, life: number, data: string) { - return this.#redisClient.psetexAsync(key, life, data); - } - - deleteKeys(keys: string[]) { - return this.#redisClient.delAsync(keys); - } - - getKey(key: string) { - return this.#redisClient.getAsync(key); - } - - quit() { - return this.#redisClient.quit(); - } -} diff --git a/src/types/typings.d.ts b/src/types/typings.d.ts index 23acddf..a71f9da 100644 --- a/src/types/typings.d.ts +++ b/src/types/typings.d.ts @@ -95,15 +95,16 @@ export interface SlUserDoc extends Document, IdentifiedObj { profile: any; } -export interface SlSession { - expires: number; - issued: number; - password: string; +export interface SlRefreshSession extends TimeRestricted { provider: string; roles: string[]; token: string; - userDBs: { [db: string]: string }; user_id: string; +} + +export interface SlLoginSession extends SlRefreshSession { + password: string; + userDBs: { [db: string]: string }; ip?: string; profile?: string; user_uid?: string; diff --git a/src/user.ts b/src/user.ts index 3a02e94..a33cca3 100644 --- a/src/user.ts +++ b/src/user.ts @@ -12,8 +12,9 @@ import { import { CouchDbAuthDoc, SessionObj, + SlLoginSession, + SlRefreshSession, SlRequest, - SlSession, SlUserDoc } from './types/typings'; import Model, { Sofa } from '@sl-nx/sofa-model'; @@ -714,7 +715,7 @@ export class User { const password = token.password; const newToken = token; newToken.provider = provider; - await this.#session.storeToken(newToken); + //await this.#session.storeToken(newToken); await this.#dbAuth.storeKey( user_id, newToken.key, @@ -735,7 +736,7 @@ export class User { if (!user.session) { user.session = {}; } - const newSession: Partial = { + const newSession: Partial = { issued: newToken.issued, expires: newToken.expires, provider: provider, @@ -799,7 +800,7 @@ export class User { } } this.emitter.emit('login', newSession, provider); - return newSession; + return newSession as SlLoginSession; } handleFailedLogin(user: SlUserDoc, req: Partial) { @@ -878,39 +879,27 @@ export class User { /** * Extends the life of your current token and returns updated token information. * The only field that will change is expires. Expired sessions are removed. + * todo: + * - handle error if invalid state occurs that doc is not present. + * - I'd need to store salts & derived keys within sl-users as well for staying + * compatible with legacy auth on cloudant + * - ensure that ip is removed/ not sent */ - refreshSession(key: string): Promise { - let newSession; - return this.#session - .fetchToken(key) - .then(oldToken => { - newSession = oldToken; - newSession.expires = Date.now() + this.sessionLife * 1000; - return Promise.all([ - this.userDB.get(newSession._id), - this.#session.storeToken(newSession) - ]); - }) - .then(results => { - const userDoc = results[0]; - userDoc.session[key].expires = newSession.expires; - // Clean out expired sessions on refresh - return this.logoutUserSessions(userDoc, Cleanup.expired); - }) - .then(finalUser => { - return this.userDB.insert(finalUser); - }) - .then(() => { - delete newSession.password; - newSession.token = newSession.key; - delete newSession.key; - newSession.user_id = newSession._id; - delete newSession._id; - delete newSession.salt; - delete newSession.derived_key; - this.emitter.emit('refresh', newSession); - return Promise.resolve(newSession); - }); + async refreshSession(key: string): Promise { + const userDoc = await this.findUserDocBySession(key); + userDoc.session[key].expires = Date.now() + this.sessionLife * 1000; + // Clean out expired sessions on refresh + const finalUser = await this.logoutUserSessions(userDoc, Cleanup.expired); + await this.userDB.insert(finalUser); + const newSession: SlRefreshSession = { + ...userDoc.session[key], + token: key, + user_id: userDoc._id, + roles: userDoc.roles + }; + delete newSession['ip']; + this.emitter.emit('refresh', newSession); + return newSession; } /** @@ -1200,6 +1189,18 @@ export class User { return this.userDB.insert(finalUser); } + async findUserDocBySession(key: string): Promise { + const results = await this.userDB.view('auth', 'session', { + key, + include_docs: true + }); + if (results.rows.length > 0) { + return results.rows[0].doc as SlUserDoc; + } else { + return undefined; + } + } + addUserDB( user_id: string, dbName: string, @@ -1283,17 +1284,15 @@ export class User { status: 401 }); } - promise = this.userDB - .view('auth', 'session', { key: session_id, include_docs: true }) - .then(results => { - if (!results.rows.length) { - return Promise.reject({ - error: 'unauthorized', - status: 401 - }); - } - return Promise.resolve(results.rows[0].doc); - }); + promise = this.findUserDocBySession(session_id).then(userDoc => { + if (!userDoc) { + return Promise.reject({ + error: 'unauthorized', + status: 401 + }); + } + return Promise.resolve(userDoc); + }); } return promise .then(record => { @@ -1308,45 +1307,41 @@ export class User { }); } + /** + * todo: Should I really allow to fail after `removeKeys`? + * -> I'd like my `sl-users` to be single source of truth, don't I? + */ async logoutSession(session_id: string) { - let user; let startSessions = 0; let endSessions = 0; - const results = await this.userDB.view('auth', 'session', { - key: session_id, - include_docs: true - }); - if (!results.rows.length) { + let user = await this.findUserDocBySession(session_id); + if (!user) { throw { error: 'unauthorized', status: 401 }; } - user = results.rows[0].doc; if (user.session) { startSessions = Object.keys(user.session).length; if (user.session[session_id]) { delete user.session[session_id]; } } - // 1.) if this fails, the whole logout has failed! Else ok, can clean up later. + // 1.) if this fails, the whole logout has failed! Else ok, will be cleaned up later. await this.#dbAuth.removeKeys(session_id); let caughtError = {}; try { - const removals = [this.#session.deleteTokens(session_id)]; - if (user) { - removals.push(this.#dbAuth.deauthorizeUser(user, session_id)); - } - await Promise.all(removals); + // 2) deauthorize from user's dbs + await this.#dbAuth.deauthorizeUser(user, session_id); // Clean out expired sessions - const finalUser = await this.logoutUserSessions(user, Cleanup.expired); - user = finalUser; + user = await this.logoutUserSessions(user, Cleanup.expired); if (user.session) { endSessions = Object.keys(user.session).length; } this.emitter.emit('logout', user._id); if (startSessions !== endSessions) { + // 3) update the sl-doc return this.userDB.insert(user); } else { return false; @@ -1370,13 +1365,8 @@ export class User { } async logoutOthers(session_id) { - let user; - const results = await this.userDB.view('auth', 'session', { - key: session_id, - include_docs: true - }); - if (results.rows.length) { - user = results.rows[0].doc; + const user = await this.findUserDocBySession(session_id); + if (user) { if (user.session && user.session[session_id]) { const finalUser = await this.logoutUserSessions( user, @@ -1412,10 +1402,7 @@ export class User { // 1.) Remove the keys from our couchDB auth database. Must happen first. await this.#dbAuth.removeKeys(sessions); // 2.) Deauthorize keys from each personal database and from session store - await Promise.all([ - this.#dbAuth.deauthorizeUser(userDoc, sessions), - this.#session.deleteTokens(sessions) - ]); + await this.#dbAuth.deauthorizeUser(userDoc, sessions); if (op === Cleanup.expired || op === Cleanup.other) { sessions.forEach(session => { delete userDoc.session[session]; @@ -1444,40 +1431,30 @@ export class User { return this.userDB.destroy(user._id, user._rev); } + /** + * Confirms the user:password that has been passed as Bearer Token + * Todo: maybe just look in superlogin-users or try to access DB? + */ async confirmSession(key: string, password: string) { try { - return await this.#session.confirmToken(key, password); - } catch (error) { - if (this.useDbFallback) { - try { - const doc = await this.#dbAuth.retrieveKey(key); - if (doc.expires > Date.now()) { - const token: any = doc; - token._id = token.user_id; - token.key = key; - delete token.user_id; - delete token.name; - delete token.type; - delete token._rev; - delete token.password_scheme; - delete token.iterations; - await this.#session.storeToken(token); - return this.#session.confirmToken(key, password); - } - } catch {} + const doc = await this.#dbAuth.retrieveKey(key); + if (doc.expires > Date.now()) { + const token: any = doc; + token._id = token.user_id; + token.key = key; + delete token.user_id; + delete token.name; + delete token.type; + delete token._rev; + delete token.password_scheme; + return this.#session.confirmToken(token, password); + } else { + this.#dbAuth.removeKeys(key); } - } + } catch {} throw Session.invalidMsg; } - removeFromSessionCache(keys) { - return this.#session.deleteTokens(keys); - } - - quitRedis() { - return this.#session.quit(); - } - generateSession(username, roles) { return this.#dbAuth.getApiKey().then(key => { const now = Date.now(); diff --git a/test/session.spec.js b/test/session.spec.js index 1e7fbe3..6aa4a5c 100644 --- a/test/session.spec.js +++ b/test/session.spec.js @@ -1,129 +1,62 @@ 'use strict'; -const util = require('util'); const expect = require('chai').expect; const Session = require('../lib/session').Session; -const Configure = require('../lib/config/configure').ConfigHelper; -const rimraf = util.promisify(require('rimraf')); +let previous; +const session = new Session(); const testToken = { _id: 'colinskow', roles: ['admin', 'user'], key: 'test123', - password: 'pass123', issued: Date.now(), - expires: Date.now() + 50000 + expires: Date.now() + 50000, + password_scheme: 'pbkdf2', + iterations: 10, + salt: '991bc3c09ff7322f7f1361e383a9d9f8', + derived_key: 'e04e30ee0ef31d541f1fb731c9631a9f48fa5196' }; - -const config = new Configure({ - session: { - adapter: 'memory' - } -}); - -const fileConfig = new Configure({ - session: { - adapter: 'file', - file: { - sessionsRoot: '.session' - } - } -}); - -describe('Session', function () { - return runTest(config, 'Memory adapter') - .finally(function () { - return runTest(fileConfig, 'File adapter'); - }) - .finally(function () { - config.setItem('session.adapter', 'redis'); - return runTest(config, 'Redis adapter'); - }) - .finally(function () { - return rimraf('./.session'); - }); -}); - -function runTest(config, adapter) { - const session = new Session(config); - let previous; - - return new Promise(function (resolve, reject) { - describe(adapter, function () { - it('should store a token', function (done) { - previous = session - .storeToken(testToken) - .then(function () { - return session.confirmToken(testToken.key, testToken.password); - }) - .then(function (result) { - console.log('stored token'); - expect(result.key).to.equal(testToken.key); - done(); - }) - .catch(function (err) { - done(err); - }); - }); - - it('should confirm a key and return the full token if valid', function (done) { - previous.then(function () { - return session - .confirmToken(testToken.key, testToken.password) - .then(function (result) { - console.log('confirmed token'); - expect(result._id).to.equal('colinskow'); - done(); - }) - .catch(function (err) { - done(err); - }); - }); - }); - - it('should reject an invalid token', function (done) { - previous.then(function () { - return session - .confirmToken('faketoken', testToken.password) - .catch(function (err) { - console.log('rejected invalid token'); - expect(err).to.equal('invalid token'); - done(); - }); +const badToken = { + ...testToken, + salt: 'salt', + derived_key: 'key' +}; +describe('Session', async function () { + previous = Promise.resolve(); + + it('should confirm a token and return it if valid', function (done) { + previous.then(function () { + return session + .confirmToken(testToken, 'pass123') + .then(function (result) { + console.log('confirmed valid token.'); + expect(result._id).to.equal('colinskow'); + done(); + }) + .catch(function (err) { + done(err); }); - }); + }); + }); - it('should reject a wrong password', function (done) { - previous.then(function () { - return session - .confirmToken(testToken.key, 'wrongpass') - .catch(function (err) { - console.log('rejected invalid token'); - expect(err).to.equal('invalid token'); - done(); - }); + it('should reject a bad token', function (done) { + previous.then(function () { + return session + .confirmToken(badToken, testToken.password) + .catch(function (err) { + console.log('rejected invalid token'); + expect(err).to.equal('invalid token'); + done(); }); - }); + }); + }); - it('should delete a token', function (done) { - previous.then(function () { - return session - .deleteTokens(testToken.key) - .then(function (result) { - expect(result).to.equal(1); - return session.confirmToken(testToken.key); - }) - .then(function () { - throw new Error('failed to delete token'); - }) - .catch(function (err) { - console.log('deleted token'); - expect(err).to.equal('invalid token'); - session.quit(); - done(); - resolve(); - }); - }); + it('should reject a wrong password', function (done) { + previous.then(function () { + return session.confirmToken(testToken, 'wrongpass').catch(function (err) { + console.log('rejected invalid token'); + expect(err).to.equal('invalid token'); + done(); }); }); }); -} +}); diff --git a/test/test-server.js b/test/test-server.js index 404c146..72d2332 100644 --- a/test/test-server.js +++ b/test/test-server.js @@ -45,7 +45,6 @@ function start(config) { const server = http.createServer(app).listen(app.get('port')); app.shutdown = function () { - superlogin.quitRedis(); server.close(); }; diff --git a/test/user.spec.js b/test/user.spec.js index 50e12d7..99380c6 100644 --- a/test/user.spec.js +++ b/test/user.spec.js @@ -350,11 +350,8 @@ describe('User Model', async function () { }); }); - it('should restore a valid session', function (done) { + it('should confirm a session', function (done) { previous - .then(() => { - return user.removeFromSessionCache(sessionKey); - }) .then(() => { return user.confirmSession(sessionKey, sessionPass); }) @@ -369,7 +366,8 @@ describe('User Model', async function () { } console.log('confirmed with db fallback'); done(); - }); + }) + .catch(err => done(err)); }); it('should log out of a session', function () {