fix: Improve handling of suspended users signing in with email (#2012)

* chore: Separate signin/auth middleware
fix: Email signin token parsed by JWT middleware
fix: Email signin marked as active when logging in as suspended
fix: Suspended email signin correctly redirected to login screen
closes #1740

* refactor middleware -> lib

* lint
This commit is contained in:
Tom Moor 2021-04-08 20:40:04 -07:00 committed by GitHub
parent 1a889e9913
commit 190f0b6dc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 96 additions and 109 deletions

View File

@ -4,10 +4,10 @@ import Router from "koa-router";
import { find } from "lodash"; import { find } from "lodash";
import { AuthorizationError } from "../../errors"; import { AuthorizationError } from "../../errors";
import mailer from "../../mailer"; import mailer from "../../mailer";
import auth from "../../middlewares/authentication";
import methodOverride from "../../middlewares/methodOverride"; import methodOverride from "../../middlewares/methodOverride";
import validation from "../../middlewares/validation"; import validation from "../../middlewares/validation";
import { User, Team } from "../../models"; import { User, Team } from "../../models";
import { signIn } from "../../utils/authentication";
import { getUserForEmailSigninToken } from "../../utils/jwt"; import { getUserForEmailSigninToken } from "../../utils/jwt";
const router = new Router(); const router = new Router();
@ -84,25 +84,26 @@ router.post("email", async (ctx) => {
}; };
}); });
router.get("email.callback", auth({ required: false }), async (ctx) => { router.get("email.callback", async (ctx) => {
const { token } = ctx.request.query; const { token } = ctx.request.query;
ctx.assertPresent(token, "token is required"); ctx.assertPresent(token, "token is required");
try { try {
const user = await getUserForEmailSigninToken(token); const user = await getUserForEmailSigninToken(token);
if (!user.team.guestSignin) {
const team = await Team.findByPk(user.teamId); return ctx.redirect("/?notice=auth-error");
if (!team.guestSignin) { }
throw new AuthorizationError(); if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
} }
await user.update({ lastActiveAt: new Date() }); await user.update({ lastActiveAt: new Date() });
// set cookies on response and redirect to team subdomain // set cookies on response and redirect to team subdomain
ctx.signIn(user, team, "email", false); signIn(ctx, user, user.team, "email", false);
} catch (err) { } catch (err) {
ctx.redirect(`${process.env.URL}?notice=expired-token`); ctx.redirect(`/?notice=expired-token`);
} }
}); });

View File

@ -9,7 +9,6 @@ import {
GoogleWorkspaceRequiredError, GoogleWorkspaceRequiredError,
GoogleWorkspaceInvalidError, GoogleWorkspaceInvalidError,
} from "../../errors"; } from "../../errors";
import auth from "../../middlewares/authentication";
import passportMiddleware from "../../middlewares/passport"; import passportMiddleware from "../../middlewares/passport";
import { getAllowedDomains } from "../../utils/authentication"; import { getAllowedDomains } from "../../utils/authentication";
import { StateStore } from "../../utils/passport"; import { StateStore } from "../../utils/passport";
@ -90,11 +89,7 @@ if (GOOGLE_CLIENT_ID) {
router.get("google", passport.authenticate(providerName)); router.get("google", passport.authenticate(providerName));
router.get( router.get("google.callback", passportMiddleware(providerName));
"google.callback",
auth({ required: false }),
passportMiddleware(providerName)
);
} }
export default router; export default router;

View File

@ -76,11 +76,7 @@ if (SLACK_CLIENT_ID) {
router.get("slack", passport.authenticate(providerName)); router.get("slack", passport.authenticate(providerName));
router.get( router.get("slack.callback", passportMiddleware(providerName));
"slack.callback",
auth({ required: false }),
passportMiddleware(providerName)
);
router.get("slack.commands", auth({ required: false }), async (ctx) => { router.get("slack.commands", auth({ required: false }), async (ctx) => {
const { code, state, error } = ctx.request.query; const { code, state, error } = ctx.request.query;

View File

@ -1,10 +1,7 @@
// @flow // @flow
import addMonths from "date-fns/add_months";
import JWT from "jsonwebtoken";
import { AuthenticationError, UserSuspendedError } from "../errors"; import { AuthenticationError, UserSuspendedError } from "../errors";
import { User, Event, Team, ApiKey } from "../models"; import { User, Team, ApiKey } from "../models";
import type { ContextWithState } from "../types"; import type { ContextWithState } from "../types";
import { getCookieDomain } from "../utils/domains";
import { getUserForJWT } from "../utils/jwt"; import { getUserForJWT } from "../utils/jwt";
export default function auth(options?: { required?: boolean } = {}) { export default function auth(options?: { required?: boolean } = {}) {
@ -94,78 +91,6 @@ export default function auth(options?: { required?: boolean } = {}) {
ctx.state.user = user; ctx.state.user = user;
} }
ctx.signIn = (user: User, team: Team, service, isFirstSignin = false) => {
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
}
// update the database when the user last signed in
user.updateSignedIn(ctx.request.ip);
// don't await event creation for a faster sign-in
Event.create({
name: "users.signin",
actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service,
},
ip: ctx.request.ip,
});
const domain = getCookieDomain(ctx.request.hostname);
const expires = addMonths(new Date(), 3);
// set a cookie for which service we last signed in with. This is
// only used to display a UI hint for the user for next time
ctx.cookies.set("lastSignedIn", service, {
httpOnly: false,
expires: new Date("2100"),
domain,
});
// set a transfer cookie for the access token itself and redirect
// to the teams subdomain if subdomains are enabled
if (process.env.SUBDOMAINS_ENABLED === "true" && team.subdomain) {
// get any existing sessions (teams signed in) and add this team
const existing = JSON.parse(
decodeURIComponent(ctx.cookies.get("sessions") || "") || "{}"
);
const sessions = encodeURIComponent(
JSON.stringify({
...existing,
[team.id]: {
name: team.name,
logoUrl: team.logoUrl,
url: team.url,
},
})
);
ctx.cookies.set("sessions", sessions, {
httpOnly: false,
expires,
domain,
});
ctx.redirect(
`${team.url}/auth/redirect?token=${user.getTransferToken()}`
);
} else {
ctx.cookies.set("accessToken", user.getJwtToken(), {
httpOnly: false,
expires,
});
ctx.redirect(`${team.url}/home${isFirstSignin ? "?welcome" : ""}`);
}
};
return next(); return next();
}; };
} }
// Export JWT methods as a convenience
export const sign = JWT.sign;
export const verify = JWT.verify;
export const decode = JWT.decode;

