feat(server): authenticate user before ws connected (#7777)

This commit is contained in:
forehalo
2024-08-08 08:30:55 +00:00
parent 83244f0201
commit f2eafc374c
17 changed files with 232 additions and 87 deletions

View File

@@ -152,6 +152,8 @@ function buildAppModule() {
factor factor
// common fundamental modules // common fundamental modules
.use(...FunctionalityModules) .use(...FunctionalityModules)
.useIf(config => config.flavor.sync, WebSocketModule)
// auth // auth
.use(AuthModule) .use(AuthModule)
@@ -159,7 +161,7 @@ function buildAppModule() {
.use(DocModule) .use(DocModule)
// sync server only // sync server only
.useIf(config => config.flavor.sync, WebSocketModule, SyncModule) .useIf(config => config.flavor.sync, SyncModule)
// graphql server only // graphql server only
.useIf( .useIf(

View File

@@ -1,6 +1,6 @@
import type { ExecutionContext } from '@nestjs/common'; import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common'; import { createParamDecorator } from '@nestjs/common';
import { User } from '@prisma/client'; import { User, UserSession } from '@prisma/client';
import { getRequestResponseFromContext } from '../../fundamentals'; import { getRequestResponseFromContext } from '../../fundamentals';
@@ -53,3 +53,5 @@ export interface CurrentUser
hasPassword: boolean | null; hasPassword: boolean | null;
emailVerified: boolean; emailVerified: boolean;
} }
export { type UserSession };

View File

@@ -1,15 +1,22 @@
import type { import type {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
FactoryProvider,
OnModuleInit, OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common'; import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core'; import { ModuleRef, Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { import {
AuthenticationRequired, AuthenticationRequired,
Config,
getRequestResponseFromContext, getRequestResponseFromContext,
mapAnyError,
parseCookies,
} from '../../fundamentals'; } from '../../fundamentals';
import { WEBSOCKET_OPTIONS } from '../../fundamentals/websocket';
import { CurrentUser, UserSession } from './current-user';
import { AuthService, parseAuthUserSeqNum } from './service'; import { AuthService, parseAuthUserSeqNum } from './service';
function extractTokenFromHeader(authorization: string) { function extractTokenFromHeader(authorization: string) {
@@ -38,37 +45,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
const { req, res } = getRequestResponseFromContext(context); const { req, res } = getRequestResponseFromContext(context);
// check cookie const userSession = await this.signIn(req);
let sessionToken: string | undefined = if (res && userSession && userSession.session.expiresAt) {
req.cookies[AuthService.sessionCookieName]; await this.auth.refreshUserSessionIfNeeded(req, res, userSession.session);
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
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;
req.user = user;
}
} }
// api is public // api is public
@@ -84,9 +63,44 @@ export class AuthGuard implements CanActivate, OnModuleInit {
if (!req.user) { if (!req.user) {
throw new AuthenticationRequired(); throw new AuthenticationRequired();
} }
return true; return true;
} }
async signIn(
req: Request
): Promise<{ user: CurrentUser; session: UserSession } | null> {
if (req.user && req.session) {
return {
user: req.user,
session: req.session,
};
}
parseCookies(req);
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const userSession = await this.auth.getUserSession(sessionToken, userSeq);
if (userSession) {
req.session = userSession.session;
req.user = userSession.user;
}
return userSession;
}
return null;
}
} }
/** /**
@@ -111,3 +125,35 @@ export const Auth = () => {
// api is public accessible // api is public accessible
export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true); export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true);
export const AuthWebsocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config, guard: AuthGuard) => {
return {
...config.websocket,
allowRequest: async (
req: any,
pass: (err: string | null | undefined, success: boolean) => void
) => {
if (!config.websocket.requireAuthentication) {
return pass(null, true);
}
try {
const authentication = await guard.signIn(req);
if (authentication) {
return pass(null, true);
} else {
return pass('unauthenticated', false);
}
} catch (e) {
const error = mapAnyError(e);
error.log('Websocket');
return pass('unauthenticated', false);
}
},
};
},
inject: [Config, AuthGuard],
};

View File

@@ -6,15 +6,21 @@ import { FeatureModule } from '../features';
import { QuotaModule } from '../quota'; import { QuotaModule } from '../quota';
import { UserModule } from '../user'; import { UserModule } from '../user';
import { AuthController } from './controller'; import { AuthController } from './controller';
import { AuthGuard } from './guard'; import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
import { AuthResolver } from './resolver'; import { AuthResolver } from './resolver';
import { AuthService } from './service'; import { AuthService } from './service';
import { TokenService, TokenType } from './token'; import { TokenService, TokenType } from './token';
@Module({ @Module({
imports: [FeatureModule, UserModule, QuotaModule], imports: [FeatureModule, UserModule, QuotaModule],
providers: [AuthService, AuthResolver, TokenService, AuthGuard], providers: [
exports: [AuthService, AuthGuard], AuthService,
AuthResolver,
TokenService,
AuthGuard,
AuthWebsocketOptionsProvider,
],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -1,6 +1,6 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import type { User } from '@prisma/client'; import type { User, UserSession } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express'; import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es'; import { assign, omit } from 'lodash-es';
@@ -121,27 +121,27 @@ export class AuthService implements OnApplicationBootstrap {
return sessionUser(user); return sessionUser(user);
} }
async getUser( async getUserSession(
token: string, token: string,
seq = 0 seq = 0
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> { ): Promise<{ user: CurrentUser; session: UserSession } | null> {
const session = await this.getSession(token); const session = await this.getSession(token);
// no such session // no such session
if (!session) { if (!session) {
return { user: null, expiresAt: null }; return 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 { user: null, expiresAt: null }; return null;
} }
// user session expired // user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) { if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return { user: null, expiresAt: null }; return null;
} }
const user = await this.db.user.findUnique({ const user = await this.db.user.findUnique({
@@ -149,10 +149,10 @@ export class AuthService implements OnApplicationBootstrap {
}); });
if (!user) { if (!user) {
return { user: null, expiresAt: null }; return null;
} }
return { user: sessionUser(user), expiresAt: userSession.expiresAt }; return { user: sessionUser(user), session: userSession };
} }
async getUserList(token: string) { async getUserList(token: string) {
@@ -251,12 +251,13 @@ export class AuthService implements OnApplicationBootstrap {
async refreshUserSessionIfNeeded( async refreshUserSessionIfNeeded(
_req: Request, _req: Request,
res: Response, res: Response,
sessionId: string, session: UserSession,
userId: string,
expiresAt: Date,
ttr = this.config.auth.session.ttr ttr = this.config.auth.session.ttr
): Promise<boolean> { ): Promise<boolean> {
if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) { if (
session.expiresAt &&
session.expiresAt.getTime() - Date.now() > ttr * 1000
) {
// no need to refresh // no need to refresh
return false; return false;
} }
@@ -267,17 +268,14 @@ export class AuthService implements OnApplicationBootstrap {
await this.db.userSession.update({ await this.db.userSession.update({
where: { where: {
sessionId_userId: { id: session.id,
sessionId,
userId,
},
}, },
data: { data: {
expiresAt: newExpiresAt, expiresAt: newExpiresAt,
}, },
}); });
res.cookie(AuthService.sessionCookieName, sessionId, { res.cookie(AuthService.sessionCookieName, session.sessionId, {
expires: newExpiresAt, expires: newExpiresAt,
...this.cookieOptions, ...this.cookieOptions,
}); });

View File

@@ -50,12 +50,7 @@ function Awareness(workspaceId: string): `${string}:awareness` {
return `${workspaceId}:awareness`; return `${workspaceId}:awareness`;
} }
@WebSocketGateway({ @WebSocketGateway()
cors: !AFFiNE.node.prod,
transports: ['websocket'],
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
maxHttpBufferSize: 1e8, // 100 MB
})
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect { export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
protected logger = new Logger(EventsGateway.name); protected logger = new Logger(EventsGateway.name);
private connectionCount = 0; private connectionCount = 0;

View File

@@ -36,5 +36,6 @@ export {
getRequestFromHost, getRequestFromHost,
getRequestResponseFromContext, getRequestResponseFromContext,
getRequestResponseFromHost, getRequestResponseFromHost,
parseCookies,
} from './utils/request'; } from './utils/request';
export type * from './utils/types'; export type * from './utils/types';

View File

@@ -2,8 +2,10 @@ import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core'; import { BaseExceptionFilter } from '@nestjs/core';
import { GqlContextType } from '@nestjs/graphql'; import { GqlContextType } from '@nestjs/graphql';
import { ThrottlerException } from '@nestjs/throttler'; import { ThrottlerException } from '@nestjs/throttler';
import { BaseWsExceptionFilter } from '@nestjs/websockets';
import { Response } from 'express'; import { Response } from 'express';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { Socket } from 'socket.io';
import { import {
InternalServerError, InternalServerError,
@@ -44,6 +46,20 @@ export class GlobalExceptionFilter extends BaseExceptionFilter {
} }
} }
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
// @ts-expect-error satisfies the override
override handleError(client: Socket, exception: any): void {
const error = mapAnyError(exception);
error.log('Websocket');
metrics.socketio
.counter('unhandled_error')
.add(1, { status: error.status });
client.emit('error', {
error: toWebsocketError(error),
});
}
}
/** /**
* Only exists for websocket error body backward compatibility * Only exists for websocket error body backward compatibility
* *

View File

@@ -57,7 +57,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
override getTracker(req: Request): Promise<string> { override getTracker(req: Request): Promise<string> {
return Promise.resolve( return Promise.resolve(
// ↓ prefer session id if available // ↓ prefer session id if available
`throttler:${req.sid ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}` `throttler:${req.session?.sessionId ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
// ^ throttler prefix make the key in store recognizable // ^ throttler prefix make the key in store recognizable
); );
} }

View File

@@ -66,3 +66,29 @@ export function getRequestFromHost(host: ArgumentsHost) {
export function getRequestResponseFromContext(ctx: ExecutionContext) { export function getRequestResponseFromContext(ctx: ExecutionContext) {
return getRequestResponseFromHost(ctx); return getRequestResponseFromHost(ctx);
} }
/**
* simple patch for request not protected by `cookie-parser`
* only take effect if `req.cookies` is not defined
*/
export function parseCookies(req: Request) {
if (req.cookies) {
return;
}
const cookieStr = req?.headers?.cookie ?? '';
req.cookies = cookieStr.split(';').reduce(
(cookies, cookie) => {
const [key, val] = cookie.split('=');
if (key) {
cookies[decodeURIComponent(key.trim())] = val
? decodeURIComponent(val.trim())
: val;
}
return cookies;
},
{} as Record<string, string>
);
}

View File

@@ -0,0 +1,20 @@
import { GatewayMetadata } from '@nestjs/websockets';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
websocket: ModuleConfig<
GatewayMetadata & {
requireAuthentication?: boolean;
}
>;
}
}
defineStartupConfig('websocket', {
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
transports: ['websocket'],
maxHttpBufferSize: 1e8, // 100 MB
requireAuthentication: true,
});

