diff --git a/.env.sample b/.env.sample index db8c5a02..46a6b14d 100644 --- a/.env.sample +++ b/.env.sample @@ -14,10 +14,13 @@ DEPLOYMENT=self ENABLE_UPDATES=true DEBUG=sql,cache,presenters,events -# Third party credentials (required) +# Slack signin credentials (at least one is required) SLACK_KEY=71315967491.XXXXXXXXXX SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + # Third party credentials (optional) SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY SLACK_APP_ID=A0XXXXXXX diff --git a/app/components/Auth.js b/app/components/Auth.js index 6a9d3f86..2218461d 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -15,7 +15,12 @@ type Props = { let authenticatedStores; const Auth = ({ children }: Props) => { - if (stores.auth.authenticated && stores.auth.team && stores.auth.user) { + if (stores.auth.authenticated) { + if (!stores.auth.team || !stores.auth.user) { + stores.auth.fetch(); + return null; + } + // Only initialize stores once. Kept in global scope because otherwise they // will get overridden on route change if (!authenticatedStores) { @@ -42,7 +47,6 @@ const Auth = ({ children }: Props) => { }; } - stores.auth.fetch(); authenticatedStores.collections.fetchPage({ limit: 100 }); } diff --git a/app/stores/AuthStore.js b/app/stores/AuthStore.js index 29b2f0e8..2468787a 100644 --- a/app/stores/AuthStore.js +++ b/app/stores/AuthStore.js @@ -123,7 +123,9 @@ class AuthStore { } this.user = data.user; this.team = data.team; - this.token = data.token; + this.token = Cookie.get('accessToken') || data.token; + console.log('TOKEN', this.token); + this.oauthState = data.oauthState; autorun(() => { diff --git a/package.json b/package.json index aac8a19c..40ef2e46 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "file-loader": "^1.1.6", "flow-typed": "^2.4.0", "fs-extra": "^4.0.2", + "google-auth-library": "^1.5.0", "history": "3.0.0", "html-webpack-plugin": "2.17.0", "http-errors": "1.4.0", @@ -168,11 +169,11 @@ "styled-components-breakpoint": "^1.0.1", "styled-components-grid": "^1.0.0-preview.15", "styled-normalize": "^2.2.1", + "uglifyjs-webpack-plugin": "1.2.5", "url-loader": "^0.6.2", "uuid": "2.0.2", "validator": "5.2.0", "webpack": "3.10.0", - "uglifyjs-webpack-plugin": "1.2.5", "webpack-manifest-plugin": "^1.3.2" }, "devDependencies": { diff --git a/server/api/apiKeys.js b/server/api/apiKeys.js index 2452030b..3086300f 100644 --- a/server/api/apiKeys.js +++ b/server/api/apiKeys.js @@ -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'; diff --git a/server/api/auth.js b/server/api/auth.js index e520cb7d..5b92a450 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -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; diff --git a/server/api/auth.test.js b/server/api/auth.test.js deleted file mode 100644 index 202e5ee9..00000000 --- a/server/api/auth.test.js +++ /dev/null @@ -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(); - }); -}); diff --git a/server/api/collections.js b/server/api/collections.js index 86b44764..85ed0b0b 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -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'; diff --git a/server/api/documents.js b/server/api/documents.js index 5cf4a3b2..0813df7e 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -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'; diff --git a/server/api/index.js b/server/api/index.js index 26e9e1dc..8088c313 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -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()); diff --git a/server/api/integrations.js b/server/api/integrations.js index 56998aeb..53b96447 100644 --- a/server/api/integrations.js +++ b/server/api/integrations.js @@ -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'; diff --git a/server/api/middlewares/apiWrapper.js b/server/api/middlewares/apiWrapper.js index d4f8ba33..e980e190 100644 --- a/server/api/middlewares/apiWrapper.js +++ b/server/api/middlewares/apiWrapper.js @@ -4,7 +4,7 @@ import { type Context } from 'koa'; export default function apiWrapper() { return async function apiWrapperMiddleware( ctx: Context, - next: () => Promise + next: () => Promise<*> ) { await next(); diff --git a/server/middlewares/cache.js b/server/api/middlewares/cache.js similarity index 80% rename from server/middlewares/cache.js rename to server/api/middlewares/cache.js index 22068f62..7caf9ddd 100644 --- a/server/middlewares/cache.js +++ b/server/api/middlewares/cache.js @@ -1,10 +1,11 @@ // @flow import debug from 'debug'; +import { type Context } from 'koa'; const debugCache = debug('cache'); export default function cache() { - return async function cacheMiddleware(ctx: Object, next: Function) { + return async function cacheMiddleware(ctx: Context, next: () => Promise<*>) { ctx.cache = {}; ctx.cache.set = async (id, value) => { diff --git a/server/api/middlewares/errorHandling.js b/server/api/middlewares/errorHandling.js new file mode 100644 index 00000000..5acd500a --- /dev/null +++ b/server/api/middlewares/errorHandling.js @@ -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, + }; + } + }; +} diff --git a/server/middlewares/methodOverride.js b/server/api/middlewares/methodOverride.js similarity index 93% rename from server/middlewares/methodOverride.js rename to server/api/middlewares/methodOverride.js index 030b6d66..7872dfc1 100644 --- a/server/middlewares/methodOverride.js +++ b/server/api/middlewares/methodOverride.js @@ -5,7 +5,7 @@ import { type Context } from 'koa'; export default function methodOverride() { return async function methodOverrideMiddleware( ctx: Context, - next: () => Promise + next: () => Promise<*> ) { if (ctx.method === 'POST') { // $FlowFixMe diff --git a/server/api/middlewares/pagination.js b/server/api/middlewares/pagination.js index 456d7930..f63daf1a 100644 --- a/server/api/middlewares/pagination.js +++ b/server/api/middlewares/pagination.js @@ -6,7 +6,7 @@ import { type Context } from 'koa'; export default function pagination(options?: Object) { return async function paginationMiddleware( ctx: Context, - next: () => Promise + next: () => Promise<*> ) { const opts = { defaultLimit: 15, diff --git a/server/api/shares.js b/server/api/shares.js index ce2fb9e1..677b0222 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -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'; diff --git a/server/api/team.js b/server/api/team.js index d51cba7b..d41bac40 100644 --- a/server/api/team.js +++ b/server/api/team.js @@ -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'; diff --git a/server/api/user.js b/server/api/user.js index b8a1dbf8..4cadd2d0 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -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'; diff --git a/server/api/views.js b/server/api/views.js index 7e59794a..0d73387f 100644 --- a/server/api/views.js +++ b/server/api/views.js @@ -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'; diff --git a/server/auth/google.js b/server/auth/google.js new file mode 100644 index 00000000..330601c8 --- /dev/null +++ b/server/auth/google.js @@ -0,0 +1,86 @@ +// @flow +import Router from 'koa-router'; +import addMonths from 'date-fns/add_months'; +import { OAuth2Client } from 'google-auth-library'; +import { User, Team } from '../models'; + +const router = new Router(); +const client = new OAuth2Client( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + `${process.env.URL}/auth/google.callback` +); + +// start the oauth process and redirect user to Google +router.get('google', async ctx => { + // Generate the url that will be used for the consent dialog. + const authorizeUrl = client.generateAuthUrl({ + access_type: 'offline', + scope: [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', + ], + prompt: 'consent', + }); + ctx.redirect(authorizeUrl); +}); + +// signin callback from Google +router.get('google.callback', async ctx => { + const { code } = ctx.request.query; + ctx.assertPresent(code, 'code is required'); + const response = await client.getToken(code); + client.setCredentials(response.tokens); + + console.log('Tokens acquired.'); + console.log(response.tokens); + + const profile = await client.request({ + url: 'https://www.googleapis.com/oauth2/v1/userinfo', + }); + + const teamName = profile.data.hd.split('.')[0]; + const [team, isFirstUser] = await Team.findOrCreate({ + where: { + slackId: profile.data.hd, + }, + defaults: { + name: teamName, + avatarUrl: `https://logo.clearbit.com/${profile.data.hd}`, + }, + }); + + const [user, isFirstSignin] = await User.findOrCreate({ + where: { + slackId: profile.data.id, + teamId: team.id, + }, + defaults: { + name: profile.data.name, + email: profile.data.email, + isAdmin: isFirstUser, + avatarUrl: profile.data.picture, + }, + }); + + if (!isFirstSignin) { + await user.save(); + } + + if (isFirstUser) { + await team.createFirstCollection(user.id); + } + + ctx.cookies.set('lastLoggedIn', 'google', { + httpOnly: false, + expires: new Date('2100'), + }); + ctx.cookies.set('accessToken', user.getJwtToken(), { + httpOnly: false, + expires: addMonths(new Date(), 6), + }); + + ctx.redirect('/'); +}); + +export default router; diff --git a/server/auth/index.js b/server/auth/index.js new file mode 100644 index 00000000..ed416e76 --- /dev/null +++ b/server/auth/index.js @@ -0,0 +1,20 @@ +// @flow +import bodyParser from 'koa-bodyparser'; +import Koa from 'koa'; +import Router from 'koa-router'; +import validation from '../middlewares/validation'; + +import slack from './slack'; +import google from './google'; + +const auth = new Koa(); +const router = new Router(); + +router.use('/', slack.routes()); +router.use('/', google.routes()); + +auth.use(bodyParser()); +auth.use(validation()); +auth.use(router.routes()); + +export default auth; diff --git a/server/auth/slack.js b/server/auth/slack.js new file mode 100644 index 00000000..dede5531 --- /dev/null +++ b/server/auth/slack.js @@ -0,0 +1,145 @@ +// @flow +import Router from 'koa-router'; +import auth from '../middlewares/authentication'; +import { slackAuth } from '../../shared/utils/routeHelpers'; +import { presentUser, presentTeam } from '../presenters'; +import { Authentication, Integration, User, Team } from '../models'; +import * as Slack from '../slack'; + +const router = new Router(); + +router.get('auth.slack', async ctx => { + const state = Math.random() + .toString(36) + .substring(7); + + ctx.cookies.set('state', state, { + httpOnly: false, + expires: new Date('2100'), + }); + ctx.redirect(slackAuth(state)); +}); + +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; diff --git a/server/index.js b/server/index.js index 6ce3afa0..d547d637 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ import bugsnag from 'bugsnag'; import onerror from 'koa-onerror'; import updates from './utils/updates'; +import auth from './auth'; import api from './api'; import emails from './emails'; import routes from './routes'; @@ -79,6 +80,7 @@ if (process.env.NODE_ENV === 'development') { } } +app.use(mount('/auth', auth)); app.use(mount('/api', api)); app.use(mount(routes)); diff --git a/server/api/middlewares/authentication.js b/server/middlewares/authentication.js similarity index 92% rename from server/api/middlewares/authentication.js rename to server/middlewares/authentication.js index 3cd33ab6..11f3717c 100644 --- a/server/api/middlewares/authentication.js +++ b/server/middlewares/authentication.js @@ -1,14 +1,11 @@ // @flow import JWT from 'jsonwebtoken'; import { type Context } from 'koa'; -import { User, ApiKey } from '../../models'; -import { AuthenticationError, UserSuspendedError } from '../../errors'; +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 - ) { + return async function authMiddleware(ctx: Context, next: () => Promise<*>) { let token; const authorizationHeader = ctx.request.get('authorization'); diff --git a/server/api/middlewares/authentication.test.js b/server/middlewares/authentication.test.js similarity index 100% rename from server/api/middlewares/authentication.test.js rename to server/middlewares/authentication.test.js diff --git a/server/api/middlewares/validation.js b/server/middlewares/validation.js similarity index 80% rename from server/api/middlewares/validation.js rename to server/middlewares/validation.js index 5ac0ef36..30780850 100644 --- a/server/api/middlewares/validation.js +++ b/server/middlewares/validation.js @@ -1,10 +1,11 @@ // @flow import validator from 'validator'; -import { ParamRequiredError, ValidationError } from '../../errors'; -import { validateColorHex } from '../../../shared/utils/color'; +import { type Context } from 'koa'; +import { ParamRequiredError, ValidationError } from '../errors'; +import { validateColorHex } from '../../shared/utils/color'; export default function validation() { - return function validationMiddleware(ctx: Object, next: Function) { + return function validationMiddleware(ctx: Context, next: () => Promise<*>) { ctx.assertPresent = (value, message) => { if (value === undefined || value === null || value === '') { throw new ParamRequiredError(message); diff --git a/server/pages/Home.js b/server/pages/Home.js index 5aee066b..945a2c95 100644 --- a/server/pages/Home.js +++ b/server/pages/Home.js @@ -9,7 +9,11 @@ import SignupButton from './components/SignupButton'; import { developers, githubUrl } from '../../shared/utils/routeHelpers'; import { color } from '../../shared/styles/constants'; -function Home() { +type Props = { + lastLoggedIn: string, +}; + +function Home({ lastLoggedIn }: Props) { return ( @@ -23,7 +27,7 @@ function Home() { logs, brainstorming, & moreā€¦