View File

@ -1,10 +1,11 @@
// @flow // @flow
import passport from "@outlinewiki/koa-passport"; import passport from "@outlinewiki/koa-passport";
import { type Context } from "koa";
import type { AccountProvisionerResult } from "../commands/accountProvisioner"; import type { AccountProvisionerResult } from "../commands/accountProvisioner";
import type { ContextWithAuthMiddleware } from "../types"; import { signIn } from "../utils/authentication";
export default function createMiddleware(providerName: string) { export default function createMiddleware(providerName: string) {
return function passportMiddleware(ctx: ContextWithAuthMiddleware) { return function passportMiddleware(ctx: Context) {
return passport.authorize( return passport.authorize(
providerName, providerName,
{ session: false }, { session: false },
@ -27,7 +28,7 @@ export default function createMiddleware(providerName: string) {
return ctx.redirect("/?notice=suspended"); return ctx.redirect("/?notice=suspended");
} }
ctx.signIn(result.user, result.team, providerName, result.isNewUser); signIn(ctx, result.user, result.team, providerName, result.isNewUser);
} }
)(ctx); )(ctx);
}; };

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { type Context } from "koa"; import { type Context } from "koa";
import { User, Team } from "./models"; import { User } from "./models";
export type ContextWithState = {| export type ContextWithState = {|
...$Exact<Context>, ...$Exact<Context>,
@ -10,13 +10,3 @@ export type ContextWithState = {|
authType: "app" | "api", authType: "app" | "api",
}, },
|}; |};
export type ContextWithAuthMiddleware = {|
...$Exact<ContextWithState>,
signIn: (
user: User,
team: Team,
providerName: string,
isFirstSignin: boolean
) => void,
|};

View File

@ -1,7 +1,82 @@
// @flow // @flow
import addMonths from "date-fns/add_months";
import { type Context } from "koa";
import { User, Event, Team } from "../models";
import { getCookieDomain } from "../utils/domains";
export function getAllowedDomains(): string[] { export function getAllowedDomains(): string[] {
// GOOGLE_ALLOWED_DOMAINS included here for backwards compatability // GOOGLE_ALLOWED_DOMAINS included here for backwards compatability
const env = process.env.ALLOWED_DOMAINS || process.env.GOOGLE_ALLOWED_DOMAINS; const env = process.env.ALLOWED_DOMAINS || process.env.GOOGLE_ALLOWED_DOMAINS;
return env ? env.split(",") : []; return env ? env.split(",") : [];
} }
export function signIn(
ctx: Context,
user: User,
team: Team,
service: string,
isFirstSignin: boolean = false
) {
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
}
// update the database when the user last signed in
user.updateSignedIn(ctx.request.ip);
// don't await event creation for a faster sign-in
Event.create({
name: "users.signin",
actorId: user.id,
userId: user.id,
teamId: team.id,
data: {
name: user.name,
service,
},
ip: ctx.request.ip,
});
const domain = getCookieDomain(ctx.request.hostname);
const expires = addMonths(new Date(), 3);
// set a cookie for which service we last signed in with. This is
// only used to display a UI hint for the user for next time
ctx.cookies.set("lastSignedIn", service, {
httpOnly: false,
expires: new Date("2100"),
domain,
});
// set a transfer cookie for the access token itself and redirect
// to the teams subdomain if subdomains are enabled
if (process.env.SUBDOMAINS_ENABLED === "true" && team.subdomain) {
// get any existing sessions (teams signed in) and add this team
const existing = JSON.parse(
decodeURIComponent(ctx.cookies.get("sessions") || "") || "{}"
);
const sessions = encodeURIComponent(
JSON.stringify({
...existing,
[team.id]: {
name: team.name,
logoUrl: team.logoUrl,
url: team.url,
},
})
);
ctx.cookies.set("sessions", sessions, {
httpOnly: false,
expires,
domain,
});
ctx.redirect(`${team.url}/auth/redirect?token=${user.getTransferToken()}`);
} else {
ctx.cookies.set("accessToken", user.getJwtToken(), {
httpOnly: false,
expires,
});
ctx.redirect(`${team.url}/home${isFirstSignin ? "?welcome" : ""}`);
}
}

View File

@ -21,6 +21,10 @@ 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);
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.expiresAt) { if (payload.expiresAt) {
if (new Date(payload.expiresAt) < new Date()) { if (new Date(payload.expiresAt) < new Date()) {