Skip to content

Commit

Permalink
Merge pull request #28 from onaio/use-redis-session-store
Browse files Browse the repository at this point in the history
add support for redis and redis sentinel for session storage
  • Loading branch information
machariamuguku authored Jun 24, 2022
2 parents b25df2e + fd9ca53 commit 7374620
Show file tree
Hide file tree
Showing 13 changed files with 331 additions and 45 deletions.
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ EXPRESS_LOGS_FILE_PATH='/home/.express/reveal-express-server.log

# https://github.com/helmetjs/helmet#reference
EXPRESS_CONTENT_SECURITY_POLICY_CONFIG=`{"default-src":["'self'"]}`

EXPRESS_REDIS_STAND_ALONE_URL=redis://username:[email protected]:6379/4

EXPRESS_REDIS_SENTINEL_CONFIG='{"name":"master","sentinelUsername":"u_name","sentinelPassword":"pass","db":4,"sentinels":[{"host":"127.0.0.1","port":6379},{"host":"127.0.0.1","port":6379}]}'
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist
coverage
.eslintrc.js
jest.config.js
jest.setup.js
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ module.exports = {
testEnvironment: 'node',
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/tests/', '!src/index.ts'],
coverageReporters: ['lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};
4 changes: 4 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
global.console = {
...console,
log: jest.fn(),
};
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@
"@onaio/gatekeeper": "^0.1.2",
"client-oauth2": "^4.3.3",
"compression": "^1.7.4",
"connect-redis": "^6.1.3",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.0",
"express": "^4.17.3",
"express-session": "^1.17.2",
"helmet": "^5.0.2",
"ioredis": "^5.0.6",
"morgan": "^1.10.0",
"node-fetch": "2.6.7",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router": "^5.2.1",
"react-router-dom": "^5.2.1",
"redis": "^4.1.0",
"redux": "^4.1.2",
"request": "^2.88.0",
"seamless-immutable": "^7.1.4",
Expand All @@ -48,10 +51,12 @@
},
"devDependencies": {
"@types/compression": "^1.7.2",
"@types/connect-redis": "^0.0.18",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
"@types/helmet": "^4.0.0",
"@types/ioredis-mock": "^5.6.0",
"@types/jest": "^27.4.1",
"@types/morgan": "^1.9.3",
"@types/node": "^17.0.21",
Expand All @@ -75,6 +80,7 @@
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-sonarjs": "^0.12.0",
"husky": "^7.0.4",
"ioredis-mock": "^8.2.2",
"jest": "^27.5.1",
"lint-staged": "^12.3.4",
"mockdate": "^3.0.5",
Expand Down
54 changes: 46 additions & 8 deletions src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import compression from 'compression';
import cookieParser from 'cookie-parser';
import express from 'express';
import session from 'express-session';
import connectRedis from 'connect-redis';
import Redis from 'ioredis';
import helmet from 'helmet';
import fetch from 'node-fetch';
import morgan from 'morgan';
Expand Down Expand Up @@ -34,6 +36,8 @@ import {
EXPRESS_SESSION_NAME,
EXPRESS_SESSION_PATH,
EXPRESS_SESSION_SECRET,
EXPRESS_REDIS_STAND_ALONE_URL,
EXPRESS_REDIS_SENTINEL_CONFIG,
EXPRESS_CONTENT_SECURITY_POLICY_CONFIG,
} from '../configs/envs';
import { SESSION_IS_EXPIRED, TOKEN_NOT_FOUND, TOKEN_REFRESH_FAILED } from '../constants';
Expand Down Expand Up @@ -67,14 +71,48 @@ app.use(
crossOriginEmbedderPolicy: false,
}),
);
app.use(morgan('combined', { stream: winstonStream })); // send logs to winston
app.use(morgan('combined', { stream: winstonStream })); // send request logs to winston streamer

const FileStore = sessionFileStore(session);
const fileStoreOptions: sessionFileStore.Options = {
path: EXPRESS_SESSION_FILESTORE_PATH || './sessions',
// channel session-file-store warnings to winston
logFn: (message) => winstonLogger.info(message),
};
let sessionStore: session.Store;

