chore: Use httpOnly authentication cookie (#5552)
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user