nsarrazin HF staff coyotte508 HF staff commited on
Commit
e4a770a
1 Parent(s): b1c120f

Session management improvements: Multi sessions, renew on login/logout (#603)

Browse files

* 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>

src/hooks.server.ts CHANGED
@@ -7,18 +7,12 @@ import {
7
  } from "$env/static/public";
8
  import { collections } from "$lib/server/database";
9
  import { base } from "$app/paths";
10
- import { refreshSessionCookie, requiresUser } from "$lib/server/auth";
11
  import { ERROR_MESSAGES } from "$lib/stores/errors";
 
 
12
 
13
  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
-
22
  function errorResponse(status: number, message: string) {
23
  const sendJson =
24
  event.request.headers.get("accept")?.includes("application/json") ||
@@ -31,17 +25,31 @@ export const handle: Handle = async ({ event, resolve }) => {
31
  });
32
  }
33
 
34
- if (!token) {
35
- const sessionId = crypto.randomUUID();
36
- if (await collections.users.findOne({ sessionId })) {
37
- return errorResponse(500, "Session ID collision");
 
 
 
 
 
 
 
 
 
38
  }
39
- event.locals.sessionId = sessionId;
40
  } else {
41
- event.locals.sessionId = token;
 
 
 
 
 
 
42
  }
43
 
44
- Object.freeze(event.locals);
45
 
46
  // CSRF protection
47
  const requestContentType = event.request.headers.get("content-type")?.split(";")[0] ?? "";
@@ -73,13 +81,23 @@ export const handle: Handle = async ({ event, resolve }) => {
73
  }
74
  }
75
 
 
 
 
 
 
 
 
 
 
 
76
  if (
77
  !event.url.pathname.startsWith(`${base}/login`) &&
78
  !event.url.pathname.startsWith(`${base}/admin`) &&
79
  !["GET", "OPTIONS", "HEAD"].includes(event.request.method)
80
  ) {
81
  if (
82
- !user &&
83
  requiresUser &&
84
  !((MESSAGES_BEFORE_LOGIN ? parseInt(MESSAGES_BEFORE_LOGIN) : 0) > 0)
85
  ) {
 
7
  } from "$env/static/public";
8
  import { collections } from "$lib/server/database";
9
  import { base } from "$app/paths";
10
+ import { findUser, refreshSessionCookie, requiresUser } from "$lib/server/auth";
11
  import { ERROR_MESSAGES } from "$lib/stores/errors";
12
+ import { sha256 } from "$lib/utils/sha256";
13
+ import { addWeeks } from "date-fns";
14
 
15
  export const handle: Handle = async ({ event, resolve }) => {
 
 
 
 
 
 
 
 
16
  function errorResponse(status: number, message: string) {
17
  const sendJson =
18
  event.request.headers.get("accept")?.includes("application/json") ||
 
25
  });
26
  }
27
 
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;
41
  }
 
42
  } else {
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
+ }
50
  }
51
 
52
+ event.locals.sessionId = sessionId;
53
 
54
  // CSRF protection
55
  const requestContentType = event.request.headers.get("content-type")?.split(";")[0] ?? "";
 
81
  }
82
  }