View File

@@ -1,17 +1,46 @@
import { Module, Provider } from '@nestjs/common'; import './config';
import {
FactoryProvider,
INestApplicationContext,
Module,
Provider,
} from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io'; import { IoAdapter } from '@nestjs/platform-socket.io';
import { Server } from 'socket.io';
import { Config } from '../config';
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl'); export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
export class SocketIoAdapter extends IoAdapter {} export class SocketIoAdapter extends IoAdapter {
constructor(protected readonly app: INestApplicationContext) {
super(app);
}
override createIOServer(port: number, options?: any): Server {
const config = this.app.get(WEBSOCKET_OPTIONS);
return super.createIOServer(port, { ...config, ...options });
}
}
const SocketIoAdapterImplProvider: Provider = { const SocketIoAdapterImplProvider: Provider = {
provide: SocketIoAdapterImpl, provide: SocketIoAdapterImpl,
useValue: SocketIoAdapter, useValue: SocketIoAdapter,
}; };
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
export const websocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config) => {
return config.websocket;
},
inject: [Config],
};
@Module({ @Module({
providers: [SocketIoAdapterImplProvider], providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
exports: [SocketIoAdapterImplProvider], exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
}) })
export class WebSocketModule {} export class WebSocketModule {}

View File

@@ -1,7 +1,7 @@
declare namespace Express { declare namespace Express {
interface Request { interface Request {
user?: import('./core/auth/current-user').CurrentUser; user?: import('./core/auth/current-user').CurrentUser;
sid?: string; session?: import('./core/auth/current-user').UserSession;
} }
} }