// use redis session store if redis is available else default to file store
// check for and use a single redis node
if (EXPRESS_REDIS_STAND_ALONE_URL !== undefined) {
const RedisStore = connectRedis(session);
const redisClient = new Redis(EXPRESS_REDIS_STAND_ALONE_URL);

redisClient.on('connect', () => winstonLogger.info('Redis single node client connected!'));
redisClient.on('reconnecting', () => winstonLogger.info('Redis single node client trying to reconnect'));
redisClient.on('error', (err) => winstonLogger.error('Redis single node client error:', err));
redisClient.on('end', () => winstonLogger.error('Redis single node client error: Redis client disconnected'));

sessionStore = new RedisStore({ client: redisClient });
}
// check for and use redis sentinel if available
else if (EXPRESS_REDIS_SENTINEL_CONFIG !== undefined && Object.keys(EXPRESS_REDIS_SENTINEL_CONFIG).length > 0) {
const RedisStore = connectRedis(session);

const redisClient = new Redis({
...EXPRESS_REDIS_SENTINEL_CONFIG,
});

redisClient.on('connect', () => winstonLogger.info('Redis sentinel client connected!'));
redisClient.on('reconnecting', () => winstonLogger.info('Redis sentinel client trying to reconnect'));
redisClient.on('error', (err) => winstonLogger.error('Redis sentinel client error:', err));
redisClient.on('end', () => winstonLogger.error('Redis sentinel client error: Redis client disconnected'));

sessionStore = new RedisStore({ client: redisClient });
}
// else default to file store
else {
winstonLogger.error('Redis Connection Error: Redis configs not provided using file session store');

const FileStore = sessionFileStore(session);
sessionStore = new FileStore({
path: EXPRESS_SESSION_FILESTORE_PATH || './sessions',
logFn: (message) => winstonLogger.info(message),
});
}

let nextPath: string | undefined;

Expand All @@ -88,7 +126,7 @@ const sess = {
resave: true,
saveUninitialized: true,
secret: EXPRESS_SESSION_SECRET || 'hunter2',
store: new FileStore(fileStoreOptions),
store: sessionStore,
};

if (app.get('env') === 'production') {
Expand Down
10 changes: 0 additions & 10 deletions src/app/tests/index.errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,6 @@ const panic = (err: Error, done: jest.DoneCallback): void => {
}
};