83
 
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
+
94
  if (
95
  !event.url.pathname.startsWith(`${base}/login`) &&
96
  !event.url.pathname.startsWith(`${base}/admin`) &&
97
  !["GET", "OPTIONS", "HEAD"].includes(event.request.method)
98
  ) {
99
  if (
100
+ !event.locals.user &&
101
  requiresUser &&
102
  !((MESSAGES_BEFORE_LOGIN ? parseInt(MESSAGES_BEFORE_LOGIN) : 0) > 0)
103
  ) {
src/lib/server/auth.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { Issuer, BaseClient, type UserinfoResponse, TokenSet, custom } from "openid-client";
2
- import { addHours, addYears } from "date-fns";
3
  import {
4
  COOKIE_NAME,
5
  OPENID_CLIENT_ID,
@@ -14,6 +14,7 @@ import { sha256 } from "$lib/utils/sha256";
14
  import { z } from "zod";
15
  import { dev } from "$app/environment";
16
  import type { Cookies } from "@sveltejs/kit";
 
17
 
18
  export interface OIDCSettings {
19
  redirectURI: string;
@@ -50,10 +51,19 @@ export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
50
  sameSite: dev ? "lax" : "none",
51
  secure: !dev,
52
  httpOnly: true,
53
- expires: addYears(new Date(), 1),
54
  });
55
  }
56
 
 
 
 
 
 
 
 
 
 
57
  export const authCondition = (locals: App.Locals) => {
58
  return locals.user
59
  ? { userId: locals.user._id }
 
1
  import { Issuer, BaseClient, type UserinfoResponse, TokenSet, custom } from "openid-client";
2
+ import { addHours, addWeeks } from "date-fns";
3
  import {
4
  COOKIE_NAME,
5
  OPENID_CLIENT_ID,
 
14
  import { z } from "zod";
15
  import { dev } from "$app/environment";
16
  import type { Cookies } from "@sveltejs/kit";
17
+ import { collections } from "./database";
18
 
19
  export interface OIDCSettings {
20
  redirectURI: string;
 
51
  sameSite: dev ? "lax" : "none",
52
  secure: !dev,
53
  httpOnly: true,
54
+ expires: addWeeks(new Date(), 2),
55
  });
56
  }
57
 
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
+ }
67
  export const authCondition = (locals: App.Locals) => {
68
  return locals.user
69
  ? { userId: locals.user._id }
src/lib/server/database.ts CHANGED
@@ -7,6 +7,7 @@ import type { AbortedGeneration } from "$lib/types/AbortedGeneration";
7
  import type { Settings } from "$lib/types/Settings";
8
  import type { User } from "$lib/types/User";
9
  import type { MessageEvent } from "$lib/types/MessageEvent";
 
10
 
11
  if (!MONGODB_URL) {
12
  throw new Error(
@@ -27,6 +28,7 @@ const sharedConversations = db.collection<SharedConversation>("sharedConversatio
27
  const abortedGenerations = db.collection<AbortedGeneration>("abortedGenerations");
28
  const settings = db.collection<Settings>("settings");
29
  const users = db.collection<User>("users");
 
30
  const webSearches = db.collection<WebSearch>("webSearches");
31
  const messageEvents = db.collection<MessageEvent>("messageEvents");
32
  const bucket = new GridFSBucket(db, { bucketName: "files" });
@@ -38,6 +40,7 @@ export const collections = {
38
  abortedGenerations,
39
  settings,
40
  users,
 
41
  webSearches,
42
  messageEvents,
43
  bucket,
@@ -65,4 +68,6 @@ client.on("open", () => {
65
  users.createIndex({ hfUserId: 1 }, { unique: true }).catch(console.error);
66
  users.createIndex({ sessionId: 1 }, { unique: true, sparse: true }).catch(console.error);
67
  messageEvents.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 }).catch(console.error);
 
 
68
  });
 
7
  import type { Settings } from "$lib/types/Settings";
8
  import type { User } from "$lib/types/User";
9
  import type { MessageEvent } from "$lib/types/MessageEvent";
10
+ import type { Session } from "$lib/types/Session";
11
 
12
  if (!MONGODB_URL) {
13
  throw new Error(
 
28
  const abortedGenerations = db.collection<AbortedGeneration>("abortedGenerations");
29
  const settings = db.collection<Settings>("settings");
30
  const users = db.collection<User>("users");
31
+ const sessions = db.collection<Session>("sessions");
32
  const webSearches = db.collection<WebSearch>("webSearches");
33
  const messageEvents = db.collection<MessageEvent>("messageEvents");
34
  const bucket = new GridFSBucket(db, { bucketName: "files" });
 
40
  abortedGenerations,
41
  settings,
42
  users,
43
+ sessions,
44
  webSearches,
45
  messageEvents,
46
  bucket,
 
68
  users.createIndex({ hfUserId: 1 }, { unique: true }).catch(console.error);
69
  users.createIndex({ sessionId: 1 }, { unique: true, sparse: true }).catch(console.error);
70
  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);
73
  });
src/lib/types/MessageEvent.ts CHANGED
@@ -1,7 +1,8 @@
 
1
  import type { Timestamps } from "./Timestamps";
2
  import type { User } from "./User";
3
 
4
  export interface MessageEvent extends Pick<Timestamps, "createdAt"> {
5
- userId: User["_id"] | User["sessionId"];
6
  ip?: string;
7
  }
 
1
+ import type { Session } from "./Session";
2
  import type { Timestamps } from "./Timestamps";
3
  import type { User } from "./User";
4
 
5
  export interface MessageEvent extends Pick<Timestamps, "createdAt"> {
6
+ userId: User["_id"] | Session["sessionId"];
7
  ip?: string;
8
  }
src/lib/types/Session.ts ADDED
@@ -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 CHANGED
@@ -9,7 +9,4 @@ export interface User extends Timestamps {
9
  email?: string;
10
  avatarUrl: string;
11
  hfUserId: string;
12
-
13
- // Session identifier, stored in the cookie
14
- sessionId: string;
15
  }
 
9
  email?: string;
10
  avatarUrl: string;
11
  hfUserId: string;
 
 
 
12
  }
src/routes/login/callback/+page.server.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod";
4
  import { base } from "$app/paths";
5
  import { updateUser } from "./updateUser";
6
 
7
- export async function load({ url, locals, cookies }) {
8
  const { error: errorName, error_description: errorDescription } = z
9
  .object({
10
  error: z.string().optional(),
@@ -33,7 +33,13 @@ export async function load({ url, locals, cookies }) {
33
 
34
  const { userData } = await getOIDCUserData({ redirectURI: validatedToken.redirectUrl }, code);
35
 
36
- await updateUser({ userData, locals, cookies });
 
 
 
 
 
 
37
 
38
  throw redirect(302, `${base}/`);
39
  }
 
4
  import { base } from "$app/paths";
5
  import { updateUser } from "./updateUser";
6
 
7
+ export async function load({ url, locals, cookies, request, getClientAddress }) {
8
  const { error: errorName, error_description: errorDescription } = z
9
  .object({
10
  error: z.string().optional(),
 
33
 
34
  const { userData } = await getOIDCUserData({ redirectURI: validatedToken.redirectUrl }, code);
35
 
36
+ await updateUser({
37
+ userData,
38
+ locals,
39
+ cookies,
40
+ userAgent: request.headers.get("user-agent") ?? undefined,
41
+ ip: getClientAddress(),
42
+ });
43
 
44
  throw redirect(302, `${base}/`);
45
  }
src/routes/login/callback/updateUser.spec.ts CHANGED
@@ -5,6 +5,7 @@ import { updateUser } from "./updateUser";
5
  import { ObjectId } from "mongodb";
6
  import { DEFAULT_SETTINGS } from "$lib/types/Settings";
7
  import { defaultModel } from "$lib/server/models";
 
8
 
9
  const userData = {
10
  preferred_username: "new-username",
@@ -12,6 +13,7 @@ const userData = {
12
  picture: "https://example.com/avatar.png",
13
  sub: "1234567890",
14
  };
 
15
 
16
  const locals = {
17
  userId: "1234567890",
@@ -32,7 +34,6 @@ const insertRandomUser = async () => {
32
  name: userData.name,
33
  avatarUrl: userData.picture,
34
  hfUserId: userData.sub,
35
- sessionId: locals.sessionId,
36
  });
37
 
38
  return res.insertedId;
@@ -87,7 +88,7 @@ describe("login", () => {
87
  it("should create default settings for new user", async () => {
88
  await updateUser({ userData, locals, cookies: cookiesMock });
89
 
90
- const user = await collections.users.findOne({ sessionId: locals.sessionId });
91
 
92
  assert.exists(user);
93
 
@@ -140,5 +141,9 @@ describe("login", () => {
140
 
141
  afterEach(async () => {
142
  await collections.users.deleteMany({ hfUserId: userData.sub });
 
 
 
 
143
  vi.clearAllMocks();
144
  });
 
5
  import { ObjectId } from "mongodb";
6
  import { DEFAULT_SETTINGS } from "$lib/types/Settings";
7
  import { defaultModel } from "$lib/server/models";
8
+ import { findUser } from "$lib/server/auth";
9
 
10
  const userData = {
11
  preferred_username: "new-username",
 
13
  picture: "https://example.com/avatar.png",
14
  sub: "1234567890",
15
  };
16
+ Object.freeze(userData);
17
 
18
  const locals = {
19
  userId: "1234567890",
 
34
  name: userData.name,
35
  avatarUrl: userData.picture,
36
  hfUserId: userData.sub,
 
37
  });
38
 
39
  return res.insertedId;
 
88
  it("should create default settings for new user", async () => {
89
  await updateUser({ userData, locals, cookies: cookiesMock });
90
 
91
+ const user = await findUser(locals.sessionId);
92
 
93
  assert.exists(user);
94
 
 
141
 
142
  afterEach(async () => {
143
  await collections.users.deleteMany({ hfUserId: userData.sub });
144
+ await collections.sessions.deleteMany({});
145
+
146
+ locals.userId = "1234567890";
147
+ locals.sessionId = "1234567890";
148
  vi.clearAllMocks();
149
  });
src/routes/login/callback/updateUser.ts CHANGED
@@ -1,17 +1,23 @@
1
- import { authCondition, refreshSessionCookie } from "$lib/server/auth";
2
  import { collections } from "$lib/server/database";
3
  import { ObjectId } from "mongodb";
4
  import { DEFAULT_SETTINGS } from "$lib/types/Settings";
5
  import { z } from "zod";
6
  import type { UserinfoResponse } from "openid-client";
7
- import type { Cookies } from "@sveltejs/kit";
 
 
 
8
 
9
  export async function updateUser(params: {
10
  userData: UserinfoResponse;
11
  locals: App.Locals;
12
  cookies: Cookies;
 
 
13
  }) {
14
- const { userData, locals, cookies } = params;
 
15
  const {
16
  preferred_username: username,
17
  name,
@@ -31,17 +37,43 @@ export async function updateUser(params: {
31
  })
32
  .parse(userData);
33
 
 
34
  const existingUser = await collections.users.findOne({ hfUserId });
35
  let userId = existingUser?._id;
36
 
 
 
 
 
 
 
 
 
 
 
 
37
  if (existingUser) {
38
  // update existing user if any
39
  await collections.users.updateOne(
40
  { _id: existingUser._id },
41
  { $set: { username, name, avatarUrl } }
42
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  // refresh session cookie
44
- refreshSessionCookie(cookies, existingUser.sessionId);
45
  } else {
46
  // user doesn't exist yet, create a new one
47
  const { insertedId } = await collections.users.insertOne({
@@ -53,19 +85,32 @@ export async function updateUser(params: {
53
  email,
54
  avatarUrl,
55
  hfUserId,
56
- sessionId: locals.sessionId,
57
  });
58
 
59
  userId = insertedId;
60
 
61
- // update pre-existing settings
62
- const { matchedCount } = await collections.settings.updateOne(authCondition(locals), {
63
- $set: { userId, updatedAt: new Date() },
64
- $unset: { sessionId: "" },
 
 
 
 
 
65
  });
66
 
 
 
 
 
 
 
 
 
 
67
  if (!matchedCount) {
68
- // create new default settings
69
  await collections.settings.insertOne({
70
  userId,
71
  ethicsModalAcceptedAt: new Date(),
@@ -77,8 +122,11 @@ export async function updateUser(params: {
77
  }
78
 
79
  // migrate pre-existing conversations
80
- await collections.conversations.updateMany(authCondition(locals), {
81
- $set: { userId },
82
- $unset: { sessionId: "" },
83
- });
 
 
 
84
  }
 
1
+ import { refreshSessionCookie } from "$lib/server/auth";
2
  import { collections } from "$lib/server/database";
3
  import { ObjectId } from "mongodb";
4
  import { DEFAULT_SETTINGS } from "$lib/types/Settings";
5
  import { z } from "zod";
6
  import type { UserinfoResponse } from "openid-client";
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";
11
 
12
  export async function updateUser(params: {
13
  userData: UserinfoResponse;
14
  locals: App.Locals;
15
  cookies: Cookies;
16
+ userAgent?: string;
17
+ ip?: string;
18
  }) {
19
+ const { userData, locals, cookies, userAgent, ip } = params;
20
+
21
  const {
22
  preferred_username: username,
23
  name,
 
37
  })
38
  .parse(userData);
39
 
40
+ // check if user already exists
41
  const existingUser = await collections.users.findOne({ hfUserId });
42
  let userId = existingUser?._id;
43
 
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
+
55
  if (existingUser) {
56
  // update existing user if any
57
  await collections.users.updateOne(
58
  { _id: existingUser._id },
59
  { $set: { username, name, avatarUrl } }
60
  );
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
+
75
  // refresh session cookie
76
+ refreshSessionCookie(cookies, secretSessionId);
77
  } else {
78
  // user doesn't exist yet, create a new one
79
  const { insertedId } = await collections.users.insertOne({
 
85
  email,
86
  avatarUrl,
87
  hfUserId,
 
88
  });
89
 
90
  userId = insertedId;
91
 
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),
101
  });
102
 
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
+
112
  if (!matchedCount) {
113
+ // if no settings found for user, create default settings
114
  await collections.settings.insertOne({
115
  userId,
116
  ethicsModalAcceptedAt: new Date(),
 
122
  }
123
 
124
  // migrate pre-existing conversations
125
+ await collections.conversations.updateMany(
126
+ { sessionId: previousSessionId },
127
+ {
128
+ $set: { userId },
129
+ $unset: { sessionId: "" },
130
+ }
131
+ );
132
  }
src/routes/logout/+page.server.ts CHANGED
@@ -1,10 +1,13 @@
1
  import { dev } from "$app/environment";
2
  import { base } from "$app/paths";
3
  import { COOKIE_NAME } from "$env/static/private";
 
4
  import { redirect } from "@sveltejs/kit";
5
 
6
  export const actions = {
7
- default: async function ({ cookies }) {
 
 
8
  cookies.delete(COOKIE_NAME, {
9
  path: "/",
10
  // So that it works inside the space's iframe
 
1
  import { dev } from "$app/environment";
2
  import { base } from "$app/paths";
3
  import { COOKIE_NAME } from "$env/static/private";
4
+ import { collections } from "$lib/server/database";
5
  import { redirect } from "@sveltejs/kit";
6
 
7
  export const actions = {
8
+ default: async function ({ cookies, locals }) {
9
+ await collections.sessions.deleteOne({ sessionId: locals.sessionId });
10
+
11
  cookies.delete(COOKIE_NAME, {
12
  path: "/",
13
  // So that it works inside the space's iframe