Skip to content

Commit e4a770a

Browse files
Session management improvements: Multi sessions, renew on login/logout (#603)
* wip: update sessionId on every login * comment out object.freeze * only refresh cookies on post * Add support for multiple sessions per user * fix tests * 🛂 Hash sessionId in DB * 🐛 do not forget about event.locals.sessionId * Update src/lib/server/auth.ts Co-authored-by: Eliott C. <coyotte508@gmail.com> * Add `expiresAt` field * remove index causing errors * Fix bug where sessions were not properly being deleted on logout * Moved session refresh outside of form content check --------- Co-authored-by: coyotte508 <coyotte508@gmail.com>
1 parent b1c120f commit e4a770a

File tree

10 files changed

+147
-42
lines changed

10 files changed

+147
-42
lines changed

src/hooks.server.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,12 @@ import {
77
} from "$env/static/public";
88
import { collections } from "$lib/server/database";
99
import { base } from "$app/paths";
10-
import { refreshSessionCookie, requiresUser } from "$lib/server/auth";
10+
import { findUser, refreshSessionCookie, requiresUser } from "$lib/server/auth";
1111
import { ERROR_MESSAGES } from "$lib/stores/errors";
12+
import { sha256 } from "$lib/utils/sha256";
13+
import { addWeeks } from "date-fns";
1214

1315
export const handle: Handle = async ({ event, resolve }) => {
14-
const token = event.cookies.get(COOKIE_NAME);
15-
16-
const user = token ? await collections.users.findOne({ sessionId: token }) : null;
17-
18-
if (user) {
19-
event.locals.user = user;
20-
}
21-
2216
function errorResponse(status: number, message: string) {
2317
const sendJson =
2418
event.request.headers.get("accept")?.includes("application/json") ||
@@ -31,17 +25,31 @@ export const handle: Handle = async ({ event, resolve }) => {
3125
});
3226
}
3327

34-
if (!token) {
35-
const sessionId = crypto.randomUUID();
36-
if (await collections.users.findOne({ sessionId })) {
37-
return errorResponse(500, "Session ID collision");
28+
const token = event.cookies.get(COOKIE_NAME);
29+
30+
let secretSessionId: string;
31+
let sessionId: string;
32+
33+
if (token) {
34+
secretSessionId = token;
35+
sessionId = await sha256(token);
36+
37+
const user = await findUser(sessionId);
38+
39+
if (user) {
40+
event.locals.user = user;
3841
}
39-
event.locals.sessionId = sessionId;
4042
} else {
41-
event.locals.sessionId = token;
43+
// if the user doesn't have any cookie, we generate one for him
44+
secretSessionId = crypto.randomUUID();
45+
sessionId = await sha256(secretSessionId);
46+
47+
if (await collections.sessions.findOne({ sessionId })) {
48+
return errorResponse(500, "Session ID collision");
49+
}
4250
}
4351

44-
Object.freeze(event.locals);
52+
event.locals.sessionId = sessionId;
4553

4654
// CSRF protection
4755
const requestContentType = event.request.headers.get("content-type")?.split(";")[0] ?? "";
@@ -73,13 +81,23 @@ export const handle: Handle = async ({ event, resolve }) => {
7381
}
7482
}
7583

84+
if (event.request.method === "POST") {
85+
// if the request is a POST request we refresh the cookie
86+
refreshSessionCookie(event.cookies, secretSessionId);
87+
88+
await collections.sessions.updateOne(
89+
{ sessionId },
90+
{ $set: { updatedAt: new Date(), expiresAt: addWeeks(new Date(), 2) } }
91+
);
92+
}
93+
7694
if (
7795
!event.url.pathname.startsWith(`${base}/login`) &&
7896
!event.url.pathname.startsWith(`${base}/admin`) &&
7997
!["GET", "OPTIONS", "HEAD"].includes(event.request.method)
8098
) {
8199
if (
82-
!user &&
100+
!event.locals.user &&
83101
requiresUser &&
84102
!((MESSAGES_BEFORE_LOGIN ? parseInt(MESSAGES_BEFORE_LOGIN) : 0) > 0)
85103
) {

src/lib/server/auth.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Issuer, BaseClient, type UserinfoResponse, TokenSet, custom } from "openid-client";
2-
import { addHours, addYears } from "date-fns";
2+
import { addHours, addWeeks } from "date-fns";
33
import {
44
COOKIE_NAME,
55
OPENID_CLIENT_ID,
@@ -14,6 +14,7 @@ import { sha256 } from "$lib/utils/sha256";
1414
import { z } from "zod";
1515
import { dev } from "$app/environment";
1616
import type { Cookies } from "@sveltejs/kit";
17+
import { collections } from "./database";
1718

1819
export interface OIDCSettings {
1920
redirectURI: string;
@@ -50,10 +51,19 @@ export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
5051
sameSite: dev ? "lax" : "none",
5152
secure: !dev,
5253
httpOnly: true,
53-
expires: addYears(new Date(), 1),
54+
expires: addWeeks(new Date(), 2),
5455
});
5556
}
5657

58+
export async function findUser(sessionId: string) {
59+
const session = await collections.sessions.findOne({ sessionId: sessionId });
60+
61+
if (!session) {
62+
return null;
63+
}
64+
65+
return await collections.users.findOne({ _id: session.userId });
66+
}
5767
export const authCondition = (locals: App.Locals) => {
5868
return locals.user
5969
? { userId: locals.user._id }

src/lib/server/database.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AbortedGeneration } from "$lib/types/AbortedGeneration";
77
import type { Settings } from "$lib/types/Settings";
88
import type { User } from "$lib/types/User";
99
import type { MessageEvent } from "$lib/types/MessageEvent";
10+
import type { Session } from "$lib/types/Session";
1011

1112
if (!MONGODB_URL) {
1213
throw new Error(
@@ -27,6 +28,7 @@ const sharedConversations = db.collection<SharedConversation>("sharedConversatio
2728
const abortedGenerations = db.collection<AbortedGeneration>("abortedGenerations");
2829
const settings = db.collection<Settings>("settings");
2930
const users = db.collection<User>("users");
31+
const sessions = db.collection<Session>("sessions");
3032
const webSearches = db.collection<WebSearch>("webSearches");
3133
const messageEvents = db.collection<MessageEvent>("messageEvents");
3234
const bucket = new GridFSBucket(db, { bucketName: "files" });
@@ -38,6 +40,7 @@ export const collections = {
3840
abortedGenerations,
3941
settings,
4042
users,
43+
sessions,
4144
webSearches,
4245
messageEvents,
4346
bucket,
@@ -65,4 +68,6 @@ client.on("open", () => {
6568
users.createIndex({ hfUserId: 1 }, { unique: true }).catch(console.error);
6669
users.createIndex({ sessionId: 1 }, { unique: true, sparse: true }).catch(console.error);
6770
messageEvents.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 }).catch(console.error);
71+
sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(console.error);
72+
sessions.createIndex({ sessionId: 1 }, { unique: true }).catch(console.error);
6873
});

src/lib/types/MessageEvent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import type { Session } from "./Session";
12
import type { Timestamps } from "./Timestamps";
23
import type { User } from "./User";
34

45
export interface MessageEvent extends Pick<Timestamps, "createdAt"> {
5-
userId: User["_id"] | User["sessionId"];
6+
userId: User["_id"] | Session["sessionId"];
67
ip?: string;
78
}

src/lib/types/Session.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { ObjectId } from "bson";
2+
import type { Timestamps } from "./Timestamps";
3+
import type { User } from "./User";
4+
5+
export interface Session extends Timestamps {
6+
_id: ObjectId;
7+
sessionId: string;
8+
userId: User["_id"];
9+
userAgent?: string;
10+
ip?: string;
11+
expiresAt: Date;
12+
}

src/lib/types/User.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,4 @@ export interface User extends Timestamps {
99
email?: string;
1010
avatarUrl: string;
1111
hfUserId: string;
12-
13-
// Session identifier, stored in the cookie
14-
sessionId: string;
1512
}

src/routes/login/callback/+page.server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from "zod";
44
import { base } from "$app/paths";
55
import { updateUser } from "./updateUser";
66

7-
export async function load({ url, locals, cookies }) {
7+
export async function load({ url, locals, cookies, request, getClientAddress }) {
88
const { error: errorName, error_description: errorDescription } = z
99
.object({
1010
error: z.string().optional(),
@@ -33,7 +33,13 @@ export async function load({ url, locals, cookies }) {
3333

3434
const { userData } = await getOIDCUserData({ redirectURI: validatedToken.redirectUrl }, code);
3535

36-
await updateUser({ userData, locals, cookies });
36+
await updateUser({
37+
userData,
38+
locals,
39+
cookies,
40+
userAgent: request.headers.get("user-agent") ?? undefined,
41+
ip: getClientAddress(),
42+
});
3743

3844
throw redirect(302, `${base}/`);
3945
}

src/routes/login/callback/updateUser.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { updateUser } from "./updateUser";
55
import { ObjectId } from "mongodb";
66
import { DEFAULT_SETTINGS } from "$lib/types/Settings";
77
import { defaultModel } from "$lib/server/models";
8+
import { findUser } from "$lib/server/auth";
89

910
const userData = {
1011
preferred_username: "new-username",
1112
name: "name",
1213
picture: "https://example.com/avatar.png",
1314
sub: "1234567890",
1415
};
16+
Object.freeze(userData);
1517

1618
const locals = {
1719
userId: "1234567890",
@@ -32,7 +34,6 @@ const insertRandomUser = async () => {
3234
name: userData.name,
3335
avatarUrl: userData.picture,
3436
hfUserId: userData.sub,
35-
sessionId: locals.sessionId,
3637
});
3738

3839
return res.insertedId;
@@ -87,7 +88,7 @@ describe("login", () => {
8788
it("should create default settings for new user", async () => {
8889
await updateUser({ userData, locals, cookies: cookiesMock });
8990

90-
const user = await collections.users.findOne({ sessionId: locals.sessionId });
91+
const user = await findUser(locals.sessionId);
9192

9293
assert.exists(user);
9394

@@ -140,5 +141,9 @@ describe("login", () => {
140141

141142
afterEach(async () => {
142143
await collections.users.deleteMany({ hfUserId: userData.sub });
144+
await collections.sessions.deleteMany({});
145+
146+
locals.userId = "1234567890";
147+
locals.sessionId = "1234567890";
143148
vi.clearAllMocks();
144149
});

src/routes/login/callback/updateUser.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1-
import { authCondition, refreshSessionCookie } from "$lib/server/auth";
1+
import { refreshSessionCookie } from "$lib/server/auth";
22
import { collections } from "$lib/server/database";
33
import { ObjectId } from "mongodb";
44
import { DEFAULT_SETTINGS } from "$lib/types/Settings";
55
import { z } from "zod";
66
import type { UserinfoResponse } from "openid-client";
7-
import type { Cookies } from "@sveltejs/kit";
7+
import { error, type Cookies } from "@sveltejs/kit";
8+
import crypto from "crypto";
9+
import { sha256 } from "$lib/utils/sha256";
10+
import { addWeeks } from "date-fns";
811

912
export async function updateUser(params: {
1013
userData: UserinfoResponse;
1114
locals: App.Locals;
1215
cookies: Cookies;
16+
userAgent?: string;
17+
ip?: string;
1318
}) {
14-
const { userData, locals, cookies } = params;
19+
const { userData, locals, cookies, userAgent, ip } = params;
20+
1521
const {
1622
preferred_username: username,
1723
name,
@@ -31,17 +37,43 @@ export async function updateUser(params: {
3137
})
3238
.parse(userData);
3339

40+
// check if user already exists
3441
const existingUser = await collections.users.findOne({ hfUserId });
3542
let userId = existingUser?._id;
3643

44+
// update session cookie on login
45+
const previousSessionId = locals.sessionId;
46+
const secretSessionId = crypto.randomUUID();
47+
const sessionId = await sha256(secretSessionId);
48+
49+
if (await collections.sessions.findOne({ sessionId })) {
50+
throw error(500, "Session ID collision");
51+
}
52+
53+
locals.sessionId = sessionId;
54+
3755
if (existingUser) {
3856
// update existing user if any
3957
await collections.users.updateOne(
4058
{ _id: existingUser._id },
4159
{ $set: { username, name, avatarUrl } }
4260
);
61+
62+
// remove previous session if it exists and add new one
63+
await collections.sessions.deleteOne({ sessionId: previousSessionId });
64+
await collections.sessions.insertOne({
65+
_id: new ObjectId(),
66+
sessionId: locals.sessionId,
67+
userId: existingUser._id,
68+
createdAt: new Date(),
69+
updatedAt: new Date(),
70+
userAgent,
71+
ip,
72+
expiresAt: addWeeks(new Date(), 2),
73+
});
74+
4375
// refresh session cookie
44-
refreshSessionCookie(cookies, existingUser.sessionId);
76+
refreshSessionCookie(cookies, secretSessionId);
4577
} else {
4678
// user doesn't exist yet, create a new one
4779
const { insertedId } = await collections.users.insertOne({
@@ -53,19 +85,32 @@ export async function updateUser(params: {
5385
email,
5486
avatarUrl,
5587
hfUserId,
56-
sessionId: locals.sessionId,
5788
});
5889

5990
userId = insertedId;
6091

61-
// update pre-existing settings
62-
const { matchedCount } = await collections.settings.updateOne(authCondition(locals), {
63-
$set: { userId, updatedAt: new Date() },
64-
$unset: { sessionId: "" },
92+
await collections.sessions.insertOne({
93+
_id: new ObjectId(),
94+
sessionId: locals.sessionId,
95+
userId,
96+
createdAt: new Date(),
97+
updatedAt: new Date(),
98+
userAgent,
99+
ip,
100+
expiresAt: addWeeks(new Date(), 2),
65101
});
66102

103+
// move pre-existing settings to new user
104+
const { matchedCount } = await collections.settings.updateOne(
105+
{ sessionId: previousSessionId },
106+
{
107+
$set: { userId, updatedAt: new Date() },
108+
$unset: { sessionId: "" },
109+
}
110+
);
111+
67112
if (!matchedCount) {
68-
// create new default settings
113+
// if no settings found for user, create default settings
69114
await collections.settings.insertOne({
70115
userId,
71116
ethicsModalAcceptedAt: new Date(),
@@ -77,8 +122,11 @@ export async function updateUser(params: {
77122
}
78123

79124
// migrate pre-existing conversations
80-
await collections.conversations.updateMany(authCondition(locals), {
81-
$set: { userId },
82-
$unset: { sessionId: "" },
83-
});
125+
await collections.conversations.updateMany(
126+
{ sessionId: previousSessionId },
127+
{
128+
$set: { userId },
129+
$unset: { sessionId: "" },
130+
}
131+
);
84132
}

src/routes/logout/+page.server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { dev } from "$app/environment";
22
import { base } from "$app/paths";
33
import { COOKIE_NAME } from "$env/static/private";
4+
import { collections } from "$lib/server/database";
45
import { redirect } from "@sveltejs/kit";
56

67
export const actions = {
7-
default: async function ({ cookies }) {
8+
default: async function ({ cookies, locals }) {
9+
await collections.sessions.deleteOne({ sessionId: locals.sessionId });
10+
811
cookies.delete(COOKIE_NAME, {
912
path: "/",
1013
// So that it works inside the space's iframe

0 commit comments

Comments
 (0)