diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index 99479ab02a..c519418b27 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -36,7 +36,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { } async canActivate(context: ExecutionContext) { - const { req } = getRequestResponseFromContext(context); + const { req, res } = getRequestResponseFromContext(context); // check cookie let sessionToken: string | undefined = @@ -51,7 +51,19 @@ export class AuthGuard implements CanActivate, OnModuleInit { req.headers[AuthService.authUserSeqHeaderName] ); - const user = await this.auth.getUser(sessionToken, userSeq); + const { user, expiresAt } = await this.auth.getUser( + sessionToken, + userSeq + ); + if (res && user && expiresAt) { + await this.auth.refreshUserSessionIfNeeded( + req, + res, + sessionToken, + user.id, + expiresAt + ); + } if (user) { req.sid = sessionToken; diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index f71301257a..1c4eacb205 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -145,24 +145,27 @@ export class AuthService implements OnApplicationBootstrap { return sessionUser(user); } - async getUser(token: string, seq = 0): Promise { + async getUser( + token: string, + seq = 0 + ): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> { const session = await this.getSession(token); // no such session if (!session) { - return null; + return { user: null, expiresAt: null }; } const userSession = session.userSessions.at(seq); // no such user session if (!userSession) { - return null; + return { user: null, expiresAt: null }; } // user session expired if (userSession.expiresAt && userSession.expiresAt <= new Date()) { - return null; + return { user: null, expiresAt: null }; } const user = await this.db.user.findUnique({ @@ -170,10 +173,10 @@ export class AuthService implements OnApplicationBootstrap { }); if (!user) { - return null; + return { user: null, expiresAt: null }; } - return sessionUser(user); + return { user: sessionUser(user), expiresAt: userSession.expiresAt }; } async getUserList(token: string) { @@ -269,6 +272,43 @@ export class AuthService implements OnApplicationBootstrap { }); } + async refreshUserSessionIfNeeded( + _req: Request, + res: Response, + sessionId: string, + userId: string, + expiresAt: Date, + ttr = this.config.auth.session.ttr + ): Promise { + if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) { + // no need to refresh + return false; + } + + const newExpiresAt = new Date( + Date.now() + this.config.auth.session.ttl * 1000 + ); + + await this.db.userSession.update({ + where: { + sessionId_userId: { + sessionId, + userId, + }, + }, + data: { + expiresAt: newExpiresAt, + }, + }); + + res.cookie(AuthService.sessionCookieName, sessionId, { + expires: newExpiresAt, + ...this.cookieOptions, + }); + + return true; + } + async createUserSession( user: { id: string }, existingSession?: string, diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 6517293d2c..c93e314d59 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -240,6 +240,13 @@ export interface AFFiNEConfig { * @default 15 days */ ttl: number; + + /** + * Application auth time to refresh in seconds + * + * @default 7 days + */ + ttr: number; }; /** diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index d4ff5b1f8f..6b7816265a 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -153,6 +153,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { }, session: { ttl: 15 * ONE_DAY_IN_SEC, + ttr: 7 * ONE_DAY_IN_SEC, }, accessToken: { ttl: 7 * ONE_DAY_IN_SEC, diff --git a/packages/backend/server/tests/auth/guard.spec.ts b/packages/backend/server/tests/auth/guard.spec.ts index 1841b38480..b5b7a73187 100644 --- a/packages/backend/server/tests/auth/guard.spec.ts +++ b/packages/backend/server/tests/auth/guard.spec.ts @@ -69,7 +69,7 @@ test('should be able to visit public api if signed in', async t => { const { app, auth } = t.context; // @ts-expect-error mock - auth.getUser.resolves({ id: '1' }); + auth.getUser.resolves({ user: { id: '1' } }); const res = await request(app.getHttpServer()) .get('/public') @@ -98,7 +98,7 @@ test('should be able to visit private api if signed in', async t => { const { app, auth } = t.context; // @ts-expect-error mock - auth.getUser.resolves({ id: '1' }); + auth.getUser.resolves({ user: { id: '1' } }); const res = await request(app.getHttpServer()) .get('/private') @@ -111,6 +111,9 @@ test('should be able to visit private api if signed in', async t => { test('should be able to parse session cookie', async t => { const { app, auth } = t.context; + // @ts-expect-error mock + auth.getUser.resolves({ user: { id: '1' } }); + await request(app.getHttpServer()) .get('/public') .set('cookie', `${AuthService.sessionCookieName}=1`) @@ -122,6 +125,9 @@ test('should be able to parse session cookie', async t => { test('should be able to parse bearer token', async t => { const { app, auth } = t.context; + // @ts-expect-error mock + auth.getUser.resolves({ user: { id: '1' } }); + await request(app.getHttpServer()) .get('/public') .auth('1', { type: 'bearer' }) diff --git a/packages/backend/server/tests/auth/service.spec.ts b/packages/backend/server/tests/auth/service.spec.ts index 3d4db7b37f..017ce4e303 100644 --- a/packages/backend/server/tests/auth/service.spec.ts +++ b/packages/backend/server/tests/auth/service.spec.ts @@ -156,7 +156,7 @@ test('should be able to get user from session', async t => { const session = await auth.createUserSession(u1); - const user = await auth.getUser(session.sessionId); + const { user } = await auth.getUser(session.sessionId); t.not(user, null); t.is(user!.id, u1.id); @@ -202,8 +202,8 @@ test('should be able to signout multi accounts session', async t => { t.not(signedOutSession, null); - const signedU2 = await auth.getUser(session.sessionId, 0); - const noUser = await auth.getUser(session.sessionId, 1); + const { user: signedU2 } = await auth.getUser(session.sessionId, 0); + const { user: noUser } = await auth.getUser(session.sessionId, 1); t.is(noUser, null); t.not(signedU2, null); @@ -215,6 +215,6 @@ test('should be able to signout multi accounts session', async t => { t.is(signedOutSession, null); - const noUser2 = await auth.getUser(session.sessionId, 0); + const { user: noUser2 } = await auth.getUser(session.sessionId, 0); t.is(noUser2, null); }); diff --git a/packages/backend/server/tests/oauth/controller.spec.ts b/packages/backend/server/tests/oauth/controller.spec.ts index bbc7984ddb..d6d4a257dd 100644 --- a/packages/backend/server/tests/oauth/controller.spec.ts +++ b/packages/backend/server/tests/oauth/controller.spec.ts @@ -303,7 +303,7 @@ test('should throw if oauth account already connected', async t => { }); // @ts-expect-error mock - Sinon.stub(auth, 'getUser').resolves({ id: 'u2-id' }); + Sinon.stub(auth, 'getUser').resolves({ user: { id: 'u2-id' } }); mockOAuthProvider(app, 'u2@affine.pro'); @@ -325,7 +325,7 @@ test('should be able to connect oauth account', async t => { const { app, u1, auth, db } = t.context; // @ts-expect-error mock - Sinon.stub(auth, 'getUser').resolves({ id: u1.id }); + Sinon.stub(auth, 'getUser').resolves({ user: { id: u1.id } }); mockOAuthProvider(app, u1.email);