chore: Refactor authentication pass between subdomains (#1619)
* fix: Use get request instead of cookie to transfer token between domains * Add domain to database Add redirects to team domain when present * 30s -> 1m * fix: Avoid redirect loop if subdomain and domain set * fix: Create a transfer specific token to prevent replay requests * refactor: Move isCustomDomain out of shared as it won't work on the client
This commit is contained in:
@ -21,9 +21,14 @@ const Authenticated = observer(({ auth, children }: Props) => {
|
|||||||
return <LoadingIndicator />;
|
return <LoadingIndicator />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're authenticated but viewing a subdomain that doesn't match the
|
// If we're authenticated but viewing a domain that doesn't match the
|
||||||
// currently authenticated team then kick the user to the teams subdomain.
|
// current team then kick the user to the teams correct domain.
|
||||||
if (
|
if (team.domain) {
|
||||||
|
if (team.domain !== hostname) {
|
||||||
|
window.location.href = `${team.url}${window.location.pathname}`;
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
env.SUBDOMAINS_ENABLED &&
|
env.SUBDOMAINS_ENABLED &&
|
||||||
team.subdomain &&
|
team.subdomain &&
|
||||||
isCustomSubdomain(hostname) &&
|
isCustomSubdomain(hostname) &&
|
||||||
|
@ -12,6 +12,7 @@ class Team extends BaseModel {
|
|||||||
documentEmbeds: boolean;
|
documentEmbeds: boolean;
|
||||||
guestSignin: boolean;
|
guestSignin: boolean;
|
||||||
subdomain: ?string;
|
subdomain: ?string;
|
||||||
|
domain: ?string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
@ -6,6 +6,7 @@ import { signin } from "../../shared/utils/routeHelpers";
|
|||||||
import auth from "../middlewares/authentication";
|
import auth from "../middlewares/authentication";
|
||||||
import { Team } from "../models";
|
import { Team } from "../models";
|
||||||
import { presentUser, presentTeam, presentPolicies } from "../presenters";
|
import { presentUser, presentTeam, presentPolicies } from "../presenters";
|
||||||
|
import { isCustomDomain } from "../utils/domains";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
@ -68,11 +69,29 @@ router.post("auth.config", async (ctx) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCustomDomain(ctx.request.hostname)) {
|
||||||
|
const team = await Team.findOne({
|
||||||
|
where: { domain: ctx.request.hostname },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
ctx.body = {
|
||||||
|
data: {
|
||||||
|
name: team.name,
|
||||||
|
hostname: ctx.request.hostname,
|
||||||
|
services: filterServices(team),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If subdomain signin page then we return minimal team details to allow
|
// If subdomain signin page then we return minimal team details to allow
|
||||||
// for a custom screen showing only relevant signin options for that team.
|
// for a custom screen showing only relevant signin options for that team.
|
||||||
if (
|
if (
|
||||||
process.env.SUBDOMAINS_ENABLED === "true" &&
|
process.env.SUBDOMAINS_ENABLED === "true" &&
|
||||||
isCustomSubdomain(ctx.request.hostname)
|
isCustomSubdomain(ctx.request.hostname) &&
|
||||||
|
!isCustomDomain(ctx.request.hostname)
|
||||||
) {
|
) {
|
||||||
const domain = parseDomain(ctx.request.hostname);
|
const domain = parseDomain(ctx.request.hostname);
|
||||||
const subdomain = domain ? domain.subdomain : undefined;
|
const subdomain = domain ? domain.subdomain : undefined;
|
||||||
|
@ -3,10 +3,10 @@ import addMonths from "date-fns/add_months";
|
|||||||
import Koa from "koa";
|
import Koa from "koa";
|
||||||
import bodyParser from "koa-body";
|
import bodyParser from "koa-body";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { AuthenticationError } from "../errors";
|
||||||
import auth from "../middlewares/authentication";
|
import auth from "../middlewares/authentication";
|
||||||
import validation from "../middlewares/validation";
|
import validation from "../middlewares/validation";
|
||||||
import { Team } from "../models";
|
import { Team } from "../models";
|
||||||
import { getCookieDomain } from "../utils/domains";
|
|
||||||
|
|
||||||
import email from "./email";
|
import email from "./email";
|
||||||
import google from "./google";
|
import google from "./google";
|
||||||
@ -21,23 +21,20 @@ router.use("/", email.routes());
|
|||||||
|
|
||||||
router.get("/redirect", auth(), async (ctx) => {
|
router.get("/redirect", auth(), async (ctx) => {
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
|
||||||
// transfer access token cookie from root to subdomain
|
|
||||||
const rootToken = ctx.cookies.get("accessToken");
|
|
||||||
const jwtToken = user.getJwtToken();
|
const jwtToken = user.getJwtToken();
|
||||||
|
|
||||||
if (rootToken === jwtToken) {
|
if (jwtToken === ctx.params.token) {
|
||||||
ctx.cookies.set("accessToken", undefined, {
|
throw new AuthenticationError("Cannot extend token");
|
||||||
httpOnly: true,
|
|
||||||
domain: getCookieDomain(ctx.request.hostname),
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.cookies.set("accessToken", jwtToken, {
|
|
||||||
httpOnly: false,
|
|
||||||
expires: addMonths(new Date(), 3),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensure that the lastActiveAt on user is updated to prevent replay requests
|
||||||
|
await user.updateActiveAt(ctx.request.ip, true);
|
||||||
|
|
||||||
|
ctx.cookies.set("accessToken", jwtToken, {
|
||||||
|
httpOnly: false,
|
||||||
|
expires: addMonths(new Date(), 3),
|
||||||
|
});
|
||||||
|
|
||||||
const team = await Team.findByPk(user.teamId);
|
const team = await Team.findByPk(user.teamId);
|
||||||
ctx.redirect(`${team.url}/home`);
|
ctx.redirect(`${team.url}/home`);
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import addMinutes from "date-fns/add_minutes";
|
|
||||||
import addMonths from "date-fns/add_months";
|
import addMonths from "date-fns/add_months";
|
||||||
import JWT from "jsonwebtoken";
|
import JWT from "jsonwebtoken";
|
||||||
import { AuthenticationError, UserSuspendedError } from "../errors";
|
import { AuthenticationError, UserSuspendedError } from "../errors";
|
||||||
@ -62,7 +61,15 @@ export default function auth(options?: { required?: boolean } = {}) {
|
|||||||
throw new AuthenticationError("Invalid API key");
|
throw new AuthenticationError("Invalid API key");
|
||||||
}
|
}
|
||||||
|
|
||||||
user = await User.findByPk(apiKey.userId);
|
user = await User.findByPk(apiKey.userId, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Team,
|
||||||
|
as: "team",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError("Invalid API key");
|
throw new AuthenticationError("Invalid API key");
|
||||||
}
|
}
|
||||||
@ -134,12 +141,9 @@ export default function auth(options?: { required?: boolean } = {}) {
|
|||||||
domain,
|
domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.cookies.set("accessToken", user.getJwtToken(), {
|
ctx.redirect(
|
||||||
httpOnly: true,
|
`${team.url}/auth/redirect?token=${user.getTransferToken()}`
|
||||||
expires: addMinutes(new Date(), 1),
|
);
|
||||||
domain,
|
|
||||||
});
|
|
||||||
ctx.redirect(`${team.url}/auth/redirect`);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.cookies.set("accessToken", user.getJwtToken(), {
|
ctx.cookies.set("accessToken", user.getJwtToken(), {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
|
15
server/migrations/20201103050534-custom-domains.js
Normal file
15
server/migrations/20201103050534-custom-domains.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
up: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.addColumn('teams', 'domain', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
unique: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
down: async (queryInterface, Sequelize) => {
|
||||||
|
await queryInterface.removeColumn('teams', 'domain');
|
||||||
|
}
|
||||||
|
};
|
@ -47,6 +47,11 @@ const Team = sequelize.define(
|
|||||||
},
|
},
|
||||||
unique: true,
|
unique: true,
|
||||||
},
|
},
|
||||||
|
domain: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
slackId: { type: DataTypes.STRING, allowNull: true },
|
slackId: { type: DataTypes.STRING, allowNull: true },
|
||||||
googleId: { type: DataTypes.STRING, allowNull: true },
|
googleId: { type: DataTypes.STRING, allowNull: true },
|
||||||
avatarUrl: { type: DataTypes.STRING, allowNull: true },
|
avatarUrl: { type: DataTypes.STRING, allowNull: true },
|
||||||
@ -66,6 +71,9 @@ const Team = sequelize.define(
|
|||||||
{
|
{
|
||||||
getterMethods: {
|
getterMethods: {
|
||||||
url() {
|
url() {
|
||||||
|
if (this.domain) {
|
||||||
|
return `https://${this.domain}`;
|
||||||
|
}
|
||||||
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
|
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
|
||||||
return process.env.URL;
|
return process.env.URL;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
import addMinutes from "date-fns/add_minutes";
|
||||||
import subMinutes from "date-fns/sub_minutes";
|
import subMinutes from "date-fns/sub_minutes";
|
||||||
import JWT from "jsonwebtoken";
|
import JWT from "jsonwebtoken";
|
||||||
import uuid from "uuid";
|
import uuid from "uuid";
|
||||||
@ -91,12 +92,12 @@ User.prototype.collectionIds = async function (options = {}) {
|
|||||||
.map((c) => c.id);
|
.map((c) => c.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
User.prototype.updateActiveAt = function (ip) {
|
User.prototype.updateActiveAt = function (ip, force = false) {
|
||||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||||
|
|
||||||
// ensure this is updated only every few minutes otherwise
|
// ensure this is updated only every few minutes otherwise
|
||||||
// we'll be constantly writing to the DB as API requests happen
|
// we'll be constantly writing to the DB as API requests happen
|
||||||
if (this.lastActiveAt < fiveMinutesAgo) {
|
if (this.lastActiveAt < fiveMinutesAgo || force) {
|
||||||
this.lastActiveAt = new Date();
|
this.lastActiveAt = new Date();
|
||||||
this.lastActiveIp = ip;
|
this.lastActiveIp = ip;
|
||||||
return this.save({ hooks: false });
|
return this.save({ hooks: false });
|
||||||
@ -109,17 +110,42 @@ User.prototype.updateSignedIn = function (ip) {
|
|||||||
return this.save({ hooks: false });
|
return this.save({ hooks: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
User.prototype.getJwtToken = function () {
|
// Returns a session token that is used to make API requests and is stored
|
||||||
return JWT.sign({ id: this.id }, this.jwtSecret);
|
// in the client browser cookies to remain logged in.
|
||||||
|
User.prototype.getJwtToken = function (expiresAt?: Date) {
|
||||||
|
return JWT.sign(
|
||||||
|
{
|
||||||
|
id: this.id,
|
||||||
|
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||||
|
type: "session",
|
||||||
|
},
|
||||||
|
this.jwtSecret
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Returns a temporary token that is only used for transferring a session
|
||||||
|
// between subdomains or domains. It has a short expiry and can only be used once
|
||||||
|
User.prototype.getTransferToken = function () {
|
||||||
|
return JWT.sign(
|
||||||
|
{
|
||||||
|
id: this.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
||||||
|
type: "transfer",
|
||||||
|
},
|
||||||
|
this.jwtSecret
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 () {
|
User.prototype.getEmailSigninToken = function () {
|
||||||
if (this.service && this.service !== "email") {
|
if (this.service && this.service !== "email") {
|
||||||
throw new Error("Cannot generate email signin token for OAuth user");
|
throw new Error("Cannot generate email signin token for OAuth user");
|
||||||
}
|
}
|
||||||
|
|
||||||
return JWT.sign(
|
return JWT.sign(
|
||||||
{ id: this.id, createdAt: new Date().toISOString() },
|
{ id: this.id, createdAt: new Date().toISOString(), type: "email-signin" },
|
||||||
this.jwtSecret
|
this.jwtSecret
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -12,6 +12,7 @@ export default function present(team: Team) {
|
|||||||
documentEmbeds: team.documentEmbeds,
|
documentEmbeds: team.documentEmbeds,
|
||||||
guestSignin: team.guestSignin,
|
guestSignin: team.guestSignin,
|
||||||
subdomain: team.subdomain,
|
subdomain: team.subdomain,
|
||||||
|
domain: team.domain,
|
||||||
url: team.url,
|
url: team.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,16 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { stripSubdomain } from "../../shared/utils/domains";
|
import { parseDomain, stripSubdomain } from "../../shared/utils/domains";
|
||||||
|
|
||||||
export function getCookieDomain(domain: string) {
|
export function getCookieDomain(domain: string) {
|
||||||
return process.env.SUBDOMAINS_ENABLED === "true"
|
return process.env.SUBDOMAINS_ENABLED === "true"
|
||||||
? stripSubdomain(domain)
|
? stripSubdomain(domain)
|
||||||
: domain;
|
: domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCustomDomain(hostname: string) {
|
||||||
|
const parsed = parseDomain(hostname);
|
||||||
|
const main = parseDomain(process.env.URL);
|
||||||
|
return (
|
||||||
|
parsed && main && (main.domain !== parsed.domain || main.tld !== parsed.tld)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -20,8 +20,24 @@ function getJWTPayload(token) {
|
|||||||
|
|
||||||
export async function getUserForJWT(token: string): Promise<User> {
|
export async function getUserForJWT(token: string): Promise<User> {
|
||||||
const payload = getJWTPayload(token);
|
const payload = getJWTPayload(token);
|
||||||
|
|
||||||
|
// check the token is within it's expiration time
|
||||||
|
if (payload.expiresAt) {
|
||||||
|
if (new Date(payload.expiresAt) < new Date()) {
|
||||||
|
throw new AuthenticationError("Expired token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await User.findByPk(payload.id);
|
const user = await User.findByPk(payload.id);
|
||||||
|
|
||||||
|
if (payload.type === "transfer") {
|
||||||
|
// If the user has made a single API request since the transfer token was
|
||||||
|
// created then it's no longer valid, they'll need to sign in again.
|
||||||
|
if (user.lastActiveAt > new Date(payload.createdAt)) {
|
||||||
|
throw new AuthenticationError("Token has already been used");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JWT.verify(token, user.jwtSecret);
|
JWT.verify(token, user.jwtSecret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -34,6 +50,10 @@ export async function getUserForJWT(token: string): Promise<User> {
|
|||||||
export async function getUserForEmailSigninToken(token: string): Promise<User> {
|
export async function getUserForEmailSigninToken(token: string): Promise<User> {
|
||||||
const payload = getJWTPayload(token);
|
const payload = getJWTPayload(token);
|
||||||
|
|
||||||
|
if (payload.type !== "email-signin") {
|
||||||
|
throw new AuthenticationError("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
// check the token is within it's expiration time
|
// check the token is within it's expiration time
|
||||||
if (payload.createdAt) {
|
if (payload.createdAt) {
|
||||||
if (new Date(payload.createdAt) < subMinutes(new Date(), 10)) {
|
if (new Date(payload.createdAt) < subMinutes(new Date(), 10)) {
|
||||||
|
Reference in New Issue
Block a user