View File

@@ -18,7 +18,7 @@ export function createSockerIoAdapterImpl(
console.error(err); console.error(err);
}); });
const server = super.createIOServer(port, options) as Server; const server = super.createIOServer(port, options);
server.adapter(createAdapter(pubClient, subClient)); server.adapter(createAdapter(pubClient, subClient));
return server; return server;
} }

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({ user: { id: '1' } }); auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get('/public') .get('/public')
@@ -100,7 +100,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({ user: { id: '1' } }); auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get('/private') .get('/private')
@@ -114,26 +114,26 @@ test('should be able to parse session cookie', async t => {
const { app, auth } = t.context; const { app, auth } = t.context;
// @ts-expect-error mock // @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } }); auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer()) await request(app.getHttpServer())
.get('/public') .get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`) .set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200); .expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]); t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
}); });
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 // @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } }); auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer()) await request(app.getHttpServer())
.get('/public') .get('/public')
.auth('1', { type: 'bearer' }) .auth('1', { type: 'bearer' })
.expect(200); .expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]); t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
}); });

View File

@@ -157,10 +157,10 @@ 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 userSession = await auth.getUserSession(session.sessionId);
t.not(user, null); t.not(userSession, null);
t.is(user!.id, u1.id); t.is(userSession!.user.id, u1.id);
}); });
test('should be able to sign out session', async t => { test('should be able to sign out session', async t => {
@@ -203,19 +203,19 @@ test('should be able to signout multi accounts session', async t => {
t.not(signedOutSession, null); t.not(signedOutSession, null);
const { user: signedU2 } = await auth.getUser(session.sessionId, 0); const userSession1 = await auth.getUserSession(session.sessionId, 0);
const { user: noUser } = await auth.getUser(session.sessionId, 1); const userSession2 = await auth.getUserSession(session.sessionId, 1);
t.is(noUser, null); t.is(userSession2, null);
t.not(signedU2, null); t.not(userSession1, null);
t.is(signedU2!.id, u2.id); t.is(userSession1!.user.id, u2.id);
// sign out user at seq(0) // sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId); signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null); t.is(signedOutSession, null);
const { user: noUser2 } = await auth.getUser(session.sessionId, 0); const userSession3 = await auth.getUserSession(session.sessionId, 0);
t.is(noUser2, null); t.is(userSession3, null);
}); });

View File

@@ -341,8 +341,10 @@ test('should throw if oauth account already connected', async t => {
}, },
}); });
// @ts-expect-error mock Sinon.stub(auth, 'getUserSession').resolves({
Sinon.stub(auth, 'getUser').resolves({ user: { id: 'u2-id' } }); user: { id: 'u2-id' },
session: {},
} as any);
mockOAuthProvider(app, 'u2@affine.pro'); mockOAuthProvider(app, 'u2@affine.pro');
@@ -363,8 +365,10 @@ test('should throw if oauth account already connected', async t => {
test('should be able to connect oauth account', async t => { 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 Sinon.stub(auth, 'getUserSession').resolves({
Sinon.stub(auth, 'getUser').resolves({ user: { id: u1.id } }); user: { id: u1.id },
session: {},
} as any);
mockOAuthProvider(app, u1.email); mockOAuthProvider(app, u1.email);