UNPKG

connect-mongo

Version:

MongoDB session store for Express and Connect

546 lines (517 loc) 15.5 kB
import { assert } from 'console' import util from 'util' import * as session from 'express-session' import { Collection, MongoClient, MongoClientOptions, WriteConcernSettings, } from 'mongodb' import Debug from 'debug' import Kruptein from 'kruptein' const debug = Debug('connect-mongo') export type CryptoOptions = { secret: false | string algorithm?: string hashing?: string encodeas?: string key_size?: number iv_size?: number at_size?: number } export type ConnectMongoOptions = { mongoUrl?: string clientPromise?: Promise<MongoClient> client?: MongoClient collectionName?: string mongoOptions?: MongoClientOptions dbName?: string ttl?: number touchAfter?: number stringify?: boolean createAutoRemoveIdx?: boolean autoRemove?: 'native' | 'interval' | 'disabled' autoRemoveInterval?: number // FIXME: remove those any serialize?: (a: any) => any unserialize?: (a: any) => any writeOperationOptions?: WriteConcernSettings transformId?: (a: any) => any crypto?: CryptoOptions } type ConcretCryptoOptions = Required<CryptoOptions> type ConcretConnectMongoOptions = { mongoUrl?: string clientPromise?: Promise<MongoClient> client?: MongoClient collectionName: string mongoOptions: MongoClientOptions dbName?: string ttl: number createAutoRemoveIdx?: boolean autoRemove: 'native' | 'interval' | 'disabled' autoRemoveInterval: number touchAfter: number stringify: boolean // FIXME: remove those any serialize?: (a: any) => any unserialize?: (a: any) => any writeOperationOptions?: WriteConcernSettings transformId?: (a: any) => any // FIXME: remove above any crypto: ConcretCryptoOptions } type InternalSessionType = { _id: string session: any expires?: Date lastModified?: Date } // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {} const unit: <T>(a: T) => T = (a) => a function defaultSerializeFunction( session: session.SessionData ): session.SessionData { // Copy each property of the session to a new object const obj = {} let prop for (prop in session) { if (prop === 'cookie') { // Convert the cookie instance to an object, if possible // This gets rid of the duplicate object under session.cookie.data property // @ts-ignore FIXME: obj.cookie = session.cookie.toJSON ? // @ts-ignore FIXME: session.cookie.toJSON() : session.cookie } else { // @ts-ignore FIXME: obj[prop] = session[prop] } } return obj as session.SessionData } function computeTransformFunctions(options: ConcretConnectMongoOptions) { if (options.serialize || options.unserialize) { return { serialize: options.serialize || defaultSerializeFunction, unserialize: options.unserialize || unit, } } if (options.stringify === false) { return { serialize: defaultSerializeFunction, unserialize: unit, } } // Default case return { serialize: JSON.stringify, unserialize: JSON.parse, } } export default class MongoStore extends session.Store { private clientP: Promise<MongoClient> private crypto: Kruptein | null = null private timer?: NodeJS.Timeout collectionP: Promise<Collection<InternalSessionType>> private options: ConcretConnectMongoOptions // FIXME: remvoe any private transformFunctions: { serialize: (a: any) => any unserialize: (a: any) => any } constructor({ collectionName = 'sessions', ttl = 1209600, mongoOptions = {}, autoRemove = 'native', autoRemoveInterval = 10, touchAfter = 0, stringify = true, crypto, ...required }: ConnectMongoOptions) { super() debug('create MongoStore instance') const options: ConcretConnectMongoOptions = { collectionName, ttl, mongoOptions, autoRemove, autoRemoveInterval, touchAfter, stringify, crypto: { ...{ secret: false, algorithm: 'aes-256-gcm', hashing: 'sha512', encodeas: 'base64', key_size: 32, iv_size: 16, at_size: 16, }, ...crypto, }, ...required, } // Check params assert( options.mongoUrl || options.clientPromise || options.client, 'You must provide either mongoUrl|clientPromise|client in options' ) assert( options.createAutoRemoveIdx === null || options.createAutoRemoveIdx === undefined, 'options.createAutoRemoveIdx has been reverted to autoRemove and autoRemoveInterval' ) assert( !options.autoRemoveInterval || options.autoRemoveInterval <= 71582, /* (Math.pow(2, 32) - 1) / (1000 * 60) */ 'autoRemoveInterval is too large. options.autoRemoveInterval is in minutes but not seconds nor mills' ) this.transformFunctions = computeTransformFunctions(options) let _clientP: Promise<MongoClient> if (options.mongoUrl) { _clientP = MongoClient.connect(options.mongoUrl, options.mongoOptions) } else if (options.clientPromise) { _clientP = options.clientPromise } else if (options.client) { _clientP = Promise.resolve(options.client) } else { throw new Error('Cannot init client. Please provide correct options') } assert(!!_clientP, 'Client is null|undefined') this.clientP = _clientP this.options = options this.collectionP = _clientP.then(async (con) => { const collection = con .db(options.dbName) .collection<InternalSessionType>(options.collectionName) await this.setAutoRemove(collection) return collection }) if (options.crypto.secret) { this.crypto = require('kruptein')(options.crypto) } } static create(options: ConnectMongoOptions): MongoStore { return new MongoStore(options) } private setAutoRemove( collection: Collection<InternalSessionType> ): Promise<unknown> { const removeQuery = () => ({ expires: { $lt: new Date(), }, }) switch (this.options.autoRemove) { case 'native': debug('Creating MongoDB TTL index') return collection.createIndex( { expires: 1 }, { background: true, expireAfterSeconds: 0, } ) case 'interval': debug('create Timer to remove expired sessions') this.timer = setInterval( () => collection.deleteMany(removeQuery(), { writeConcern: { w: 0, j: false, }, }), this.options.autoRemoveInterval * 1000 * 60 ) this.timer.unref() return Promise.resolve() case 'disabled': default: return Promise.resolve() } } private computeStorageId(sessionId: string) { if ( this.options.transformId && typeof this.options.transformId === 'function' ) { return this.options.transformId(sessionId) } return sessionId } /** * promisify and bind the `this.crypto.get` function. * Please check !!this.crypto === true before using this getter! */ private get cryptoGet() { if (!this.crypto) { throw new Error('Check this.crypto before calling this.cryptoGet!') } return util.promisify(this.crypto.get).bind(this.crypto) } /** * Decrypt given session data * @param session session data to be decrypt. Mutate the input session. */ private async decryptSession( session: session.SessionData | undefined | null ) { if (this.crypto && session) { const plaintext = await this.cryptoGet( this.options.crypto.secret as string, session.session ).catch((err) => { throw new Error(err) }) // @ts-ignore session.session = JSON.parse(plaintext) } } /** * Get a session from the store given a session ID (sid) * @param sid session ID */ get( sid: string, callback: (err: any, session?: session.SessionData | null) => void ): void { ;(async () => { try { debug(`MongoStore#get=${sid}`) const collection = await this.collectionP const session = await collection.findOne({ _id: this.computeStorageId(sid), $or: [ { expires: { $exists: false } }, { expires: { $gt: new Date() } }, ], }) if (this.crypto && session) { await this.decryptSession( session as unknown as session.SessionData ).catch((err) => callback(err)) } const s = session && this.transformFunctions.unserialize(session.session) if (this.options.touchAfter > 0 && session?.lastModified) { s.lastModified = session.lastModified } this.emit('get', sid) callback(null, s === undefined ? null : s) } catch (error) { callback(error) } })() } /** * Upsert a session into the store given a session ID (sid) and session (session) object. * @param sid session ID * @param session session object */ set( sid: string, session: session.SessionData, callback: (err: any) => void = noop ): void { ;(async () => { try { debug(`MongoStore#set=${sid}`) // Removing the lastModified prop from the session object before update // @ts-ignore if (this.options.touchAfter > 0 && session?.lastModified) { // @ts-ignore delete session.lastModified } const s: InternalSessionType = { _id: this.computeStorageId(sid), session: this.transformFunctions.serialize(session), } // Expire handling if (session?.cookie?.expires) { s.expires = new Date(session.cookie.expires) } else { // If there's no expiration date specified, it is // browser-session cookie or there is no cookie at all, // as per the connect docs. // // So we set the expiration to two-weeks from now // - as is common practice in the industry (e.g Django) - // or the default specified in the options. s.expires = new Date(Date.now() + this.options.ttl * 1000) } // Last modify handling if (this.options.touchAfter > 0) { s.lastModified = new Date() } if (this.crypto) { const cryptoSet = util.promisify(this.crypto.set).bind(this.crypto) const data = await cryptoSet( this.options.crypto.secret as string, s.session ).catch((err) => { throw new Error(err) }) s.session = data as unknown as session.SessionData } const collection = await this.collectionP const rawResp = await collection.updateOne( { _id: s._id }, { $set: s }, { upsert: true, writeConcern: this.options.writeOperationOptions, } ) if (rawResp.upsertedCount > 0) { this.emit('create', sid) } else { this.emit('update', sid) } this.emit('set', sid) } catch (error) { return callback(error) } return callback(null) })() } touch( sid: string, session: session.SessionData & { lastModified?: Date }, callback: (err: any) => void = noop ): void { ;(async () => { try { debug(`MongoStore#touch=${sid}`) const updateFields: { lastModified?: Date expires?: Date session?: session.SessionData } = {} const touchAfter = this.options.touchAfter * 1000 const lastModified = session.lastModified ? session.lastModified.getTime() : 0 const currentDate = new Date() // If the given options has a touchAfter property, check if the // current timestamp - lastModified timestamp is bigger than // the specified, if it's not, don't touch the session if (touchAfter > 0 && lastModified > 0) { const timeElapsed = currentDate.getTime() - lastModified if (timeElapsed < touchAfter) { debug(`Skip touching session=${sid}`) return callback(null) } updateFields.lastModified = currentDate } if (session?.cookie?.expires) { updateFields.expires = new Date(session.cookie.expires) } else { updateFields.expires = new Date(Date.now() + this.options.ttl * 1000) } const collection = await this.collectionP const rawResp = await collection.updateOne( { _id: this.computeStorageId(sid) }, { $set: updateFields }, { writeConcern: this.options.writeOperationOptions } ) if (rawResp.matchedCount === 0) { return callback(new Error('Unable to find the session to touch')) } else { this.emit('touch', sid, session) return callback(null) } } catch (error) { return callback(error) } })() } /** * Get all sessions in the store as an array */ all( callback: ( err: any, obj?: | session.SessionData[] | { [sid: string]: session.SessionData } | null ) => void ): void { ;(async () => { try { debug('MongoStore#all()') const collection = await this.collectionP const sessions = collection.find({ $or: [ { expires: { $exists: false } }, { expires: { $gt: new Date() } }, ], }) const results: session.SessionData[] = [] for await (const session of sessions) { if (this.crypto && session) { await this.decryptSession(session as unknown as session.SessionData) } results.push(this.transformFunctions.unserialize(session.session)) } this.emit('all', results) callback(null, results) } catch (error) { callback(error) } })() } /** * Destroy/delete a session from the store given a session ID (sid) * @param sid session ID */ destroy(sid: string, callback: (err: any) => void = noop): void { debug(`MongoStore#destroy=${sid}`) this.collectionP .then((colleciton) => colleciton.deleteOne( { _id: this.computeStorageId(sid) }, { writeConcern: this.options.writeOperationOptions } ) ) .then(() => { this.emit('destroy', sid) callback(null) }) .catch((err) => callback(err)) } /** * Get the count of all sessions in the store */ length(callback: (err: any, length: number) => void): void { debug('MongoStore#length()') this.collectionP .then((collection) => collection.countDocuments()) .then((c) => callback(null, c)) // @ts-ignore .catch((err) => callback(err)) } /** * Delete all sessions from the store. */ clear(callback: (err: any) => void = noop): void { debug('MongoStore#clear()') this.collectionP .then((collection) => collection.drop()) .then(() => callback(null)) .catch((err) => callback(err)) } /** * Close database connection */ close(): Promise<void> { debug('MongoStore#close()') return this.clientP.then((c) => c.close()) } }