WIP: Successful Google Auth, broke pretty much everything else in the process

This commit is contained in:
Tom Moor
2018-05-28 11:36:37 -07:00
parent 1ba5c1cf96
commit ddd2b82d20
33 changed files with 443 additions and 387 deletions

View File

@@ -1,7 +1,7 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentApiKey } from '../presenters';
import { ApiKey } from '../models';

View File

@@ -1,9 +1,8 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentUser, presentTeam } from '../presenters';
import { Authentication, Integration, User, Team } from '../models';
import * as Slack from '../slack';
import { Team } from '../models';
const router = new Router();
@@ -19,126 +18,4 @@ router.post('auth.info', auth(), async ctx => {
};
});
router.post('auth.slack', async ctx => {
const { code } = ctx.body;
ctx.assertPresent(code, 'code is required');
const data = await Slack.oauthAccess(code);
let user = await User.findOne({ where: { slackId: data.user.id } });
let team = await Team.findOne({ where: { slackId: data.team.id } });
const isFirstUser = !team;
if (team) {
team.name = data.team.name;
team.slackData = data.team;
await team.save();
} else {
team = await Team.create({
name: data.team.name,
slackId: data.team.id,
slackData: data.team,
});
}
if (user) {
user.slackAccessToken = data.access_token;
user.slackData = data.user;
await user.save();
} else {
user = await User.create({
slackId: data.user.id,
name: data.user.name,
email: data.user.email,
teamId: team.id,
isAdmin: isFirstUser,
slackData: data.user,
slackAccessToken: data.access_token,
});
// Set initial avatar
await user.updateAvatar();
await user.save();
}
if (isFirstUser) {
await team.createFirstCollection(user.id);
}
// Signal to backend that the user is logged in.
// This is only used to signal SSR rendering, not
// used for auth.
ctx.cookies.set('loggedIn', 'true', {
httpOnly: false,
expires: new Date('2100'),
});
ctx.body = {
data: {
user: await presentUser(ctx, user),
team: await presentTeam(ctx, team),
accessToken: user.getJwtToken(),
},
};
});
router.post('auth.slackCommands', auth(), async ctx => {
const { code } = ctx.body;
ctx.assertPresent(code, 'code is required');
const user = ctx.state.user;
const endpoint = `${process.env.URL || ''}/auth/slack/commands`;
const data = await Slack.oauthAccess(code, endpoint);
const serviceId = 'slack';
const authentication = await Authentication.create({
serviceId,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
await Integration.create({
serviceId,
type: 'command',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
});
});
router.post('auth.slackPost', auth(), async ctx => {
const { code, collectionId } = ctx.body;
ctx.assertPresent(code, 'code is required');
const user = ctx.state.user;
const endpoint = `${process.env.URL || ''}/auth/slack/post`;
const data = await Slack.oauthAccess(code, endpoint);
const serviceId = 'slack';
const authentication = await Authentication.create({
serviceId,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(','),
});
await Integration.create({
serviceId,
type: 'post',
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
});
export default router;

View File

@@ -1,164 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import { flushdb, seed } from '../test/support';
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe.skip('#auth.signup', async () => {
it('should signup a new user', async () => {
const welcomeEmailMock = jest.fn();
jest.doMock('../mailer', () => {
return {
welcome: welcomeEmailMock,
};
});
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
name: 'Test User',
email: 'new.user@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toBeTruthy();
expect(welcomeEmailMock).toBeCalledWith('new.user@example.com');
});
it('should require params', async () => {
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require valid email', async () => {
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
name: 'Test User',
email: 'example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require unique email', async () => {
await seed();
const res = await server.post('/api/auth.signup', {
body: {
username: 'testuser',
name: 'Test User',
email: 'user1@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
it('should require unique username', async () => {
await seed();
const res = await server.post('/api/auth.signup', {
body: {
username: 'user1',
name: 'Test User',
email: 'userone@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
});
describe.skip('#auth.login', () => {
test('should login with email', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
username: 'user1@example.com',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toMatchSnapshot();
});
test('should login with username', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
username: 'user1',
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.ok).toBe(true);
expect(body.data.user).toMatchSnapshot();
});
test('should validate password', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
email: 'user1@example.com',
password: 'bad_password',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
test('should require either username or email', async () => {
const res = await server.post('/api/auth.login', {
body: {
password: 'test123!',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
test('should require password', async () => {
await seed();
const res = await server.post('/api/auth.login', {
body: {
email: 'user1@example.com',
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body).toMatchSnapshot();
});
});

View File

@@ -1,7 +1,7 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentCollection } from '../presenters';
import { Collection } from '../models';

View File

@@ -1,7 +1,7 @@
// @flow
import Router from 'koa-router';
import Sequelize from 'sequelize';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentDocument, presentRevision } from '../presenters';
import { Document, Collection, Share, Star, View, Revision } from '../models';

View File

@@ -2,8 +2,6 @@
import bodyParser from 'koa-bodyparser';
import Koa from 'koa';
import Router from 'koa-router';
import Sequelize from 'sequelize';
import _ from 'lodash';
import auth from './auth';
import user from './user';
@@ -16,58 +14,24 @@ import shares from './shares';
import team from './team';
import integrations from './integrations';
import validation from './middlewares/validation';
import methodOverride from '../middlewares/methodOverride';
import cache from '../middlewares/cache';
import errorHandling from './middlewares/errorHandling';
import validation from '../middlewares/validation';
import methodOverride from './middlewares/methodOverride';
import cache from './middlewares/cache';
import apiWrapper from './middlewares/apiWrapper';
const api = new Koa();
const router = new Router();
// API error handler
api.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
let message = err.message || err.name;
let error;
if (err instanceof Sequelize.ValidationError) {
// super basic form error handling
ctx.status = 400;
if (err.errors && err.errors[0]) {
message = `${err.errors[0].message} (${err.errors[0].path})`;
}
}
if (message.match('Authorization error')) {
ctx.status = 403;
error = 'authorization_error';
}
if (ctx.status === 500) {
message = 'Internal Server Error';
error = 'internal_server_error';
ctx.app.emit('error', err, ctx);
}
ctx.body = {
ok: false,
error: _.snakeCase(err.id || error),
status: err.status,
message,
data: err.errorData ? err.errorData : undefined,
};
}
});
// middlewares
api.use(errorHandling());
api.use(bodyParser());
api.use(methodOverride());
api.use(cache());
api.use(validation());
api.use(apiWrapper());
// routes
router.use('/', auth.routes());
router.use('/', user.routes());
router.use('/', collections.routes());

View File

@@ -2,7 +2,7 @@
import Router from 'koa-router';
import Integration from '../models/Integration';
import pagination from './middlewares/pagination';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentIntegration } from '../presenters';
import policy from '../policies';

View File

@@ -4,7 +4,7 @@ import { type Context } from 'koa';
export default function apiWrapper() {
return async function apiWrapperMiddleware(
ctx: Context,
next: () => Promise<void>
next: () => Promise<*>
) {
await next();

View File

@@ -1,98 +0,0 @@
// @flow
import JWT from 'jsonwebtoken';
import { type Context } from 'koa';
import { User, ApiKey } from '../../models';
import { AuthenticationError, UserSuspendedError } from '../../errors';
export default function auth(options?: { required?: boolean } = {}) {
return async function authMiddleware(
ctx: Context,
next: () => Promise<void>
) {
let token;
const authorizationHeader = ctx.request.get('authorization');
if (authorizationHeader) {
const parts = authorizationHeader.split(' ');
if (parts.length === 2) {
const scheme = parts[0];
const credentials = parts[1];
if (/^Bearer$/i.test(scheme)) {
token = credentials;
}
} else {
throw new AuthenticationError(
`Bad Authorization header format. Format is "Authorization: Bearer <token>"`
);
}
// $FlowFixMe
} else if (ctx.body.token) {
token = ctx.body.token;
} else if (ctx.request.query.token) {
token = ctx.request.query.token;
}
if (!token && options.required !== false) {
throw new AuthenticationError('Authentication required');
}
let user;
if (token) {
if (String(token).match(/^[\w]{38}$/)) {
// API key
let apiKey;
try {
apiKey = await ApiKey.findOne({
where: {
secret: token,
},
});
} catch (e) {
throw new AuthenticationError('Invalid API key');
}
if (!apiKey) throw new AuthenticationError('Invalid API key');
user = await User.findById(apiKey.userId);
if (!user) throw new AuthenticationError('Invalid API key');
} else {
// JWT
// Get user without verifying payload signature
let payload;
try {
payload = JWT.decode(token);
} catch (e) {
throw new AuthenticationError('Unable to decode JWT token');
}
if (!payload) throw new AuthenticationError('Invalid token');
user = await User.findById(payload.id);
try {
JWT.verify(token, user.jwtSecret);
} catch (e) {
throw new AuthenticationError('Invalid token');
}
}
if (user.isSuspended) {
const suspendingAdmin = await User.findById(user.suspendedById);
throw new UserSuspendedError({ adminEmail: suspendingAdmin.email });
}
ctx.state.token = token;
ctx.state.user = user;
// $FlowFixMe
ctx.cache[user.id] = user;
}
return next();
};
}
// Export JWT methods as a convenience
export const sign = JWT.sign;
export const verify = JWT.verify;
export const decode = JWT.decode;

View File

@@ -1,187 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { flushdb, seed } from '../../test/support';
import { buildUser } from '../../test/factories';
import { ApiKey } from '../../models';
import randomstring from 'randomstring';
import auth from './authentication';
beforeEach(flushdb);
describe('Authentication middleware', async () => {
describe('with JWT', () => {
it('should authenticate with correct token', async () => {
const state = {};
const { user } = await seed();
const authMiddleware = auth();
await authMiddleware(
{
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
);
expect(state.user.id).toEqual(user.id);
});
it('should return error with invalid token', async () => {
const state = {};
const { user } = await seed();
const authMiddleware = auth();
try {
await authMiddleware(
{
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}error`),
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toBe('Invalid token');
}
});
});
describe('with API key', () => {
it('should authenticate user with valid API key', async () => {
const state = {};
const { user } = await seed();
const authMiddleware = auth();
const key = await ApiKey.create({
userId: user.id,
});
await authMiddleware(
{
request: {
get: jest.fn(() => `Bearer ${key.secret}`),
},
state,
cache: {},
},
jest.fn()
);
expect(state.user.id).toEqual(user.id);
});
it('should return error with invalid API key', async () => {
const state = {};
const authMiddleware = auth();
try {
await authMiddleware(
{
request: {
get: jest.fn(() => `Bearer ${randomstring.generate(38)}`),
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toBe('Invalid API key');
}
});
});
it('should return error message if no auth token is available', async () => {
const state = {};
const authMiddleware = auth();
try {
await authMiddleware(
{
request: {
get: jest.fn(() => 'error'),
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toBe(
'Bad Authorization header format. Format is "Authorization: Bearer <token>"'
);
}
});
it('should allow passing auth token as a GET param', async () => {
const state = {};
const { user } = await seed();
const authMiddleware = auth();
await authMiddleware(
{
request: {
get: jest.fn(() => null),
query: {
token: user.getJwtToken(),
},
},
body: {},
state,
cache: {},
},
jest.fn()
);
expect(state.user.id).toEqual(user.id);
});
it('should allow passing auth token in body params', async () => {
const state = {};
const { user } = await seed();
const authMiddleware = auth();
await authMiddleware(
{
request: {
get: jest.fn(() => null),
},
body: {
token: user.getJwtToken(),
},
state,
cache: {},
},
jest.fn()
);
expect(state.user.id).toEqual(user.id);
});
it('should return an error for suspended users', async () => {
const state = {};
const admin = await buildUser({});
const user = await buildUser({
suspendedAt: new Date(),
suspendedById: admin.id,
});
const authMiddleware = auth();
try {
await authMiddleware(
{
request: {
get: jest.fn(() => `Bearer ${user.getJwtToken()}`),
},
state,
cache: {},
},
jest.fn()
);
} catch (e) {
expect(e.message).toEqual(
'Your access has been suspended by the team admin'
);
expect(e.errorData.adminEmail).toEqual(admin.email);
}
});
});

View File

@@ -0,0 +1,26 @@
// @flow
import debug from 'debug';
import { type Context } from 'koa';
const debugCache = debug('cache');
export default function cache() {
return async function cacheMiddleware(ctx: Context, next: () => Promise<*>) {
ctx.cache = {};
ctx.cache.set = async (id, value) => {
ctx.cache[id] = value;
};
ctx.cache.get = async (id, def) => {
if (ctx.cache[id]) {
debugCache(`hit: ${id}`);
} else {
debugCache(`miss: ${id}`);
ctx.cache.set(id, await def());
}
return ctx.cache[id];
};
return next();
};
}

View File

@@ -0,0 +1,46 @@
// @flow
import Sequelize from 'sequelize';
import { snakeCase } from 'lodash';
import { type Context } from 'koa';
export default function errorHandling() {
return async function errorHandlingMiddleware(
ctx: Context,
next: () => Promise<*>
) {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
let message = err.message || err.name;
let error;
if (err instanceof Sequelize.ValidationError) {
// super basic form error handling
ctx.status = 400;
if (err.errors && err.errors[0]) {
message = `${err.errors[0].message} (${err.errors[0].path})`;
}
}
if (message.match('Authorization error')) {
ctx.status = 403;
error = 'authorization_error';
}
if (ctx.status === 500) {
message = 'Internal Server Error';
error = 'internal_server_error';
ctx.app.emit('error', err, ctx);
}
ctx.body = {
ok: false,
error: snakeCase(err.id || error),
status: err.status,
message,
data: err.errorData ? err.errorData : undefined,
};
}
};
}

View File

@@ -0,0 +1,19 @@
// @flow
import queryString from 'query-string';
import { type Context } from 'koa';
export default function methodOverride() {
return async function methodOverrideMiddleware(
ctx: Context,
next: () => Promise<*>
) {
if (ctx.method === 'POST') {
// $FlowFixMe
ctx.body = ctx.request.body;
} else if (ctx.method === 'GET') {
ctx.method = 'POST'; // eslint-disable-line
ctx.body = queryString.parse(ctx.querystring);
}
return next();
};
}

View File

@@ -6,7 +6,7 @@ import { type Context } from 'koa';
export default function pagination(options?: Object) {
return async function paginationMiddleware(
ctx: Context,
next: () => Promise<void>
next: () => Promise<*>
) {
const opts = {
defaultLimit: 15,

View File

@@ -1,46 +0,0 @@
// @flow
import validator from 'validator';
import { ParamRequiredError, ValidationError } from '../../errors';
import { validateColorHex } from '../../../shared/utils/color';
export default function validation() {
return function validationMiddleware(ctx: Object, next: Function) {
ctx.assertPresent = (value, message) => {
if (value === undefined || value === null || value === '') {
throw new ParamRequiredError(message);
}
};
ctx.assertNotEmpty = (value, message) => {
if (value === '') {
throw new ValidationError(message);
}
};
ctx.assertEmail = (value, message) => {
if (!validator.isEmail(value)) {
throw new ValidationError(message);
}
};
ctx.assertUuid = (value, message) => {
if (!validator.isUUID(value)) {
throw new ValidationError(message);
}
};
ctx.assertPositiveInteger = (value, message) => {
if (!validator.isInt(value, { min: 0 })) {
throw new ValidationError(message);
}
};
ctx.assertHexColor = (value, message) => {
if (!validateColorHex(value)) {
throw new ValidationError(message);
}
};
return next();
};
}

View File

@@ -1,6 +1,6 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentShare } from '../presenters';
import { Document, User, Share } from '../models';

View File

@@ -2,7 +2,7 @@
import Router from 'koa-router';
import { User } from '../models';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentUser } from '../presenters';

View File

@@ -4,7 +4,7 @@ import Router from 'koa-router';
import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3';
import { ValidationError } from '../errors';
import { Event, User, Team } from '../models';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentUser } from '../presenters';
import policy from '../policies';

View File

@@ -1,6 +1,6 @@
// @flow
import Router from 'koa-router';
import auth from './middlewares/authentication';
import auth from '../middlewares/authentication';
import { presentView } from '../presenters';
import { View, Document } from '../models';
import policy from '../policies';