// mock out winston logger and stream methods - reduce log noise in test output
jest.mock('../../configs/winston', () => ({
winstonLogger: {
info: jest.fn(),
error: jest.fn(),
},
winstonStream: {
write: jest.fn(),
},
}));
jest.mock('client-oauth2', () => {
class CodeFlow {
private client: ClientOauth2;
Expand Down
12 changes: 1 addition & 11 deletions src/app/tests/index.logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,6 @@ const panic = (err: Error, done: jest.DoneCallback): void => {
};

jest.mock('../../configs/envs');
// mock out winston logger and stream methods - reduce log noise in test output
jest.mock('../../configs/winston', () => ({
winstonLogger: {
info: jest.fn(),
error: jest.fn(),
},
winstonStream: {
write: jest.fn(),
},
}));

const errorText = 'Token not found';

Expand Down Expand Up @@ -116,7 +106,7 @@ describe('src/index.ts', () => {
.get('/oauth/state')
.then((res) => {
// one by winston, other by morgan
expect(logsSpy).toHaveBeenCalledTimes(1);
expect(logsSpy).toHaveBeenCalledTimes(2);
expect(logsSpy).toHaveBeenCalledWith('Not authorized');
expect(res.status).toBe(200);
expect(res.text).toMatch('Not authorized');
Expand Down
63 changes: 50 additions & 13 deletions src/app/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import ClientOauth2 from 'client-oauth2';
import nock from 'nock';
import request from 'supertest';
import express from 'express';
import Redis from 'ioredis';
import {
EXPRESS_FRONTEND_OPENSRP_CALLBACK_URL,
EXPRESS_SESSION_LOGIN_URL,
Expand All @@ -29,19 +30,9 @@ const panic = (err: Error, done: jest.DoneCallback): void => {
}
};

jest.mock('ioredis', () => jest.requireActual('ioredis-mock'));
jest.mock('../../configs/envs');
// mock out winston logger and stream methods - reduce log noise in test output
jest.mock('../../configs/winston', () => ({
winstonLogger: {
info: jest.fn(),
error: jest.fn(),
},
winstonStream: {
write: jest.fn(),
},
}));
jest.mock('node-fetch');

jest.mock('client-oauth2', () => {
class CodeFlow {
private client: ClientOauth2;
Expand Down Expand Up @@ -113,10 +104,14 @@ describe('src/index.ts', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cookie: { [key: string]: any };

afterEach(() => {
afterEach((done) => {
JSON.parse = actualJsonParse;
jest.resetAllMocks();
jest.clearAllMocks();
new Redis()
.flushall()
.then(() => done())
.catch((err) => panic(err, done));
});

it('serves the build.index.html file', (done) => {
Expand Down Expand Up @@ -430,7 +425,49 @@ describe('src/index.ts', () => {
);

expect(winston).toHaveBeenCalledTimes(2);

done();
});

it('uses single redis node as session storage', (done) => {
jest.resetModules();
jest.mock('../../configs/envs', () => ({
...jest.requireActual('../../configs/envs'),
EXPRESS_REDIS_STAND_ALONE_URL: 'redis://:@127.0.0.1:1234',
}));
const { default: app2 } = jest.requireActual('../index');
const { winstonLogger: winstonLogger2 } = jest.requireActual('../../configs/winston');
const logsSpy = jest.spyOn(winstonLogger2, 'info');

request(app2)
.get('/test/endpoint')
.then(() => {
expect(logsSpy).toHaveBeenCalledWith('Redis single node client connected!');
done();
})
.catch((err) => {
panic(err, done);
});
});

it('uses redis sentinel as session storage', (done) => {
jest.resetModules();
jest.mock('../../configs/envs', () => ({
...jest.requireActual('../../configs/envs'),
EXPRESS_REDIS_SENTINEL_CONFIG:
'{"name":"mymaster","sentinels":[{"host":"127.0.0.1","port":26379},{"host":"127.0.0.1","port":6380},{"host":"127.0.0.1","port":6379}]}',
}));
const { default: app2 } = jest.requireActual('../index');
const { winstonLogger: winstonLogger2 } = jest.requireActual('../../configs/winston');
const logsSpy = jest.spyOn(winstonLogger2, 'info');

request(app2)
.get('/test/endpoint')
.then(() => {
expect(logsSpy).toHaveBeenCalledWith('Redis sentinel client connected!');
done();
})
.catch((err) => {
panic(err, done);
});
});
});
6 changes: 6 additions & 0 deletions src/configs/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,9 @@ export const EXPRESS_CONTENT_SECURITY_POLICY_CONFIG = JSON.parse(
process.env.EXPRESS_CONTENT_SECURITY_POLICY_CONFIG || defaultCsp,
);
export type EXPRESS_CONTENT_SECURITY_POLICY_CONFIG = typeof EXPRESS_CONTENT_SECURITY_POLICY_CONFIG;

// see https://github.com/luin/ioredis#connect-to-redis
export const { EXPRESS_REDIS_STAND_ALONE_URL } = process.env;

// see https://github.com/luin/ioredis#sentinel
export const EXPRESS_REDIS_SENTINEL_CONFIG = JSON.parse(process.env.EXPRESS_REDIS_SENTINEL_CONFIG || '{}');
8 changes: 7 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
/* eslint-disable @typescript-eslint/no-explicit-any */
import 'express-session';
import 'redis';

declare module 'express-session' {
interface Session {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preloadedState?: Record<string, any>;
}
}

declare module 'redis' {
interface RedisClient {}
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"paths": {
"*": ["node_modules/*"]
},
"strictNullChecks": true
"strictNullChecks": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"typeRoots": [
Expand Down
Loading

0 comments on commit 7374620

Please sign in to comment.