chore: Use httpOnly authentication cookie (#5552)

This commit is contained in:
Tom Moor
2023-07-15 16:56:32 -04:00
committed by GitHub
parent b1230d0c81
commit 39e12cef65
16 changed files with 114 additions and 120 deletions

View File

@@ -19,7 +19,7 @@ export default class AuthenticationExtension implements Extension {
throw AuthenticationError("Authentication required");
}
const user = await getUserForJWT(token);
const user = await getUserForJWT(token, ["session", "collaboration"]);
if (user.isSuspended) {
throw AuthenticationError("Account suspended");

View File

@@ -1,5 +1,5 @@
import crypto from "crypto";
import { addMinutes, subMinutes } from "date-fns";
import { addHours, addMinutes, subMinutes } from "date-fns";
import JWT from "jsonwebtoken";
import { Context } from "koa";
import { Transaction, QueryTypes, SaveOptions, Op } from "sequelize";
@@ -453,6 +453,22 @@ class User extends ParanoidModel {
this.jwtSecret
);
/**
* Returns a session token that is used to make collaboration requests and is
* stored in the client memory.
*
* @returns The session token
*/
getCollaborationToken = () =>
JWT.sign(
{
id: this.id,
expiresAt: addHours(new Date(), 24).toISOString(),
type: "collaboration",
},
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

View File

@@ -1,8 +1,8 @@
import { subHours } from "date-fns";
import { subHours, subMinutes } from "date-fns";
import Router from "koa-router";
import { uniqBy } from "lodash";
import { TeamPreference } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
@@ -139,6 +139,7 @@ router.post("auth.info", auth(), async (ctx: APIContext<T.AuthInfoReq>) => {
includeDetails: true,
}),
team: presentTeam(team),
collaborationToken: user.getCollaborationToken(),
availableTeams: uniqBy([...signedInTeams, ...availableTeams], "id").map(
(team) =>
presentAvailableTeam(
@@ -176,6 +177,11 @@ router.post(
}
);
ctx.cookies.set("accessToken", "", {
expires: subMinutes(new Date(), 1),
domain: getCookieDomain(ctx.hostname),
});
ctx.body = {
success: true,
};

View File

@@ -32,7 +32,6 @@ router.get("/redirect", auth(), async (ctx: APIContext) => {
await user.updateActiveAt(ctx, true);
ctx.cookies.set("accessToken", jwtToken, {
httpOnly: false,
sameSite: "lax",
expires: addMonths(new Date(), 3),
});

View File

@@ -1,6 +1,6 @@
import http, { IncomingMessage } from "http";
import { Duplex } from "stream";
import invariant from "invariant";
import cookie from "cookie";
import Koa from "koa";
import IO from "socket.io";
import { createAdapter } from "socket.io-redis";
@@ -56,8 +56,7 @@ export default function init(
server.on(
"upgrade",
function (req: IncomingMessage, socket: Duplex, head: Buffer) {
if (req.url?.startsWith(path)) {
invariant(ioHandleUpgrade, "Existing upgrade handler must exist");
if (req.url?.startsWith(path) && ioHandleUpgrade) {
ioHandleUpgrade(req, socket, head);
return;
}
@@ -92,29 +91,13 @@ export default function init(
}
});
io.on("connection", (socket: SocketWithAuth) => {
io.on("connection", async (socket: SocketWithAuth) => {
Metrics.increment("websockets.connected");
Metrics.gaugePerInstance("websockets.count", io.engine.clientsCount);
socket.on("authentication", async function (data) {
try {
await authenticate(socket, data);
Logger.debug("websockets", `Authenticated socket ${socket.id}`);
socket.emit("authenticated", true);
void authenticated(io, socket);
} catch (err) {
Logger.error(`Authentication error socket ${socket.id}`, err);
socket.emit("unauthorized", { message: err.message }, function () {
socket.disconnect();
});
}
});
socket.on("disconnect", async () => {
Metrics.increment("websockets.disconnected");
Metrics.gaugePerInstance("websockets.count", io.engine.clientsCount);
await Redis.defaultClient.hdel(socket.id, "userId");
});
setTimeout(function () {
@@ -126,6 +109,19 @@ export default function init(
socket.disconnect("unauthorized");
}
}, 1000);
try {
await authenticate(socket);
Logger.debug("websockets", `Authenticated socket ${socket.id}`);
socket.emit("authenticated", true);
void authenticated(io, socket);
} catch (err) {
Logger.error(`Authentication error socket ${socket.id}`, err);
socket.emit("unauthorized", { message: err.message }, function () {
socket.disconnect();
});
}
});
// Handle events from event queue that should be sent to the clients down ws
@@ -204,15 +200,17 @@ async function authenticated(io: IO.Server, socket: SocketWithAuth) {
* Authenticate the socket with the given token, attach the user model for the
* duration of the session.
*/
async function authenticate(socket: SocketWithAuth, data: { token: string }) {
const { token } = data;
async function authenticate(socket: SocketWithAuth) {
const cookies = socket.request.headers.cookie
? cookie.parse(socket.request.headers.cookie)
: {};
const { accessToken } = cookies;
const user = await getUserForJWT(token);
if (!accessToken) {
throw new Error("No access token");
}
const user = await getUserForJWT(accessToken);
socket.client.user = user;
// store the mapping between socket id and user id in redis so that it is
// accessible across multiple websocket servers. Lasts 24 hours, if they have
// a websocket connection that lasts this long then well done.
await Redis.defaultClient.hset(socket.id, "userId", user.id);
await Redis.defaultClient.expire(socket.id, 3600 * 24);
return user;
}

View File

@@ -118,9 +118,8 @@ export async function signIn(
);
}
} else {
ctx.cookies.set("accessToken", user.getJwtToken(), {
ctx.cookies.set("accessToken", user.getJwtToken(expires), {
sameSite: "lax",
httpOnly: false,
expires,
});

View File

@@ -20,10 +20,13 @@ function getJWTPayload(token: string) {
}
}
export async function getUserForJWT(token: string): Promise<User> {
export async function getUserForJWT(
token: string,
allowedTypes = ["session", "transfer"]
): Promise<User> {
const payload = getJWTPayload(token);
if (payload.type === "email-signin") {
if (!allowedTypes.includes(payload.type)) {
throw AuthenticationError("Invalid token");
}

View File

@@ -27,7 +27,6 @@ export class StateStore {
const state = buildState(host, token, client);
ctx.cookies.set(this.key, state, {
httpOnly: false,
expires: addMinutes(new Date(), 10),
domain: getCookieDomain(ctx.hostname),
});
@@ -54,7 +53,6 @@ export class StateStore {
// Destroy the one-time pad token and ensure it matches
ctx.cookies.set(this.key, "", {
httpOnly: false,
expires: subMinutes(new Date(), 1),
domain: getCookieDomain(ctx.hostname),
});