fix: Do not show actively disabled auth providers in self-hosted install (#4794)

* fix: Do not show actively disabled auth providers in self-hosted installation

* self review

* Refactor for easier mocking
This commit is contained in:
Tom Moor
2023-01-28 10:02:25 -08:00
committed by GitHub
parent aac495fa58
commit 075555a867
17 changed files with 95 additions and 62 deletions

View File

@@ -72,7 +72,7 @@ async function teamProvisioner({
}; };
} else if (teamId) { } else if (teamId) {
// The user is attempting to log into a team with an unfamiliar SSO provider // The user is attempting to log into a team with an unfamiliar SSO provider
if (env.DEPLOYMENT === "hosted") { if (env.isCloudHosted()) {
throw InvalidAuthenticationError(); throw InvalidAuthenticationError();
} }

View File

@@ -4,7 +4,6 @@ import Oy from "oy-vey";
import env from "@server/env"; import env from "@server/env";
import Logger from "@server/logging/Logger"; import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing"; import { trace } from "@server/logging/tracing";
import isCloudHosted from "@server/utils/isCloudHosted";
import { baseStyles } from "./templates/components/EmailLayout"; import { baseStyles } from "./templates/components/EmailLayout";
const useTestEmailService = const useTestEmailService =
@@ -79,7 +78,7 @@ export class Mailer {
subject: data.subject, subject: data.subject,
html, html,
text: data.text, text: data.text,
attachments: isCloudHosted attachments: env.isCloudHosted()
? undefined ? undefined
: [ : [
{ {

View File

@@ -1,7 +1,6 @@
import { Table, TBody, TR, TD } from "oy-vey"; import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react"; import * as React from "react";
import env from "@server/env"; import env from "@server/env";
import isCloudHosted from "@server/utils/isCloudHosted";
import EmptySpace from "./EmptySpace"; import EmptySpace from "./EmptySpace";
const url = env.CDN_URL ?? env.URL; const url = env.CDN_URL ?? env.URL;
@@ -16,7 +15,7 @@ export default () => {
<img <img
alt={env.APP_NAME} alt={env.APP_NAME}
src={ src={
isCloudHosted env.isCloudHosted()
? `${url}/email/header-logo.png` ? `${url}/email/header-logo.png`
: "cid:header-image" : "cid:header-image"
} }

View File

@@ -575,6 +575,14 @@ export class Environment {
*/ */
public APP_NAME = "Outline"; public APP_NAME = "Outline";
/**
* Returns true if the current installation is the cloud hosted version at
* getoutline.com
*/
public isCloudHosted() {
return this.DEPLOYMENT === "hosted";
}
private toOptionalString(value: string | undefined) { private toOptionalString(value: string | undefined) {
return value ? value : undefined; return value ? value : undefined;
} }

View File

@@ -24,7 +24,6 @@ import { CollectionPermission, TeamPreference } from "@shared/types";
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import env from "@server/env"; import env from "@server/env";
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask"; import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
import isCloudHosted from "@server/utils/isCloudHosted";
import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import Attachment from "./Attachment"; import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider"; import AuthenticationProvider from "./AuthenticationProvider";
@@ -67,9 +66,9 @@ class Team extends ParanoidModel {
@Unique @Unique
@Length({ @Length({
min: 2, min: 2,
max: isCloudHosted ? 32 : 255, max: env.isCloudHosted() ? 32 : 255,
msg: `subdomain must be between 2 and ${ msg: `subdomain must be between 2 and ${
isCloudHosted ? 32 : 255 env.isCloudHosted() ? 32 : 255
} characters`, } characters`,
}) })
@Is({ @Is({

View File

@@ -10,8 +10,8 @@ import {
BeforeCreate, BeforeCreate,
} from "sequelize-typescript"; } from "sequelize-typescript";
import { TeamValidation } from "@shared/validations"; import { TeamValidation } from "@shared/validations";
import env from "@server/env";
import { ValidationError } from "@server/errors"; import { ValidationError } from "@server/errors";
import isCloudHosted from "@server/utils/isCloudHosted";
import Team from "./Team"; import Team from "./Team";
import User from "./User"; import User from "./User";
import IdModel from "./base/IdModel"; import IdModel from "./base/IdModel";
@@ -23,7 +23,7 @@ import Length from "./validators/Length";
@Fix @Fix
class TeamDomain extends IdModel { class TeamDomain extends IdModel {
@NotIn({ @NotIn({
args: isCloudHosted ? [emailProviders] : [], args: env.isCloudHosted() ? [emailProviders] : [],
msg: "You chose a restricted domain, please try another.", msg: "You chose a restricted domain, please try another.",
}) })
@NotEmpty @NotEmpty

View File

@@ -0,0 +1,42 @@
import { find } from "lodash";
import env from "@server/env";
import Team from "@server/models/Team";
import providerConfigs from "../../routes/auth/providers";
export default class AuthenticationHelper {
/**
* Returns the enabled authentication provider configurations for a team,
* if given otherwise all enabled providers are returned.
*
* @param team The team to get enabled providers for
* @returns A list of authentication providers
*/
static providersForTeam(team?: Team) {
const isCloudHosted = env.isCloudHosted();
return providerConfigs
.sort((config) => (config.id === "email" ? 1 : -1))
.filter((config) => {
// guest sign-in is an exception as it does not have an authentication
// provider using passport, instead it exists as a boolean option on the team
if (config.id === "email") {
return team?.emailSigninEnabled;
}
if (!team) {
return true;
}
const authProvider = find(team.authenticationProviders, {
name: config.id,
});
// If cloud hosted then the auth provider must be enabled for the team,
// If self-hosted then it must not be actively disabled, otherwise all
// providers are considered.
return (
(!isCloudHosted && authProvider?.enabled !== false) ||
(isCloudHosted && authProvider?.enabled)
);
});
}
}

View File

@@ -13,7 +13,7 @@ allow(User, "share", Team, (user, team) => {
}); });
allow(User, "createTeam", Team, () => { allow(User, "createTeam", Team, () => {
if (env.DEPLOYMENT !== "hosted") { if (!env.isCloudHosted()) {
throw IncorrectEditionError("createTeam only available on cloud"); throw IncorrectEditionError("createTeam only available on cloud");
} }
}); });

View File

@@ -14,6 +14,7 @@ import presentMembership from "./membership";
import presentNotificationSetting from "./notificationSetting"; import presentNotificationSetting from "./notificationSetting";
import presentPin from "./pin"; import presentPin from "./pin";
import presentPolicies from "./policy"; import presentPolicies from "./policy";
import presentProviderConfig from "./providerConfig";
import presentRevision from "./revision"; import presentRevision from "./revision";
import presentSearchQuery from "./searchQuery"; import presentSearchQuery from "./searchQuery";
import presentShare from "./share"; import presentShare from "./share";
@@ -43,6 +44,7 @@ export {
presentNotificationSetting, presentNotificationSetting,
presentPin, presentPin,
presentPolicies, presentPolicies,
presentProviderConfig,
presentRevision, presentRevision,
presentSearchQuery, presentSearchQuery,
presentShare, presentShare,

View File

@@ -0,0 +1,12 @@
import { signin } from "@shared/utils/urlHelpers";
import { AuthenticationProviderConfig } from "@server/routes/auth/providers";
export default function presentProviderConfig(
config: AuthenticationProviderConfig
) {
return {
id: config.id,
name: config.name,
authUrl: signin(config.id),
};
}

View File

@@ -1,55 +1,30 @@
import Router from "koa-router"; import Router from "koa-router";
import { find, uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { TeamPreference } from "@shared/types"; import { TeamPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains"; import { parseDomain } from "@shared/utils/domains";
import env from "@server/env"; import env from "@server/env";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction"; import { transaction } from "@server/middlewares/transaction";
import { Event, Team } from "@server/models"; import { Event, Team } from "@server/models";
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
import { import {
presentUser, presentUser,
presentTeam, presentTeam,
presentPolicies, presentPolicies,
presentProviderConfig,
presentAvailableTeam, presentAvailableTeam,
} from "@server/presenters"; } from "@server/presenters";
import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask"; import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask";
import { APIContext } from "@server/types"; import { APIContext } from "@server/types";
import { getSessionsInCookie } from "@server/utils/authentication"; import { getSessionsInCookie } from "@server/utils/authentication";
import providers from "../auth/providers";
const router = new Router(); const router = new Router();
function filterProviders(team?: Team) {
return providers
.sort((provider) => (provider.id === "email" ? 1 : -1))
.filter((provider) => {
// guest sign-in is an exception as it does not have an authentication
// provider using passport, instead it exists as a boolean option on the team
if (provider.id === "email") {
return team?.emailSigninEnabled;
}
return (
!team ||
env.DEPLOYMENT !== "hosted" ||
find(team.authenticationProviders, {
name: provider.id,
enabled: true,
})
);
})
.map((provider) => ({
id: provider.id,
name: provider.name,
authUrl: provider.authUrl,
}));
}
router.post("auth.config", async (ctx: APIContext) => { router.post("auth.config", async (ctx: APIContext) => {
// If self hosted AND there is only one team then that team becomes the // If self hosted AND there is only one team then that team becomes the
// 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 (env.DEPLOYMENT !== "hosted") { if (!env.isCloudHosted()) {
const team = await Team.scope("withAuthenticationProviders").findOne(); const team = await Team.scope("withAuthenticationProviders").findOne();
if (team) { if (team) {
@@ -59,7 +34,9 @@ router.post("auth.config", async (ctx: APIContext) => {
logo: team.getPreference(TeamPreference.PublicBranding) logo: team.getPreference(TeamPreference.PublicBranding)
? team.avatarUrl ? team.avatarUrl
: undefined, : undefined,
providers: filterProviders(team), providers: AuthenticationHelper.providersForTeam(team).map(
presentProviderConfig
),
}, },
}; };
return; return;
@@ -83,7 +60,9 @@ router.post("auth.config", async (ctx: APIContext) => {
? team.avatarUrl ? team.avatarUrl
: undefined, : undefined,
hostname: ctx.request.hostname, hostname: ctx.request.hostname,
providers: filterProviders(team), providers: AuthenticationHelper.providersForTeam(team).map(
presentProviderConfig
),
}, },
}; };
return; return;
@@ -107,7 +86,9 @@ router.post("auth.config", async (ctx: APIContext) => {
? team.avatarUrl ? team.avatarUrl
: undefined, : undefined,
hostname: ctx.request.hostname, hostname: ctx.request.hostname,
providers: filterProviders(team), providers: AuthenticationHelper.providersForTeam(team).map(
presentProviderConfig
),
}, },
}; };
return; return;
@@ -117,7 +98,9 @@ router.post("auth.config", async (ctx: APIContext) => {
// Otherwise, we're requesting from the standard root signin page // Otherwise, we're requesting from the standard root signin page
ctx.body = { ctx.body = {
data: { data: {
providers: filterProviders(), providers: AuthenticationHelper.providersForTeam().map(
presentProviderConfig
),
}, },
}; };
}); });

View File

@@ -31,7 +31,7 @@ router.post(
const domain = parseDomain(ctx.request.hostname); const domain = parseDomain(ctx.request.hostname);
let team: Team | null | undefined; let team: Team | null | undefined;
if (env.DEPLOYMENT !== "hosted") { if (!env.isCloudHosted()) {
team = await Team.scope("withAuthenticationProviders").findOne(); team = await Team.scope("withAuthenticationProviders").findOne();
} else if (domain.custom) { } else if (domain.custom) {
team = await Team.scope("withAuthenticationProviders").findOne({ team = await Team.scope("withAuthenticationProviders").findOne({

View File

@@ -1,17 +1,15 @@
import Router from "koa-router"; import Router from "koa-router";
import { sortBy } from "lodash"; import { sortBy } from "lodash";
import { signin } from "@shared/utils/urlHelpers";
import { requireDirectory } from "@server/utils/fs"; import { requireDirectory } from "@server/utils/fs";
export type AuthenticationProviderConfig = { export type AuthenticationProviderConfig = {
id: string; id: string;
name: string; name: string;
enabled: boolean; enabled: boolean;
authUrl: string;
router: Router; router: Router;
}; };
const providers: AuthenticationProviderConfig[] = []; const authenticationProviderConfigs: AuthenticationProviderConfig[] = [];
requireDirectory(__dirname).forEach(([module, id]) => { requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'config' does not exist on type 'unknown'... Remove this comment to see the full error message // @ts-expect-error ts-migrate(2339) FIXME: Property 'config' does not exist on type 'unknown'... Remove this comment to see the full error message
@@ -34,14 +32,13 @@ requireDirectory(__dirname).forEach(([module, id]) => {
} }
if (config && config.enabled) { if (config && config.enabled) {
providers.push({ authenticationProviderConfigs.push({
id, id,
name: config.name, name: config.name,
enabled: config.enabled, enabled: config.enabled,
authUrl: signin(id),
router, router,
}); });
} }
}); });
export default sortBy(providers, "id"); export default sortBy(authenticationProviderConfigs, "id");

View File

@@ -1,8 +0,0 @@
import env from "@server/env";
/**
* True if the current installation is the cloud hosted version at getoutline.com
*/
const isCloudHosted = env.DEPLOYMENT === "hosted";
export default isCloudHosted;

View File

@@ -102,7 +102,7 @@ export async function getTeamFromContext(ctx: Context) {
const domain = parseDomain(host); const domain = parseDomain(host);
let team; let team;
if (env.DEPLOYMENT !== "hosted") { if (!env.isCloudHosted()) {
team = await Team.findOne(); team = await Team.findOne();
} else if (domain.custom) { } else if (domain.custom) {
team = await Team.findOne({ where: { domain: domain.host } }); team = await Team.findOne({ where: { domain: domain.host } });

View File

@@ -1,7 +1,7 @@
import env from "@server/env"; import env from "@server/env";
export const robotsResponse = () => { export const robotsResponse = () => {
if (env.DEPLOYMENT === "hosted") { if (env.isCloudHosted()) {
return ` return `
User-agent: * User-agent: *
Allow: / Allow: /

View File

@@ -46,7 +46,7 @@ export function checkPendingMigrations() {
} }
export async function checkMigrations() { export async function checkMigrations() {
if (env.DEPLOYMENT === "hosted") { if (env.isCloudHosted()) {
return; return;
} }