From ed2a42ac279e0ae23abcf846b621cf6bcf03e75f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 9 Mar 2021 12:22:08 -0800 Subject: [PATCH] chore: Migrate authentication to new tables (#1929) This work provides a foundation for a more pluggable authentication system such as the one outlined in #1317. closes #1317 --- .gitignore | 1 - app/models/Team.js | 8 +- app/scenes/Settings/Notifications.js | 3 +- flow-typed/globals.js | 1 + server/.jestconfig.json | 2 +- server/api/auth.js | 14 +- server/api/hooks.js | 70 ++++++-- server/api/hooks.test.js | 44 ++--- server/auth/email.js | 23 +-- server/auth/google.js | 105 +++++------- server/auth/slack.js | 85 +++++----- server/commands/teamCreator.js | 95 +++++++++++ server/commands/teamCreator.test.js | 61 +++++++ server/commands/userCreator.js | 151 +++++++++++++++++ server/commands/userCreator.test.js | 94 +++++++++++ server/index.js | 6 - server/main.js | 6 +- server/middlewares/authentication.js | 37 ++--- ...20210226232041-authentication-providers.js | 98 +++++++++++ server/models/AuthenticationProvider.js | 51 ++++++ server/models/Collection.test.js | 4 +- server/models/Team.js | 12 +- server/models/User.js | 9 +- server/models/UserAuthentication.js | 24 +++ server/models/index.js | 6 + server/presenters/team.js | 2 - .../20210226232041-migrate-authentication.js | 102 ++++++++++++ ...10226232041-migrate-authentication.test.js | 152 ++++++++++++++++++ server/scripts/bootstrap.js | 6 + server/services/index.js | 32 ++-- server/test/factories.js | 87 +++++++--- server/test/support.js | 92 ++++++----- server/utils/avatars.js | 32 ++++ server/utils/avatars.test.js | 41 +++++ server/utils/startup.js | 21 +++ 35 files changed, 1280 insertions(+), 297 deletions(-) create mode 100644 server/commands/teamCreator.js create mode 100644 server/commands/teamCreator.test.js create mode 100644 server/commands/userCreator.js create mode 100644 server/commands/userCreator.test.js create mode 100644 server/migrations/20210226232041-authentication-providers.js create mode 100644 server/models/AuthenticationProvider.js create mode 100644 server/models/UserAuthentication.js create mode 100644 server/scripts/20210226232041-migrate-authentication.js create mode 100644 server/scripts/20210226232041-migrate-authentication.test.js create mode 100644 server/scripts/bootstrap.js create mode 100644 server/utils/avatars.js create mode 100644 server/utils/avatars.test.js create mode 100644 server/utils/startup.js diff --git a/.gitignore b/.gitignore index df445813e..9c279077d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ dist build node_modules/* -server/scripts .env .log npm-debug.log diff --git a/app/models/Team.js b/app/models/Team.js index 73b5647bb..fbe18bf93 100644 --- a/app/models/Team.js +++ b/app/models/Team.js @@ -6,8 +6,6 @@ class Team extends BaseModel { id: string; name: string; avatarUrl: string; - slackConnected: boolean; - googleConnected: boolean; sharing: boolean; documentEmbeds: boolean; guestSignin: boolean; @@ -17,11 +15,7 @@ class Team extends BaseModel { @computed get signinMethods(): string { - if (this.slackConnected && this.googleConnected) { - return "Slack or Google"; - } - if (this.slackConnected) return "Slack"; - return "Google"; + return "SSO"; } } diff --git a/app/scenes/Settings/Notifications.js b/app/scenes/Settings/Notifications.js index 36c5913f3..d655fae99 100644 --- a/app/scenes/Settings/Notifications.js +++ b/app/scenes/Settings/Notifications.js @@ -97,8 +97,7 @@ class Notifications extends React.Component { Manage when and where you receive email notifications from Outline. - Your email address can be updated in your{" "} - {team.slackConnected ? "Slack" : "Google"} account. + Your email address can be updated in your SSO provider. void, env: { [string]: string, }, diff --git a/server/.jestconfig.json b/server/.jestconfig.json index a84e92771..332f1ddf3 100644 --- a/server/.jestconfig.json +++ b/server/.jestconfig.json @@ -1,5 +1,5 @@ { - "verbose": true, + "verbose": false, "rootDir": "..", "roots": [ "/server", diff --git a/server/api/auth.js b/server/api/auth.js index 53a61e57c..6d67a9bef 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -37,10 +37,14 @@ services.push({ function filterServices(team) { let output = services; - if (team && !team.googleId) { + const providerNames = team + ? team.authenticationProviders.map((provider) => provider.name) + : []; + + if (team && !providerNames.includes("google")) { output = reject(output, (service) => service.id === "google"); } - if (team && !team.slackId) { + if (team && !providerNames.includes("slack")) { output = reject(output, (service) => service.id === "slack"); } if (!team || !team.guestSignin) { @@ -55,7 +59,7 @@ router.post("auth.config", async (ctx) => { // brand for the knowledge base and it's guest signin option is used for the // root login page. if (process.env.DEPLOYMENT !== "hosted") { - const teams = await Team.findAll(); + const teams = await Team.scope("withAuthenticationProviders").findAll(); if (teams.length === 1) { const team = teams[0]; @@ -70,7 +74,7 @@ router.post("auth.config", async (ctx) => { } if (isCustomDomain(ctx.request.hostname)) { - const team = await Team.findOne({ + const team = await Team.scope("withAuthenticationProviders").findOne({ where: { domain: ctx.request.hostname }, }); @@ -95,7 +99,7 @@ router.post("auth.config", async (ctx) => { ) { const domain = parseDomain(ctx.request.hostname); const subdomain = domain ? domain.subdomain : undefined; - const team = await Team.findOne({ + const team = await Team.scope("withAuthenticationProviders").findOne({ where: { subdomain }, }); diff --git a/server/api/hooks.js b/server/api/hooks.js index 6fd1e2f64..d3b088c46 100644 --- a/server/api/hooks.js +++ b/server/api/hooks.js @@ -3,6 +3,8 @@ import Router from "koa-router"; import { escapeRegExp } from "lodash"; import { AuthenticationError, InvalidRequestError } from "../errors"; import { + UserAuthentication, + AuthenticationProvider, Authentication, Document, User, @@ -25,7 +27,14 @@ router.post("hooks.unfurl", async (ctx) => { } const user = await User.findOne({ - where: { service: "slack", serviceId: event.user }, + include: [ + { + where: { providerId: event.user }, + model: UserAuthentication, + as: "authentications", + required: true, + }, + ], }); if (!user) return; @@ -70,11 +79,21 @@ router.post("hooks.interactive", async (ctx) => { throw new AuthenticationError("Invalid verification token"); } - const team = await Team.findOne({ - where: { slackId: data.team.id }, + const authProvider = await AuthenticationProvider.findOne({ + where: { + name: "slack", + providerId: data.team.id, + }, + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], }); - if (!team) { + if (!authProvider) { ctx.body = { text: "Sorry, we couldn’t find an integration for your team. Head to your Outline settings to set one up.", @@ -84,6 +103,8 @@ router.post("hooks.interactive", async (ctx) => { return; } + const { team } = authProvider; + // we find the document based on the users teamId to ensure access const document = await Document.findOne({ where: { @@ -131,20 +152,41 @@ router.post("hooks.slack", async (ctx) => { return; } - let user; + let user, team; // attempt to find the corresponding team for this request based on the team_id - let team = await Team.findOne({ - where: { slackId: team_id }, - }); - if (team) { - user = await User.findOne({ - where: { - teamId: team.id, - service: "slack", - serviceId: user_id, + team = await Team.findOne({ + include: [ + { + where: { + name: "slack", + providerId: team_id, + }, + as: "authenticationProviders", + model: AuthenticationProvider, + required: true, }, + ], + }); + + if (team) { + const authentication = await UserAuthentication.findOne({ + where: { + providerId: user_id, + }, + include: [ + { + where: { teamId: team.id }, + model: User, + as: "user", + required: true, + }, + ], }); + + if (authentication) { + user = authentication.user; + } } else { // If we couldn't find a team it's still possible that the request is from // a team that authenticated with a different service, but connected Slack diff --git a/server/api/hooks.test.js b/server/api/hooks.test.js index 0be67adfa..bf284397a 100644 --- a/server/api/hooks.test.js +++ b/server/api/hooks.test.js @@ -33,7 +33,7 @@ describe("#hooks.unfurl", () => { event: { type: "link_shared", channel: "Cxxxxxx", - user: user.serviceId, + user: user.authentications[0].providerId, message_ts: "123456789.9875", links: [ { @@ -56,8 +56,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "dsfkndfskndsfkn", }, }); @@ -76,8 +76,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "contains", }, }); @@ -98,8 +98,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "*contains", }, }); @@ -118,8 +118,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "contains", }, }); @@ -137,8 +137,8 @@ describe("#hooks.slack", () => { await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "contains", }, }); @@ -161,8 +161,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "help", }, }); @@ -176,8 +176,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: process.env.SLACK_VERIFICATION_TOKEN, - user_id: user.serviceId, - team_id: team.slackId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "", }, }); @@ -206,7 +206,7 @@ describe("#hooks.slack", () => { body: { token: process.env.SLACK_VERIFICATION_TOKEN, user_id: "unknown-slack-user-id", - team_id: team.slackId, + team_id: team.authenticationProviders[0].providerId, text: "contains", }, }); @@ -260,8 +260,8 @@ describe("#hooks.slack", () => { const res = await server.post("/api/hooks.slack", { body: { token: "wrong-verification-token", - team_id: team.slackId, - user_id: user.serviceId, + user_id: user.authentications[0].providerId, + team_id: team.authenticationProviders[0].providerId, text: "Welcome", }, }); @@ -280,8 +280,8 @@ describe("#hooks.interactive", () => { const payload = JSON.stringify({ token: process.env.SLACK_VERIFICATION_TOKEN, - user: { id: user.serviceId }, - team: { id: team.slackId }, + user: { id: user.authentications[0].providerId }, + team: { id: team.authenticationProviders[0].providerId }, callback_id: document.id, }); const res = await server.post("/api/hooks.interactive", { @@ -305,7 +305,7 @@ describe("#hooks.interactive", () => { const payload = JSON.stringify({ token: process.env.SLACK_VERIFICATION_TOKEN, user: { id: "unknown-slack-user-id" }, - team: { id: team.slackId }, + team: { id: team.authenticationProviders[0].providerId }, callback_id: document.id, }); const res = await server.post("/api/hooks.interactive", { @@ -322,7 +322,7 @@ describe("#hooks.interactive", () => { const { user } = await seed(); const payload = JSON.stringify({ token: "wrong-verification-token", - user: { id: user.serviceId, name: user.name }, + user: { id: user.authentications[0].providerId, name: user.name }, callback_id: "doesnt-matter", }); const res = await server.post("/api/hooks.interactive", { diff --git a/server/auth/email.js b/server/auth/email.js index 4159c7dc6..1e40d8bd4 100644 --- a/server/auth/email.js +++ b/server/auth/email.js @@ -1,6 +1,7 @@ // @flow import subMinutes from "date-fns/sub_minutes"; import Router from "koa-router"; +import { find } from "lodash"; import { AuthorizationError } from "../errors"; import mailer from "../mailer"; import auth from "../middlewares/authentication"; @@ -19,23 +20,27 @@ router.post("email", async (ctx) => { ctx.assertEmail(email, "email is required"); - const user = await User.findOne({ + const user = await User.scope("withAuthentications").findOne({ where: { email: email.toLowerCase() }, }); if (user) { - const team = await Team.findByPk(user.teamId); + const team = await Team.scope("withAuthenticationProviders").findByPk( + user.teamId + ); if (!team) { ctx.redirect(`/?notice=auth-error`); return; } // If the user matches an email address associated with an SSO - // signin then just forward them directly to that service's - // login page - if (user.service && user.service !== "email") { + // provider then just forward them directly to that sign-in page + if (user.authentications.length) { + const authProvider = find(team.authenticationProviders, { + id: user.authentications[0].authenticationProviderId, + }); ctx.body = { - redirect: `${team.url}/auth/${user.service}`, + redirect: `${team.url}/auth/${authProvider.name}`, }; return; } @@ -87,11 +92,7 @@ router.get("email.callback", auth({ required: false }), async (ctx) => { throw new AuthorizationError(); } - if (!user.service) { - user.service = "email"; - user.lastActiveAt = new Date(); - await user.save(); - } + await user.update({ lastActiveAt: new Date() }); // set cookies on response and redirect to team subdomain ctx.signIn(user, team, "email", false); diff --git a/server/auth/google.js b/server/auth/google.js index 6bad65183..948f8ddb2 100644 --- a/server/auth/google.js +++ b/server/auth/google.js @@ -1,14 +1,14 @@ // @flow -import crypto from "crypto"; +import * as Sentry from "@sentry/node"; import { OAuth2Client } from "google-auth-library"; import invariant from "invariant"; import Router from "koa-router"; import { capitalize } from "lodash"; import Sequelize from "sequelize"; +import teamCreator from "../commands/teamCreator"; +import userCreator from "../commands/userCreator"; import auth from "../middlewares/authentication"; -import { User, Team } from "../models"; - -const Op = Sequelize.Op; +import { User } from "../models"; const router = new Router(); const client = new OAuth2Client( @@ -55,90 +55,60 @@ router.get("google.callback", auth({ required: false }), async (ctx) => { return; } - const googleId = profile.data.hd; - const hostname = profile.data.hd.split(".")[0]; - const teamName = capitalize(hostname); + const domain = profile.data.hd; + const subdomain = profile.data.hd.split(".")[0]; + const teamName = capitalize(subdomain); - // attempt to get logo from Clearbit API. If one doesn't exist then - // fall back to using tiley to generate a placeholder logo - const hash = crypto.createHash("sha256"); - hash.update(googleId); - const hashedGoogleId = hash.digest("hex"); - const cbUrl = `https://logo.clearbit.com/${profile.data.hd}`; - const tileyUrl = `https://tiley.herokuapp.com/avatar/${hashedGoogleId}/${teamName[0]}.png`; - const cbResponse = await fetch(cbUrl); - const avatarUrl = cbResponse.status === 200 ? cbUrl : tileyUrl; - - let team, isFirstUser; + let result; try { - [team, isFirstUser] = await Team.findOrCreate({ - where: { - googleId, - }, - defaults: { - name: teamName, - avatarUrl, + result = await teamCreator({ + name: teamName, + domain, + subdomain, + authenticationProvider: { + name: "google", + providerId: domain, }, }); } catch (err) { if (err instanceof Sequelize.UniqueConstraintError) { - ctx.redirect(`/?notice=auth-error`); + ctx.redirect(`/?notice=auth-error&error=team-exists`); return; } } - invariant(team, "Team must exist"); + + invariant(result, "Team creator result must exist"); + const { team, isNewTeam, authenticationProvider } = result; try { - const [user, isFirstSignin] = await User.findOrCreate({ - where: { - [Op.or]: [ - { - service: "google", - serviceId: profile.data.id, - }, - { - service: { [Op.eq]: null }, - email: profile.data.email, - }, - ], - teamId: team.id, - }, - defaults: { - service: "google", - serviceId: profile.data.id, - name: profile.data.name, - email: profile.data.email, - isAdmin: isFirstUser, - avatarUrl: profile.data.picture, + const result = await userCreator({ + name: profile.data.name, + email: profile.data.email, + isAdmin: isNewTeam, + avatarUrl: profile.data.picture, + teamId: team.id, + ip: ctx.request.ip, + authentication: { + authenticationProviderId: authenticationProvider.id, + providerId: profile.data.id, + accessToken: response.tokens.access_token, + refreshToken: response.tokens.refresh_token, + scopes: response.tokens.scope.split(" "), }, }); - // update the user with fresh details if they just accepted an invite - if (!user.serviceId || !user.service) { - await user.update({ - service: "google", - serviceId: profile.data.id, - avatarUrl: profile.data.picture, - }); - } + const { user, isNewUser } = result; - // update email address if it's changed in Google - if (!isFirstSignin && profile.data.email !== user.email) { - await user.update({ email: profile.data.email }); - } - - if (isFirstUser) { + if (isNewTeam) { await team.provisionFirstCollection(user.id); - await team.provisionSubdomain(hostname); } // set cookies on response and redirect to team subdomain - ctx.signIn(user, team, "google", isFirstSignin); + ctx.signIn(user, team, "google", isNewUser); } catch (err) { if (err instanceof Sequelize.UniqueConstraintError) { const exists = await User.findOne({ where: { - service: "email", email: profile.data.email, teamId: team.id, }, @@ -147,6 +117,11 @@ router.get("google.callback", auth({ required: false }), async (ctx) => { if (exists) { ctx.redirect(`${team.url}?notice=email-auth-required`); } else { + if (process.env.SENTRY_DSN) { + Sentry.captureException(err); + } else { + console.error(err); + } ctx.redirect(`${team.url}?notice=auth-error`); } diff --git a/server/auth/slack.js b/server/auth/slack.js index b8aaca5dd..70d2f2926 100644 --- a/server/auth/slack.js +++ b/server/auth/slack.js @@ -1,15 +1,17 @@ // @flow +import * as Sentry from "@sentry/node"; import addHours from "date-fns/add_hours"; import invariant from "invariant"; import Router from "koa-router"; import Sequelize from "sequelize"; import { slackAuth } from "../../shared/utils/routeHelpers"; +import teamCreator from "../commands/teamCreator"; +import userCreator from "../commands/userCreator"; import auth from "../middlewares/authentication"; import { Authentication, Collection, Integration, User, Team } from "../models"; import * as Slack from "../slack"; import { getCookieDomain } from "../utils/domains"; -const Op = Sequelize.Op; const router = new Router(); // start the oauth process and redirect user to Slack @@ -41,76 +43,56 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => { const data = await Slack.oauthAccess(code); - let team, isFirstUser; + let result; try { - [team, isFirstUser] = await Team.findOrCreate({ - where: { - slackId: data.team.id, - }, - defaults: { - name: data.team.name, - avatarUrl: data.team.image_88, + result = await teamCreator({ + name: data.team.name, + subdomain: data.team.domain, + avatarUrl: data.team.image_230, + authenticationProvider: { + name: "slack", + providerId: data.team.id, }, }); } catch (err) { if (err instanceof Sequelize.UniqueConstraintError) { - ctx.redirect(`/?notice=auth-error`); + ctx.redirect(`/?notice=auth-error&error=team-exists`); return; } + throw err; } - invariant(team, "Team must exist"); + + invariant(result, "Team creator result must exist"); + const { authenticationProvider, team, isNewTeam } = result; try { - const [user, isFirstSignin] = await User.findOrCreate({ - where: { - [Op.or]: [ - { - service: "slack", - serviceId: data.user.id, - }, - { - service: { [Op.eq]: null }, - email: data.user.email, - }, - ], - teamId: team.id, - }, - defaults: { - service: "slack", - serviceId: data.user.id, - name: data.user.name, - email: data.user.email, - isAdmin: isFirstUser, - avatarUrl: data.user.image_192, + const result = await userCreator({ + name: data.user.name, + email: data.user.email, + isAdmin: isNewTeam, + avatarUrl: data.user.image_192, + teamId: team.id, + ip: ctx.request.ip, + authentication: { + authenticationProviderId: authenticationProvider.id, + providerId: data.user.id, + accessToken: data.access_token, + scopes: data.scope.split(","), }, }); - // update the user with fresh details if they just accepted an invite - if (!user.serviceId || !user.service) { - await user.update({ - service: "slack", - serviceId: data.user.id, - avatarUrl: data.user.image_192, - }); - } + const { user, isNewUser } = result; - // update email address if it's changed in Slack - if (!isFirstSignin && data.user.email !== user.email) { - await user.update({ email: data.user.email }); - } - - if (isFirstUser) { + if (isNewTeam) { await team.provisionFirstCollection(user.id); - await team.provisionSubdomain(data.team.domain); } // set cookies on response and redirect to team subdomain - ctx.signIn(user, team, "slack", isFirstSignin); + ctx.signIn(user, team, "slack", isNewUser); } catch (err) { if (err instanceof Sequelize.UniqueConstraintError) { const exists = await User.findOne({ where: { - service: "email", email: data.user.email, teamId: team.id, }, @@ -119,6 +101,11 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => { if (exists) { ctx.redirect(`${team.url}?notice=email-auth-required`); } else { + if (process.env.SENTRY_DSN) { + Sentry.captureException(err); + } else { + console.error(err); + } ctx.redirect(`${team.url}?notice=auth-error`); } diff --git a/server/commands/teamCreator.js b/server/commands/teamCreator.js new file mode 100644 index 000000000..a7e180373 --- /dev/null +++ b/server/commands/teamCreator.js @@ -0,0 +1,95 @@ +// @flow +import debug from "debug"; +import { Team, AuthenticationProvider } from "../models"; +import { sequelize } from "../sequelize"; +import { generateAvatarUrl } from "../utils/avatars"; + +const log = debug("server"); + +type TeamCreatorResult = {| + team: Team, + authenticationProvider: AuthenticationProvider, + isNewTeam: boolean, +|}; + +export default async function teamCreator({ + name, + domain, + subdomain, + avatarUrl, + authenticationProvider, +}: {| + name: string, + domain?: string, + subdomain: string, + avatarUrl?: string, + authenticationProvider: {| + name: string, + providerId: string, + |}, +|}): Promise { + const authP = await AuthenticationProvider.findOne({ + where: authenticationProvider, + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], + }); + + // This authentication provider already exists which means we have a team and + // there is nothing left to do but return the existing credentials + if (authP) { + return { + authenticationProvider: authP, + team: authP.team, + isNewTeam: false, + }; + } + + // If the service did not provide a logo/avatar then we attempt to generate + // one via ClearBit, or fallback to colored initials in worst case scenario + if (!avatarUrl) { + avatarUrl = await generateAvatarUrl({ + name, + domain, + id: subdomain, + }); + } + + // This team has never been seen before, time to create all the new stuff + let transaction = await sequelize.transaction(); + let team; + try { + team = await Team.create( + { + name, + avatarUrl, + authenticationProviders: [authenticationProvider], + }, + { + include: "authenticationProviders", + transaction, + } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + + try { + await team.provisionSubdomain(subdomain); + } catch (err) { + log(`Provisioning subdomain failed: ${err.message}`); + } + + return { + team, + authenticationProvider: team.authenticationProviders[0], + isNewTeam: true, + }; +} diff --git a/server/commands/teamCreator.test.js b/server/commands/teamCreator.test.js new file mode 100644 index 000000000..336aecfdf --- /dev/null +++ b/server/commands/teamCreator.test.js @@ -0,0 +1,61 @@ +// @flow +import { buildTeam } from "../test/factories"; +import { flushdb } from "../test/support"; +import teamCreator from "./teamCreator"; + +jest.mock("aws-sdk", () => { + const mS3 = { putObject: jest.fn().mockReturnThis(), promise: jest.fn() }; + return { + S3: jest.fn(() => mS3), + Endpoint: jest.fn(), + }; +}); + +beforeEach(() => flushdb()); + +describe("teamCreator", () => { + it("should create team and authentication provider", async () => { + const result = await teamCreator({ + name: "Test team", + subdomain: "example", + avatarUrl: "http://example.com/logo.png", + authenticationProvider: { + name: "google", + providerId: "example.com", + }, + }); + + const { team, authenticationProvider, isNewTeam } = result; + + expect(authenticationProvider.name).toEqual("google"); + expect(authenticationProvider.providerId).toEqual("example.com"); + expect(team.name).toEqual("Test team"); + expect(team.subdomain).toEqual("example"); + expect(isNewTeam).toEqual(true); + }); + + it("should return exising team", async () => { + const authenticationProvider = { + name: "google", + providerId: "example.com", + }; + + const existing = await buildTeam({ + subdomain: "example", + authenticationProviders: [authenticationProvider], + }); + + const result = await teamCreator({ + name: "Updated name", + subdomain: "example", + authenticationProvider, + }); + + const { team, isNewTeam } = result; + + expect(team.id).toEqual(existing.id); + expect(team.name).toEqual(existing.name); + expect(team.subdomain).toEqual("example"); + expect(isNewTeam).toEqual(false); + }); +}); diff --git a/server/commands/userCreator.js b/server/commands/userCreator.js new file mode 100644 index 000000000..531a4ab8c --- /dev/null +++ b/server/commands/userCreator.js @@ -0,0 +1,151 @@ +// @flow +import Sequelize from "sequelize"; +import { Event, User, UserAuthentication } from "../models"; +import { sequelize } from "../sequelize"; + +const Op = Sequelize.Op; + +type UserCreatorResult = {| + user: User, + isNewUser: boolean, + authentication: UserAuthentication, +|}; + +export default async function userCreator({ + name, + email, + isAdmin, + avatarUrl, + teamId, + authentication, + ip, +}: {| + name: string, + email: string, + isAdmin?: boolean, + avatarUrl?: string, + teamId: string, + ip: string, + authentication: {| + authenticationProviderId: string, + providerId: string, + scopes: string[], + accessToken?: string, + refreshToken?: string, + |}, +|}): Promise { + const { authenticationProviderId, providerId, ...rest } = authentication; + const auth = await UserAuthentication.findOne({ + where: { + authenticationProviderId, + providerId, + }, + include: [ + { + model: User, + as: "user", + }, + ], + }); + + // Someone has signed in with this authentication before, we just + // want to update the details instead of creating a new record + if (auth) { + const { user } = auth; + + await user.update({ email }); + await auth.update(rest); + + return { user, authentication: auth, isNewUser: false }; + } + + // A `user` record might exist in the form of an invite even if there is no + // existing authentication record that matches. In Outline an invite is a + // shell user record. + const invite = await User.findOne({ + where: { + email, + teamId, + lastActiveAt: { + [Op.eq]: null, + }, + }, + include: [ + { + model: UserAuthentication, + as: "authentications", + required: false, + }, + ], + }); + + // We have an existing invite for his user, so we need to update it with our + // new details and link up the authentication method + if (invite && !invite.authentications.length) { + let transaction = await sequelize.transaction(); + let auth; + try { + await invite.update( + { + name, + avatarUrl, + }, + { transaction } + ); + auth = await invite.createAuthentication(authentication, { + transaction, + }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + + return { user: invite, authentication: auth, isNewUser: false }; + } + + // No auth, no user – this is an entirely new sign in. + let transaction = await sequelize.transaction(); + + try { + const user = await User.create( + { + name, + email, + isAdmin, + teamId, + avatarUrl, + service: null, + authentications: [authentication], + }, + { + include: "authentications", + transaction, + } + ); + await Event.create( + { + name: "users.create", + actorId: user.id, + userId: user.id, + teamId: user.teamId, + data: { + name: user.name, + }, + ip, + }, + { + transaction, + } + ); + await transaction.commit(); + return { + user, + authentication: user.authentications[0], + isNewUser: true, + }; + } catch (err) { + await transaction.rollback(); + throw err; + } +} diff --git a/server/commands/userCreator.test.js b/server/commands/userCreator.test.js new file mode 100644 index 000000000..a7d2bd7a4 --- /dev/null +++ b/server/commands/userCreator.test.js @@ -0,0 +1,94 @@ +// @flow +import { buildUser, buildTeam, buildInvite } from "../test/factories"; +import { flushdb } from "../test/support"; +import userCreator from "./userCreator"; + +beforeEach(() => flushdb()); + +describe("userCreator", () => { + const ip = "127.0.0.1"; + + it("should update exising user and authentication", async () => { + const existing = await buildUser(); + const authentications = await existing.getAuthentications(); + const existingAuth = authentications[0]; + const newEmail = "test@example.com"; + + const result = await userCreator({ + name: existing.name, + email: newEmail, + avatarUrl: existing.avatarUrl, + teamId: existing.teamId, + ip, + authentication: { + authenticationProviderId: existingAuth.authenticationProviderId, + providerId: existingAuth.providerId, + accessToken: "123", + scopes: ["read"], + }, + }); + + const { user, authentication, isNewUser } = result; + + expect(authentication.accessToken).toEqual("123"); + expect(authentication.scopes.length).toEqual(1); + expect(authentication.scopes[0]).toEqual("read"); + expect(user.email).toEqual(newEmail); + expect(isNewUser).toEqual(false); + }); + + it("should create a new user", async () => { + const team = await buildTeam(); + const authenticationProviders = await team.getAuthenticationProviders(); + const authenticationProvider = authenticationProviders[0]; + + const result = await userCreator({ + name: "Test Name", + email: "test@example.com", + teamId: team.id, + ip, + authentication: { + authenticationProviderId: authenticationProvider.id, + providerId: "fake-service-id", + accessToken: "123", + scopes: ["read"], + }, + }); + + const { user, authentication, isNewUser } = result; + + expect(authentication.accessToken).toEqual("123"); + expect(authentication.scopes.length).toEqual(1); + expect(authentication.scopes[0]).toEqual("read"); + expect(user.email).toEqual("test@example.com"); + expect(isNewUser).toEqual(true); + }); + + it("should create a user from an invited user", async () => { + const team = await buildTeam(); + const invite = await buildInvite({ teamId: team.id }); + const authenticationProviders = await team.getAuthenticationProviders(); + const authenticationProvider = authenticationProviders[0]; + + const result = await userCreator({ + name: invite.name, + email: invite.email, + teamId: invite.teamId, + ip, + authentication: { + authenticationProviderId: authenticationProvider.id, + providerId: "fake-service-id", + accessToken: "123", + scopes: ["read"], + }, + }); + + const { user, authentication, isNewUser } = result; + + expect(authentication.accessToken).toEqual("123"); + expect(authentication.scopes.length).toEqual(1); + expect(authentication.scopes[0]).toEqual("read"); + expect(user.email).toEqual(invite.email); + expect(isNewUser).toEqual(false); + }); +}); diff --git a/server/index.js b/server/index.js index d8d8587bf..120568366 100644 --- a/server/index.js +++ b/server/index.js @@ -18,7 +18,6 @@ if ( console.error( "The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`" ); - // $FlowFixMe process.exit(1); } @@ -31,7 +30,6 @@ if (process.env.AWS_ACCESS_KEY_ID) { ].forEach((key) => { if (!process.env[key]) { console.error(`The ${key} env variable must be set when using AWS`); - // $FlowFixMe process.exit(1); } }); @@ -42,7 +40,6 @@ if (process.env.SLACK_KEY) { console.error( `The SLACK_SECRET env variable must be set when using Slack Sign In` ); - // $FlowFixMe process.exit(1); } } @@ -51,7 +48,6 @@ if (!process.env.URL) { console.error( "The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)" ); - // $FlowFixMe process.exit(1); } @@ -59,7 +55,6 @@ if (!process.env.DATABASE_URL) { console.error( "The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port" ); - // $FlowFixMe process.exit(1); } @@ -67,7 +62,6 @@ if (!process.env.REDIS_URL) { console.error( "The REDIS_URL env variable must be set to the location of your redis server, including authentication and port" ); - // $FlowFixMe process.exit(1); } diff --git a/server/main.js b/server/main.js index cf8cc1885..e35a60ecf 100644 --- a/server/main.js +++ b/server/main.js @@ -8,6 +8,7 @@ import { Document, Collection, View } from "./models"; import policy from "./policies"; import { client, subscriber } from "./redis"; import { getUserForJWT } from "./utils/jwt"; +import { checkMigrations } from "./utils/startup"; const server = http.createServer(app.callback()); let io; @@ -191,7 +192,10 @@ server.on("listening", () => { console.log(`\n> Listening on http://localhost:${address.port}\n`); }); -server.listen(process.env.PORT || "3000"); +(async () => { + await checkMigrations(); + server.listen(process.env.PORT || "3000"); +})(); export const socketio = io; diff --git a/server/middlewares/authentication.js b/server/middlewares/authentication.js index 126221334..0e22e3e75 100644 --- a/server/middlewares/authentication.js +++ b/server/middlewares/authentication.js @@ -102,31 +102,18 @@ export default function auth(options?: { required?: boolean } = {}) { // update the database when the user last signed in user.updateSignedIn(ctx.request.ip); - if (isFirstSignin) { - Event.create({ - name: "users.create", - actorId: user.id, - userId: user.id, - teamId: team.id, - data: { - name: user.name, - service, - }, - ip: ctx.request.ip, - }); - } else { - Event.create({ - name: "users.signin", - actorId: user.id, - userId: user.id, - teamId: team.id, - data: { - name: user.name, - service, - }, - ip: ctx.request.ip, - }); - } + // don't await event creation for a faster sign-in + Event.create({ + name: "users.signin", + actorId: user.id, + userId: user.id, + teamId: team.id, + data: { + name: user.name, + service, + }, + ip: ctx.request.ip, + }); const domain = getCookieDomain(ctx.request.hostname); const expires = addMonths(new Date(), 3); diff --git a/server/migrations/20210226232041-authentication-providers.js b/server/migrations/20210226232041-authentication-providers.js new file mode 100644 index 000000000..71eff12c9 --- /dev/null +++ b/server/migrations/20210226232041-authentication-providers.js @@ -0,0 +1,98 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("authentication_providers", { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + providerId: { + type: Sequelize.STRING, + unique: true, + allowNull: false, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true + }, + teamId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "teams" + } + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + await queryInterface.createTable("user_authentications", { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users" + } + }, + authenticationProviderId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "authentication_providers" + } + }, + accessToken: { + type: Sequelize.BLOB, + allowNull: true, + }, + refreshToken: { + type: Sequelize.BLOB, + allowNull: true, + }, + scopes: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + }, + providerId: { + type: Sequelize.STRING, + unique: true, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + await queryInterface.removeColumn("users", "slackAccessToken") + await queryInterface.addIndex("authentication_providers", ["providerId"]); + await queryInterface.addIndex("user_authentications", ["providerId"]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("user_authentications"); + await queryInterface.dropTable("authentication_providers"); + await queryInterface.addColumn("users", "slackAccessToken", { + type: 'bytea', + allowNull: true, + }); + } +}; diff --git a/server/models/AuthenticationProvider.js b/server/models/AuthenticationProvider.js new file mode 100644 index 000000000..2d1b35454 --- /dev/null +++ b/server/models/AuthenticationProvider.js @@ -0,0 +1,51 @@ +// @flow +import fs from "fs"; +import path from "path"; +import { DataTypes, sequelize } from "../sequelize"; + +// Each authentication provider must have a definition under server/auth, the +// name of the file will be used as reference in the db, one less thing to config +const authProviders = fs + .readdirSync(path.resolve(__dirname, "..", "auth")) + .filter( + (file) => + file.indexOf(".") !== 0 && + !file.includes(".test") && + !file.includes("index.js") + ) + .map((fileName) => fileName.replace(".js", "")); + +const AuthenticationProvider = sequelize.define( + "authentication_providers", + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + validate: { + isIn: [authProviders], + }, + }, + enabled: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + providerId: { + type: DataTypes.STRING, + }, + }, + { + timestamps: true, + updatedAt: false, + } +); + +AuthenticationProvider.associate = (models) => { + AuthenticationProvider.belongsTo(models.Team); + AuthenticationProvider.hasMany(models.UserAuthentication); +}; + +export default AuthenticationProvider; diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js index c217e83e9..71d91438e 100644 --- a/server/models/Collection.test.js +++ b/server/models/Collection.test.js @@ -249,9 +249,7 @@ describe("#membershipUserIds", () => { const users = await Promise.all( Array(6) .fill() - .map(() => { - return buildUser({ teamId }); - }) + .map(() => buildUser({ teamId })) ); const collection = await buildCollection({ diff --git a/server/models/Team.js b/server/models/Team.js index eb26c1217..1fb2ae1f5 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -96,6 +96,14 @@ Team.associate = (models) => { Team.hasMany(models.Collection, { as: "collections" }); Team.hasMany(models.Document, { as: "documents" }); Team.hasMany(models.User, { as: "users" }); + Team.hasMany(models.AuthenticationProvider, { + as: "authenticationProviders", + }); + Team.addScope("withAuthenticationProviders", { + include: [ + { model: models.AuthenticationProvider, as: "authenticationProviders" }, + ], + }); }; const uploadAvatar = async (model) => { @@ -121,13 +129,13 @@ const uploadAvatar = async (model) => { } }; -Team.prototype.provisionSubdomain = async function (subdomain) { +Team.prototype.provisionSubdomain = async function (subdomain, options = {}) { if (this.subdomain) return this.subdomain; let append = 0; while (true) { try { - await this.update({ subdomain }); + await this.update({ subdomain }, options); break; } catch (err) { // subdomain was invalid or already used, try again diff --git a/server/models/User.js b/server/models/User.js index 5670d7fe5..fc179767a 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -79,7 +79,12 @@ User.associate = (models) => { }); User.hasMany(models.Document, { as: "documents" }); User.hasMany(models.View, { as: "views" }); + User.hasMany(models.UserAuthentication, { as: "authentications" }); User.belongsTo(models.Team); + + User.addScope("withAuthentications", { + include: [{ model: models.UserAuthentication, as: "authentications" }], + }); }; // Instance methods @@ -151,10 +156,6 @@ User.prototype.getTransferToken = function () { // Returns a temporary token that is only used for logging in from an email // It can only be used to sign in once and has a medium length expiry User.prototype.getEmailSigninToken = function () { - if (this.service && this.service !== "email") { - throw new Error("Cannot generate email signin token for OAuth user"); - } - return JWT.sign( { id: this.id, createdAt: new Date().toISOString(), type: "email-signin" }, this.jwtSecret diff --git a/server/models/UserAuthentication.js b/server/models/UserAuthentication.js new file mode 100644 index 000000000..0bfc567f3 --- /dev/null +++ b/server/models/UserAuthentication.js @@ -0,0 +1,24 @@ +// @flow +import { DataTypes, sequelize, encryptedFields } from "../sequelize"; + +const UserAuthentication = sequelize.define("user_authentications", { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + scopes: DataTypes.ARRAY(DataTypes.STRING), + accessToken: encryptedFields().vault("accessToken"), + refreshToken: encryptedFields().vault("refreshToken"), + providerId: { + type: DataTypes.STRING, + unique: true, + }, +}); + +UserAuthentication.associate = (models) => { + UserAuthentication.belongsTo(models.AuthenticationProvider); + UserAuthentication.belongsTo(models.User); +}; + +export default UserAuthentication; diff --git a/server/models/index.js b/server/models/index.js index c5f2da6ac..6d7ff844c 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -2,6 +2,7 @@ import ApiKey from "./ApiKey"; import Attachment from "./Attachment"; import Authentication from "./Authentication"; +import AuthenticationProvider from "./AuthenticationProvider"; import Backlink from "./Backlink"; import Collection from "./Collection"; import CollectionGroup from "./CollectionGroup"; @@ -19,12 +20,14 @@ import Share from "./Share"; import Star from "./Star"; import Team from "./Team"; import User from "./User"; +import UserAuthentication from "./UserAuthentication"; import View from "./View"; const models = { ApiKey, Attachment, Authentication, + AuthenticationProvider, Backlink, Collection, CollectionGroup, @@ -42,6 +45,7 @@ const models = { Star, Team, User, + UserAuthentication, View, }; @@ -56,6 +60,7 @@ export { ApiKey, Attachment, Authentication, + AuthenticationProvider, Backlink, Collection, CollectionGroup, @@ -73,5 +78,6 @@ export { Star, Team, User, + UserAuthentication, View, }; diff --git a/server/presenters/team.js b/server/presenters/team.js index bc75d9d8e..58eb5b149 100644 --- a/server/presenters/team.js +++ b/server/presenters/team.js @@ -6,8 +6,6 @@ export default function present(team: Team) { id: team.id, name: team.name, avatarUrl: team.logoUrl, - slackConnected: !!team.slackId, - googleConnected: !!team.googleId, sharing: team.sharing, documentEmbeds: team.documentEmbeds, guestSignin: team.guestSignin, diff --git a/server/scripts/20210226232041-migrate-authentication.js b/server/scripts/20210226232041-migrate-authentication.js new file mode 100644 index 000000000..5d092871f --- /dev/null +++ b/server/scripts/20210226232041-migrate-authentication.js @@ -0,0 +1,102 @@ +// @flow +import "./bootstrap"; +import debug from "debug"; +import { + Team, + User, + AuthenticationProvider, + UserAuthentication, +} from "../models"; +import { Op } from "../sequelize"; + +const log = debug("server"); +const cache = {}; +let page = 0; +let limit = 100; + +export default async function main(exit = false) { + const work = async (page: number) => { + log(`Migrating authentication data… page ${page}`); + + const users = await User.findAll({ + limit, + offset: page * limit, + paranoid: false, + order: [["createdAt", "ASC"]], + where: { + serviceId: { + [Op.ne]: "email", + }, + }, + include: [ + { + model: Team, + as: "team", + required: true, + paranoid: false, + }, + ], + }); + + for (const user of users) { + const provider = user.service; + const providerId = user.team[`${provider}Id`]; + if (!providerId) { + console.error( + `user ${user.id} has serviceId ${user.serviceId}, but team ${provider}Id missing` + ); + continue; + } + if (providerId.startsWith("transferred")) { + console.log( + `skipping previously transferred ${user.team.name} (${user.team.id})` + ); + continue; + } + + let authenticationProviderId = cache[providerId]; + if (!authenticationProviderId) { + const [ + authenticationProvider, + ] = await AuthenticationProvider.findOrCreate({ + where: { + name: provider, + providerId, + teamId: user.teamId, + }, + }); + + cache[providerId] = authenticationProviderId = + authenticationProvider.id; + } + + try { + await UserAuthentication.create({ + authenticationProviderId, + providerId: user.serviceId, + teamId: user.teamId, + userId: user.id, + }); + } catch (err) { + console.error( + `serviceId ${user.serviceId} exists, for user ${user.id}` + ); + continue; + } + } + + return users.length === limit ? work(page + 1) : undefined; + }; + + await work(page); + + if (exit) { + log("Migration complete"); + process.exit(0); + } +} + +// In the test suite we import the script rather than run via node CLI +if (process.env.NODE_ENV !== "test") { + main(true); +} diff --git a/server/scripts/20210226232041-migrate-authentication.test.js b/server/scripts/20210226232041-migrate-authentication.test.js new file mode 100644 index 000000000..c795bda1f --- /dev/null +++ b/server/scripts/20210226232041-migrate-authentication.test.js @@ -0,0 +1,152 @@ +// @flow +import { + User, + Team, + UserAuthentication, + AuthenticationProvider, +} from "../models"; +import { flushdb } from "../test/support"; +import script from "./20210226232041-migrate-authentication"; + +beforeEach(() => flushdb()); + +describe("#work", () => { + it("should create authentication record for users", async () => { + const team = await Team.create({ + name: `Test`, + slackId: "T123", + }); + const user = await User.create({ + email: `test@example.com`, + name: `Test`, + serviceId: "U123", + teamId: team.id, + }); + + await script(); + + const authProvider = await AuthenticationProvider.findOne({ + where: { + providerId: "T123", + }, + }); + + const auth = await UserAuthentication.findOne({ + where: { + providerId: "U123", + }, + }); + expect(authProvider.name).toEqual("slack"); + expect(auth.userId).toEqual(user.id); + }); + + it("should create authentication record for deleted users", async () => { + const team = await Team.create({ + name: `Test`, + googleId: "domain.com", + }); + const user = await User.create({ + email: `test1@example.com`, + name: `Test`, + service: "google", + serviceId: "123456789", + teamId: team.id, + deletedAt: new Date(), + }); + + await script(); + + const authProvider = await AuthenticationProvider.findOne({ + where: { + providerId: "domain.com", + }, + }); + + const auth = await UserAuthentication.findOne({ + where: { + providerId: "123456789", + }, + }); + expect(authProvider.name).toEqual("google"); + expect(auth.userId).toEqual(user.id); + }); + + it("should create authentication record for suspended users", async () => { + const team = await Team.create({ + name: `Test`, + googleId: "example.com", + }); + const user = await User.create({ + email: `test1@example.com`, + name: `Test`, + service: "google", + serviceId: "123456789", + teamId: team.id, + suspendedAt: new Date(), + }); + + await script(); + + const authProvider = await AuthenticationProvider.findOne({ + where: { + providerId: "example.com", + }, + }); + + const auth = await UserAuthentication.findOne({ + where: { + providerId: "123456789", + }, + }); + expect(authProvider.name).toEqual("google"); + expect(auth.userId).toEqual(user.id); + }); + + it("should create correct authentication record when team has both slackId and googleId", async () => { + const team = await Team.create({ + name: `Test`, + slackId: "T456", + googleId: "example.com", + }); + const user = await User.create({ + email: `test1@example.com`, + name: `Test`, + service: "slack", + serviceId: "U456", + teamId: team.id, + }); + + await script(); + + const authProvider = await AuthenticationProvider.findOne({ + where: { + providerId: "T456", + }, + }); + + const auth = await UserAuthentication.findOne({ + where: { + providerId: "U456", + }, + }); + expect(authProvider.name).toEqual("slack"); + expect(auth.userId).toEqual(user.id); + }); + + it("should skip invited users", async () => { + const team = await Team.create({ + name: `Test`, + slackId: "T789", + }); + await User.create({ + email: `test2@example.com`, + name: `Test`, + teamId: team.id, + }); + + await script(); + + const count = await UserAuthentication.count(); + expect(count).toEqual(0); + }); +}); diff --git a/server/scripts/bootstrap.js b/server/scripts/bootstrap.js new file mode 100644 index 000000000..3c5007e84 --- /dev/null +++ b/server/scripts/bootstrap.js @@ -0,0 +1,6 @@ +// @flow +if (process.env.NODE_ENV !== "test") { + require("dotenv").config({ silent: true }); +} + +process.env.SINGLE_RUN = true; diff --git a/server/services/index.js b/server/services/index.js index ecb0270e7..8d822dfd9 100644 --- a/server/services/index.js +++ b/server/services/index.js @@ -6,20 +6,22 @@ import fs from "fs-extra"; const log = debug("services"); const services = {}; -fs.readdirSync(__dirname) - .filter( - (file) => - file.indexOf(".") !== 0 && - file !== path.basename(__filename) && - !file.includes(".test") - ) - .forEach((fileName) => { - const servicePath = path.join(__dirname, fileName); - const name = path.basename(servicePath.replace(/\.js$/, "")); - // $FlowIssue - const Service = require(servicePath).default; - services[name] = new Service(); - log(`loaded ${name} service`); - }); +if (!process.env.SINGLE_RUN) { + fs.readdirSync(__dirname) + .filter( + (file) => + file.indexOf(".") !== 0 && + file !== path.basename(__filename) && + !file.includes(".test") + ) + .forEach((fileName) => { + const servicePath = path.join(__dirname, fileName); + const name = path.basename(servicePath.replace(/\.js$/, "")); + // $FlowIssue + const Service = require(servicePath).default; + services[name] = new Service(); + log(`loaded ${name} service`); + }); +} export default services; diff --git a/server/test/factories.js b/server/test/factories.js index 02ff43696..95027a540 100644 --- a/server/test/factories.js +++ b/server/test/factories.js @@ -12,9 +12,10 @@ import { Attachment, Authentication, Integration, + AuthenticationProvider, } from "../models"; -let count = 0; +let count = 1; export async function buildShare(overrides: Object = {}) { if (!overrides.teamId) { @@ -35,11 +36,21 @@ export async function buildShare(overrides: Object = {}) { export function buildTeam(overrides: Object = {}) { count++; - return Team.create({ - name: `Team ${count}`, - slackId: uuid.v4(), - ...overrides, - }); + return Team.create( + { + name: `Team ${count}`, + authenticationProviders: [ + { + name: "slack", + providerId: uuid.v4(), + }, + ], + ...overrides, + }, + { + include: "authenticationProviders", + } + ); } export function buildEvent(overrides: Object = {}) { @@ -51,21 +62,51 @@ export function buildEvent(overrides: Object = {}) { } export async function buildUser(overrides: Object = {}) { - count++; - if (!overrides.teamId) { const team = await buildTeam(); overrides.teamId = team.id; } + const authenticationProvider = await AuthenticationProvider.findOne({ + where: { + teamId: overrides.teamId, + }, + }); + + count++; + + return User.create( + { + email: `user${count}@example.com`, + name: `User ${count}`, + createdAt: new Date("2018-01-01T00:00:00.000Z"), + lastActiveAt: new Date("2018-01-01T00:00:00.000Z"), + authentications: [ + { + authenticationProviderId: authenticationProvider.id, + providerId: uuid.v4(), + }, + ], + ...overrides, + }, + { + include: "authentications", + } + ); +} + +export async function buildInvite(overrides: Object = {}) { + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + + count++; + return User.create({ email: `user${count}@example.com`, - username: `user${count}`, name: `User ${count}`, - service: "slack", - serviceId: uuid.v4(), createdAt: new Date("2018-01-01T00:00:00.000Z"), - lastActiveAt: new Date("2018-01-01T00:00:00.000Z"), ...overrides, }); } @@ -98,8 +139,6 @@ export async function buildIntegration(overrides: Object = {}) { } export async function buildCollection(overrides: Object = {}) { - count++; - if (!overrides.teamId) { const team = await buildTeam(); overrides.teamId = team.id; @@ -110,6 +149,8 @@ export async function buildCollection(overrides: Object = {}) { overrides.userId = user.id; } + count++; + return Collection.create({ name: `Test Collection ${count}`, description: "Test collection description", @@ -119,8 +160,6 @@ export async function buildCollection(overrides: Object = {}) { } export async function buildGroup(overrides: Object = {}) { - count++; - if (!overrides.teamId) { const team = await buildTeam(); overrides.teamId = team.id; @@ -131,6 +170,8 @@ export async function buildGroup(overrides: Object = {}) { overrides.userId = user.id; } + count++; + return Group.create({ name: `Test Group ${count}`, createdById: overrides.userId, @@ -139,8 +180,6 @@ export async function buildGroup(overrides: Object = {}) { } export async function buildGroupUser(overrides: Object = {}) { - count++; - if (!overrides.teamId) { const team = await buildTeam(); overrides.teamId = team.id; @@ -151,6 +190,8 @@ export async function buildGroupUser(overrides: Object = {}) { overrides.userId = user.id; } + count++; + return GroupUser.create({ createdById: overrides.userId, ...overrides, @@ -158,8 +199,6 @@ export async function buildGroupUser(overrides: Object = {}) { } export async function buildDocument(overrides: Object = {}) { - count++; - if (!overrides.teamId) { const team = await buildTeam(); overrides.teamId = team.id; @@ -175,6 +214,8 @@ export async function buildDocument(overrides: Object = {}) { overrides.collectionId = collection.id; } + count++; + return Document.create({ title: `Document ${count}`, text: "This is the text in an example document", @@ -186,15 +227,13 @@ export async function buildDocument(overrides: Object = {}) { } export async function buildAttachment(overrides: Object = {}) { - count++; - if (!overrides.teamId) { const team = await buildTeam(); overrides.teamId = team.id; } if (!overrides.userId) { - const user = await buildUser(); + const user = await buildUser({ teamId: overrides.teamId }); overrides.userId = user.id; } @@ -208,6 +247,8 @@ export async function buildAttachment(overrides: Object = {}) { overrides.documentId = document.id; } + count++; + return Attachment.create({ key: `uploads/key/to/file ${count}.png`, url: `https://redirect.url.com/uploads/key/to/file ${count}.png`, diff --git a/server/test/support.js b/server/test/support.js index 2f3cb5706..f0f3b454c 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -1,4 +1,5 @@ // @flow +import uuid from "uuid"; import { User, Document, Collection, Team } from "../models"; import { sequelize } from "../sequelize"; @@ -15,49 +16,64 @@ export function flushdb() { return sequelize.query(query); } -const seed = async () => { - const team = await Team.create({ - id: "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", - name: "Team", - slackId: "T2399UF2P", - slackData: { - id: "T2399UF2P", +export const seed = async () => { + const team = await Team.create( + { + name: "Team", + authenticationProviders: [ + { + name: "slack", + providerId: uuid.v4(), + }, + ], }, - }); + { + include: "authenticationProviders", + } + ); - const admin = await User.create({ - id: "fa952cff-fa64-4d42-a6ea-6955c9689046", - email: "admin@example.com", - username: "admin", - name: "Admin User", - teamId: team.id, - isAdmin: true, - service: "slack", - serviceId: "U2399UF1P", - slackData: { - id: "U2399UF1P", - image_192: "http://example.com/avatar.png", - }, - createdAt: new Date("2018-01-01T00:00:00.000Z"), - }); + const authenticationProvider = team.authenticationProviders[0]; - const user = await User.create({ - id: "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", - email: "user1@example.com", - username: "user1", - name: "User 1", - teamId: team.id, - service: "slack", - serviceId: "U2399UF2P", - slackData: { - id: "U2399UF2P", - image_192: "http://example.com/avatar.png", + const admin = await User.create( + { + email: "admin@example.com", + username: "admin", + name: "Admin User", + teamId: team.id, + isAdmin: true, + createdAt: new Date("2018-01-01T00:00:00.000Z"), + authentications: [ + { + authenticationProviderId: authenticationProvider.id, + providerId: uuid.v4(), + }, + ], }, - createdAt: new Date("2018-01-02T00:00:00.000Z"), - }); + { + include: "authentications", + } + ); + + const user = await User.create( + { + id: "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + email: "user1@example.com", + name: "User 1", + teamId: team.id, + createdAt: new Date("2018-01-02T00:00:00.000Z"), + authentications: [ + { + authenticationProviderId: authenticationProvider.id, + providerId: uuid.v4(), + }, + ], + }, + { + include: "authentications", + } + ); const collection = await Collection.create({ - id: "26fde1d4-0050-428f-9f0b-0bf77f8bdf62", name: "Collection", urlId: "collection", teamId: team.id, @@ -85,5 +101,3 @@ const seed = async () => { team, }; }; - -export { seed, sequelize }; diff --git a/server/utils/avatars.js b/server/utils/avatars.js new file mode 100644 index 000000000..a86e530a6 --- /dev/null +++ b/server/utils/avatars.js @@ -0,0 +1,32 @@ +// @flow +import crypto from "crypto"; +import fetch from "isomorphic-fetch"; + +export async function generateAvatarUrl({ + id, + domain, + name = "Unknown", +}: { + id: string, + domain?: string, + name?: string, +}) { + // attempt to get logo from Clearbit API. If one doesn't exist then + // fall back to using tiley to generate a placeholder logo + const hash = crypto.createHash("sha256"); + hash.update(id); + const hashedId = hash.digest("hex"); + + let cbResponse, cbUrl; + if (domain) { + cbUrl = `https://logo.clearbit.com/${domain}`; + try { + cbResponse = await fetch(cbUrl); + } catch (err) { + // okay + } + } + + const tileyUrl = `https://tiley.herokuapp.com/avatar/${hashedId}/${name[0]}.png`; + return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : tileyUrl; +} diff --git a/server/utils/avatars.test.js b/server/utils/avatars.test.js new file mode 100644 index 000000000..937a07e8e --- /dev/null +++ b/server/utils/avatars.test.js @@ -0,0 +1,41 @@ +// @flow +import { generateAvatarUrl } from "./avatars"; + +it("should return clearbit url if available", async () => { + const url = await generateAvatarUrl({ + id: "google", + domain: "google.com", + name: "Google", + }); + expect(url).toBe("https://logo.clearbit.com/google.com"); +}); + +it("should return tiley url if clearbit unavailable", async () => { + const url = await generateAvatarUrl({ + id: "invalid", + domain: "example.invalid", + name: "Invalid", + }); + expect(url).toBe( + "https://tiley.herokuapp.com/avatar/f1234d75178d892a133a410355a5a990cf75d2f33eba25d575943d4df632f3a4/I.png" + ); +}); + +it("should return tiley url if domain not provided", async () => { + const url = await generateAvatarUrl({ + id: "google", + name: "Google", + }); + expect(url).toBe( + "https://tiley.herokuapp.com/avatar/bbdefa2950f49882f295b1285d4fa9dec45fc4144bfb07ee6acc68762d12c2e3/G.png" + ); +}); + +it("should return tiley url if name not provided", async () => { + const url = await generateAvatarUrl({ + id: "google", + }); + expect(url).toBe( + "https://tiley.herokuapp.com/avatar/bbdefa2950f49882f295b1285d4fa9dec45fc4144bfb07ee6acc68762d12c2e3/U.png" + ); +}); diff --git a/server/utils/startup.js b/server/utils/startup.js new file mode 100644 index 000000000..038d0e8e2 --- /dev/null +++ b/server/utils/startup.js @@ -0,0 +1,21 @@ +// @flow +import { Team, AuthenticationProvider } from "../models"; + +export async function checkMigrations() { + if (process.env.DEPLOYMENT === "hosted") { + return; + } + + const teams = await Team.count(); + const providers = await AuthenticationProvider.count(); + + if (teams && !providers) { + console.error(` +This version of Outline cannot start until a data migration is complete. +Backup your database, run the database migrations and the following script: + +$ node ./build/server/scripts/20210226232041-migrate-authentication.js +`); + process.exit(1); + } +}