Skip to content

Commit

Permalink
fix: Expire and remove authenticated real-time connections (#1512)
Browse files Browse the repository at this point in the history
  • Loading branch information
daffl authored Aug 18, 2019
1 parent 4329feb commit 2707c33
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 45 deletions.
15 changes: 13 additions & 2 deletions packages/authentication/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import jsonwebtoken, { SignOptions, Secret, VerifyOptions } from 'jsonwebtoken';
import uuidv4 from 'uuid/v4';
import { NotAuthenticated } from '@feathersjs/errors';
import Debug from 'debug';
import { Application, Params, HookContext } from '@feathersjs/feathers';
import { Application, Params } from '@feathersjs/feathers';
import { IncomingMessage, ServerResponse } from 'http';
import defaultOptions from './options';

Expand All @@ -21,6 +21,8 @@ export interface AuthenticationRequest {
[key: string]: any;
}

export type ConnectionEvent = 'login'|'logout'|'disconnect';

export interface AuthenticationStrategy {
/**
* Implement this method to get access to the AuthenticationService
Expand Down Expand Up @@ -55,7 +57,7 @@ export interface AuthenticationStrategy {
* @param connection The real-time connection
* @param context The hook context
*/
handleConnection? (connection: any, context: HookContext): Promise<HookContext>;
handleConnection? (event: ConnectionEvent, connection: any, authResult?: AuthenticationResult): Promise<void>;
/**
* Parse a basic HTTP request and response for authentication request information.
* @param req The HTTP request
Expand Down Expand Up @@ -223,6 +225,15 @@ export class AuthenticationBase {
});
}

async handleConnection (event: ConnectionEvent, connection: any, authResult?: AuthenticationResult) {
const strategies = this.getStrategies(...Object.keys(this.strategies))
.filter(current => typeof current.handleConnection === 'function');

for (const strategy of strategies) {
await strategy.handleConnection(event, connection, authResult);
}
}

/**
* Parse an HTTP request and response for authentication request information.
* @param req The HTTP request
Expand Down
11 changes: 3 additions & 8 deletions packages/authentication/src/hooks/connection.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import { HookContext } from '@feathersjs/feathers';
import { omit } from 'lodash';
import { AuthenticationBase, ConnectionEvent } from '../core';

import { AuthenticationBase } from '../core';

export default () => async (context: HookContext) => {
export default (event: ConnectionEvent) => async (context: HookContext) => {
const { result, params: { connection } } = context;

if (!connection) {
return context;
}

const service = context.service as unknown as AuthenticationBase;
const strategies = service.getStrategies(...Object.keys(service.strategies))
.filter(current => typeof current.handleConnection === 'function');

Object.assign(connection, omit(result, 'accessToken', 'authentication'));

for (const strategy of strategies) {
await strategy.handleConnection(connection, context);
}
await service.handleConnection(event, connection, result);

return context;
};
7 changes: 4 additions & 3 deletions packages/authentication/src/hooks/event.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import Debug from 'debug';
import { HookContext } from '@feathersjs/feathers';
import { ConnectionEvent } from '../core';

const debug = Debug('@feathersjs/authentication/hooks/connection');

export default (event: string) => (context: HookContext) => {
const { type, app, result, params } = context;
export default (event: ConnectionEvent) => async (context: HookContext) => {
const { app, result, params } = context;

if (type === 'after' && params.provider && result) {
if (params.provider && result) {
debug(`Sending authentication event '${event}'`);
app.emit(event, result, params, context);
}
Expand Down
54 changes: 33 additions & 21 deletions packages/authentication/src/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { NotAuthenticated } from '@feathersjs/errors';
import { IncomingMessage } from 'http';
import { omit } from 'lodash';
import Debug from 'debug';
import { Params, HookContext } from '@feathersjs/feathers';
import { omit } from 'lodash';
import { IncomingMessage } from 'http';
import { NotAuthenticated } from '@feathersjs/errors';
import { Params } from '@feathersjs/feathers';

import { AuthenticationBaseStrategy } from './strategy';
import { AuthenticationRequest, AuthenticationResult } from './core';
import { AuthenticationRequest, AuthenticationResult, ConnectionEvent } from './core';

const debug = Debug('@feathersjs/authentication/jwt');
const SPLIT_HEADER = /(\S+)\s+(\S+)/;

export class JWTStrategy extends AuthenticationBaseStrategy {
expirationTimers = new WeakMap();

get configuration () {
const authConfig = this.authentication.configuration;
const config = super.configuration;
Expand All @@ -24,23 +26,33 @@ export class JWTStrategy extends AuthenticationBaseStrategy {
};
}

async handleConnection (connection: any, context: HookContext) {
const { result: { accessToken }, method } = context;

if (accessToken) {
if (method === 'create') {
debug('Adding authentication information to connection');
connection.authentication = {
strategy: this.name,
accessToken
};
} else if (method === 'remove' && accessToken === connection.authentication.accessToken) {
debug('Removing authentication information from connection');
delete connection.authentication;
}
async handleConnection (event: ConnectionEvent, connection: any, authResult?: AuthenticationResult): Promise<void> {
const isValidLogout = event === 'logout' && connection.authentication && authResult &&
connection.authentication.accessToken === authResult.accessToken;

if (authResult && event === 'login') {
const { accessToken } = authResult;
const { exp } = await this.authentication.verifyAccessToken(accessToken);
// The time (in ms) until the token expires
const duration = (exp * 1000) - new Date().getTime();
// This may have to be a `logout` event but right now we don't want
// the whole context object lingering around until the timer is gone
const timer = setTimeout(() => this.app.emit('disconnect', connection), duration);

debug(`Registering connection expiration timer for ${duration}ms`);
this.expirationTimers.set(connection, timer);

debug('Adding authentication information to connection');
connection.authentication = {
strategy: this.name,
accessToken
};
} else if (event === 'disconnect' || isValidLogout) {
debug('Removing authentication information and expiration timer from connection');

delete connection.authentication;
clearTimeout(this.expirationTimers.get(connection));
}

return context;
}

verifyConfiguration () {
Expand Down
7 changes: 3 additions & 4 deletions packages/authentication/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,9 @@ export class AuthenticationService extends AuthenticationBase implements Partial

// @ts-ignore
this.hooks({
after: [ connection() ],
finally: {
create: [ event('login') ],
remove: [ event('logout') ]
after: {
create: [ connection('login'), event('login') ],
remove: [ connection('logout'), event('logout') ]
}
});
}
Expand Down
23 changes: 22 additions & 1 deletion packages/authentication/test/jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('authentication/jwt', () => {
});
});

describe('updateConnection', () => {
describe('handleConnection', () => {
it('adds authentication information on create', async () => {
const connection: any = {};

Expand All @@ -108,6 +108,27 @@ describe('authentication/jwt', () => {
});
});

it('sends disconnect event when connection token expires and removes authentication', async () => {
const connection: any = {};
const token: string = await app.service('authentication').createAccessToken({}, {
subject: `${user.id}`,
expiresIn: '1s'
});

const result = await app.service('authentication').create({
strategy: 'jwt',
accessToken: token
}, { connection });

assert.ok(connection.authentication);

assert.strictEqual(result.accessToken, token);

const disconnection = await new Promise(resolve => app.once('disconnect', resolve));

assert.strictEqual(disconnection, connection);
});

it('deletes authentication information on remove', async () => {
const connection: any = {};

Expand Down
2 changes: 1 addition & 1 deletion packages/socketio/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const methodTests = require('./methods.js');
const eventTests = require('./events');
const socketio = require('../lib');

describe.only('@feathersjs/socketio', () => {
describe('@feathersjs/socketio', () => {
let app;
let server;
let socket;
Expand Down
19 changes: 14 additions & 5 deletions packages/transport-commons/src/socket/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Application, Params } from '@feathersjs/feathers';
import Debug from 'debug';
import { channels } from '../channels';
import { routing } from '../routing';
import { getDispatcher, runMethod } from './utils';
import { Application } from '@feathersjs/feathers';
import { RealTimeConnection } from '../channels/channel/base';

const debug = Debug('@feathersjs/transport-commons');
Expand All @@ -16,15 +16,24 @@ export interface SocketOptions {

export function socket ({ done, emit, socketMap, getParams }: SocketOptions) {
return (app: Application) => {
const leaveChannels = (connection: RealTimeConnection) => {
const { channels } = app;

if (channels.length) {
app.channel(app.channels).leave(connection);
}
};

app.configure(channels());
app.configure(routing());

app.on('publish', getDispatcher(emit, socketMap));
app.on('disconnect', connection => {
const { channels } = app;
app.on('disconnect', leaveChannels);
app.on('logout', (_authResult: any, params: Params) => {
const { connection } = params;

if (channels.length) {
app.channel(app.channels).leave(connection);
if (connection) {
leaveChannels(connection);
}
});

Expand Down

0 comments on commit 2707c33

Please # to comment.