diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index f6cae441b..19875fc68 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -12,7 +12,7 @@ import { users } from "@server/db"; import { db } from "@server/db"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; @@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise { } } +export async function invalidateAllSessionsExceptCurrent( + userId: string, + currentSessionId: string +): Promise { + try { + await db.transaction(async (trx) => { + const userSessions = await trx + .select() + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + + if (userSessions.length > 0) { + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + } + + await trx + .delete(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + }); + } catch (e) { + logger.error("Failed to invalidate user sessions except current", e); + } +} + export function serializeSessionCookie( token: string, isSecure: boolean, diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 528298727..07786e87d 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -119,8 +119,7 @@ export async function verifyAccessTokenAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 0dbeac2cb..da4f88af9 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -56,8 +56,7 @@ export async function verifyAdmin( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 2522a1e8b..ea1bdac18 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -113,8 +113,7 @@ export async function verifyApiKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index 1d994b53f..3aee4f38c 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -107,8 +107,7 @@ export async function verifyClientAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } @@ -129,10 +128,7 @@ export async function verifyClientAccess( .where( and( eq(roleClients.clientId, client.clientId), - inArray( - roleClients.roleId, - req.userOrgRoleIds! - ) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 783132a1a..173c3a54b 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -88,8 +88,7 @@ export async function verifyDomainAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index e464f7b89..030b5f702 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getFirstString } from "@server/lib/requestParams"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -54,13 +55,15 @@ export async function verifyOrgAccess( userId, session: req.session }); + logger.debug("failed policy check", { + policyCheck + }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index f790a481a..2689cdb2d 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -105,8 +105,7 @@ export async function verifyResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts index 30fe48e8c..667680c0f 100644 --- a/server/middlewares/verifyResourcePolicyAccess.ts +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -102,8 +102,7 @@ export async function verifyResourcePolicyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 380b82048..3264a3bd9 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -132,8 +132,7 @@ export async function verifyRoleAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceClients.ts b/server/middlewares/verifySetResourceClients.ts index 8f9c1ecaf..443483a28 100644 --- a/server/middlewares/verifySetResourceClients.ts +++ b/server/middlewares/verifySetResourceClients.ts @@ -45,8 +45,7 @@ export async function verifySetResourceClients( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceUsers.ts b/server/middlewares/verifySetResourceUsers.ts index 94600b9b4..cc9375e4a 100644 --- a/server/middlewares/verifySetResourceUsers.ts +++ b/server/middlewares/verifySetResourceUsers.ts @@ -40,8 +40,7 @@ export async function verifySetResourceUsers( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index c4d35a52f..50a940855 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteProvisioningKeyAccess.ts b/server/middlewares/verifySiteProvisioningKeyAccess.ts index 73393e1e9..9cb9a28f3 100644 --- a/server/middlewares/verifySiteProvisioningKeyAccess.ts +++ b/server/middlewares/verifySiteProvisioningKeyAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteProvisioningKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index 8d5bd656f..c87518a9e 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -103,8 +103,7 @@ export async function verifySiteResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 8bbed6fca..24b8abd22 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -122,8 +122,7 @@ export async function verifyTargetAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyUserAccess.ts b/server/middlewares/verifyUserAccess.ts index fcc4d0cb9..83c344ae0 100644 --- a/server/middlewares/verifyUserAccess.ts +++ b/server/middlewares/verifyUserAccess.ts @@ -59,8 +59,7 @@ export async function verifyUserAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index b861c1ae6..9a03f9e09 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -21,6 +21,49 @@ import { } from "@server/lib/checkOrgAccessPolicy"; import { UserType } from "@server/types/UserTypes"; +function formatMaxSessionLengthRequirement( + maxSessionLengthHours: number +): string { + if (maxSessionLengthHours < 24) { + return `This organization requires you to log in every ${maxSessionLengthHours} hours.`; + } + + const maxDays = Math.round(maxSessionLengthHours / 24); + return `This organization requires you to log in every ${maxDays} days.`; +} + +function buildOrgAccessPolicyError( + policies: CheckOrgAccessPolicyResult["policies"] +): string | undefined { + if (!policies) { + return undefined; + } + + const errors: string[] = []; + + if (policies.requiredTwoFactor === false) { + errors.push( + "This organization requires two-factor authentication. Enable two-factor authentication on your account to continue." + ); + } + + if (policies.maxSessionLength?.compliant === false) { + errors.push( + `Your session has expired. ${formatMaxSessionLengthRequirement( + policies.maxSessionLength.maxSessionLengthHours + )}` + ); + } + + if (policies.passwordAge?.compliant === false) { + errors.push( + `Your password has expired. This organization requires you to change your password every ${policies.passwordAge.maxPasswordAgeDays} days.` + ); + } + + return errors.length > 0 ? errors.join(" ") : undefined; +} + export function enforceResourceSessionLength( resourceSession: ResourceSession, org: Org @@ -36,13 +79,17 @@ export function enforceResourceSessionLength( if (sessionAgeMs > maxSessionLengthMs) { return { valid: false, - error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session has expired. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } else { return { valid: false, - error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } @@ -60,14 +107,20 @@ export async function checkOrgAccessPolicy( if (!orgId) { return { allowed: false, - error: "Organization ID is required" + error: "Unable to verify organization access. Organization information is missing." }; } if (!userId) { - return { allowed: false, error: "User ID is required" }; + return { + allowed: false, + error: "Unable to verify organization access. User information is missing." + }; } if (!sessionId) { - return { allowed: false, error: "Session ID is required" }; + return { + allowed: false, + error: "Your session is invalid. Please log in again." + }; } if (build === "enterprise") { @@ -89,7 +142,10 @@ export async function checkOrgAccessPolicy( .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { - return { allowed: false, error: "Organization not found" }; + return { + allowed: false, + error: "This organization could not be found." + }; } } @@ -100,7 +156,10 @@ export async function checkOrgAccessPolicy( .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { - return { allowed: false, error: "User not found" }; + return { + allowed: false, + error: "Your account could not be found." + }; } } @@ -111,14 +170,17 @@ export async function checkOrgAccessPolicy( .where(eq(sessions.sessionId, sessionId)); props.session = sessionQuery; if (!props.session) { - return { allowed: false, error: "Session not found" }; + return { + allowed: false, + error: "Your session has expired. Please log in again." + }; } } if (props.session.userId !== props.user.userId) { return { allowed: false, - error: "Session does not belong to the user" + error: "Your session is invalid. Please log in again." }; } @@ -187,8 +249,14 @@ export async function checkOrgAccessPolicy( allowed = false; } + const policyError = buildOrgAccessPolicyError(policies); + return { allowed, - policies + policies, + error: allowed + ? undefined + : (policyError ?? + "You do not meet this organization's security requirements.") }; } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 1a26b9117..256396763 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; -import { invalidateAllSessions } from "@server/auth/sessions/app"; -import { sessions, resourceSessions } from "@server/db"; -import { and, eq, ne, inArray } from "drizzle-orm"; +import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app"; +import { eq } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { sendEmail } from "@server/emails"; @@ -31,48 +30,6 @@ export type ChangePasswordResponse = { codeRequested?: boolean; }; -async function invalidateAllSessionsExceptCurrent( - userId: string, - currentSessionId: string -): Promise { - try { - await db.transaction(async (trx) => { - // Get all user sessions except the current one - const userSessions = await trx - .select() - .from(sessions) - .where( - and( - eq(sessions.userId, userId), - ne(sessions.sessionId, currentSessionId) - ) - ); - - // Delete resource sessions for the sessions we're invalidating - if (userSessions.length > 0) { - await trx.delete(resourceSessions).where( - inArray( - resourceSessions.userSessionId, - userSessions.map((s) => s.sessionId) - ) - ); - } - - // Delete the user sessions (except current) - await trx - .delete(sessions) - .where( - and( - eq(sessions.userId, userId), - ne(sessions.sessionId, currentSessionId) - ) - ); - }); - } catch (e) { - logger.error("Failed to invalidate user sessions except current", e); - } -} - export async function changePassword( req: Request, res: Response, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 207287ea0..5fc6cf13c 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { generateBackupCodes } from "@server/lib/totp"; +import { + invalidateAllSessions, + invalidateAllSessionsExceptCurrent +} from "@server/auth/sessions/app"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; @@ -168,6 +172,15 @@ export async function verifyTotp( ); } + if (existingSession) { + await invalidateAllSessionsExceptCurrent( + user.userId, + existingSession.sessionId + ); + } else { + await invalidateAllSessions(user.userId); + } + sendEmail( TwoFactorAuthNotification({ email: user.email!, diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 23151d534..0561fadbf 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -80,8 +80,7 @@ export async function getExchangeToken( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (hasAccess.error || "Unknown error") + "" + (hasAccess.error || "Unknown error") ) ); }