- +

diff --git a/server/pages/components/SignupButton.js b/server/pages/components/SignupButton.js index 155243c8..5c6c1504 100644 --- a/server/pages/components/SignupButton.js +++ b/server/pages/components/SignupButton.js @@ -2,15 +2,32 @@ import * as React from 'react'; import styled from 'styled-components'; import { signin } from '../../../shared/utils/routeHelpers'; +import Flex from '../../../shared/components/Flex'; import SlackLogo from '../../../shared/components/SlackLogo'; import { color } from '../../../shared/styles/constants'; -const SlackSignin = () => { +type Props = { + lastLoggedIn: string, +}; + +const SlackSignin = ({ lastLoggedIn }: Props) => { return ( - + + + + {lastLoggedIn === 'slack' && 'You signed in with Slack previously'} + +   + + + {lastLoggedIn === 'google' && 'You signed in with Google previously'} + + ); }; diff --git a/server/presenters/user.js b/server/presenters/user.js index 6aa12b0f..767e3e7e 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -19,8 +19,6 @@ export default ( user: User, options: Options = {} ): UserPresentation => { - ctx.cache.set(user.id, user); - const userData = {}; userData.id = user.id; userData.username = user.username; diff --git a/server/routes.js b/server/routes.js index f883118f..1c05f47b 100644 --- a/server/routes.js +++ b/server/routes.js @@ -7,7 +7,6 @@ import sendfile from 'koa-sendfile'; import serve from 'koa-static'; import subdomainRedirect from './middlewares/subdomainRedirect'; import renderpage from './utils/renderpage'; -import { slackAuth } from '../shared/utils/routeHelpers'; import { robotsResponse } from './utils/robots'; import { NotFoundError } from './errors'; @@ -48,19 +47,6 @@ if (process.env.NODE_ENV === 'production') { }); } -// slack direct install -router.get('/auth/slack/install', async ctx => { - const state = Math.random() - .toString(36) - .substring(7); - - ctx.cookies.set('state', state, { - httpOnly: false, - expires: new Date('2100'), - }); - ctx.redirect(slackAuth(state)); -}); - // static pages router.get('/about', ctx => renderpage(ctx, )); router.get('/pricing', ctx => renderpage(ctx, )); @@ -76,10 +62,14 @@ router.get('/changelog', async ctx => { // home page router.get('/', async ctx => { - if (ctx.cookies.get('loggedIn')) { + const lastLoggedIn = ctx.cookies.get('lastLoggedIn'); + const accessToken = ctx.cookies.get('accessToken'); + console.log(lastLoggedIn, accessToken); + + if (accessToken) { await renderapp(ctx); } else { - await renderpage(ctx, ); + await renderpage(ctx, ); } }); diff --git a/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js index 826a8327..a9c7772a 100644 --- a/shared/utils/routeHelpers.js +++ b/shared/utils/routeHelpers.js @@ -57,8 +57,8 @@ export function changelog(): string { return '/changelog'; } -export function signin(): string { - return '/auth/slack'; +export function signin(service: string = 'slack'): string { + return `/auth/${service}`; } export function about(): string { diff --git a/yarn.lock b/yarn.lock index 902dcb13..b7175aba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -490,6 +490,13 @@ aws4@^1.2.1, aws4@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" +axios@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102" + dependencies: + follow-redirects "^1.3.0" + is-buffer "^1.1.5" + axobject-query@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-0.1.0.tgz#62f59dbc59c9f9242759ca349960e7a2fe3c36c0" @@ -3441,7 +3448,7 @@ express-session@~1.11.3: uid-safe "~2.0.0" utils-merge "1.0.0" -extend@^3.0.0, extend@~3.0.0, extend@~3.0.1: +extend@^3.0.0, extend@^3.0.1, extend@~3.0.0, extend@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" @@ -3739,6 +3746,12 @@ flush-write-stream@^1.0.0: inherits "^2.0.1" readable-stream "^2.0.4" +follow-redirects@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.0.tgz#234f49cf770b7f35b40e790f636ceba0c3a0ab77" + dependencies: + debug "^3.1.0" + for-in@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3927,6 +3940,14 @@ gaze@^0.5.1: dependencies: globule "~0.1.0" +gcp-metadata@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-0.6.3.tgz#4550c08859c528b370459bd77a7187ea0bdbc4ab" + dependencies: + axios "^0.18.0" + extend "^3.0.1" + retry-axios "0.3.2" + generic-pool@2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff" @@ -4099,6 +4120,18 @@ good-listener@^1.2.2: dependencies: delegate "^3.1.2" +google-auth-library@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-1.5.0.tgz#d9068f8bad9017224a4c41abcdcb6cf6a704e83b" + dependencies: + axios "^0.18.0" + gcp-metadata "^0.6.3" + gtoken "^2.3.0" + jws "^3.1.4" + lodash.isstring "^4.0.1" + lru-cache "^4.1.2" + retry-axios "^0.3.2" + google-closure-compiler-js@^20170423.0.0: version "20170423.0.0" resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20170423.0.0.tgz#e9e8b40dadfdf0e64044c9479b5d26d228778fbc" @@ -4107,6 +4140,13 @@ google-closure-compiler-js@^20170423.0.0: vinyl "^2.0.1" webpack-core "^0.6.8" +google-p12-pem@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-1.0.2.tgz#c8a3843504012283a0dbffc7430b7c753ecd4b07" + dependencies: + node-forge "^0.7.4" + pify "^3.0.0" + got@^3.2.0: version "3.3.1" resolved "https://registry.yarnpkg.com/got/-/got-3.3.1.tgz#e5d0ed4af55fc3eef4d56007769d98192bcb2eca" @@ -4163,6 +4203,16 @@ growly@^1.2.0, growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" +gtoken@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-2.3.0.tgz#4e0ffc16432d7041a1b3dbc1d97aac17a5dc964a" + dependencies: + axios "^0.18.0" + google-p12-pem "^1.0.0" + jws "^3.1.4" + mime "^2.2.0" + pify "^3.0.0" + gulp-help@~1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/gulp-help/-/gulp-help-1.6.1.tgz#261db186e18397fef3f6a2c22e9c315bfa88ae0c" @@ -6406,7 +6456,7 @@ lru-cache@^4.0.1: pseudomap "^1.0.2" yallist "^2.1.2" -lru-cache@^4.1.1: +lru-cache@^4.1.1, lru-cache@^4.1.2: version "4.1.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c" dependencies: @@ -6603,6 +6653,10 @@ mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" +mime@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" + mimic-fn@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" @@ -6855,6 +6909,10 @@ node-fetch@^1.0.1, node-fetch@^1.5.1: encoding "^0.1.11" is-stream "^1.0.1" +node-forge@^0.7.4: + version "0.7.5" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.5.tgz#6c152c345ce11c52f465c2abd957e8639cd674df" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -8762,6 +8820,10 @@ retry-as-promised@^2.3.1: bluebird "^3.4.6" debug "^2.6.9" +retry-axios@0.3.2, retry-axios@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-0.3.2.tgz#5757c80f585b4cc4c4986aa2ffd47a60c6d35e13" + rich-markdown-editor@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-1.1.2.tgz#c44f14425b5b5f0da3adce8bf389ed6e20b705a4"