feat(server): auto refresh session (#6613)

This commit is contained in:
EYHN
2024-04-19 07:00:12 +00:00
parent a2fa9149ff
commit 5e243de392
7 changed files with 82 additions and 16 deletions

View File

@@ -36,7 +36,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
} }
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context); const { req, res } = getRequestResponseFromContext(context);
// check cookie // check cookie
let sessionToken: string | undefined = let sessionToken: string | undefined =
@@ -51,7 +51,19 @@ export class AuthGuard implements CanActivate, OnModuleInit {
req.headers[AuthService.authUserSeqHeaderName] 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) { if (user) {
req.sid = sessionToken; req.sid = sessionToken;

View File

@@ -145,24 +145,27 @@ export class AuthService implements OnApplicationBootstrap {
return sessionUser(user); return sessionUser(user);
} }
async getUser(token: string, seq = 0): Promise<CurrentUser | null> { async getUser(
token: string,
seq = 0
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> {
const session = await this.getSession(token); const session = await this.getSession(token);
// no such session // no such session
if (!session) { if (!session) {
return null; return { user: null, expiresAt: null };
} }
const userSession = session.userSessions.at(seq); const userSession = session.userSessions.at(seq);
// no such user session // no such user session
if (!userSession) { if (!userSession) {
return null; return { user: null, expiresAt: null };
} }
// user session expired // user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) { if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return null; return { user: null, expiresAt: null };
} }
const user = await this.db.user.findUnique({ const user = await this.db.user.findUnique({
@@ -170,10 +173,10 @@ export class AuthService implements OnApplicationBootstrap {
}); });
if (!user) { if (!user) {
return null; return { user: null, expiresAt: null };
} }
return sessionUser(user); return { user: sessionUser(user), expiresAt: userSession.expiresAt };
} }
async getUserList(token: string) { 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<boolean> {
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( async createUserSession(
user: { id: string }, user: { id: string },
existingSession?: string, existingSession?: string,

View File

@@ -240,6 +240,13 @@ export interface AFFiNEConfig {
* @default 15 days * @default 15 days
*/ */
ttl: number; ttl: number;
/**
* Application auth time to refresh in seconds
*
* @default 7 days
*/
ttr: number;
}; };
/** /**

View File

@@ -153,6 +153,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
}, },
session: { session: {
ttl: 15 * ONE_DAY_IN_SEC, ttl: 15 * ONE_DAY_IN_SEC,
ttr: 7 * ONE_DAY_IN_SEC,
}, },
accessToken: { accessToken: {
ttl: 7 * ONE_DAY_IN_SEC, ttl: 7 * ONE_DAY_IN_SEC,

View File

@@ -69,7 +69,7 @@ test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context; const { app, auth } = t.context;
// @ts-expect-error mock // @ts-expect-error mock
auth.getUser.resolves({ id: '1' }); auth.getUser.resolves({ user: { id: '1' } });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get('/public') .get('/public')
@@ -98,7 +98,7 @@ test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context; const { app, auth } = t.context;
// @ts-expect-error mock // @ts-expect-error mock
auth.getUser.resolves({ id: '1' }); auth.getUser.resolves({ user: { id: '1' } });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get('/private') .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 => { test('should be able to parse session cookie', async t => {
const { app, auth } = t.context; const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
await request(app.getHttpServer()) await request(app.getHttpServer())
.get('/public') .get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`) .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 => { test('should be able to parse bearer token', async t => {
const { app, auth } = t.context; const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
await request(app.getHttpServer()) await request(app.getHttpServer())
.get('/public') .get('/public')
.auth('1', { type: 'bearer' }) .auth('1', { type: 'bearer' })

View File

@@ -156,7 +156,7 @@ test('should be able to get user from session', async t => {
const session = await auth.createUserSession(u1); 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.not(user, null);
t.is(user!.id, u1.id); 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); t.not(signedOutSession, null);
const signedU2 = await auth.getUser(session.sessionId, 0); const { user: signedU2 } = await auth.getUser(session.sessionId, 0);
const noUser = await auth.getUser(session.sessionId, 1); const { user: noUser } = await auth.getUser(session.sessionId, 1);
t.is(noUser, null); t.is(noUser, null);
t.not(signedU2, null); t.not(signedU2, null);
@@ -215,6 +215,6 @@ test('should be able to signout multi accounts session', async t => {
t.is(signedOutSession, null); 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); t.is(noUser2, null);
}); });

View File

@@ -303,7 +303,7 @@ test('should throw if oauth account already connected', async t => {
}); });
// @ts-expect-error mock // @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'); 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; const { app, u1, auth, db } = t.context;
// @ts-expect-error mock // @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); mockOAuthProvider(app, u1.email);