From c3f5563e7fc1074d1737214380c28b26c828e2ab Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Tue, 19 Jul 2022 06:50:55 -0700 Subject: [PATCH] feat: scope login attempts to specific subdomains if available - do not switch subdomains (#3741) * make the user lookup in user creator sensitive to team * add team specific logic to oidc strat * factor out slugifyDomain * change type of req during auth to Koa.Context --- app/scenes/Login/Notices.tsx | 9 +++++ server/commands/accountProvisioner.ts | 9 +++-- server/commands/teamCreator.ts | 17 +++++++-- server/commands/userCreator.ts | 2 ++ server/errors.ts | 19 +++++++--- server/routes/auth/providers/azure.ts | 15 +++++--- server/routes/auth/providers/google.ts | 43 ++++++++++------------ server/routes/auth/providers/oidc.ts | 19 +++++++--- server/routes/auth/providers/slack.ts | 10 +++--- server/utils/passport.ts | 49 +++++++++++++++++++------- shared/utils/domains.test.ts | 10 +++++- shared/utils/domains.ts | 10 ++++++ 12 files changed, 148 insertions(+), 64 deletions(-) diff --git a/app/scenes/Login/Notices.tsx b/app/scenes/Login/Notices.tsx index 6b3a890c5d..c43816dee1 100644 --- a/app/scenes/Login/Notices.tsx +++ b/app/scenes/Login/Notices.tsx @@ -57,6 +57,15 @@ export default function Notices() { Please try again. ))} + {notice === "invalid-authentication" && + (description ? ( + {description} + ) : ( + + Authentication failed – you do not have permission to access this + team. + + ))} {notice === "expired-token" && ( Sorry, it looks like that sign-in link is no longer valid, please try diff --git a/server/commands/accountProvisioner.ts b/server/commands/accountProvisioner.ts index ef60088fca..27dabc5a6d 100644 --- a/server/commands/accountProvisioner.ts +++ b/server/commands/accountProvisioner.ts @@ -3,6 +3,7 @@ import { UniqueConstraintError } from "sequelize"; import WelcomeEmail from "@server/emails/templates/WelcomeEmail"; import { AuthenticationError, + InvalidAuthenticationError, EmailAuthenticationRequiredError, AuthenticationProviderDisabledError, } from "@server/errors"; @@ -20,6 +21,7 @@ type Props = { username?: string; }; team: { + id?: string; name: string; domain?: string; subdomain: string; @@ -56,15 +58,12 @@ async function accountProvisioner({ try { result = await teamCreator({ - name: teamParams.name, - domain: teamParams.domain, - subdomain: teamParams.subdomain, - avatarUrl: teamParams.avatarUrl, + ...teamParams, authenticationProvider: authenticationProviderParams, ip, }); } catch (err) { - throw AuthenticationError(err.message); + throw InvalidAuthenticationError(err.message); } invariant(result, "Team creator result must exist"); diff --git a/server/commands/teamCreator.ts b/server/commands/teamCreator.ts index 2c175ef1e6..7d43f146a6 100644 --- a/server/commands/teamCreator.ts +++ b/server/commands/teamCreator.ts @@ -1,6 +1,10 @@ import { sequelize } from "@server/database/sequelize"; import env from "@server/env"; -import { DomainNotAllowedError, MaximumTeamsError } from "@server/errors"; +import { + InvalidAuthenticationError, + DomainNotAllowedError, + MaximumTeamsError, +} from "@server/errors"; import Logger from "@server/logging/Logger"; import { APM } from "@server/logging/tracing"; import { Team, AuthenticationProvider, Event } from "@server/models"; @@ -13,6 +17,7 @@ type TeamCreatorResult = { }; type Props = { + id?: string; name: string; domain?: string; subdomain: string; @@ -25,6 +30,7 @@ type Props = { }; async function teamCreator({ + id, name, domain, subdomain, @@ -33,7 +39,9 @@ async function teamCreator({ ip, }: Props): Promise { let authP = await AuthenticationProvider.findOne({ - where: authenticationProvider, + where: id + ? { ...authenticationProvider, teamId: id } + : authenticationProvider, include: [ { model: Team, @@ -52,6 +60,11 @@ async function teamCreator({ isNewTeam: false, }; } + // A team id was provided but no auth provider was found matching those credentials + // The user is attempting to log into a team with an incorrect SSO - fail the login + else if (id) { + throw InvalidAuthenticationError("incorrect authentication credentials"); + } // This team has never been seen before, if self hosted the logic is different // to the multi-tenant version, we want to restrict to a single team that MAY diff --git a/server/commands/userCreator.ts b/server/commands/userCreator.ts index 59448f67b1..f9c6a9bfb0 100644 --- a/server/commands/userCreator.ts +++ b/server/commands/userCreator.ts @@ -47,6 +47,8 @@ export default async function userCreator({ { model: User, as: "user", + where: { teamId }, + required: true, }, ], }); diff --git a/server/errors.ts b/server/errors.ts index 40233102aa..d1735581e7 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -1,9 +1,8 @@ import httpErrors from "http-errors"; -import env from "./env"; export function AuthenticationError( - message = "Invalid authentication", - redirectUrl = env.URL + message = "Authentication required", + redirectUrl = "/" ) { return httpErrors(401, message, { redirectUrl, @@ -11,6 +10,16 @@ export function AuthenticationError( }); } +export function InvalidAuthenticationError( + message = "Invalid authentication", + redirectUrl = "/" +) { + return httpErrors(401, message, { + redirectUrl, + id: "invalid_authentication", + }); +} + export function AuthorizationError( message = "You do not have permission to access this resource" ) { @@ -112,7 +121,7 @@ export function MaximumTeamsError( export function EmailAuthenticationRequiredError( message = "User must authenticate with email", - redirectUrl = env.URL + redirectUrl = "/" ) { return httpErrors(400, message, { redirectUrl, @@ -164,7 +173,7 @@ export function OIDCMalformedUserInfoError( export function AuthenticationProviderDisabledError( message = "Authentication method has been disabled by an admin", - redirectUrl = env.URL + redirectUrl = "/" ) { return httpErrors(400, message, { redirectUrl, diff --git a/server/routes/auth/providers/azure.ts b/server/routes/auth/providers/azure.ts index 9c2ad25ef3..0e99e4bf01 100644 --- a/server/routes/auth/providers/azure.ts +++ b/server/routes/auth/providers/azure.ts @@ -1,7 +1,7 @@ import passport from "@outlinewiki/koa-passport"; import { Strategy as AzureStrategy } from "@outlinewiki/passport-azure-ad-oauth2"; import jwt from "jsonwebtoken"; -import { Request } from "koa"; +import type { Context } from "koa"; import Router from "koa-router"; import { Profile } from "passport"; import accountProvisioner, { @@ -11,7 +11,11 @@ import env from "@server/env"; import { MicrosoftGraphError } from "@server/errors"; import passportMiddleware from "@server/middlewares/passport"; import { User } from "@server/models"; -import { StateStore, request } from "@server/utils/passport"; +import { + StateStore, + request, + getTeamFromContext, +} from "@server/utils/passport"; const router = new Router(); const providerName = "azure"; @@ -36,7 +40,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) { scope: scopes, }, async function ( - req: Request, + ctx: Context, accessToken: string, refreshToken: string, params: { expires_in: number; id_token: string }, @@ -88,12 +92,15 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) { ); } + const team = await getTeamFromContext(ctx); + const domain = email.split("@")[1]; const subdomain = domain.split(".")[0]; const teamName = organization.displayName; const result = await accountProvisioner({ - ip: req.ip, + ip: ctx.ip, team: { + id: team?.id, name: teamName, domain, subdomain, diff --git a/server/routes/auth/providers/google.ts b/server/routes/auth/providers/google.ts index a1251e11d5..16af9d9126 100644 --- a/server/routes/auth/providers/google.ts +++ b/server/routes/auth/providers/google.ts @@ -1,10 +1,10 @@ import passport from "@outlinewiki/koa-passport"; -import type { Request } from "express"; +import type { Context } from "koa"; import Router from "koa-router"; import { capitalize } from "lodash"; import { Profile } from "passport"; import { Strategy as GoogleStrategy } from "passport-google-oauth2"; -import { parseDomain } from "@shared/utils/domains"; +import { slugifyDomain } from "@shared/utils/domains"; import accountProvisioner, { AccountProvisionerResult, } from "@server/commands/accountProvisioner"; @@ -15,8 +15,8 @@ import { TeamDomainRequiredError, } from "@server/errors"; import passportMiddleware from "@server/middlewares/passport"; -import { Team, User } from "@server/models"; -import { StateStore, parseState } from "@server/utils/passport"; +import { User } from "@server/models"; +import { StateStore, getTeamFromContext } from "@server/utils/passport"; const router = new Router(); const providerName = "google"; @@ -51,7 +51,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { scope: scopes, }, async function ( - req: Request, + ctx: Context, accessToken: string, refreshToken: string, params: { expires_in: number }, @@ -63,27 +63,32 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { ) => void ) { try { - const state = req.cookies.get("state"); - const host = state ? parseState(state).host : req.hostname; - // appDomain is the domain the user originated from when attempting auth - const appDomain = parseDomain(host); - let result; + + // "domain" is the Google Workspaces domain const domain = profile._json.hd; + const team = await getTeamFromContext(ctx); // Existence of domain means this is a Google Workspaces account // so we'll attempt to provision an account (team and user) if (domain) { - const subdomain = domain.split(".")[0]; + // remove the TLD and form a subdomain from the remaining + // subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains + // see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain + const subdomain = slugifyDomain(domain); const teamName = capitalize(subdomain); // Request a larger size profile picture than the default by tweaking // the query parameter. const avatarUrl = profile.picture.replace("=s96-c", "=s128-c"); + // if a team can be inferred, we assume the user is only interested in signing into + // that team in particular; otherwise, we will do a best effort at finding their account + // or provisioning a new one (within AccountProvisioner) result = await accountProvisioner({ - ip: req.ip, + ip: ctx.ip, team: { + id: team?.id, name: teamName, domain, subdomain, @@ -107,19 +112,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { }); } else { // No domain means it's a personal Gmail account - // We only allow sign-in to existing user accounts - - let team; - if (appDomain.custom) { - team = await Team.findOne({ where: { domain: appDomain.host } }); - } else if (env.SUBDOMAINS_ENABLED && appDomain.teamSubdomain) { - team = await Team.findOne({ - where: { subdomain: appDomain.teamSubdomain }, - }); - } else if (env.DEPLOYMENT !== "hosted") { - team = await Team.findOne(); - } - + // We only allow sign-in to existing user accounts with these if (!team) { // No team usually means this is the apex domain // Throw different errors depending on whether we think the user is diff --git a/server/routes/auth/providers/oidc.ts b/server/routes/auth/providers/oidc.ts index 276c204baa..625a36d10a 100644 --- a/server/routes/auth/providers/oidc.ts +++ b/server/routes/auth/providers/oidc.ts @@ -1,8 +1,9 @@ import passport from "@outlinewiki/koa-passport"; -import { Request } from "koa"; +import type { Context } from "koa"; import Router from "koa-router"; import { get } from "lodash"; import { Strategy } from "passport-oauth2"; +import { slugifyDomain } from "@shared/utils/domains"; import accountProvisioner, { AccountProvisionerResult, } from "@server/commands/accountProvisioner"; @@ -13,7 +14,11 @@ import { } from "@server/errors"; import passportMiddleware from "@server/middlewares/passport"; import { User } from "@server/models"; -import { StateStore, request } from "@server/utils/passport"; +import { + StateStore, + request, + getTeamFromContext, +} from "@server/utils/passport"; const router = new Router(); const providerName = "oidc"; @@ -60,7 +65,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) { // Any claim supplied in response to the userinfo request will be // available on the `profile` parameter async function ( - req: Request, + ctx: Context, accessToken: string, refreshToken: string, params: { expires_in: number }, @@ -77,6 +82,7 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) { `An email field was not returned in the profile parameter, but is required.` ); } + const team = await getTeamFromContext(ctx); const parts = profile.email.toLowerCase().split("@"); const domain = parts.length && parts[1]; @@ -85,10 +91,13 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) { throw OIDCMalformedUserInfoError(); } - const subdomain = domain.split(".")[0]; + // remove the TLD and form a subdomain from the remaining + const subdomain = slugifyDomain(domain); + const result = await accountProvisioner({ - ip: req.ip, + ip: ctx.ip, team: { + id: team?.id, // https://github.com/outline/outline/pull/2388#discussion_r681120223 name: "Wiki", domain, diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts index f020edda7c..ba2126f369 100644 --- a/server/routes/auth/providers/slack.ts +++ b/server/routes/auth/providers/slack.ts @@ -1,5 +1,5 @@ import passport from "@outlinewiki/koa-passport"; -import { Request } from "koa"; +import type { Context } from "koa"; import Router from "koa-router"; import { Profile } from "passport"; import { Strategy as SlackStrategy } from "passport-slack-oauth2"; @@ -16,7 +16,7 @@ import { Team, User, } from "@server/models"; -import { StateStore } from "@server/utils/passport"; +import { getTeamFromContext, StateStore } from "@server/utils/passport"; import * as Slack from "@server/utils/slack"; import { assertPresent, assertUuid } from "@server/validation"; @@ -63,7 +63,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { scope: scopes, }, async function ( - req: Request, + ctx: Context, accessToken: string, refreshToken: string, params: { expires_in: number }, @@ -75,9 +75,11 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { ) => void ) { try { + const team = await getTeamFromContext(ctx); const result = await accountProvisioner({ - ip: req.ip, + ip: ctx.ip, team: { + id: team?.id, name: profile.team.name, subdomain: profile.team.domain, avatarUrl: profile.team.image_230, diff --git a/server/utils/passport.ts b/server/utils/passport.ts index 34f43e6bee..95915911ec 100644 --- a/server/utils/passport.ts +++ b/server/utils/passport.ts @@ -1,40 +1,42 @@ import crypto from "crypto"; import { addMinutes, subMinutes } from "date-fns"; -import type { Request } from "express"; import fetch from "fetch-with-proxy"; +import type { Context } from "koa"; import { StateStoreStoreCallback, StateStoreVerifyCallback, } from "passport-oauth2"; import { getCookieDomain, parseDomain } from "@shared/utils/domains"; +import env from "@server/env"; +import { Team } from "@server/models"; import { AuthRedirectError, OAuthStateMismatchError } from "../errors"; export class StateStore { key = "state"; - store = (req: Request, callback: StateStoreStoreCallback) => { + store = (ctx: Context, callback: StateStoreStoreCallback) => { // token is a short lived one-time pad to prevent replay attacks // appDomain is the domain the user originated from when attempting auth // we expect it to be a team subdomain, custom domain, or apex domain const token = crypto.randomBytes(8).toString("hex"); - const appDomain = parseDomain(req.hostname); + const appDomain = parseDomain(ctx.hostname); const state = buildState(appDomain.host, token); - req.cookies.set(this.key, state, { + ctx.cookies.set(this.key, state, { httpOnly: false, expires: addMinutes(new Date(), 10), - domain: getCookieDomain(req.hostname), + domain: getCookieDomain(ctx.hostname), }); callback(null, token); }; verify = ( - req: Request, + ctx: Context, providedToken: string, callback: StateStoreVerifyCallback ) => { - const state = req.cookies.get(this.key); + const state = ctx.cookies.get(this.key); if (!state) { return callback( @@ -51,10 +53,10 @@ export class StateStore { // If there is an error during auth, the user will end up on the same domain // that they started from. const appDomain = parseDomain(host); - if (appDomain.host !== parseDomain(req.hostname).host) { - const reqProtocol = req.protocol; - const requestHost = req.get("host"); - const requestPath = req.originalUrl; + if (appDomain.host !== parseDomain(ctx.hostname).host) { + const reqProtocol = ctx.protocol; + const requestHost = ctx.get("host"); + const requestPath = ctx.originalUrl; const requestUrl = `${reqProtocol}://${requestHost}${requestPath}`; const url = new URL(requestUrl); @@ -64,10 +66,10 @@ export class StateStore { } // Destroy the one-time pad token and ensure it matches - req.cookies.set(this.key, "", { + ctx.cookies.set(this.key, "", { httpOnly: false, expires: subMinutes(new Date(), 1), - domain: getCookieDomain(req.hostname), + domain: getCookieDomain(ctx.hostname), }); if (!token || token !== providedToken) { @@ -98,3 +100,24 @@ export function parseState(state: string) { const [host, token] = state.split("|"); return { host, token }; } + +export async function getTeamFromContext(ctx: Context) { + // "domain" is the domain the user came from when attempting auth + // we use it to infer the team they intend on signing into + const state = ctx.cookies.get("state"); + const host = state ? parseState(state).host : ctx.hostname; + const domain = parseDomain(host); + + let team; + if (env.DEPLOYMENT !== "hosted") { + team = await Team.findOne(); + } else if (domain.custom) { + team = await Team.findOne({ where: { domain: domain.host } }); + } else if (env.SUBDOMAINS_ENABLED && domain.teamSubdomain) { + team = await Team.findOne({ + where: { subdomain: domain.teamSubdomain }, + }); + } + + return team; +} diff --git a/shared/utils/domains.test.ts b/shared/utils/domains.test.ts index d18f2bc306..3746e7b0ca 100644 --- a/shared/utils/domains.test.ts +++ b/shared/utils/domains.test.ts @@ -1,5 +1,5 @@ import env from "@shared/env"; -import { parseDomain, getCookieDomain } from "./domains"; +import { parseDomain, getCookieDomain, slugifyDomain } from "./domains"; // test suite is based on subset of parse-domain module we want to support // https://github.com/peerigon/parse-domain/blob/master/test/parseDomain.test.js @@ -158,6 +158,14 @@ describe("#parseDomain", () => { }); }); +describe("#slugifyDomain", () => { + it("strips the last . delineated segment from strings", () => { + expect(slugifyDomain("foo.co")).toBe("foo"); + expect(slugifyDomain("foo.co.uk")).toBe("foo-co"); + expect(slugifyDomain("www.foo.co.uk")).toBe("www-foo-co"); + }); +}); + describe("#getCookieDomain", () => { beforeEach(() => { env.URL = "https://example.com"; diff --git a/shared/utils/domains.ts b/shared/utils/domains.ts index a27b8633af..0dc96c5fd7 100644 --- a/shared/utils/domains.ts +++ b/shared/utils/domains.ts @@ -7,6 +7,16 @@ type Domain = { custom: boolean; }; +/** + * Removes the the top level domain from the argument and slugifies it + * + * @param domain Domain string to slugify + * @returns String with only non top-level domains + */ +export function slugifyDomain(domain: string) { + return domain.split(".").slice(0, -1).join("-"); +} + // strips protocol and whitespace from input // then strips the path and query string function normalizeUrl(url: string) {