mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 06:18:45 +08:00
feat: drop outdated session (#14373)
#### PR Dependency Tree * **PR #14373** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added client version tracking and validation to ensure application compatibility across authentication flows and sessions. * Enhanced OAuth authentication with improved version handling during sign-in and refresh operations. * **Bug Fixes** * Improved payment callback URL handling with safer defaults for redirect links. * **Tests** * Expanded test coverage for client version enforcement and session management. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
import { Injectable, SetMetadata } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import type { Request, Response } from 'express';
|
||||
import semver from 'semver';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
import {
|
||||
@@ -15,8 +16,10 @@ import {
|
||||
Cache,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
getClientVersionFromRequest,
|
||||
getRequestResponseFromContext,
|
||||
parseCookies,
|
||||
UnsupportedClientVersion,
|
||||
} from '../../base';
|
||||
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
||||
import { AuthService } from './service';
|
||||
@@ -30,10 +33,13 @@ const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
private auth!: AuthService;
|
||||
private readonly cachedVersionRange = new Map<string, semver.Range | null>();
|
||||
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
|
||||
|
||||
constructor(
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly cache: Cache,
|
||||
private readonly config: Config,
|
||||
private readonly ref: ModuleRef,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
@@ -78,14 +84,14 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
throw new AccessDenied('Invalid internal request');
|
||||
}
|
||||
|
||||
const authedUser = await this.signIn(req, res);
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||
PUBLIC_ENTRYPOINT_SYMBOL,
|
||||
[clazz, handler]
|
||||
);
|
||||
|
||||
const authedUser = await this.signIn(req, res, isPublic);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
@@ -99,9 +105,10 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
async signIn(
|
||||
req: Request,
|
||||
res?: Response
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | TokenSession | null> {
|
||||
const userSession = await this.signInWithCookie(req, res);
|
||||
const userSession = await this.signInWithCookie(req, res, isPublic);
|
||||
if (userSession) {
|
||||
return userSession;
|
||||
}
|
||||
@@ -111,7 +118,8 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
async signInWithCookie(
|
||||
req: Request,
|
||||
res?: Response
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | null> {
|
||||
if (req.session) {
|
||||
return req.session;
|
||||
@@ -121,8 +129,38 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
const userSession = await this.auth.getUserSessionFromRequest(req, res);
|
||||
|
||||
if (userSession) {
|
||||
const headerClientVersion = getClientVersionFromRequest(req);
|
||||
if (this.config.client.versionControl.enabled) {
|
||||
const clientVersion =
|
||||
headerClientVersion ??
|
||||
userSession.session.refreshClientVersion ??
|
||||
userSession.session.signInClientVersion;
|
||||
|
||||
const versionCheckResult = this.checkClientVersion(clientVersion);
|
||||
if (!versionCheckResult.ok) {
|
||||
await this.auth.signOut(userSession.session.sessionId);
|
||||
if (res) {
|
||||
await this.auth.refreshCookies(res, userSession.session.sessionId);
|
||||
}
|
||||
|
||||
if (isPublic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
requiredVersion: versionCheckResult.requiredVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (res) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession.session);
|
||||
await this.auth.refreshUserSessionIfNeeded(
|
||||
res,
|
||||
userSession.session,
|
||||
undefined,
|
||||
headerClientVersion
|
||||
);
|
||||
}
|
||||
|
||||
req.session = {
|
||||
@@ -154,6 +192,59 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getVersionRange(versionRange: string): semver.Range | null {
|
||||
if (this.cachedVersionRange.has(versionRange)) {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return this.cachedVersionRange.get(versionRange)!;
|
||||
}
|
||||
|
||||
let range: semver.Range | null = null;
|
||||
try {
|
||||
range = new semver.Range(versionRange, { loose: false });
|
||||
if (!semver.validRange(range)) {
|
||||
range = null;
|
||||
}
|
||||
} catch {
|
||||
range = null;
|
||||
}
|
||||
|
||||
this.cachedVersionRange.set(versionRange, range);
|
||||
return range;
|
||||
}
|
||||
|
||||
private checkClientVersion(
|
||||
clientVersion?: string | null
|
||||
): { ok: true } | { ok: false; requiredVersion: string } {
|
||||
const requiredVersion = this.config.client.versionControl.requiredVersion;
|
||||
|
||||
const configRange = this.getVersionRange(requiredVersion);
|
||||
if (
|
||||
configRange &&
|
||||
(!clientVersion ||
|
||||
!semver.satisfies(clientVersion, configRange, {
|
||||
includePrerelease: true,
|
||||
}))
|
||||
) {
|
||||
return { ok: false, requiredVersion };
|
||||
}
|
||||
|
||||
const hardRange = this.getVersionRange(AuthGuard.HARD_REQUIRED_VERSION);
|
||||
if (!hardRange) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (
|
||||
!clientVersion ||
|
||||
!semver.satisfies(clientVersion, hardRange, {
|
||||
includePrerelease: true,
|
||||
})
|
||||
) {
|
||||
return { ok: false, requiredVersion: AuthGuard.HARD_REQUIRED_VERSION };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,7 +275,13 @@ export const AuthWebsocketOptionsProvider: FactoryProvider = {
|
||||
...upgradeReq.cookies,
|
||||
};
|
||||
|
||||
const session = await guard.signIn(upgradeReq);
|
||||
const session = await (async () => {
|
||||
try {
|
||||
return await guard.signIn(upgradeReq);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return !!session;
|
||||
},
|
||||
|
||||
@@ -4,7 +4,11 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import {
|
||||
Config,
|
||||
getClientVersionFromRequest,
|
||||
SignUpForbidden,
|
||||
} from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
import { createDevUsers } from './dev';
|
||||
@@ -130,11 +134,17 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return await this.models.session.findUserSessionsBySessionId(sessionId);
|
||||
}
|
||||
|
||||
async createUserSession(userId: string, sessionId?: string, ttl?: number) {
|
||||
async createUserSession(
|
||||
userId: string,
|
||||
sessionId?: string,
|
||||
ttl?: number,
|
||||
signInClientVersion?: string
|
||||
) {
|
||||
return await this.models.session.createOrRefreshUserSession(
|
||||
userId,
|
||||
sessionId,
|
||||
ttl
|
||||
ttl,
|
||||
signInClientVersion
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,11 +169,13 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
async refreshUserSessionIfNeeded(
|
||||
res: Response,
|
||||
userSession: UserSession,
|
||||
ttr?: number
|
||||
ttr?: number,
|
||||
refreshClientVersion?: string
|
||||
): Promise<boolean> {
|
||||
const newExpiresAt = await this.models.session.refreshUserSessionIfNeeded(
|
||||
userSession,
|
||||
ttr
|
||||
ttr,
|
||||
refreshClientVersion
|
||||
);
|
||||
if (!newExpiresAt) {
|
||||
// no need to refresh
|
||||
@@ -205,10 +217,22 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
};
|
||||
}
|
||||
|
||||
async setCookies(req: Request, res: Response, userId: string) {
|
||||
async setCookies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
userId: string,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const { sessionId } = this.getSessionOptionsFromRequest(req);
|
||||
|
||||
const userSession = await this.createUserSession(userId, sessionId);
|
||||
const signInClientVersion =
|
||||
clientVersion ?? getClientVersionFromRequest(req);
|
||||
const userSession = await this.createUserSession(
|
||||
userId,
|
||||
sessionId,
|
||||
undefined,
|
||||
signInClientVersion
|
||||
);
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, userSession.sessionId, {
|
||||
...this.cookieOptions,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
Config,
|
||||
getClientVersionFromRequest,
|
||||
getRequestResponseFromContext,
|
||||
GuardProvider,
|
||||
} from '../../base';
|
||||
@@ -33,7 +34,7 @@ export class VersionGuardProvider
|
||||
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
|
||||
const version = req.headers['x-affine-version'] as string | undefined;
|
||||
const version = getClientVersionFromRequest(req);
|
||||
|
||||
return this.version.checkVersion(version);
|
||||
}
|
||||
|
||||
@@ -6,23 +6,24 @@ import { Config, UnsupportedClientVersion } from '../../base';
|
||||
@Injectable()
|
||||
export class VersionService {
|
||||
private readonly logger = new Logger(VersionService.name);
|
||||
private static readonly HARD_REQUIRED_VERSION = '>=0.25.0';
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async checkVersion(clientVersion?: string) {
|
||||
const requiredVersion = this.config.client.versionControl.requiredVersion;
|
||||
|
||||
const range = await this.getVersionRange(requiredVersion);
|
||||
if (!range) {
|
||||
// ignore invalid allowed version config
|
||||
return true;
|
||||
}
|
||||
const hardRange = await this.getVersionRange(
|
||||
VersionService.HARD_REQUIRED_VERSION
|
||||
);
|
||||
const configRange = await this.getVersionRange(requiredVersion);
|
||||
|
||||
if (
|
||||
!clientVersion ||
|
||||
!semver.satisfies(clientVersion, range, {
|
||||
includePrerelease: true,
|
||||
})
|
||||
configRange &&
|
||||
(!clientVersion ||
|
||||
!semver.satisfies(clientVersion, configRange, {
|
||||
includePrerelease: true,
|
||||
}))
|
||||
) {
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
@@ -30,6 +31,19 @@ export class VersionService {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hardRange &&
|
||||
(!clientVersion ||
|
||||
!semver.satisfies(clientVersion, hardRange, {
|
||||
includePrerelease: true,
|
||||
}))
|
||||
) {
|
||||
throw new UnsupportedClientVersion({
|
||||
clientVersion: clientVersion ?? 'unset_or_invalid',
|
||||
requiredVersion: VersionService.HARD_REQUIRED_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user