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
build
node_modules/*
server/scripts
.env
.log
npm-debug.log

View File

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

View File

@ -97,8 +97,7 @@ class Notifications extends React.Component<Props> {
<HelpText>
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.
</HelpText>
<Input

View File

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

View File

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

View File

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

View File

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

View File

@ -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", {

View File

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

View File

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

View File

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

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

View File

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

View File

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

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(
Array(6)
.fill()
.map(() => {
return buildUser({ teamId });
})
.map(() => buildUser({ teamId }))
);
const collection = await buildCollection({

View File

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

View File

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

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

View File

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

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

View File

@ -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`,

View File

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

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