Skip to content

Commit 4eb4c23

Browse files
committed
feat(server): manage auth cookies (#8317)
1 parent 096f50b commit 4eb4c23

File tree

5 files changed

+232
-42
lines changed

5 files changed

+232
-42
lines changed

packages/backend/server/src/core/auth/controller.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,16 +172,19 @@ export class AuthController {
172172
});
173173
}
174174

175+
@Public()
175176
@Get('/sign-out')
176177
async signOut(
177178
@Res() res: Response,
178-
@Session() session: Session,
179-
@Body() { all }: { all: boolean }
179+
@Session() session: Session | undefined,
180+
@Query('user_id') userId: string | undefined
180181
) {
181-
await this.auth.signOut(
182-
session.sessionId,
183-
all ? undefined : session.userId
184-
);
182+
if (!session) {
183+
return;
184+
}
185+
186+
await this.auth.signOut(session.sessionId, userId);
187+
await this.auth.refreshCookies(res, session.sessionId);
185188

186189
res.status(HttpStatus.OK).send({});
187190
}

packages/backend/server/src/core/auth/guard.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
} from '@nestjs/common';
77
import { Injectable, SetMetadata } from '@nestjs/common';
88
import { ModuleRef, Reflector } from '@nestjs/core';
9-
import type { Request } from 'express';
9+
import type { Request, Response } from 'express';
1010

