From 41fdebd7f0a04d0e9c6f1edb19f0e563c627b3c7 Mon Sep 17 00:00:00 2001 From: Carmen Fan Date: Sun, 2 Mar 2025 13:23:45 +0000 Subject: [PATCH] ISSUE #5412 verified teamspace auth is working --- .../v5/middleware/permissions/permissions.js | 22 ++++- backend/src/v5/middleware/sessions.js | 81 +++++++++---------- backend/src/v5/middleware/sso/frontegg.js | 11 ++- backend/src/v5/middleware/sso/index.js | 2 +- backend/src/v5/models/users.js | 7 +- backend/src/v5/routes/authentication.js | 43 +++++++++- backend/src/v5/utils/responseCodes.js | 5 +- 7 files changed, 119 insertions(+), 52 deletions(-) diff --git a/backend/src/v5/middleware/permissions/permissions.js b/backend/src/v5/middleware/permissions/permissions.js index 6e56d277615..6ef25f5d2e3 100644 --- a/backend/src/v5/middleware/permissions/permissions.js +++ b/backend/src/v5/middleware/permissions/permissions.js @@ -24,12 +24,32 @@ const { const { isTeamspaceAdmin, isTeamspaceMember } = require('./components/teamspaces'); const { isProjectAdmin } = require('./components/projects'); const { modelTypes } = require('../../models/modelSettings.constants'); +const { respond } = require('../../utils/responder'); +const { templates } = require('../../utils/responseCodes'); const { validSession } = require('../auth'); const { validateMany } = require('../common'); const Permissions = {}; -Permissions.hasAccessToTeamspace = validateMany([convertAllUUIDs, validSession, isTeamspaceMember]); +const isCookieAuthenticatedAgainstTS = async (req, res, next) => { + if (!req.session.user?.auth) { + // No auth info - this is an apiKey. + await next(); + } + + const authenticatedTeamspace = req.session.user.auth?.authorisedTeamspace; + if (authenticatedTeamspace === req.params.teamspace) { + await next(); + } else { + respond(req, res, templates.notAuthenticatedAgainstTeamspace); + } +}; + +// Use this one when you don't care if the user is authenticated against the teamspace +Permissions.isMemberOfTeamspace = validateMany([convertAllUUIDs, validSession, isTeamspaceMember]); +// Use this one when you want to make sure the user session is authenticated against the teamspace +Permissions.hasAccessToTeamspace = validateMany([ + Permissions.isMemberOfTeamspace, isCookieAuthenticatedAgainstTS]); Permissions.isTeamspaceAdmin = validateMany([Permissions.hasAccessToTeamspace, isTeamspaceAdmin]); Permissions.isAdminToProject = validateMany([Permissions.hasAccessToTeamspace, isProjectAdmin]); diff --git a/backend/src/v5/middleware/sessions.js b/backend/src/v5/middleware/sessions.js index 08b5f7daa18..f31d604a029 100644 --- a/backend/src/v5/middleware/sessions.js +++ b/backend/src/v5/middleware/sessions.js @@ -42,27 +42,48 @@ Sessions.manageSessions = async (req, res, next) => { middleware(req, res, next); }; -const updateSessionDetails = (req) => { - const updatedUser = { ...req.loginData, webSession: false }; +Sessions.destroySession = (req, res) => { + const username = req.session?.user?.username; + + try { + const sessionData = { user: { username: req.session?.user?.username } }; + const callback = () => respond({ ...req, session: sessionData }, res, templates.ok, + req.v4 ? { username } : undefined); + destroySession(req.session, res, callback, true); + } catch (err) { + // istanbul ignore next + respond(req, res, err); + } +}; + +Sessions.appendCSRFToken = async (req, res, next) => { + const { domain, maxAge } = config.cookie; + const token = generateUUIDString(); + res.cookie(CSRF_COOKIE, token, { httpOnly: false, secure: true, sameSite: 'Strict', maxAge, domain }); + req.token = token; + await next(); +}; + +Sessions.updateSession = async (req, res, next) => { const { session } = req; + if (req.token) { + session.token = req.token; + } - const { ssoInfo: { userAgent, referer } } = req.session; + const updatedUser = { ...req.loginData, webSession: session?.user?.webSession || false }; + // If there is ssoInfo, this is a new session + const { ssoInfo: { userAgent, referer } } = session; if (referer) { updatedUser.referer = referer; } - delete req.session.ssoInfo; - if (userAgent) { updatedUser.webSession = isFromWebBrowser(userAgent); updatedUser.userAgent = userAgent; } - if (req.token) { - session.token = req.token; - } + delete req.session.ssoInfo; - session.user = deleteIfUndefined(updatedUser); session.cookie.domain = config.cookie_domain; if (config.cookie.maxAge) { @@ -72,41 +93,19 @@ const updateSessionDetails = (req) => { const ipAddress = req.ips[0] || req.ip; session.ipAddress = ipAddress; - publish(events.SESSION_CREATED, { - username: updatedUser.username, - sessionID: req.sessionID, - ipAddress, - userAgent, - socketId: req.headers[SOCKET_HEADER], - referer: updatedUser.referer }); - - return session; -}; - -Sessions.destroySession = (req, res) => { - const username = req.session?.user?.username; - - try { - const sessionData = { user: { username: req.session?.user?.username } }; - const callback = () => respond({ ...req, session: sessionData }, res, templates.ok, - req.v4 ? { username } : undefined); - destroySession(req.session, res, callback, true); - } catch (err) { - // istanbul ignore next - respond(req, res, err); + if (!session.reAuth) { + publish(events.SESSION_CREATED, { + username: updatedUser.username, + sessionID: req.sessionID, + ipAddress, + userAgent, + socketId: req.headers[SOCKET_HEADER], + referer: updatedUser.referer }); } -}; -Sessions.appendCSRFToken = async (req, res, next) => { - const { domain, maxAge } = config.cookie; - const token = generateUUIDString(); - res.cookie(CSRF_COOKIE, token, { httpOnly: false, secure: true, sameSite: 'Strict', maxAge, domain }); - req.token = token; - await next(); -}; + delete req.session.reAuth; + session.user = deleteIfUndefined(updatedUser); -Sessions.updateSession = async (req, res, next) => { - updateSessionDetails(req); await next(); }; diff --git a/backend/src/v5/middleware/sso/frontegg.js b/backend/src/v5/middleware/sso/frontegg.js index b4e1082316c..16d9a70e8f0 100644 --- a/backend/src/v5/middleware/sso/frontegg.js +++ b/backend/src/v5/middleware/sso/frontegg.js @@ -30,6 +30,7 @@ const { addPkceProtection } = require('./pkce'); const { createNewUserRecord } = require('../../processors/users'); const { destroySession } = require('../../utils/sessions'); const { errorCodes } = require('../../services/sso/sso.constants'); +const { getTeamspaceRefId } = require('../../models/teamspaceSettings'); const { logger } = require('../../utils/logger'); const { respond } = require('../../utils/responder'); const { validateMany } = require('../common'); @@ -116,13 +117,19 @@ const getToken = (urlUsed) => async (req, res, next) => { } }; -const redirectForAuth = (redirectURL) => (req, res) => { +const redirectForAuth = (redirectURL) => async (req, res) => { try { if (!req.query.redirectUri) { respond(req, res, createResponseCode(templates.invalidArguments, 'redirectUri(query string) is required')); return; } + let accountId; + if (req.params.teamspace) { + accountId = await getTeamspaceRefId(req.params.teamspace); + req.session.reAuth = true; + } + req.authParams = { redirectURL, state: toBase64(JSON.stringify({ @@ -132,7 +139,7 @@ const redirectForAuth = (redirectURL) => (req, res) => { codeChallenge: req.session.pkceCodes.challenge, }; - const link = generateAuthenticationCodeUrl(req.authParams); + const link = generateAuthenticationCodeUrl(req.authParams, accountId); respond(req, res, templates.ok, { link }); } catch (err) { respond(req, res, err); diff --git a/backend/src/v5/middleware/sso/index.js b/backend/src/v5/middleware/sso/index.js index 00094f71a1a..54c03f93ba4 100644 --- a/backend/src/v5/middleware/sso/index.js +++ b/backend/src/v5/middleware/sso/index.js @@ -35,8 +35,8 @@ const setSessionInfo = async (req, res, next) => { }; req.session.ssoInfo = ssoInfo; - req.session.token = req.token; + req.session.token = req.token; await next(); }; diff --git a/backend/src/v5/models/users.js b/backend/src/v5/models/users.js index 1873f3d6a82..89a93f11a20 100644 --- a/backend/src/v5/models/users.js +++ b/backend/src/v5/models/users.js @@ -162,7 +162,12 @@ User.removeUser = (user) => db.deleteOne(USERS_DB_NAME, USERS_COL, { user }); User.removeUsers = (users) => db.deleteMany(USERS_DB_NAME, USERS_COL, { user: { $in: users } }); User.ensureIndicesExist = async () => { - await db.createIndex(USERS_DB_NAME, USERS_COL, { 'customData.userId': 1 }, { runInBackground: true, unique: true }); + try { + await db.createIndex(USERS_DB_NAME, USERS_COL, { 'customData.userId': 1 }, { runInBackground: true, unique: true }); + } catch (err) { + // Note this will fail pre 5.16 migration. + logger.logWarning('Failed to create index on user ID. Please ensure 5.16 migration script has been executed.'); + } }; module.exports = User; diff --git a/backend/src/v5/routes/authentication.js b/backend/src/v5/routes/authentication.js index 5835c652b81..2c7316a8809 100644 --- a/backend/src/v5/routes/authentication.js +++ b/backend/src/v5/routes/authentication.js @@ -16,9 +16,10 @@ */ const { generateLinkToAuthenticator, generateToken, redirectToStateURL } = require('../middleware/sso/frontegg'); +const { isLoggedIn, notLoggedIn } = require('../middleware/auth'); const { Router } = require('express'); const { createEndpointURL } = require('../utils/config'); -const { notLoggedIn } = require('../middleware/auth'); +const { isMemberOfTeamspace } = require('../middleware/permissions/permissions'); const { updateSession } = require('../middleware/sessions'); const AUTH_POST = '/authenticate-post'; @@ -31,7 +32,7 @@ const establishRoutes = () => { * @openapi * /authentication/authenticate: * get: - * description: Returns a link 3DR's authentication page and then to a URI provided upon success. The process works like the standard SSO protocol. + * description: General authentication route to establish a session. * tags: [Authentication] * operationId: authenticate * parameters: @@ -42,7 +43,7 @@ const establishRoutes = () => { * description: a URI to redirect to when authentication finished * responses: * 200: - * description: returns a link to 3D Repo's authentication page and then to a provided URI upon success + * description: Returns a link to 3DR's authentication page and then redirects to a URI provided upon success. The process works like the standard SSO protocol. * content: * application/json: * schema: @@ -55,8 +56,42 @@ const establishRoutes = () => { */ router.get('/authenticate', notLoggedIn, generateLinkToAuthenticator(authenticateRedirectUrl)); + /** + * @openapi + * /authentication/authenticate/{teamspace}: + * get: + * description: Authenticates a user against a particular teamspace, the user has to have already established a session to use this endpoint. + * tags: [Authentication] + * operationId: authenticate + * parameters: + * - in: path + * name: teamspace + * description: Name of the teamspace to authenticate against + * required: true + * schema: + * type: string + * - in: query + * name: redirectUri + * schema: + * type: string + * description: a URI to redirect to when authentication finished + * responses: + * 200: + * description: Returns a link to 3DR's authentication page and then redirects to a URI provided upon success. The process works like the standard SSO protocol. + * content: + * application/json: + * schema: + * type: object + * properties: + * link: + * type: string + * description: link to 3D Repo's authenticator + * + */ + router.get('/authenticate/:teamspace', isLoggedIn, isMemberOfTeamspace, generateLinkToAuthenticator(authenticateRedirectUrl)); + // This endpoint is not exposed in swagger as it is not designed to be called by clients - router.get(AUTH_POST, notLoggedIn, generateToken(authenticateRedirectUrl), updateSession, redirectToStateURL); + router.get(AUTH_POST, generateToken(authenticateRedirectUrl), updateSession, redirectToStateURL); return router; }; diff --git a/backend/src/v5/utils/responseCodes.js b/backend/src/v5/utils/responseCodes.js index dca3250f5ce..4e45e51c4f1 100644 --- a/backend/src/v5/utils/responseCodes.js +++ b/backend/src/v5/utils/responseCodes.js @@ -26,8 +26,9 @@ ResponseCodes.templates = { // Auth notLoggedIn: { message: 'You are not logged in.', status: 401 }, alreadyLoggedIn: { message: 'You are already logged in.', status: 401 }, - notAuthorized: { message: 'You do not have sufficient access rights for this action.', status: 401 }, - licenceExpired: { message: 'Licence expired.', status: 401 }, + notAuthenticatedAgainstTeamspace: { message: 'You are not authenticated against this teamspace.', status: 401 }, + notAuthorized: { message: 'You do not have sufficient access rights for this action.', status: 403 }, + licenceExpired: { message: 'Licence expired.', status: 403 }, incorrectUsernameOrPassword: { message: 'Incorrect username or password.', status: 400 }, incorrectPassword: { message: 'Incorrect password.', status: 400 },