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:
Tom Moor 2020-11-04 19:54:04 -08:00 committed by GitHub
parent 3d09c8f655
commit 1b6a986986
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 136 additions and 32 deletions

View File

@ -21,9 +21,14 @@ const Authenticated = observer(({ auth, children }: Props) => {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a subdomain that doesn't match the
// currently authenticated team then kick the user to the teams subdomain.
if (
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&

View File

@ -12,6 +12,7 @@ class Team extends BaseModel {
documentEmbeds: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
url: string;
@computed

View File

@ -6,6 +6,7 @@ import { signin } from "../../shared/utils/routeHelpers";
import auth from "../middlewares/authentication";
import { Team } from "../models";
import { presentUser, presentTeam, presentPolicies } from "../presenters";
import { isCustomDomain } from "../utils/domains";
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
// for a custom screen showing only relevant signin options for that team.
if (
process.env.SUBDOMAINS_ENABLED === "true" &&
isCustomSubdomain(ctx.request.hostname)
isCustomSubdomain(ctx.request.hostname) &&
!isCustomDomain(ctx.request.hostname)
) {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;

View File

@ -3,10 +3,10 @@ import addMonths from "date-fns/add_months";
import Koa from "koa";
import bodyParser from "koa-body";
import Router from "koa-router";
import { AuthenticationError } from "../errors";
import auth from "../middlewares/authentication";
import validation from "../middlewares/validation";
import { Team } from "../models";
import { getCookieDomain } from "../utils/domains";
import email from "./email";
import google from "./google";
@ -21,23 +21,20 @@ router.use("/", email.routes());
router.get("/redirect", auth(), async (ctx) => {
const user = ctx.state.user;
// transfer access token cookie from root to subdomain
const rootToken = ctx.cookies.get("accessToken");
const jwtToken = user.getJwtToken();
if (rootToken === jwtToken) {
ctx.cookies.set("accessToken", undefined, {
httpOnly: true,
domain: getCookieDomain(ctx.request.hostname),
});
ctx.cookies.set("accessToken", jwtToken, {
httpOnly: false,
expires: addMonths(new Date(), 3),
});
if (jwtToken === ctx.params.token) {
throw new AuthenticationError("Cannot extend token");
}
// 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);
ctx.redirect(`${team.url}/home`);
});

View File

@ -1,5 +1,4 @@
// @flow
import addMinutes from "date-fns/add_minutes";
import addMonths from "date-fns/add_months";
import JWT from "jsonwebtoken";
import { AuthenticationError, UserSuspendedError } from "../errors";
@ -62,7 +61,15 @@ export default function auth(options?: { required?: boolean } = {}) {
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) {
throw new AuthenticationError("Invalid API key");
}
@ -134,12 +141,9 @@ export default function auth(options?: { required?: boolean } = {}) {
domain,
});
ctx.cookies.set("accessToken", user.getJwtToken(), {
httpOnly: true,
expires: addMinutes(new Date(), 1),
domain,
});
ctx.redirect(`${team.url}/auth/redirect`);
ctx.redirect(
`${team.url}/auth/redirect?token=${user.getTransferToken()}`
);
} else {
ctx.cookies.set("accessToken", user.getJwtToken(), {
httpOnly: false,

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

View File

@ -47,6 +47,11 @@ const Team = sequelize.define(
},
unique: true,
},
domain: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
},
slackId: { type: DataTypes.STRING, allowNull: true },
googleId: { type: DataTypes.STRING, allowNull: true },
avatarUrl: { type: DataTypes.STRING, allowNull: true },
@ -66,6 +71,9 @@ const Team = sequelize.define(
{
getterMethods: {
url() {
if (this.domain) {
return `https://${this.domain}`;
}
if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") {
return process.env.URL;
}

View File

@ -1,5 +1,6 @@
// @flow
import crypto from "crypto";
import addMinutes from "date-fns/add_minutes";
import subMinutes from "date-fns/sub_minutes";
import JWT from "jsonwebtoken";
import uuid from "uuid";
@ -91,12 +92,12 @@ User.prototype.collectionIds = async function (options = {}) {
.map((c) => c.id);
};
User.prototype.updateActiveAt = function (ip) {
User.prototype.updateActiveAt = function (ip, force = false) {
const fiveMinutesAgo = subMinutes(new Date(), 5);
// ensure this is updated only every few minutes otherwise
// 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.lastActiveIp = ip;
return this.save({ hooks: false });
@ -109,17 +110,42 @@ User.prototype.updateSignedIn = function (ip) {
return this.save({ hooks: false });
};
User.prototype.getJwtToken = function () {
return JWT.sign({ id: this.id }, this.jwtSecret);
// Returns a session token that is used to make API requests and is stored
// 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 () {
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() },
{ id: this.id, createdAt: new Date().toISOString(), type: "email-signin" },
this.jwtSecret
);
};

View File

@ -12,6 +12,7 @@ export default function present(team: Team) {
documentEmbeds: team.documentEmbeds,
guestSignin: team.guestSignin,
subdomain: team.subdomain,
domain: team.domain,
url: team.url,
};
}

View File

@ -1,8 +1,16 @@
// @flow
import { stripSubdomain } from "../../shared/utils/domains";
import { parseDomain, stripSubdomain } from "../../shared/utils/domains";
export function getCookieDomain(domain: string) {
return process.env.SUBDOMAINS_ENABLED === "true"
? stripSubdomain(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)
);
}

View File

@ -20,8 +20,24 @@ function getJWTPayload(token) {
export async function getUserForJWT(token: string): Promise<User> {
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);
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 {
JWT.verify(token, user.jwtSecret);
} catch (err) {
@ -34,6 +50,10 @@ export async function getUserForJWT(token: string): Promise<User> {
export async function getUserForEmailSigninToken(token: string): Promise<User> {
const payload = getJWTPayload(token);
if (payload.type !== "email-signin") {
throw new AuthenticationError("Invalid token");
}
// check the token is within it's expiration time
if (payload.createdAt) {
if (new Date(payload.createdAt) < subMinutes(new Date(), 10)) {