/// import {RedisClient} from 'redis'; import * as Promise from 'bluebird'; import * as crypto from 'crypto'; import connect from './lib/connect'; import {RequestHandler} from 'express'; export default class RedisSessionsAnywhere { constructor(public client: RedisClient, public options?: NewOptions) { if (!client) { throw new Error('Client is not redis client!'); } this.options = merge({ prefix: 'resa:', ttl: 24 * 60 * 60 * 1000, // our data will expire in one day lock: false, // sets a lock before setting data, so data isn't overwritten lockSuffix: ':lock', lockTtl: 80 * 1000, // our lock will time out in 8 seconds lockRetry: 50 // retry in 50ms }, this.options); } public get(token: string): Promise> { if (!token || typeof token !== 'string') { throw new Error('Token must be valid string'); } return new Promise>((resolve, reject) => { this.client.get(this.options.prefix + token, (error, reply) => { if (error) { return reject(error); } if (!reply) { return resolve(null); } let parsed: SessionStorageObject; try { parsed = JSON.parse(reply); } catch (error) { return reject(error); } let object: SessionObject = {} as any; object.timeLeft = parsed.time - Date.now(); object.data = parsed.data; resolve(object); }); }); } public set(token: string, values?: S): Promise> { if (!token || typeof token !== 'string') { throw new Error('Token must be valid string'); } if (this.options.lock) { // get our lock before setting return new Promise>((resolve, reject) => { this.getLock(token, (error, unlock) => { if (error) { return reject(error); } let data: SessionObject; // unlock so that it skips this this.options.lock = false; let promise = this.set(token, values); this.options.lock = true; resolve(promise.then((object: SessionObject) => { data = object; return unlock(); }).then(() => data)); }); }); } if (!values) { values = {} as S; } let session: SessionStorageObject = { time: Date.now() + this.options.ttl, data: values }; return new Promise>((resolve, reject) => { this.client.set([this.options.prefix + token, JSON.stringify(session), 'PX', this.options.ttl], error => { if (error) { return reject(error); } let object: SessionObject = { timeLeft: this.options.ttl, data: values }; resolve(object); }); }); } public delete(token: string): Promise { if (!token || typeof token !== 'string') { throw new Error('Token must be valid string'); } if (this.options.lock) { return new Promise((resolve, reject) => { this.getLock(token, (error, unlock) => { if (error) { return reject(error); } // temp unlock this.options.lock = false; let promise = this.delete(token); this.options.lock = true; resolve(promise.then(unlock)); }); }); } return new Promise((resolve, reject) => { this.client.del(this.options.prefix + token, (error) => { if (error) { return reject(error); } resolve(null); }); }); } private getLock(token: string, callback: (error: Error, unlock?: () => Promise) => any) { let lockKey: string = this.options.prefix + token + this.options.lockSuffix; let attemptLock = () => { this.client.set([lockKey, 'L', 'NX', 'PX', this.options.lockTtl], (error, result) => { if (error) { return callback(error); } if (!result) { return setTimeout(() => attemptLock(), this.options.lockRetry); } callback(null, unlock); // we're good to go }); } let unlock = (): Promise => { return new Promise((resolve, reject) => { this.client.del(lockKey, (error) => { if (error) { return callback(error); } resolve(null); // we're finally done }); }); } attemptLock(); } } // export it here for the sake of typescript, but it gets overwritten down below export class TokenGenerator { constructor(private sessions: RedisSessionsAnywhere, private options: TokenGeneratorOptions) { if (!options.key || typeof options.key !== 'string') { throw new Error('Invalid key'); } this.options = merge({ key: '', tokenBytes: 16, checkForCollision: true }, options); } public generateClientToken(token: Buffer | string): string { if (!Buffer.isBuffer(token)) { token = new Buffer(token as string, 'hex'); } // create our two keys let hmac: crypto.Hmac = crypto.createHmac('sha256', this.options.key); hmac.update('resa-enc'); let encryptionKey = hmac.digest(); hmac = crypto.createHmac('sha256', this.options.key); hmac.update('resa-mac'); let signatureKey = hmac.digest(); // generate our iv and final str let iv: Buffer = crypto.randomBytes(16); let str: Buffer = new Buffer(token.toString('hex') + '|' + (Date.now() + this.sessions.options.ttl), 'utf8'); emptyBuffer(token as Buffer); // we empty our buffers as a security measure let cipher: crypto.Cipher = crypto.createCipheriv('aes256', encryptionKey, iv); let bufferOne = cipher.update(str); emptyBuffer(str); let bufferTwo = cipher.final(); let buffer: Buffer = Buffer.concat([bufferOne, bufferTwo]); emptyBuffer(bufferOne); emptyBuffer(bufferTwo); hmac = crypto.createHmac('sha256', signatureKey); hmac.update(iv); hmac.update('.'); hmac.update(buffer); let hmacResult: Buffer = hmac.digest(); let generated: string = base64urlencode(iv) + '.' + base64urlencode(buffer) + '.' + base64urlencode(hmacResult); return generated; } public generateKey(): Promise { if (!this.options.checkForCollision) { let token: Buffer = crypto.randomBytes(this.options.tokenBytes); return Promise.resolve({ token: token.toString('hex'), clientToken: this.generateClientToken(token) }); } let next: () => Promise = () => { let token: Buffer = crypto.randomBytes(this.options.tokenBytes); let tokenString = token.toString('hex'); return new Promise((resolve, reject) => { this.sessions.client.exists(tokenString, (error, exists) => { if (error) { return reject(error); } if (exists) { return resolve(next()); } resolve({ token: token.toString('hex'), clientToken: this.generateClientToken(token) }); }); }); }; return next(); } public parseClientToken(token: string): ClientToken { let components: string[] = token.split('.'); if (components.length !== 3) { throw new Error('Invalid component length'); } let iv: Buffer; let buffer: Buffer; let hmacResult: Buffer; let expectedHmac: Buffer; let encryptionKey: Buffer; let signatureKey: Buffer; try { // create our two keys let hmac: crypto.Hmac = crypto.createHmac('sha256', this.options.key); hmac.update('resa-enc'); encryptionKey = hmac.digest(); hmac = crypto.createHmac('sha256', this.options.key); hmac.update('resa-mac'); signatureKey = hmac.digest(); iv = base64urldecode(components[0]); buffer = base64urldecode(components[1]); hmacResult = base64urldecode(components[2]); if (iv.length !== 16) { throw new Error('Invalid iv length'); } // check expected hmac hmac = crypto.createHmac('sha256', signatureKey); hmac.update(iv); hmac.update('.'); hmac.update(buffer); expectedHmac = hmac.digest(); if (!buffersEqual(expectedHmac, hmacResult)) { clean(); throw new Error('hmac does not match'); } let cipher = crypto.createDecipheriv('aes256', encryptionKey, iv); let str: string = cipher.update(buffer).toString('utf8'); str += cipher.final('utf8'); let split = str.split('|'); if (split.length !== 2) { throw new Error('Original contents have been modified'); } let token: string = split[0]; let expiresAt: number = +split[1]; clean(); return { token: token, expiresAt: expiresAt }; } catch (err) { throw err; } function clean() { emptyBuffer(iv); emptyBuffer(buffer); emptyBuffer(hmacResult); emptyBuffer(expectedHmac); emptyBuffer(encryptionKey); emptyBuffer(signatureKey); } } public isValid(token: string | ClientToken): boolean { try { let parsed: ClientToken; if (typeof token === 'string') { parsed = this.parseClientToken(token); } else { parsed = token; } return Date.now() < parsed.expiresAt; } catch (err) { // todo maybe some way of passing this back? return false; } } public connect(options?: ConnectOptions): RequestHandler { return connect(this.sessions as any, this as any, options as any); } } function merge(original: T, object: any): T { if (object) { for (let property in object) { original[property] = object[property]; } } return original; } // taken from https://github.com/mozilla/node-client-sessions/blob/master/lib/client-sessions.js function buffersEqual(a: Buffer, b: Buffer): boolean { if (a.length !== b.length) { return false; } let ret = 0; for (let i = 0; i < a.length; i++) { ret |= a.readUInt8(i) ^ b.readUInt8(i); } return ret === 0; } function base64urlencode(arg: Buffer): string { let s: string = arg.toString('base64'); s = s.split('=')[0]; // Remove any trailing '='s s = s.replace(/\+/g, '-'); // 62nd char of encoding s = s.replace(/\//g, '_'); // 63rd char of encoding // TODO optimize this; we can do much better return s; } function base64urldecode(arg: string): Buffer { var s = arg; s = s.replace(/-/g, '+'); // 62nd char of encoding s = s.replace(/_/g, '/'); // 63rd char of encoding switch (s.length % 4) { // Pad with trailing '='s case 0: break; // No pad chars in this case case 2: s += "=="; break; // Two pad chars case 3: s += "="; break; // One pad char default: throw new Error("Illegal base64url string!"); } return new Buffer(s, 'base64'); // Standard base64 decoder } function emptyBuffer(buffer: Buffer) { if (buffer) { for (let i = 0, length = buffer.length; i < length; i++) { buffer[i] = 0; } } } interface SessionStorageObject { time: number; data: S; } export interface SessionObject { timeLeft: number; data: S; } export interface NewOptions { prefix?: string; ttl?: number; lock?: boolean; lockSuffix?: string; lockTtl?: number; lockRetry?: number; } export interface TokenGeneratorOptions { key: string; tokenBytes?: number; checkForCollision?: boolean; } export interface TokenAndClientToken { token: string; clientToken: string; } export interface ClientToken { token: string; expiresAt: number; } export interface ConnectOptions { cookieName?: string; alwaysUpdate?: boolean; sessionKey?: string; } module.exports = RedisSessionsAnywhere; // the actual export module.exports.default = RedisSessionsAnywhere; // set the default for ES6 modules or typescript module.exports.__esModule = true; // define it as a module module.exports.TokenGenerator = TokenGenerator; // and last, reexport our TokenGenerator