From 5d5bed827098ff91ba93e619988fc974c2b102ba Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 7 Sep 2025 14:36:46 +0200 Subject: [PATCH] chore: Refactor auth/CSRF middleware (#10113) * chore: Refactor auth/CSRF middleware * sp --- plugins/storage/server/api/files.ts | 2 +- server/middlewares/authentication.ts | 375 +++++++++++++---------- server/middlewares/csrf.ts | 4 +- server/routes/api/documents/documents.ts | 2 +- 4 files changed, 211 insertions(+), 172 deletions(-) diff --git a/plugins/storage/server/api/files.ts b/plugins/storage/server/api/files.ts index d776047d9b..fcb7d3e66b 100644 --- a/plugins/storage/server/api/files.ts +++ b/plugins/storage/server/api/files.ts @@ -28,7 +28,7 @@ const router = new Router(); router.post( "files.create", rateLimiter(RateLimiterStrategy.TenPerMinute), - auth({ allowMultipart: true }), + auth(), validate(T.FilesCreateSchema), multipart({ maximumFileSize: Math.max( diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index 95838d04c2..69aba2ca3d 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -17,185 +17,29 @@ import { } from "../errors"; type AuthenticationOptions = { - /** Role requuired to access the route. */ + /** Role required to access the route. */ role?: UserRole; /** Type of authentication required to access the route. */ type?: AuthenticationType | AuthenticationType[]; /** Authentication is parsed, but optional. */ optional?: boolean; - /** - * Allow multipart requests with cookie authentication, otherwise - * the request will fail if the content type is not application/json. - * This is useful for file uploads where the cookie is used to authenticate. - */ - allowMultipart?: boolean; +}; + +type AuthTransport = "cookie" | "header" | "body" | "query"; + +type AuthInput = { + /** The authentication token extracted from the request, if any. */ + token?: string; + /** The method used to receive the authentication token. */ + transport?: AuthTransport; }; export default function auth(options: AuthenticationOptions = {}) { return async function authMiddleware(ctx: AppContext, next: Next) { - let token; - const authorizationHeader = ctx.request.get("authorization"); - - if (authorizationHeader) { - const parts = authorizationHeader.split(" "); - - if (parts.length === 2) { - const scheme = parts[0]; - const credentials = parts[1]; - - if (/^Bearer$/i.test(scheme)) { - token = credentials; - } - } else { - throw AuthenticationError( - `Bad Authorization header format. Format is "Authorization: Bearer "` - ); - } - } else if ( - ctx.request.body && - typeof ctx.request.body === "object" && - "token" in ctx.request.body - ) { - token = ctx.request.body.token; - } else if (ctx.request.query?.token) { - token = ctx.request.query.token; - } else { - token = ctx.cookies.get("accessToken"); - - // check if the request is application/json encoded - // TODO: Enable once clients have updated - // if ( - // token && - // !ctx.request.is("application/json") && - // !options.allowMultipart - // ) { - // throw AuthenticationError( - // "Mismatched content type. Expected application/json" - // ); - // } - } - try { - if (!token) { - throw AuthenticationError("Authentication required"); - } + const { type, token, user } = await validateAuthentication(ctx, options); - let user: User | null; - let type: AuthenticationType; - - if (OAuthAuthentication.match(String(token))) { - if (!authorizationHeader) { - throw AuthenticationError( - "OAuth access token must be passed in the Authorization header" - ); - } - - type = AuthenticationType.OAUTH; - - let authentication; - try { - authentication = await OAuthAuthentication.findByAccessToken(token, { - rejectOnEmpty: true, - }); - } catch (_err) { - throw AuthenticationError("Invalid access token"); - } - if (!authentication) { - throw AuthenticationError("Invalid access token"); - } - if (authentication.accessTokenExpiresAt < new Date()) { - throw AuthenticationError("Access token is expired"); - } - if (!authentication.canAccess(ctx.request.url)) { - throw AuthenticationError( - "Access token does not have access to this resource" - ); - } - - user = await User.findByPk(authentication.userId, { - include: [ - { - model: Team, - as: "team", - required: true, - }, - ], - }); - if (!user) { - throw AuthenticationError("Invalid access token"); - } - - await authentication.updateActiveAt(); - } else if (ApiKey.match(String(token))) { - type = AuthenticationType.API; - let apiKey; - - try { - apiKey = await ApiKey.findByToken(token); - } catch (_err) { - throw AuthenticationError("Invalid API key"); - } - - if (!apiKey) { - throw AuthenticationError("Invalid API key"); - } - - if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { - throw AuthenticationError("API key is expired"); - } - - if (!apiKey.canAccess(ctx.request.url)) { - throw AuthenticationError( - "API key does not have access to this resource" - ); - } - - user = await User.findByPk(apiKey.userId, { - include: [ - { - model: Team, - as: "team", - required: true, - }, - ], - }); - - if (!user) { - throw AuthenticationError("Invalid API key"); - } - - await apiKey.updateActiveAt(); - } else { - type = AuthenticationType.APP; - user = await getUserForJWT(String(token)); - } - - if (user.isSuspended) { - const suspendingAdmin = await User.findOne({ - where: { - id: user.suspendedById!, - }, - paranoid: false, - }); - throw UserSuspendedError({ - adminEmail: suspendingAdmin?.email || undefined, - }); - } - - if (options.role && UserRoleHelper.isRoleLower(user.role, options.role)) { - throw AuthorizationError(`${capitalize(options.role)} role required`); - } - - if ( - options.type && - (Array.isArray(options.type) - ? !options.type.includes(type) - : type !== options.type) - ) { - throw AuthorizationError(`Invalid authentication type`); - } - - // not awaiting the promises here so that the request is not blocked + // We are not awaiting the promises here so that the request is not blocked user.updateActiveAt(ctx).catch((err) => { Logger.error("Failed to update user activeAt", err); }); @@ -205,7 +49,7 @@ export default function auth(options: AuthenticationOptions = {}) { ctx.state.auth = { user, - token: String(token), + token, type, }; @@ -240,3 +84,196 @@ export default function auth(options: AuthenticationOptions = {}) { return next(); }; } + +/** + * Parses the authentication token from the request context. + * + * @param ctx The application context containing the request information. + * @returns An object containing the token and its transport method. + */ +export function parseAuthentication(ctx: AppContext): AuthInput { + const authorizationHeader = ctx.request.get("authorization"); + + if (authorizationHeader) { + const parts = authorizationHeader.split(" "); + + if (parts.length === 2) { + const scheme = parts[0]; + const credentials = parts[1]; + + if (/^Bearer$/i.test(scheme)) { + return { + token: credentials, + transport: "header", + }; + } + } else { + throw AuthenticationError( + `Bad Authorization header format. Format is "Authorization: Bearer "` + ); + } + } else if ( + ctx.request.body && + typeof ctx.request.body === "object" && + "token" in ctx.request.body + ) { + return { + token: String(ctx.request.body.token), + transport: "body", + }; + } else if (ctx.request.query?.token) { + return { + token: String(ctx.request.query.token), + transport: "query", + }; + } else { + const accessToken = ctx.cookies.get("accessToken"); + if (accessToken) { + return { + token: accessToken, + transport: "cookie", + }; + } + } + + return { + token: undefined, + transport: undefined, + }; +} + +async function validateAuthentication( + ctx: AppContext, + options: AuthenticationOptions +): Promise<{ user: User; token: string; type: AuthenticationType }> { + const { token, transport } = parseAuthentication(ctx); + + if (!token) { + throw AuthenticationError("Authentication required"); + } + + let user: User | null; + let type: AuthenticationType; + + if (OAuthAuthentication.match(token)) { + if (transport !== "header") { + throw AuthenticationError( + "OAuth access token must be passed in the Authorization header" + ); + } + + type = AuthenticationType.OAUTH; + + let authentication; + try { + authentication = await OAuthAuthentication.findByAccessToken(token, { + rejectOnEmpty: true, + }); + } catch (_err) { + throw AuthenticationError("Invalid access token"); + } + if (!authentication) { + throw AuthenticationError("Invalid access token"); + } + if (authentication.accessTokenExpiresAt < new Date()) { + throw AuthenticationError("Access token is expired"); + } + if (!authentication.canAccess(ctx.request.url)) { + throw AuthenticationError( + "Access token does not have access to this resource" + ); + } + + user = await User.findByPk(authentication.userId, { + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], + }); + if (!user) { + throw AuthenticationError("Invalid access token"); + } + + await authentication.updateActiveAt(); + } else if (ApiKey.match(token)) { + if (transport === "cookie") { + throw AuthenticationError("API key must not be passed in the cookie"); + } + + type = AuthenticationType.API; + let apiKey; + + try { + apiKey = await ApiKey.findByToken(token); + } catch (_err) { + throw AuthenticationError("Invalid API key"); + } + + if (!apiKey) { + throw AuthenticationError("Invalid API key"); + } + + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { + throw AuthenticationError("API key is expired"); + } + + if (!apiKey.canAccess(ctx.request.url)) { + throw AuthenticationError( + "API key does not have access to this resource" + ); + } + + user = await User.findByPk(apiKey.userId, { + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], + }); + + if (!user) { + throw AuthenticationError("Invalid API key"); + } + + await apiKey.updateActiveAt(); + } else { + type = AuthenticationType.APP; + user = await getUserForJWT(token); + } + + if (user.isSuspended) { + const suspendingAdmin = await User.findOne({ + where: { + id: user.suspendedById!, + }, + paranoid: false, + }); + throw UserSuspendedError({ + adminEmail: suspendingAdmin?.email || undefined, + }); + } + + if (options.role && UserRoleHelper.isRoleLower(user.role, options.role)) { + throw AuthorizationError(`${capitalize(options.role)} role required`); + } + + if ( + options.type && + (Array.isArray(options.type) + ? !options.type.includes(type) + : type !== options.type) + ) { + throw AuthorizationError(`Invalid authentication type`); + } + + return { + user, + type, + token, + }; +} diff --git a/server/middlewares/csrf.ts b/server/middlewares/csrf.ts index 50650355c1..5392f757ba 100644 --- a/server/middlewares/csrf.ts +++ b/server/middlewares/csrf.ts @@ -11,6 +11,7 @@ import { import { getCookieDomain } from "@shared/utils/domains"; import { CSRF } from "@shared/constants"; import { CSRFError } from "@server/errors"; +import { parseAuthentication } from "./authentication"; /** * Middleware that generates and attaches CSRF tokens for safe methods @@ -48,7 +49,8 @@ export function verifyCSRFToken() { } // If not using cookie-based auth, skip CSRF protection - if (!ctx.cookies.get("accessToken")) { + const { transport } = parseAuthentication(ctx); + if (transport !== "cookie") { return false; } diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index e664393682..ca190eeaf0 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1575,7 +1575,7 @@ router.post( router.post( "documents.import", - auth({ allowMultipart: true }), + auth(), rateLimiter(RateLimiterStrategy.TwentyFivePerMinute), validate(T.DocumentsImportSchema), multipart({ maximumFileSize: env.FILE_STORAGE_IMPORT_MAX_SIZE }),