diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bcb4c5de..0280d809 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,7 +34,8 @@ Interested in more documentation on the API routes? Check out the [API documenta server ├── api - All API routes are contained within here │ └── middlewares - Koa middlewares specific to the API -├── auth - Authentication providers, in the form of passport.js strategies +├── auth - Authentication logic +│ └── providers - Authentication providers export passport.js strategies and config ├── commands - We are gradually moving to the command pattern for new write logic ├── config - Database configuration ├── emails - Transactional email templates diff --git a/server/api/auth.js b/server/api/auth.js index 429c496b..85ef2c23 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -1,29 +1,14 @@ // @flow -import path from "path"; import Router from "koa-router"; import { find } from "lodash"; import { parseDomain, isCustomSubdomain } from "../../shared/utils/domains"; -import { signin } from "../../shared/utils/routeHelpers"; +import providers from "../auth/providers"; import auth from "../middlewares/authentication"; import { Team } from "../models"; import { presentUser, presentTeam, presentPolicies } from "../presenters"; import { isCustomDomain } from "../utils/domains"; -import { requireDirectory } from "../utils/fs"; const router = new Router(); -let providers = []; - -requireDirectory(path.join(__dirname, "..", "auth")).forEach( - ([{ config }, id]) => { - if (config && config.enabled) { - providers.push({ - id, - name: config.name, - authUrl: signin(id), - }); - } - } -); function filterProviders(team) { return providers diff --git a/server/api/authenticationProviders.js b/server/api/authenticationProviders.js new file mode 100644 index 00000000..9f9ec877 --- /dev/null +++ b/server/api/authenticationProviders.js @@ -0,0 +1,85 @@ +// @flow +import Router from "koa-router"; +import allAuthenticationProviders from "../auth/providers"; +import auth from "../middlewares/authentication"; +import { AuthenticationProvider, Event } from "../models"; +import policy from "../policies"; +import { presentAuthenticationProvider, presentPolicies } from "../presenters"; + +const router = new Router(); +const { authorize } = policy; + +router.post("authenticationProviders.info", auth(), async (ctx) => { + const { id } = ctx.body; + ctx.assertUuid(id, "id is required"); + + const user = ctx.state.user; + const authenticationProvider = await AuthenticationProvider.findByPk(id); + authorize(user, "read", authenticationProvider); + + ctx.body = { + data: presentAuthenticationProvider(authenticationProvider), + policies: presentPolicies(user, [authenticationProvider]), + }; +}); + +router.post("authenticationProviders.update", auth(), async (ctx) => { + const { id, isEnabled } = ctx.body; + ctx.assertUuid(id, "id is required"); + ctx.assertPresent(isEnabled, "isEnabled is required"); + + const user = ctx.state.user; + const authenticationProvider = await AuthenticationProvider.findByPk(id); + authorize(user, "update", authenticationProvider); + + const enabled = !!isEnabled; + if (enabled) { + await authenticationProvider.enable(); + } else { + await authenticationProvider.disable(); + } + + await Event.create({ + name: "authenticationProviders.update", + data: { enabled }, + modelId: id, + teamId: user.teamId, + actorId: user.id, + ip: ctx.request.ip, + }); + + ctx.body = { + data: presentAuthenticationProvider(authenticationProvider), + policies: presentPolicies(user, [authenticationProvider]), + }; +}); + +router.post("authenticationProviders.list", auth(), async (ctx) => { + const user = ctx.state.user; + authorize(user, "read", user.team); + + const teamAuthenticationProviders = await user.team.getAuthenticationProviders(); + const otherAuthenticationProviders = allAuthenticationProviders.filter( + (p) => + !teamAuthenticationProviders.find((t) => t.name === p.id) && + p.enabled && + // email auth is dealt with separetly right now, although it definitely + // wants to be here in the future – we'll need to migrate more data though + p.id !== "email" + ); + + ctx.body = { + data: { + authenticationProviders: [ + ...teamAuthenticationProviders.map(presentAuthenticationProvider), + ...otherAuthenticationProviders.map((p) => ({ + name: p.id, + isEnabled: false, + isConnected: false, + })), + ], + }, + }; +}); + +export default router; diff --git a/server/api/authenticationProviders.test.js b/server/api/authenticationProviders.test.js new file mode 100644 index 00000000..e60b5da7 --- /dev/null +++ b/server/api/authenticationProviders.test.js @@ -0,0 +1,156 @@ +// @flow +import TestServer from "fetch-test-server"; +import uuid from "uuid"; +import app from "../app"; +import { buildUser, buildAdmin, buildTeam } from "../test/factories"; +import { flushdb } from "../test/support"; + +const server = new TestServer(app.callback()); + +beforeEach(() => flushdb()); +afterAll(() => server.close()); + +describe("#authenticationProviders.info", () => { + it("should return auth provider", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.info", { + body: { + id: authenticationProviders[0].id, + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.name).toBe("slack"); + expect(body.data.isEnabled).toBe(true); + expect(body.data.isConnected).toBe(true); + expect(body.policies[0].abilities.read).toBe(true); + expect(body.policies[0].abilities.update).toBe(false); + }); + + it("should require authorization", async () => { + const team = await buildTeam(); + const user = await buildUser(); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.info", { + body: { + id: authenticationProviders[0].id, + token: user.getJwtToken(), + }, + }); + expect(res.status).toEqual(403); + }); + + it("should require authentication", async () => { + const team = await buildTeam(); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.info", { + body: { + id: authenticationProviders[0].id, + }, + }); + expect(res.status).toEqual(401); + }); +}); + +describe("#authenticationProviders.update", () => { + it("should not allow admins to disable when last authentication provider", async () => { + const team = await buildTeam(); + const user = await buildAdmin({ teamId: team.id }); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.update", { + body: { + id: authenticationProviders[0].id, + isEnabled: false, + token: user.getJwtToken(), + }, + }); + + expect(res.status).toEqual(400); + }); + + it("should allow admins to disable", async () => { + const team = await buildTeam(); + const user = await buildAdmin({ teamId: team.id }); + await team.createAuthenticationProvider({ + name: "google", + providerId: uuid.v4(), + }); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.update", { + body: { + id: authenticationProviders[0].id, + isEnabled: false, + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.name).toBe("slack"); + expect(body.data.isEnabled).toBe(false); + expect(body.data.isConnected).toBe(true); + }); + + it("should require authorization", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.update", { + body: { + id: authenticationProviders[0].id, + isEnabled: false, + token: user.getJwtToken(), + }, + }); + expect(res.status).toEqual(403); + }); + + it("should require authentication", async () => { + const team = await buildTeam(); + const authenticationProviders = await team.getAuthenticationProviders(); + + const res = await server.post("/api/authenticationProviders.update", { + body: { + id: authenticationProviders[0].id, + isEnabled: false, + }, + }); + expect(res.status).toEqual(401); + }); +}); + +describe("#authenticationProviders.list", () => { + it("should return enabled and available auth providers", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + + const res = await server.post("/api/authenticationProviders.list", { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.authenticationProviders.length).toBe(2); + expect(body.data.authenticationProviders[0].name).toBe("slack"); + expect(body.data.authenticationProviders[0].isEnabled).toBe(true); + expect(body.data.authenticationProviders[0].isConnected).toBe(true); + expect(body.data.authenticationProviders[1].name).toBe("google"); + expect(body.data.authenticationProviders[1].isEnabled).toBe(false); + expect(body.data.authenticationProviders[1].isConnected).toBe(false); + }); + + it("should require authentication", async () => { + const res = await server.post("/api/authenticationProviders.list"); + expect(res.status).toEqual(401); + }); +}); diff --git a/server/api/index.js b/server/api/index.js index 1b273afa..e8d0cb73 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -10,6 +10,7 @@ import validation from "../middlewares/validation"; import apiKeys from "./apiKeys"; import attachments from "./attachments"; import auth from "./auth"; +import authenticationProviders from "./authenticationProviders"; import collections from "./collections"; import documents from "./documents"; import events from "./events"; @@ -45,6 +46,7 @@ api.use(editor()); // routes router.use("/", auth.routes()); +router.use("/", authenticationProviders.routes()); router.use("/", events.routes()); router.use("/", users.routes()); router.use("/", collections.routes()); diff --git a/server/auth/google.js b/server/auth/google.js deleted file mode 100644 index 8a100cf5..00000000 --- a/server/auth/google.js +++ /dev/null @@ -1,100 +0,0 @@ -// @flow -import passport from "@outlinewiki/koa-passport"; -import Router from "koa-router"; -import { capitalize } from "lodash"; -import { Strategy as GoogleStrategy } from "passport-google-oauth2"; -import accountProvisioner from "../commands/accountProvisioner"; -import env from "../env"; -import { - GoogleWorkspaceRequiredError, - GoogleWorkspaceInvalidError, -} from "../errors"; -import auth from "../middlewares/authentication"; -import passportMiddleware from "../middlewares/passport"; -import { getAllowedDomains } from "../utils/authentication"; -import { StateStore } from "../utils/passport"; - -const router = new Router(); -const providerName = "google"; -const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; -const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; -const allowedDomains = getAllowedDomains(); - -const scopes = [ - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", -]; - -export const config = { - name: "Google", - enabled: !!GOOGLE_CLIENT_ID, -}; - -if (GOOGLE_CLIENT_ID) { - passport.use( - new GoogleStrategy( - { - clientID: GOOGLE_CLIENT_ID, - clientSecret: GOOGLE_CLIENT_SECRET, - callbackURL: `${env.URL}/auth/google.callback`, - prompt: "select_account consent", - passReqToCallback: true, - store: new StateStore(), - scope: scopes, - }, - async function (req, accessToken, refreshToken, profile, done) { - try { - const domain = profile._json.hd; - - if (!domain) { - throw new GoogleWorkspaceRequiredError(); - } - - if (allowedDomains.length && !allowedDomains.includes(domain)) { - throw new GoogleWorkspaceInvalidError(); - } - - const subdomain = domain.split(".")[0]; - const teamName = capitalize(subdomain); - - const result = await accountProvisioner({ - ip: req.ip, - team: { - name: teamName, - domain, - subdomain, - }, - user: { - name: profile.displayName, - email: profile.email, - avatarUrl: profile.picture, - }, - authenticationProvider: { - name: providerName, - providerId: domain, - }, - authentication: { - providerId: profile.id, - accessToken, - refreshToken, - scopes, - }, - }); - return done(null, result.user, result); - } catch (err) { - return done(err, null); - } - } - ) - ); - - router.get("google", passport.authenticate(providerName)); - - router.get( - "google.callback", - auth({ required: false }), - passportMiddleware(providerName) - ); -} - -export default router; diff --git a/server/auth/index.js b/server/auth/index.js index 6939f3a8..6e08b475 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -9,7 +9,7 @@ import { AuthenticationError } from "../errors"; import auth from "../middlewares/authentication"; import validation from "../middlewares/validation"; import { Team } from "../models"; -import { requireDirectory } from "../utils/fs"; +import providers from "./providers"; const log = debug("server"); const app = new Koa(); @@ -17,15 +17,11 @@ const router = new Router(); router.use(passport.initialize()); -// dynamically load available authentication providers -requireDirectory(__dirname).forEach(([{ default: provider, config }]) => { - if (provider && provider.routes) { - if (!config) { - throw new Error("Auth providers must export a 'config' object"); - } - - router.use("/", provider.routes()); - log(`loaded ${config.name} auth provider`); +// dynamically load available authentication provider routes +providers.forEach((provider) => { + if (provider.enabled) { + router.use("/", provider.router.routes()); + log(`loaded ${provider.name} auth provider`); } }); diff --git a/server/auth/README.md b/server/auth/providers/README.md similarity index 82% rename from server/auth/README.md rename to server/auth/providers/README.md index 3da4f8fd..d0bbcf98 100644 --- a/server/auth/README.md +++ b/server/auth/providers/README.md @@ -8,5 +8,6 @@ Auth providers generally use [Passport](http://www.passportjs.org/) strategies, although they can use any custom logic if needed. See the `google` auth provider for the cleanest example of what is required – some rules: - The strategy name _must_ be lowercase -- The stragegy _must_ call the `accountProvisioner` command in the verify callback +- The strategy _must_ call the `accountProvisioner` command in the verify callback - The auth file _must_ export a `config` object with `name` and `enabled` keys +- The auth file _must_ have a default export with a koa-router \ No newline at end of file diff --git a/server/auth/email.js b/server/auth/providers/email.js similarity index 87% rename from server/auth/email.js rename to server/auth/providers/email.js index 59e22942..c586b1c7 100644 --- a/server/auth/email.js +++ b/server/auth/providers/email.js @@ -2,13 +2,13 @@ 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"; -import methodOverride from "../middlewares/methodOverride"; -import validation from "../middlewares/validation"; -import { User, Team } from "../models"; -import { getUserForEmailSigninToken } from "../utils/jwt"; +import { AuthorizationError } from "../../errors"; +import mailer from "../../mailer"; +import auth from "../../middlewares/authentication"; +import methodOverride from "../../middlewares/methodOverride"; +import validation from "../../middlewares/validation"; +import { User, Team } from "../../models"; +import { getUserForEmailSigninToken } from "../../utils/jwt"; const router = new Router(); diff --git a/server/auth/providers/google.js b/server/auth/providers/google.js new file mode 100644 index 00000000..71f124ac --- /dev/null +++ b/server/auth/providers/google.js @@ -0,0 +1,98 @@ +// @flow +import passport from "@outlinewiki/koa-passport"; +import Router from "koa-router"; +import { capitalize } from "lodash"; +import { Strategy as GoogleStrategy } from "passport-google-oauth2"; +import accountProvisioner from "../../commands/accountProvisioner"; +import env from "../../env"; +import { + GoogleWorkspaceRequiredError, + GoogleWorkspaceInvalidError, +} from "../../errors"; +import auth from "../../middlewares/authentication"; +import passportMiddleware from "../../middlewares/passport"; +import { getAllowedDomains } from "../../utils/authentication"; +import { StateStore } from "../../utils/passport"; + +const router = new Router(); +const providerName = "google"; +const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; +const allowedDomains = getAllowedDomains(); + +const scopes = [ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", +]; + +export const config = { + name: "Google", + enabled: !!GOOGLE_CLIENT_ID, +}; + +passport.use( + new GoogleStrategy( + { + clientID: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + callbackURL: `${env.URL}/auth/google.callback`, + prompt: "select_account consent", + passReqToCallback: true, + store: new StateStore(), + scope: scopes, + }, + async function (req, accessToken, refreshToken, profile, done) { + try { + const domain = profile._json.hd; + + if (!domain) { + throw new GoogleWorkspaceRequiredError(); + } + + if (allowedDomains.length && !allowedDomains.includes(domain)) { + throw new GoogleWorkspaceInvalidError(); + } + + const subdomain = domain.split(".")[0]; + const teamName = capitalize(subdomain); + + const result = await accountProvisioner({ + ip: req.ip, + team: { + name: teamName, + domain, + subdomain, + }, + user: { + name: profile.displayName, + email: profile.email, + avatarUrl: profile.picture, + }, + authenticationProvider: { + name: providerName, + providerId: domain, + }, + authentication: { + providerId: profile.id, + accessToken, + refreshToken, + scopes, + }, + }); + return done(null, result.user, result); + } catch (err) { + return done(err, null); + } + } + ) +); + +router.get("google", passport.authenticate(providerName)); + +router.get( + "google.callback", + auth({ required: false }), + passportMiddleware(providerName) +); + +export default router; diff --git a/server/auth/providers/index.js b/server/auth/providers/index.js new file mode 100644 index 00000000..75e1c2a5 --- /dev/null +++ b/server/auth/providers/index.js @@ -0,0 +1,37 @@ +// @flow +import { signin } from "../../../shared/utils/routeHelpers"; +import { requireDirectory } from "../../utils/fs"; + +let providers = []; + +requireDirectory(__dirname).forEach(([module, id]) => { + const { config, default: router } = module; + + if (id === "index") { + return; + } + + if (!config) { + throw new Error( + `Auth providers must export a 'config' object, missing in ${id}` + ); + } + + if (!router || !router.routes) { + throw new Error( + `Default export of an auth provider must be a koa-router, missing in ${id}` + ); + } + + if (config && config.enabled) { + providers.push({ + id, + name: config.name, + enabled: config.enabled, + authUrl: signin(id), + router: router, + }); + } +}); + +export default providers; diff --git a/server/auth/providers/slack.js b/server/auth/providers/slack.js new file mode 100644 index 00000000..15b3cc96 --- /dev/null +++ b/server/auth/providers/slack.js @@ -0,0 +1,196 @@ +// @flow +import passport from "@outlinewiki/koa-passport"; +import Router from "koa-router"; +import { Strategy as SlackStrategy } from "passport-slack-oauth2"; +import accountProvisioner from "../../commands/accountProvisioner"; +import env from "../../env"; +import auth from "../../middlewares/authentication"; +import passportMiddleware from "../../middlewares/passport"; +import { Authentication, Collection, Integration, Team } from "../../models"; +import * as Slack from "../../slack"; +import { StateStore } from "../../utils/passport"; + +const router = new Router(); +const providerName = "slack"; +const SLACK_CLIENT_ID = process.env.SLACK_KEY; +const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET; + +const scopes = [ + "identity.email", + "identity.basic", + "identity.avatar", + "identity.team", +]; + +export const config = { + name: "Slack", + enabled: !!SLACK_CLIENT_ID, +}; + +const strategy = new SlackStrategy( + { + clientID: SLACK_CLIENT_ID, + clientSecret: SLACK_CLIENT_SECRET, + callbackURL: `${env.URL}/auth/slack.callback`, + passReqToCallback: true, + store: new StateStore(), + scope: scopes, + }, + async function (req, accessToken, refreshToken, profile, done) { + try { + const result = await accountProvisioner({ + ip: req.ip, + team: { + name: profile.team.name, + subdomain: profile.team.domain, + avatarUrl: profile.team.image_230, + }, + user: { + name: profile.user.name, + email: profile.user.email, + avatarUrl: profile.user.image_192, + }, + authenticationProvider: { + name: providerName, + providerId: profile.team.id, + }, + authentication: { + providerId: profile.user.id, + accessToken, + refreshToken, + scopes, + }, + }); + return done(null, result.user, result); + } catch (err) { + return done(err, null); + } + } +); + +// For some reason the author made the strategy name capatilised, I don't know +// why but we need everything lowercase so we just monkey-patch it here. +strategy.name = providerName; +passport.use(strategy); + +router.get("slack", passport.authenticate(providerName)); + +router.get( + "slack.callback", + auth({ required: false }), + passportMiddleware(providerName) +); + +router.get("slack.commands", auth({ required: false }), async (ctx) => { + const { code, state, error } = ctx.request.query; + const user = ctx.state.user; + ctx.assertPresent(code || error, "code is required"); + + if (error) { + ctx.redirect(`/settings/integrations/slack?error=${error}`); + return; + } + + // this code block accounts for the root domain being unable to + // access authentication for subdomains. We must forward to the appropriate + // subdomain to complete the oauth flow + if (!user) { + if (state) { + try { + const team = await Team.findByPk(state); + return ctx.redirect( + `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}` + ); + } catch (err) { + return ctx.redirect( + `/settings/integrations/slack?error=unauthenticated` + ); + } + } else { + return ctx.redirect(`/settings/integrations/slack?error=unauthenticated`); + } + } + + const endpoint = `${process.env.URL || ""}/auth/slack.commands`; + const data = await Slack.oauthAccess(code, endpoint); + + const authentication = await Authentication.create({ + service: "slack", + userId: user.id, + teamId: user.teamId, + token: data.access_token, + scopes: data.scope.split(","), + }); + + await Integration.create({ + service: "slack", + type: "command", + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + settings: { + serviceTeamId: data.team_id, + }, + }); + + ctx.redirect("/settings/integrations/slack"); +}); + +router.get("slack.post", auth({ required: false }), async (ctx) => { + const { code, error, state } = ctx.request.query; + const user = ctx.state.user; + ctx.assertPresent(code || error, "code is required"); + + const collectionId = state; + ctx.assertUuid(collectionId, "collectionId must be an uuid"); + + if (error) { + ctx.redirect(`/settings/integrations/slack?error=${error}`); + return; + } + + // this code block accounts for the root domain being unable to + // access authentcation for subdomains. We must forward to the + // appropriate subdomain to complete the oauth flow + if (!user) { + try { + const collection = await Collection.findByPk(state); + const team = await Team.findByPk(collection.teamId); + return ctx.redirect( + `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}` + ); + } catch (err) { + return ctx.redirect(`/settings/integrations/slack?error=unauthenticated`); + } + } + + const endpoint = `${process.env.URL || ""}/auth/slack.post`; + const data = await Slack.oauthAccess(code, endpoint); + + const authentication = await Authentication.create({ + service: "slack", + userId: user.id, + teamId: user.teamId, + token: data.access_token, + scopes: data.scope.split(","), + }); + + await Integration.create({ + service: "slack", + type: "post", + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + collectionId, + events: [], + settings: { + url: data.incoming_webhook.url, + channel: data.incoming_webhook.channel, + channelId: data.incoming_webhook.channel_id, + }, + }); + + ctx.redirect("/settings/integrations/slack"); +}); + +export default router; diff --git a/server/auth/slack.js b/server/auth/slack.js deleted file mode 100644 index 350fabaf..00000000 --- a/server/auth/slack.js +++ /dev/null @@ -1,202 +0,0 @@ -// @flow -import passport from "@outlinewiki/koa-passport"; -import Router from "koa-router"; -import { Strategy as SlackStrategy } from "passport-slack-oauth2"; -import accountProvisioner from "../commands/accountProvisioner"; -import env from "../env"; -import auth from "../middlewares/authentication"; -import passportMiddleware from "../middlewares/passport"; -import { Authentication, Collection, Integration, Team } from "../models"; -import * as Slack from "../slack"; -import { StateStore } from "../utils/passport"; - -const router = new Router(); -const providerName = "slack"; -const SLACK_CLIENT_ID = process.env.SLACK_KEY; -const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET; - -const scopes = [ - "identity.email", - "identity.basic", - "identity.avatar", - "identity.team", -]; - -export const config = { - name: "Slack", - enabled: !!SLACK_CLIENT_ID, -}; - -if (SLACK_CLIENT_ID) { - const strategy = new SlackStrategy( - { - clientID: SLACK_CLIENT_ID, - clientSecret: SLACK_CLIENT_SECRET, - callbackURL: `${env.URL}/auth/slack.callback`, - passReqToCallback: true, - store: new StateStore(), - scope: scopes, - }, - async function (req, accessToken, refreshToken, profile, done) { - try { - const result = await accountProvisioner({ - ip: req.ip, - team: { - name: profile.team.name, - subdomain: profile.team.domain, - avatarUrl: profile.team.image_230, - }, - user: { - name: profile.user.name, - email: profile.user.email, - avatarUrl: profile.user.image_192, - }, - authenticationProvider: { - name: providerName, - providerId: profile.team.id, - }, - authentication: { - providerId: profile.user.id, - accessToken, - refreshToken, - scopes, - }, - }); - return done(null, result.user, result); - } catch (err) { - return done(err, null); - } - } - ); - - // For some reason the author made the strategy name capatilised, I don't know - // why but we need everything lowercase so we just monkey-patch it here. - strategy.name = providerName; - passport.use(strategy); - - router.get("slack", passport.authenticate(providerName)); - - router.get( - "slack.callback", - auth({ required: false }), - passportMiddleware(providerName) - ); - - router.get("slack.commands", auth({ required: false }), async (ctx) => { - const { code, state, error } = ctx.request.query; - const user = ctx.state.user; - ctx.assertPresent(code || error, "code is required"); - - if (error) { - ctx.redirect(`/settings/integrations/slack?error=${error}`); - return; - } - - // this code block accounts for the root domain being unable to - // access authentication for subdomains. We must forward to the appropriate - // subdomain to complete the oauth flow - if (!user) { - if (state) { - try { - const team = await Team.findByPk(state); - return ctx.redirect( - `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}` - ); - } catch (err) { - return ctx.redirect( - `/settings/integrations/slack?error=unauthenticated` - ); - } - } else { - return ctx.redirect( - `/settings/integrations/slack?error=unauthenticated` - ); - } - } - - const endpoint = `${process.env.URL || ""}/auth/slack.commands`; - const data = await Slack.oauthAccess(code, endpoint); - - const authentication = await Authentication.create({ - service: "slack", - userId: user.id, - teamId: user.teamId, - token: data.access_token, - scopes: data.scope.split(","), - }); - - await Integration.create({ - service: "slack", - type: "command", - userId: user.id, - teamId: user.teamId, - authenticationId: authentication.id, - settings: { - serviceTeamId: data.team_id, - }, - }); - - ctx.redirect("/settings/integrations/slack"); - }); - - router.get("slack.post", auth({ required: false }), async (ctx) => { - const { code, error, state } = ctx.request.query; - const user = ctx.state.user; - ctx.assertPresent(code || error, "code is required"); - - const collectionId = state; - ctx.assertUuid(collectionId, "collectionId must be an uuid"); - - if (error) { - ctx.redirect(`/settings/integrations/slack?error=${error}`); - return; - } - - // this code block accounts for the root domain being unable to - // access authentcation for subdomains. We must forward to the - // appropriate subdomain to complete the oauth flow - if (!user) { - try { - const collection = await Collection.findByPk(state); - const team = await Team.findByPk(collection.teamId); - return ctx.redirect( - `${team.url}/auth${ctx.request.path}?${ctx.request.querystring}` - ); - } catch (err) { - return ctx.redirect( - `/settings/integrations/slack?error=unauthenticated` - ); - } - } - - const endpoint = `${process.env.URL || ""}/auth/slack.post`; - const data = await Slack.oauthAccess(code, endpoint); - - const authentication = await Authentication.create({ - service: "slack", - userId: user.id, - teamId: user.teamId, - token: data.access_token, - scopes: data.scope.split(","), - }); - - await Integration.create({ - service: "slack", - type: "post", - userId: user.id, - teamId: user.teamId, - authenticationId: authentication.id, - collectionId, - events: [], - settings: { - url: data.incoming_webhook.url, - channel: data.incoming_webhook.channel, - channelId: data.incoming_webhook.channel_id, - }, - }); - - ctx.redirect("/settings/integrations/slack"); - }); -} - -export default router; diff --git a/server/models/AuthenticationProvider.js b/server/models/AuthenticationProvider.js index 2d1b3545..6087704f 100644 --- a/server/models/AuthenticationProvider.js +++ b/server/models/AuthenticationProvider.js @@ -1,19 +1,7 @@ // @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", "")); +import providers from "../auth/providers"; +import { ValidationError } from "../errors"; +import { DataTypes, Op, sequelize } from "../sequelize"; const AuthenticationProvider = sequelize.define( "authentication_providers", @@ -26,7 +14,7 @@ const AuthenticationProvider = sequelize.define( name: { type: DataTypes.STRING, validate: { - isIn: [authProviders], + isIn: [providers.map((p) => p.id)], }, }, enabled: { @@ -48,4 +36,29 @@ AuthenticationProvider.associate = (models) => { AuthenticationProvider.hasMany(models.UserAuthentication); }; +AuthenticationProvider.prototype.disable = async function () { + const res = await AuthenticationProvider.findAndCountAll({ + where: { + teamId: this.teamId, + enabled: true, + id: { + [Op.ne]: this.id, + }, + }, + limit: 1, + }); + + if (res.count >= 1) { + return this.update({ enabled: false }); + } else { + throw new ValidationError( + "At least one authentication provider is required" + ); + } +}; + +AuthenticationProvider.prototype.enable = async function () { + return this.update({ enabled: true }); +}; + export default AuthenticationProvider; diff --git a/server/models/Event.js b/server/models/Event.js index 899e6cdf..c677475a 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -72,6 +72,7 @@ Event.ACTIVITY_EVENTS = [ Event.AUDIT_EVENTS = [ "api_keys.create", "api_keys.delete", + "authenticationProviders.update", "collections.create", "collections.update", "collections.move", diff --git a/server/policies/authenticationProvider.js b/server/policies/authenticationProvider.js new file mode 100644 index 00000000..ba24d263 --- /dev/null +++ b/server/policies/authenticationProvider.js @@ -0,0 +1,31 @@ +// @flow +import { AdminRequiredError } from "../errors"; +import { AuthenticationProvider, User, Team } from "../models"; +import policy from "./policy"; + +const { allow } = policy; + +allow(User, "createAuthenticationProvider", Team, (actor, team) => { + if (!team || actor.teamId !== team.id) return false; + if (actor.isAdmin) return true; + throw new AdminRequiredError(); +}); + +allow( + User, + "read", + AuthenticationProvider, + (actor, authenticationProvider) => + actor && actor.teamId === authenticationProvider.teamId +); + +allow( + User, + ["update", "delete"], + AuthenticationProvider, + (actor, authenticationProvider) => { + if (actor.teamId !== authenticationProvider.teamId) return false; + if (actor.isAdmin) return true; + throw new AdminRequiredError(); + } +); diff --git a/server/policies/index.js b/server/policies/index.js index cb5df353..0aebbb31 100644 --- a/server/policies/index.js +++ b/server/policies/index.js @@ -3,6 +3,7 @@ import { Attachment, Team, User, Collection, Document, Group } from "../models"; import policy from "./policy"; import "./apiKey"; import "./attachment"; +import "./authenticationProvider"; import "./collection"; import "./document"; import "./integration"; diff --git a/server/presenters/authenticationProvider.js b/server/presenters/authenticationProvider.js new file mode 100644 index 00000000..b7aa834d --- /dev/null +++ b/server/presenters/authenticationProvider.js @@ -0,0 +1,14 @@ +// @flow +import { AuthenticationProvider } from "../models"; + +export default function present( + authenticationProvider: AuthenticationProvider +) { + return { + id: authenticationProvider.id, + name: authenticationProvider.name, + createdAt: authenticationProvider.createdAt, + isEnabled: authenticationProvider.enabled, + isConnected: true, + }; +} diff --git a/server/presenters/index.js b/server/presenters/index.js index a6af44a1..fcfa2dcc 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -1,5 +1,6 @@ // @flow import presentApiKey from "./apiKey"; +import presentAuthenticationProvider from "./authenticationProvider"; import presentCollection from "./collection"; import presentCollectionGroupMembership from "./collectionGroupMembership"; import presentDocument from "./document"; @@ -18,13 +19,14 @@ import presentUser from "./user"; import presentView from "./view"; export { + presentApiKey, + presentAuthenticationProvider, presentUser, presentView, presentDocument, presentEvent, presentRevision, presentCollection, - presentApiKey, presentShare, presentTeam, presentGroup,