1111
import {
1212
AuthenticationRequired,
@@ -37,7 +37,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
3737
async canActivate(context: ExecutionContext) {
3838
const { req, res } = getRequestResponseFromContext(context);
3939

40-
const userSession = await this.signIn(req);
40+
const userSession = await this.signIn(req, res);
4141
if (res && userSession && userSession.expiresAt) {
4242
await this.auth.refreshUserSessionIfNeeded(res, userSession);
4343
}
@@ -59,7 +59,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
5959
return true;
6060
}
6161

62-
async signIn(req: Request): Promise<Session | null> {
62+
async signIn(req: Request, res?: Response): Promise<Session | null> {
6363
if (req.session) {
6464
return req.session;
6565
}
@@ -68,7 +68,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
6868
parseCookies(req);
6969

7070
// TODO(@forehalo): a cache for user session
71-
const userSession = await this.auth.getUserSessionFromRequest(req);
71+
const userSession = await this.auth.getUserSessionFromRequest(req, res);
7272

7373
if (userSession) {
7474
req.session = {

packages/backend/server/src/core/auth/service.ts

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -122,35 +122,45 @@ export class AuthService implements OnApplicationBootstrap {
122122
sessionId: string,
123123
userId?: string
124124
): Promise<{ user: CurrentUser; session: UserSession } | null> {
125-
const userSession = await this.db.userSession.findFirst({
126-
where: {
127-
sessionId,
128-
userId,
129-
},
130-
select: {
131-
id: true,
132-
sessionId: true,
133-
userId: true,
134-
createdAt: true,
135-
expiresAt: true,
136-
user: true,
137-
},
138-
orderBy: {
139-
createdAt: 'asc',
140-
},
141-
});
125+
const sessions = await this.getUserSessions(sessionId);
142126

143-
// no such session
144-
if (!userSession) {
127+
if (!sessions.length) {
145128
return null;
146129
}
147130

148-
// user session expired
149-
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
131+
let userSession: UserSession | undefined;
132+
133+
// try read from user provided cookies.userId
134+
if (userId) {
135+
userSession = sessions.find(s => s.userId === userId);
136+
}
137+
138+
// fallback to the first valid session if user provided userId is invalid
139+
if (!userSession) {
140+
// checked
141+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
142+
userSession = sessions.at(-1)!;
143+
}
144+
145+
const user = await this.user.findUserById(userSession.userId);
146+
147+
if (!user) {
150148
return null;
151149
}
152150

153-
return { user: sessionUser(userSession.user), session: userSession };
151+
return { user: sessionUser(user), session: userSession };
152+
}
153+
154+
async getUserSessions(sessionId: string) {
155+
return this.db.userSession.findMany({
156+
where: {
157+
sessionId,
158+
OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }],
159+
},
160+
orderBy: {
161+
createdAt: 'asc',
162+
},
163+
});
154164
}
155165

156166
async createUserSession(
@@ -309,6 +319,25 @@ export class AuthService implements OnApplicationBootstrap {
309319
this.setUserCookie(res, userId);
310320
}
311321

322+
async refreshCookies(res: Response, sessionId?: string) {
323+
if (sessionId) {
324+
const users = await this.getUserList(sessionId);
325+
const candidateUser = users.at(-1);
326+
327+
if (candidateUser) {
328+
this.setUserCookie(res, candidateUser.id);
329+
return;
330+
}
331+
}
332+
333+
this.clearCookies(res);
334+
}
335+
336+
private clearCookies(res: Response<any, Record<string, any>>) {
337+
res.clearCookie(AuthService.sessionCookieName);
338+
res.clearCookie(AuthService.userCookieName);
339+
}
340+
312341
setUserCookie(res: Response, userId: string) {
313342
res.cookie(AuthService.userCookieName, userId, {
314343
...this.cookieOptions,
@@ -319,14 +348,28 @@ export class AuthService implements OnApplicationBootstrap {
319348
});
320349
}
321350

322-
async getUserSessionFromRequest(req: Request) {
351+
async getUserSessionFromRequest(req: Request, res?: Response) {
323352
const { sessionId, userId } = this.getSessionOptionsFromRequest(req);
324353

325354
if (!sessionId) {
326355
return null;
327356
}
328357

329-
return this.getUserSession(sessionId, userId);
358+
const session = await this.getUserSession(sessionId, userId);
359+
360+
if (res) {
361+
if (session) {
362+
// set user id cookie for fast authentication
363+
if (!userId || userId !== session.user.id) {
364+
this.setUserCookie(res, session.user.id);
365+
}
366+
} else if (sessionId) {
367+
// clear invalid cookies.session and cookies.userId
368+
this.clearCookies(res);
369+
}
370+
}
371+
372+
return session;
330373
}
331374

332375
async changePassword(

packages/backend/server/tests/auth/controller.spec.ts

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,155 @@ test('should be able to sign out', async t => {
161161
t.falsy(session.user);
162162
});
163163

164-
test('should not be able to sign out if not signed in', async t => {
165-
const { app } = t.context;
164+
test('should be able to correct user id cookie', async t => {
165+
const { app, u1 } = t.context;
166+
167+
const signInRes = await request(app.getHttpServer())
168+
.post('/api/auth/sign-in')
169+
.send({ email: u1.email, password: '1' })
170+
.expect(200);
171+
172+
const cookie = sessionCookie(signInRes.headers);
173+
174+
let session = await request(app.getHttpServer())
175+
.get('/api/auth/session')
176+
.set('cookie', cookie)
177+
.expect(200);
178+
179+
let userIdCookie = session.get('Set-Cookie')?.find(c => {
180+
return c.startsWith(`${AuthService.userCookieName}=`);
181+
});
182+
183+
t.true(userIdCookie?.startsWith(`${AuthService.userCookieName}=${u1.id}`));
184+
185+
session = await request(app.getHttpServer())
186+
.get('/api/auth/session')
187+
.set('cookie', `${cookie};${AuthService.userCookieName}=invalid_user_id`)
188+
.expect(200);
189+
190+
userIdCookie = session.get('Set-Cookie')?.find(c => {
191+
return c.startsWith(`${AuthService.userCookieName}=`);
192+
});
193+
194+
t.true(userIdCookie?.startsWith(`${AuthService.userCookieName}=${u1.id}`));
195+
t.is(session.body.user.id, u1.id);
196+
});
197+
198+
// multiple accounts session tests
199+
test('should be able to sign in another account in one session', async t => {
200+
const { app, u1, auth } = t.context;
201+
202+
const u2 = await auth.signUp('u3@affine.pro', '3');
203+
204+
// sign in u1
205+
const signInRes = await request(app.getHttpServer())
206+
.post('/api/auth/sign-in')
207+
.send({ email: u1.email, password: '1' })
208+
.expect(200);
209+
210+
const cookie = sessionCookie(signInRes.headers);
211+
212+
// avoid create session at the exact same time, leads to same random session users order
213+
await new Promise(resolve => setTimeout(resolve, 1));
214+
215+
// sign in u2 in the same session
216+
await request(app.getHttpServer())
217+
.post('/api/auth/sign-in')
218+
.set('cookie', cookie)
219+
.send({ email: u2.email, password: '3' })
220+
.expect(200);
221+
222+
// list [u1, u2]
223+
const sessions = await request(app.getHttpServer())
224+
.get('/api/auth/sessions')
225+
.set('cookie', cookie)
226+
.expect(200);
166227

228+
t.is(sessions.body.users.length, 2);
229+
t.is(sessions.body.users[0].id, u1.id);
230+
t.is(sessions.body.users[1].id, u2.id);
231+
232+
// default to latest signed in user: u2
233+
let session = await request(app.getHttpServer())
234+
.get('/api/auth/session')
235+
.set('cookie', cookie)
236+
.expect(200);
237+
238+
t.is(session.body.user.id, u2.id);
239+
240+
// switch to u1
241+
session = await request(app.getHttpServer())
242+
.get('/api/auth/session')
243+
.set('cookie', `${cookie};${AuthService.userCookieName}=${u1.id}`)
244+
.expect(200);
245+
246+
t.is(session.body.user.id, u1.id);
247+
});
248+
249+
test('should be able to sign out multiple accounts in one session', async t => {
250+
const { app, u1, auth } = t.context;
251+
252+
const u2 = await auth.signUp('u4@affine.pro', '4');
253+
254+
// sign in u1
255+
const signInRes = await request(app.getHttpServer())
256+
.post('/api/auth/sign-in')
257+
.send({ email: u1.email, password: '1' })
258+
.expect(200);
259+
260+
const cookie = sessionCookie(signInRes.headers);
261+
262+
await new Promise(resolve => setTimeout(resolve, 1));
263+
264+
// sign in u2 in the same session
167265
await request(app.getHttpServer())
266+
.post('/api/auth/sign-in')
267+
.set('cookie', cookie)
268+
.send({ email: u2.email, password: '4' })
269+
.expect(200);
270+
271+
// sign out u2
272+
let signOut = await request(app.getHttpServer())
273+
.get(`/api/auth/sign-out?user_id=${u2.id}`)
274+
.set('cookie', `${cookie};${AuthService.userCookieName}=${u2.id}`)
275+
.expect(200);
276+
277+
// auto switch to u1 after sign out u2
278+
const userIdCookie = signOut.get('Set-Cookie')?.find(c => {
279+
return c.startsWith(`${AuthService.userCookieName}=`);
280+
});
281+
282+
t.true(userIdCookie?.startsWith(`${AuthService.userCookieName}=${u1.id}`));
283+
284+
// list [u1]
285+
const session = await request(app.getHttpServer())
286+
.get('/api/auth/session')
287+
.set('cookie', cookie)
288+
.expect(200);
289+
290+
t.is(session.body.user.id, u1.id);
291+
292+
// sign in u2 in the same session
293+
await request(app.getHttpServer())
294+
.post('/api/auth/sign-in')
295+
.set('cookie', cookie)
296+
.send({ email: u2.email, password: '4' })
297+
.expect(200);
298+
299+
// sign out all account in session
300+
signOut = await request(app.getHttpServer())
168301
.get('/api/auth/sign-out')
169-
.expect(HttpStatus.UNAUTHORIZED);
302+
.set('cookie', cookie)
303+
.expect(200);
170304

171-
t.assert(true);
305+
t.true(
306+
signOut
307+
.get('Set-Cookie')
308+
?.some(c => c.startsWith(`${AuthService.sessionCookieName}=;`))
309+
);
310+
t.true(
311+
signOut
312+
.get('Set-Cookie')
313+
?.some(c => c.startsWith(`${AuthService.userCookieName}=;`))
314+
);
172315
});

packages/backend/server/tests/auth/service.spec.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,17 @@ test('should be able to signout multi accounts session', async t => {
202202
t.is(list.length, 1);
203203
t.is(list[0]!.id, u2.id);
204204

205-
const u1Session = await auth.getUserSession(session.id, u1.id);
205+
const u2Session = await auth.getUserSession(session.id, u1.id);
206206

207-
t.is(u1Session, null);
207+
t.is(u2Session?.session.sessionId, session.id);
208+
t.is(u2Session?.user.id, u2.id);
208209

209210
await auth.signOut(session.id, u2.id);
210211
list = await auth.getUserList(session.id);
211212

212213
t.is(list.length, 0);
213214

214-
const u2Session = await auth.getUserSession(session.id, u2.id);
215+
const nullSession = await auth.getUserSession(session.id, u2.id);
215216

216-
t.is(u2Session, null);
217+
t.is(nullSession, null);
217218
});

0 commit comments

Comments
 (0)