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
This commit is contained in:
Tom Moor 2021-03-09 12:22:08 -08:00 committed by GitHub
parent ab7b16bbb9
commit ed2a42ac27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1280 additions and 297 deletions

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
dist dist
build build
node_modules/* node_modules/*
server/scripts
.env .env
.log .log
npm-debug.log npm-debug.log

View File

@ -6,8 +6,6 @@ class Team extends BaseModel {
id: string; id: string;
name: string; name: string;
avatarUrl: string; avatarUrl: string;
slackConnected: boolean;
googleConnected: boolean;
sharing: boolean; sharing: boolean;
documentEmbeds: boolean; documentEmbeds: boolean;
guestSignin: boolean; guestSignin: boolean;
@ -17,11 +15,7 @@ class Team extends BaseModel {
@computed @computed
get signinMethods(): string { get signinMethods(): string {
if (this.slackConnected && this.googleConnected) { return "SSO";
return "Slack or Google";
}
if (this.slackConnected) return "Slack";
return "Google";
} }
} }

View File

@ -97,8 +97,7 @@ class Notifications extends React.Component<Props> {
<HelpText> <HelpText>
Manage when and where you receive email notifications from Outline. Manage when and where you receive email notifications from Outline.
Your email address can be updated in your{" "} Your email address can be updated in your SSO provider.
{team.slackConnected ? "Slack" : "Google"} account.
</HelpText> </HelpText>
<Input <Input

View File

@ -1,5 +1,6 @@
// @flow // @flow
declare var process: { declare var process: {
exit: (code?: number) => void,
env: { env: {
[string]: string, [string]: string,
}, },

View File

@ -1,5 +1,5 @@
{ {
"verbose": true, "verbose": false,
"rootDir": "..", "rootDir": "..",
"roots": [ "roots": [
"<rootDir>/server", "<rootDir>/server",

View File

@ -37,10 +37,14 @@ services.push({
function filterServices(team) { function filterServices(team) {
let output = services; 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"); output = reject(output, (service) => service.id === "google");
} }
if (team && !team.slackId) { if (team && !providerNames.includes("slack")) {
output = reject(output, (service) => service.id === "slack"); output = reject(output, (service) => service.id === "slack");
} }
if (!team || !team.guestSignin) { 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 // brand for the knowledge base and it's guest signin option is used for the
// root login page. // root login page.
if (process.env.DEPLOYMENT !== "hosted") { if (process.env.DEPLOYMENT !== "hosted") {
const teams = await Team.findAll(); const teams = await Team.scope("withAuthenticationProviders").findAll();
if (teams.length === 1) { if (teams.length === 1) {
const team = teams[0]; const team = teams[0];
@ -70,7 +74,7 @@ router.post("auth.config", async (ctx) => {
} }
if (isCustomDomain(ctx.request.hostname)) { if (isCustomDomain(ctx.request.hostname)) {
const team = await Team.findOne({ const team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname }, where: { domain: ctx.request.hostname },
}); });
@ -95,7 +99,7 @@ router.post("auth.config", async (ctx) => {
) { ) {
const domain = parseDomain(ctx.request.hostname); const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined; const subdomain = domain ? domain.subdomain : undefined;
const team = await Team.findOne({ const team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain }, where: { subdomain },
}); });

View File

@ -3,6 +3,8 @@ import Router from "koa-router";
import { escapeRegExp } from "lodash"; import { escapeRegExp } from "lodash";
import { AuthenticationError, InvalidRequestError } from "../errors"; import { AuthenticationError, InvalidRequestError } from "../errors";
import { import {
UserAuthentication,
AuthenticationProvider,
Authentication, Authentication,
Document, Document,
User, User,
@ -25,7 +27,14 @@ router.post("hooks.unfurl", async (ctx) => {
} }
const user = await User.findOne({ 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; if (!user) return;
@ -70,11 +79,21 @@ router.post("hooks.interactive", async (ctx) => {
throw new AuthenticationError("Invalid verification token"); throw new AuthenticationError("Invalid verification token");
} }
const team = await Team.findOne({ const authProvider = await AuthenticationProvider.findOne({
where: { slackId: data.team.id }, where: {
name: "slack",
providerId: data.team.id,
},
include: [
{
model: Team,
as: "team",
required: true,
},
],
}); });
if (!team) { if (!authProvider) {
ctx.body = { ctx.body = {
text: text:
"Sorry, we couldnt find an integration for your team. Head to your Outline settings to set one up.", "Sorry, we couldnt 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; return;
} }
const { team } = authProvider;
// we find the document based on the users teamId to ensure access // we find the document based on the users teamId to ensure access
const document = await Document.findOne({ const document = await Document.findOne({
where: { where: {
@ -131,20 +152,41 @@ router.post("hooks.slack", async (ctx) => {
return; return;
} }
let user; let user, team;
// attempt to find the corresponding team for this request based on the team_id // attempt to find the corresponding team for this request based on the team_id
let team = await Team.findOne({ team = await Team.findOne({
where: { slackId: team_id }, include: [
}); {
if (team) { where: {
user = await User.findOne({ name: "slack",
where: { providerId: team_id,
teamId: team.id, },
service: "slack", as: "authenticationProviders",
serviceId: user_id, 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 { } else {
// If we couldn't find a team it's still possible that the request is from // 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 // a team that authenticated with a different service, but connected Slack

View File

@ -33,7 +33,7 @@ describe("#hooks.unfurl", () => {
event: { event: {
type: "link_shared", type: "link_shared",
channel: "Cxxxxxx", channel: "Cxxxxxx",
user: user.serviceId, user: user.authentications[0].providerId,
message_ts: "123456789.9875", message_ts: "123456789.9875",
links: [ links: [
{ {
@ -56,8 +56,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "dsfkndfskndsfkn", text: "dsfkndfskndsfkn",
}, },
}); });
@ -76,8 +76,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "contains", text: "contains",
}, },
}); });
@ -98,8 +98,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "*contains", text: "*contains",
}, },
}); });
@ -118,8 +118,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "contains", text: "contains",
}, },
}); });
@ -137,8 +137,8 @@ describe("#hooks.slack", () => {
await server.post("/api/hooks.slack", { await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "contains", text: "contains",
}, },
}); });
@ -161,8 +161,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "help", text: "help",
}, },
}); });
@ -176,8 +176,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: user.serviceId, user_id: user.authentications[0].providerId,
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "", text: "",
}, },
}); });
@ -206,7 +206,7 @@ describe("#hooks.slack", () => {
body: { body: {
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user_id: "unknown-slack-user-id", user_id: "unknown-slack-user-id",
team_id: team.slackId, team_id: team.authenticationProviders[0].providerId,
text: "contains", text: "contains",
}, },
}); });
@ -260,8 +260,8 @@ describe("#hooks.slack", () => {
const res = await server.post("/api/hooks.slack", { const res = await server.post("/api/hooks.slack", {
body: { body: {
token: "wrong-verification-token", token: "wrong-verification-token",
team_id: team.slackId, user_id: user.authentications[0].providerId,
user_id: user.serviceId, team_id: team.authenticationProviders[0].providerId,
text: "Welcome", text: "Welcome",
}, },
}); });
@ -280,8 +280,8 @@ describe("#hooks.interactive", () => {
const payload = JSON.stringify({ const payload = JSON.stringify({
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user: { id: user.serviceId }, user: { id: user.authentications[0].providerId },
team: { id: team.slackId }, team: { id: team.authenticationProviders[0].providerId },
callback_id: document.id, callback_id: document.id,
}); });
const res = await server.post("/api/hooks.interactive", { const res = await server.post("/api/hooks.interactive", {
@ -305,7 +305,7 @@ describe("#hooks.interactive", () => {
const payload = JSON.stringify({ const payload = JSON.stringify({
token: process.env.SLACK_VERIFICATION_TOKEN, token: process.env.SLACK_VERIFICATION_TOKEN,
user: { id: "unknown-slack-user-id" }, user: { id: "unknown-slack-user-id" },
team: { id: team.slackId }, team: { id: team.authenticationProviders[0].providerId },
callback_id: document.id, callback_id: document.id,
}); });
const res = await server.post("/api/hooks.interactive", { const res = await server.post("/api/hooks.interactive", {
@ -322,7 +322,7 @@ describe("#hooks.interactive", () => {
const { user } = await seed(); const { user } = await seed();
const payload = JSON.stringify({ const payload = JSON.stringify({
token: "wrong-verification-token", token: "wrong-verification-token",
user: { id: user.serviceId, name: user.name }, user: { id: user.authentications[0].providerId, name: user.name },
callback_id: "doesnt-matter", callback_id: "doesnt-matter",
}); });
const res = await server.post("/api/hooks.interactive", { const res = await server.post("/api/hooks.interactive", {

View File

@ -1,6 +1,7 @@
// @flow // @flow
import subMinutes from "date-fns/sub_minutes"; import subMinutes from "date-fns/sub_minutes";
import Router from "koa-router"; import Router from "koa-router";
import { find } from "lodash";
import { AuthorizationError } from "../errors"; import { AuthorizationError } from "../errors";
import mailer from "../mailer"; import mailer from "../mailer";
import auth from "../middlewares/authentication"; import auth from "../middlewares/authentication";
@ -19,23 +20,27 @@ router.post("email", async (ctx) => {
ctx.assertEmail(email, "email is required"); ctx.assertEmail(email, "email is required");
const user = await User.findOne({ const user = await User.scope("withAuthentications").findOne({
where: { email: email.toLowerCase() }, where: { email: email.toLowerCase() },
}); });
if (user) { if (user) {
const team = await Team.findByPk(user.teamId); const team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
if (!team) { if (!team) {
ctx.redirect(`/?notice=auth-error`); ctx.redirect(`/?notice=auth-error`);
return; return;
} }
// If the user matches an email address associated with an SSO // If the user matches an email address associated with an SSO
// signin then just forward them directly to that service's // provider then just forward them directly to that sign-in page
// login page if (user.authentications.length) {
if (user.service && user.service !== "email") { const authProvider = find(team.authenticationProviders, {
id: user.authentications[0].authenticationProviderId,
});
ctx.body = { ctx.body = {
redirect: `${team.url}/auth/${user.service}`, redirect: `${team.url}/auth/${authProvider.name}`,
}; };
return; return;
} }
@ -87,11 +92,7 @@ router.get("email.callback", auth({ required: false }), async (ctx) => {
throw new AuthorizationError(); throw new AuthorizationError();
} }
if (!user.service) { await user.update({ lastActiveAt: new Date() });
user.service = "email";
user.lastActiveAt = new Date();
await user.save();
}
// set cookies on response and redirect to team subdomain // set cookies on response and redirect to team subdomain
ctx.signIn(user, team, "email", false); ctx.signIn(user, team, "email", false);

View File

@ -1,14 +1,14 @@
// @flow // @flow
import crypto from "crypto"; import * as Sentry from "@sentry/node";
import { OAuth2Client } from "google-auth-library"; import { OAuth2Client } from "google-auth-library";
import invariant from "invariant"; import invariant from "invariant";
import Router from "koa-router"; import Router from "koa-router";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import Sequelize from "sequelize"; import Sequelize from "sequelize";
import teamCreator from "../commands/teamCreator";
import userCreator from "../commands/userCreator";
import auth from "../middlewares/authentication"; import auth from "../middlewares/authentication";
import { User, Team } from "../models"; import { User } from "../models";
const Op = Sequelize.Op;
const router = new Router(); const router = new Router();
const client = new OAuth2Client( const client = new OAuth2Client(
@ -55,90 +55,60 @@ router.get("google.callback", auth({ required: false }), async (ctx) => {
return; return;
} }
const googleId = profile.data.hd; const domain = profile.data.hd;
const hostname = profile.data.hd.split(".")[0]; const subdomain = profile.data.hd.split(".")[0];
const teamName = capitalize(hostname); const teamName = capitalize(subdomain);
// attempt to get logo from Clearbit API. If one doesn't exist then let result;
// 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;
try { try {
[team, isFirstUser] = await Team.findOrCreate({ result = await teamCreator({
where: { name: teamName,
googleId, domain,
}, subdomain,
defaults: { authenticationProvider: {
name: teamName, name: "google",
avatarUrl, providerId: domain,
}, },
}); });
} catch (err) { } catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) { if (err instanceof Sequelize.UniqueConstraintError) {
ctx.redirect(`/?notice=auth-error`); ctx.redirect(`/?notice=auth-error&error=team-exists`);
return; return;
} }
} }
invariant(team, "Team must exist");
invariant(result, "Team creator result must exist");
const { team, isNewTeam, authenticationProvider } = result;
try { try {
const [user, isFirstSignin] = await User.findOrCreate({ const result = await userCreator({
where: { name: profile.data.name,
[Op.or]: [ email: profile.data.email,
{ isAdmin: isNewTeam,
service: "google", avatarUrl: profile.data.picture,
serviceId: profile.data.id, teamId: team.id,
}, ip: ctx.request.ip,
{ authentication: {
service: { [Op.eq]: null }, authenticationProviderId: authenticationProvider.id,
email: profile.data.email, providerId: profile.data.id,
}, accessToken: response.tokens.access_token,
], refreshToken: response.tokens.refresh_token,
teamId: team.id, scopes: response.tokens.scope.split(" "),
},
defaults: {
service: "google",
serviceId: profile.data.id,
name: profile.data.name,
email: profile.data.email,
isAdmin: isFirstUser,
avatarUrl: profile.data.picture,
}, },
}); });
// update the user with fresh details if they just accepted an invite const { user, isNewUser } = result;
if (!user.serviceId || !user.service) {
await user.update({
service: "google",
serviceId: profile.data.id,
avatarUrl: profile.data.picture,
});
}
// update email address if it's changed in Google if (isNewTeam) {
if (!isFirstSignin && profile.data.email !== user.email) {
await user.update({ email: profile.data.email });
}
if (isFirstUser) {
await team.provisionFirstCollection(user.id); await team.provisionFirstCollection(user.id);
await team.provisionSubdomain(hostname);
} }
// set cookies on response and redirect to team subdomain // set cookies on response and redirect to team subdomain
ctx.signIn(user, team, "google", isFirstSignin); ctx.signIn(user, team, "google", isNewUser);
} catch (err) { } catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) { if (err instanceof Sequelize.UniqueConstraintError) {
const exists = await User.findOne({ const exists = await User.findOne({
where: { where: {
service: "email",
email: profile.data.email, email: profile.data.email,
teamId: team.id, teamId: team.id,
}, },
@ -147,6 +117,11 @@ router.get("google.callback", auth({ required: false }), async (ctx) => {
if (exists) { if (exists) {
ctx.redirect(`${team.url}?notice=email-auth-required`); ctx.redirect(`${team.url}?notice=email-auth-required`);
} else { } else {
if (process.env.SENTRY_DSN) {
Sentry.captureException(err);
} else {
console.error(err);
}
ctx.redirect(`${team.url}?notice=auth-error`); ctx.redirect(`${team.url}?notice=auth-error`);
} }

View File

@ -1,15 +1,17 @@
// @flow // @flow
import * as Sentry from "@sentry/node";
import addHours from "date-fns/add_hours"; import addHours from "date-fns/add_hours";
import invariant from "invariant"; import invariant from "invariant";
import Router from "koa-router"; import Router from "koa-router";
import Sequelize from "sequelize"; import Sequelize from "sequelize";
import { slackAuth } from "../../shared/utils/routeHelpers"; import { slackAuth } from "../../shared/utils/routeHelpers";
import teamCreator from "../commands/teamCreator";
import userCreator from "../commands/userCreator";
import auth from "../middlewares/authentication"; import auth from "../middlewares/authentication";
import { Authentication, Collection, Integration, User, Team } from "../models"; import { Authentication, Collection, Integration, User, Team } from "../models";
import * as Slack from "../slack"; import * as Slack from "../slack";
import { getCookieDomain } from "../utils/domains"; import { getCookieDomain } from "../utils/domains";
const Op = Sequelize.Op;
const router = new Router(); const router = new Router();
// start the oauth process and redirect user to Slack // 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); const data = await Slack.oauthAccess(code);
let team, isFirstUser; let result;
try { try {
[team, isFirstUser] = await Team.findOrCreate({ result = await teamCreator({
where: { name: data.team.name,
slackId: data.team.id, subdomain: data.team.domain,
}, avatarUrl: data.team.image_230,
defaults: { authenticationProvider: {
name: data.team.name, name: "slack",
avatarUrl: data.team.image_88, providerId: data.team.id,
}, },
}); });
} catch (err) { } catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) { if (err instanceof Sequelize.UniqueConstraintError) {
ctx.redirect(`/?notice=auth-error`); ctx.redirect(`/?notice=auth-error&error=team-exists`);
return; return;
} }
throw err;
} }
invariant(team, "Team must exist");
invariant(result, "Team creator result must exist");
const { authenticationProvider, team, isNewTeam } = result;
try { try {
const [user, isFirstSignin] = await User.findOrCreate({ const result = await userCreator({
where: { name: data.user.name,
[Op.or]: [ email: data.user.email,
{ isAdmin: isNewTeam,
service: "slack", avatarUrl: data.user.image_192,
serviceId: data.user.id, teamId: team.id,
}, ip: ctx.request.ip,
{ authentication: {
service: { [Op.eq]: null }, authenticationProviderId: authenticationProvider.id,
email: data.user.email, providerId: data.user.id,
}, accessToken: data.access_token,
], scopes: data.scope.split(","),
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,
}, },
}); });
// update the user with fresh details if they just accepted an invite const { user, isNewUser } = result;
if (!user.serviceId || !user.service) {
await user.update({
service: "slack",
serviceId: data.user.id,
avatarUrl: data.user.image_192,
});
}
// update email address if it's changed in Slack if (isNewTeam) {
if (!isFirstSignin && data.user.email !== user.email) {
await user.update({ email: data.user.email });
}
if (isFirstUser) {
await team.provisionFirstCollection(user.id); await team.provisionFirstCollection(user.id);
await team.provisionSubdomain(data.team.domain);
} }
// set cookies on response and redirect to team subdomain // set cookies on response and redirect to team subdomain
ctx.signIn(user, team, "slack", isFirstSignin); ctx.signIn(user, team, "slack", isNewUser);
} catch (err) { } catch (err) {
if (err instanceof Sequelize.UniqueConstraintError) { if (err instanceof Sequelize.UniqueConstraintError) {
const exists = await User.findOne({ const exists = await User.findOne({
where: { where: {
service: "email",
email: data.user.email, email: data.user.email,
teamId: team.id, teamId: team.id,
}, },
@ -119,6 +101,11 @@ router.get("slack.callback", auth({ required: false }), async (ctx) => {
if (exists) { if (exists) {
ctx.redirect(`${team.url}?notice=email-auth-required`); ctx.redirect(`${team.url}?notice=email-auth-required`);
} else { } else {
if (process.env.SENTRY_DSN) {
Sentry.captureException(err);
} else {
console.error(err);
}
ctx.redirect(`${team.url}?notice=auth-error`); ctx.redirect(`${team.url}?notice=auth-error`);
} }

View File

@ -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<TeamCreatorResult> {
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,
};
}

View File

@ -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);
});
});

View File

@ -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<UserCreatorResult> {
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;
}
}

View File

@ -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);
});
});

View File

@ -18,7 +18,6 @@ if (
console.error( console.error(
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`" "The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
); );
// $FlowFixMe
process.exit(1); process.exit(1);
} }
@ -31,7 +30,6 @@ if (process.env.AWS_ACCESS_KEY_ID) {
].forEach((key) => { ].forEach((key) => {
if (!process.env[key]) { if (!process.env[key]) {
console.error(`The ${key} env variable must be set when using AWS`); console.error(`The ${key} env variable must be set when using AWS`);
// $FlowFixMe
process.exit(1); process.exit(1);
} }
}); });
@ -42,7 +40,6 @@ if (process.env.SLACK_KEY) {
console.error( console.error(
`The SLACK_SECRET env variable must be set when using Slack Sign In` `The SLACK_SECRET env variable must be set when using Slack Sign In`
); );
// $FlowFixMe
process.exit(1); process.exit(1);
} }
} }
@ -51,7 +48,6 @@ if (!process.env.URL) {
console.error( console.error(
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)" "The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
); );
// $FlowFixMe
process.exit(1); process.exit(1);
} }
@ -59,7 +55,6 @@ if (!process.env.DATABASE_URL) {
console.error( console.error(
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port" "The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
); );
// $FlowFixMe
process.exit(1); process.exit(1);
} }
@ -67,7 +62,6 @@ if (!process.env.REDIS_URL) {
console.error( console.error(
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port" "The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
); );
// $FlowFixMe
process.exit(1); process.exit(1);
} }

View File

@ -8,6 +8,7 @@ import { Document, Collection, View } from "./models";
import policy from "./policies"; import policy from "./policies";
import { client, subscriber } from "./redis"; import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt"; import { getUserForJWT } from "./utils/jwt";
import { checkMigrations } from "./utils/startup";
const server = http.createServer(app.callback()); const server = http.createServer(app.callback());
let io; let io;
@ -191,7 +192,10 @@ server.on("listening", () => {
console.log(`\n> Listening on http://localhost:${address.port}\n`); 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; export const socketio = io;

View File

@ -102,31 +102,18 @@ export default function auth(options?: { required?: boolean } = {}) {
// update the database when the user last signed in // update the database when the user last signed in
user.updateSignedIn(ctx.request.ip); user.updateSignedIn(ctx.request.ip);
if (isFirstSignin) { // don't await event creation for a faster sign-in
Event.create({ Event.create({
name: "users.create", name: "users.signin",
actorId: user.id, actorId: user.id,
userId: user.id, userId: user.id,
teamId: team.id, teamId: team.id,
data: { data: {
name: user.name, name: user.name,
service, service,
}, },
ip: ctx.request.ip, 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,
});
}
const domain = getCookieDomain(ctx.request.hostname); const domain = getCookieDomain(ctx.request.hostname);
const expires = addMonths(new Date(), 3); const expires = addMonths(new Date(), 3);

View File

@ -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,
});
}
};

View File

@ -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;

View File

@ -249,9 +249,7 @@ describe("#membershipUserIds", () => {
const users = await Promise.all( const users = await Promise.all(
Array(6) Array(6)
.fill() .fill()
.map(() => { .map(() => buildUser({ teamId }))
return buildUser({ teamId });
})
); );
const collection = await buildCollection({ const collection = await buildCollection({

View File

@ -96,6 +96,14 @@ Team.associate = (models) => {
Team.hasMany(models.Collection, { as: "collections" }); Team.hasMany(models.Collection, { as: "collections" });
Team.hasMany(models.Document, { as: "documents" }); Team.hasMany(models.Document, { as: "documents" });
Team.hasMany(models.User, { as: "users" }); 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) => { 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; if (this.subdomain) return this.subdomain;
let append = 0; let append = 0;
while (true) { while (true) {
try { try {
await this.update({ subdomain }); await this.update({ subdomain }, options);
break; break;
} catch (err) { } catch (err) {
// subdomain was invalid or already used, try again // subdomain was invalid or already used, try again

View File

@ -79,7 +79,12 @@ User.associate = (models) => {
}); });
User.hasMany(models.Document, { as: "documents" }); User.hasMany(models.Document, { as: "documents" });
User.hasMany(models.View, { as: "views" }); User.hasMany(models.View, { as: "views" });
User.hasMany(models.UserAuthentication, { as: "authentications" });
User.belongsTo(models.Team); User.belongsTo(models.Team);
User.addScope("withAuthentications", {
include: [{ model: models.UserAuthentication, as: "authentications" }],
});
}; };
// Instance methods // Instance methods
@ -151,10 +156,6 @@ User.prototype.getTransferToken = function () {
// Returns a temporary token that is only used for logging in from an email // 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 // It can only be used to sign in once and has a medium length expiry
User.prototype.getEmailSigninToken = function () { User.prototype.getEmailSigninToken = function () {
if (this.service && this.service !== "email") {
throw new Error("Cannot generate email signin token for OAuth user");
}
return JWT.sign( return JWT.sign(
{ id: this.id, createdAt: new Date().toISOString(), type: "email-signin" }, { id: this.id, createdAt: new Date().toISOString(), type: "email-signin" },
this.jwtSecret this.jwtSecret

View File

@ -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;

View File

@ -2,6 +2,7 @@
import ApiKey from "./ApiKey"; import ApiKey from "./ApiKey";
import Attachment from "./Attachment"; import Attachment from "./Attachment";
import Authentication from "./Authentication"; import Authentication from "./Authentication";
import AuthenticationProvider from "./AuthenticationProvider";
import Backlink from "./Backlink"; import Backlink from "./Backlink";
import Collection from "./Collection"; import Collection from "./Collection";
import CollectionGroup from "./CollectionGroup"; import CollectionGroup from "./CollectionGroup";
@ -19,12 +20,14 @@ import Share from "./Share";
import Star from "./Star"; import Star from "./Star";
import Team from "./Team"; import Team from "./Team";
import User from "./User"; import User from "./User";
import UserAuthentication from "./UserAuthentication";
import View from "./View"; import View from "./View";
const models = { const models = {
ApiKey, ApiKey,
Attachment, Attachment,
Authentication, Authentication,
AuthenticationProvider,
Backlink, Backlink,
Collection, Collection,
CollectionGroup, CollectionGroup,
@ -42,6 +45,7 @@ const models = {
Star, Star,
Team, Team,
User, User,
UserAuthentication,
View, View,
}; };
@ -56,6 +60,7 @@ export {
ApiKey, ApiKey,
Attachment, Attachment,
Authentication, Authentication,
AuthenticationProvider,
Backlink, Backlink,
Collection, Collection,
CollectionGroup, CollectionGroup,
@ -73,5 +78,6 @@ export {
Star, Star,
Team, Team,
User, User,
UserAuthentication,
View, View,
}; };

View File

@ -6,8 +6,6 @@ export default function present(team: Team) {
id: team.id, id: team.id,
name: team.name, name: team.name,
avatarUrl: team.logoUrl, avatarUrl: team.logoUrl,
slackConnected: !!team.slackId,
googleConnected: !!team.googleId,
sharing: team.sharing, sharing: team.sharing,
documentEmbeds: team.documentEmbeds, documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin, guestSignin: team.guestSignin,

View File

@ -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);
}

View File

@ -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);
});
});

6
server/scripts/bootstrap.js vendored Normal file
View File

@ -0,0 +1,6 @@
// @flow
if (process.env.NODE_ENV !== "test") {
require("dotenv").config({ silent: true });
}
process.env.SINGLE_RUN = true;

View File

@ -6,20 +6,22 @@ import fs from "fs-extra";
const log = debug("services"); const log = debug("services");
const services = {}; const services = {};
fs.readdirSync(__dirname) if (!process.env.SINGLE_RUN) {
.filter( fs.readdirSync(__dirname)
(file) => .filter(
file.indexOf(".") !== 0 && (file) =>
file !== path.basename(__filename) && file.indexOf(".") !== 0 &&
!file.includes(".test") file !== path.basename(__filename) &&
) !file.includes(".test")
.forEach((fileName) => { )
const servicePath = path.join(__dirname, fileName); .forEach((fileName) => {
const name = path.basename(servicePath.replace(/\.js$/, "")); const servicePath = path.join(__dirname, fileName);
// $FlowIssue const name = path.basename(servicePath.replace(/\.js$/, ""));
const Service = require(servicePath).default; // $FlowIssue
services[name] = new Service(); const Service = require(servicePath).default;
log(`loaded ${name} service`); services[name] = new Service();
}); log(`loaded ${name} service`);
});
}
export default services; export default services;

View File

@ -12,9 +12,10 @@ import {
Attachment, Attachment,
Authentication, Authentication,
Integration, Integration,
AuthenticationProvider,
} from "../models"; } from "../models";
let count = 0; let count = 1;
export async function buildShare(overrides: Object = {}) { export async function buildShare(overrides: Object = {}) {
if (!overrides.teamId) { if (!overrides.teamId) {
@ -35,11 +36,21 @@ export async function buildShare(overrides: Object = {}) {
export function buildTeam(overrides: Object = {}) { export function buildTeam(overrides: Object = {}) {
count++; count++;
return Team.create({ return Team.create(
name: `Team ${count}`, {
slackId: uuid.v4(), name: `Team ${count}`,
...overrides, authenticationProviders: [
}); {
name: "slack",
providerId: uuid.v4(),
},
],
...overrides,
},
{
include: "authenticationProviders",
}
);
} }
export function buildEvent(overrides: Object = {}) { export function buildEvent(overrides: Object = {}) {
@ -51,21 +62,51 @@ export function buildEvent(overrides: Object = {}) {
} }
export async function buildUser(overrides: Object = {}) { export async function buildUser(overrides: Object = {}) {
count++;
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();
overrides.teamId = team.id; 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({ return User.create({
email: `user${count}@example.com`, email: `user${count}@example.com`,
username: `user${count}`,
name: `User ${count}`, name: `User ${count}`,
service: "slack",
serviceId: uuid.v4(),
createdAt: new Date("2018-01-01T00:00:00.000Z"), createdAt: new Date("2018-01-01T00:00:00.000Z"),
lastActiveAt: new Date("2018-01-01T00:00:00.000Z"),
...overrides, ...overrides,
}); });
} }
@ -98,8 +139,6 @@ export async function buildIntegration(overrides: Object = {}) {
} }
export async function buildCollection(overrides: Object = {}) { export async function buildCollection(overrides: Object = {}) {
count++;
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();
overrides.teamId = team.id; overrides.teamId = team.id;
@ -110,6 +149,8 @@ export async function buildCollection(overrides: Object = {}) {
overrides.userId = user.id; overrides.userId = user.id;
} }
count++;
return Collection.create({ return Collection.create({
name: `Test Collection ${count}`, name: `Test Collection ${count}`,
description: "Test collection description", description: "Test collection description",
@ -119,8 +160,6 @@ export async function buildCollection(overrides: Object = {}) {
} }
export async function buildGroup(overrides: Object = {}) { export async function buildGroup(overrides: Object = {}) {
count++;
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();
overrides.teamId = team.id; overrides.teamId = team.id;
@ -131,6 +170,8 @@ export async function buildGroup(overrides: Object = {}) {
overrides.userId = user.id; overrides.userId = user.id;
} }
count++;
return Group.create({ return Group.create({
name: `Test Group ${count}`, name: `Test Group ${count}`,
createdById: overrides.userId, createdById: overrides.userId,
@ -139,8 +180,6 @@ export async function buildGroup(overrides: Object = {}) {
} }
export async function buildGroupUser(overrides: Object = {}) { export async function buildGroupUser(overrides: Object = {}) {
count++;
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();
overrides.teamId = team.id; overrides.teamId = team.id;
@ -151,6 +190,8 @@ export async function buildGroupUser(overrides: Object = {}) {
overrides.userId = user.id; overrides.userId = user.id;
} }
count++;
return GroupUser.create({ return GroupUser.create({
createdById: overrides.userId, createdById: overrides.userId,
...overrides, ...overrides,
@ -158,8 +199,6 @@ export async function buildGroupUser(overrides: Object = {}) {
} }
export async function buildDocument(overrides: Object = {}) { export async function buildDocument(overrides: Object = {}) {
count++;
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();
overrides.teamId = team.id; overrides.teamId = team.id;
@ -175,6 +214,8 @@ export async function buildDocument(overrides: Object = {}) {
overrides.collectionId = collection.id; overrides.collectionId = collection.id;
} }
count++;
return Document.create({ return Document.create({
title: `Document ${count}`, title: `Document ${count}`,
text: "This is the text in an example document", 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 = {}) { export async function buildAttachment(overrides: Object = {}) {
count++;
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();
overrides.teamId = team.id; overrides.teamId = team.id;
} }
if (!overrides.userId) { if (!overrides.userId) {
const user = await buildUser(); const user = await buildUser({ teamId: overrides.teamId });
overrides.userId = user.id; overrides.userId = user.id;
} }
@ -208,6 +247,8 @@ export async function buildAttachment(overrides: Object = {}) {
overrides.documentId = document.id; overrides.documentId = document.id;
} }
count++;
return Attachment.create({ return Attachment.create({
key: `uploads/key/to/file ${count}.png`, key: `uploads/key/to/file ${count}.png`,
url: `https://redirect.url.com/uploads/key/to/file ${count}.png`, url: `https://redirect.url.com/uploads/key/to/file ${count}.png`,

View File

@ -1,4 +1,5 @@
// @flow // @flow
import uuid from "uuid";
import { User, Document, Collection, Team } from "../models"; import { User, Document, Collection, Team } from "../models";
import { sequelize } from "../sequelize"; import { sequelize } from "../sequelize";
@ -15,49 +16,64 @@ export function flushdb() {
return sequelize.query(query); return sequelize.query(query);
} }
const seed = async () => { export const seed = async () => {
const team = await Team.create({ const team = await Team.create(
id: "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", {
name: "Team", name: "Team",
slackId: "T2399UF2P", authenticationProviders: [
slackData: { {
id: "T2399UF2P", name: "slack",
providerId: uuid.v4(),
},
],
}, },
}); {
include: "authenticationProviders",
}
);
const admin = await User.create({ const authenticationProvider = team.authenticationProviders[0];
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 user = await User.create({ const admin = await User.create(
id: "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", {
email: "user1@example.com", email: "admin@example.com",
username: "user1", username: "admin",
name: "User 1", name: "Admin User",
teamId: team.id, teamId: team.id,
service: "slack", isAdmin: true,
serviceId: "U2399UF2P", createdAt: new Date("2018-01-01T00:00:00.000Z"),
slackData: { authentications: [
id: "U2399UF2P", {
image_192: "http://example.com/avatar.png", 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({ const collection = await Collection.create({
id: "26fde1d4-0050-428f-9f0b-0bf77f8bdf62",
name: "Collection", name: "Collection",
urlId: "collection", urlId: "collection",
teamId: team.id, teamId: team.id,
@ -85,5 +101,3 @@ const seed = async () => {
team, team,
}; };
}; };
export { seed, sequelize };

32
server/utils/avatars.js Normal file
View File

@ -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;
}

View File

@ -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"
);
});

21
server/utils/startup.js Normal file
View File

@ -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);
}
}