From ddcf77a62d8a723c0a9122e9e3d84bec9c9d7b91 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 23 Oct 2025 18:15:00 -0700 Subject: [PATCH 01/14] add basic org policy check in middleware --- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 15 +++- server/lib/checkOrgAccessPolicy.ts | 17 ++++ server/license/license.ts | 4 + server/middlewares/verifyOrgAccess.ts | 28 +++++-- server/private/lib/checkOrgAccessPolicy.ts | 95 ++++++++++++++++++++++ 6 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 server/lib/checkOrgAccessPolicy.ts create mode 100644 server/private/lib/checkOrgAccessPolicy.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e71d7c10..520c14eb 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -25,7 +25,8 @@ export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), name: varchar("name").notNull(), subnet: varchar("subnet"), - createdAt: text("createdAt") + createdAt: text("createdAt"), + requireTwoFactor: boolean("requireTwoFactor").default(false) }); export const orgDomains = pgTable("orgDomains", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index b4b8d3e4..5868aa7d 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -18,7 +18,8 @@ export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), subnet: text("subnet"), - createdAt: text("createdAt") + createdAt: text("createdAt"), + requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }) }); export const userDomains = sqliteTable("userDomains", { @@ -141,11 +142,15 @@ export const targets = sqliteTable("targets", { }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { - targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }), + targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ + autoIncrement: true + }), targetId: integer("targetId") .notNull() .references(() => targets.targetId, { onDelete: "cascade" }), - hcEnabled: integer("hcEnabled", { mode: "boolean" }).notNull().default(false), + hcEnabled: integer("hcEnabled", { mode: "boolean" }) + .notNull() + .default(false), hcPath: text("hcPath"), hcScheme: text("hcScheme"), hcMode: text("hcMode").default("http"), @@ -155,7 +160,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds hcTimeout: integer("hcTimeout").default(5), // in seconds hcHeaders: text("hcHeaders"), - hcFollowRedirects: integer("hcFollowRedirects", { mode: "boolean" }).default(true), + hcFollowRedirects: integer("hcFollowRedirects", { + mode: "boolean" + }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy" diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts new file mode 100644 index 00000000..3f99f7f4 --- /dev/null +++ b/server/lib/checkOrgAccessPolicy.ts @@ -0,0 +1,17 @@ +import { Org, User } from "@server/db"; + +type CheckOrgAccessPolicyProps = { + orgId?: string; + org?: Org; + userId?: string; + user?: User; +}; + +export async function checkOrgAccessPolicy( + props: CheckOrgAccessPolicyProps +): Promise<{ + success: boolean; + error?: string; +}> { + return { success: true }; +} diff --git a/server/license/license.ts b/server/license/license.ts index 919fdb03..cfa45d7c 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -40,6 +40,10 @@ export class License { public setServerSecret(secret: string) { this.serverSecret = secret; } + + public async isUnlocked() { + return false; + } } await setHostMeta(); diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 521f1002..b26af280 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -1,9 +1,10 @@ import { Request, Response, NextFunction } from "express"; -import { db } from "@server/db"; +import { db, orgs } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyOrgAccess( req: Request, @@ -43,12 +44,27 @@ export async function verifyOrgAccess( "User does not have access to this organization" ) ); - } else { - // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = req.userOrg.roleId; - req.userOrgId = orgId; - return next(); } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId + }); + + if (!policyCheck.success || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + + // User has access, attach the user's role to the request for potential future use + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = orgId; + return next(); } catch (e) { return next( createHttpError( diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts new file mode 100644 index 00000000..31c1936f --- /dev/null +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -0,0 +1,95 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { build } from "@server/build"; +import { db, Org, orgs, User, users } from "@server/db"; +import { getOrgTierData } from "#private/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; +import license from "#private/license/license"; +import { eq } from "drizzle-orm"; + +type CheckOrgAccessPolicyProps = { + orgId?: string; + org?: Org; + userId?: string; + user?: User; +}; + +export async function checkOrgAccessPolicy( + props: CheckOrgAccessPolicyProps +): Promise<{ + success: boolean; + error?: string; +}> { + const userId = props.userId || props.user?.userId; + const orgId = props.orgId || props.org?.orgId; + + if (!orgId) { + return { success: false, error: "Organization ID is required" }; + } + if (!userId) { + return { success: false, error: "User ID is required" }; + } + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + // if not subscribed, don't check the policies + if (!subscribed) { + return { success: true }; + } + } + + if (build === "enterprise") { + const isUnlocked = await license.isUnlocked(); + // if not licensed, don't check the policies + if (!isUnlocked) { + return { success: true }; + } + } + + // get the needed data + + if (!props.org) { + const [orgQuery] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)); + props.org = orgQuery; + if (!props.org) { + return { success: false, error: "Organization not found" }; + } + } + + if (!props.user) { + const [userQuery] = await db + .select() + .from(users) + .where(eq(users.userId, userId)); + props.user = userQuery; + if (!props.user) { + return { success: false, error: "User not found" }; + } + } + + // now check the policies + + if (!props.org.requireTwoFactor && !props.user.twoFactorEnabled) { + return { + success: false, + error: "Two-factor authentication is required" + }; + } + + return { success: true }; +} From 629f17294ab871562c864ab7a20e920eb8f3251b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Oct 2025 14:31:50 -0700 Subject: [PATCH 02/14] 2fa policy check working --- messages/en-US.json | 21 ++ server/lib/checkOrgAccessPolicy.ts | 10 +- server/middlewares/verifyOrgAccess.ts | 5 +- server/private/lib/checkOrgAccessPolicy.ts | 48 ++--- server/routers/external.ts | 4 +- server/routers/org/checkOrgUserAccess.ts | 136 ++++++++++++ server/routers/org/index.ts | 1 + server/routers/org/updateOrg.ts | 51 ++++- server/routers/resource/getExchangeToken.ts | 21 +- src/app/[orgId]/layout.tsx | 54 +++-- src/app/[orgId]/policy/page.tsx | 3 + src/app/[orgId]/settings/general/page.tsx | 166 +++++++++++---- .../resources/[niceId]/general/page.tsx | 6 + src/app/auth/resource/[resourceGuid]/page.tsx | 32 ++- src/components/OrgPolicyRequired.tsx | 59 ++++++ src/components/OrgPolicyResult.tsx | 195 ++++++++++++++++++ 16 files changed, 724 insertions(+), 88 deletions(-) create mode 100644 server/routers/org/checkOrgUserAccess.ts create mode 100644 src/app/[orgId]/policy/page.tsx create mode 100644 src/components/OrgPolicyRequired.tsx create mode 100644 src/components/OrgPolicyResult.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4cffaf98..f7b98eac 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1340,6 +1340,15 @@ "securityKeyUnknownError": "There was a problem using your security key. Please try again.", "twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactor": "Two-Factor Authentication", + "twoFactorAuthentication": "Two-Factor Authentication", + "twoFactorDescription": "Add an extra layer of security to your account with two-factor authentication", + "enableTwoFactor": "Enable Two-Factor Authentication", + "organizationSecurityPolicy": "Organization Security Policy", + "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", + "securityRequirements": "Security Requirements", + "allRequirementsMet": "All requirements have been met", + "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", + "youCanNowAccessOrganization": "You can now access this organization", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "securityKeyAdd": "Add Security Key", "securityKeyRegisterTitle": "Register New Security Key", @@ -1726,6 +1735,18 @@ "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", + "additionalSecurityRequired": "Additional Security Required", + "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "completeTheseSteps": "Complete these steps", + "enableTwoFactorAuthentication": "Enable two-factor authentication", + "completeSecuritySteps": "Complete Security Steps", + "securitySettings": "Security Settings", + "securitySettingsDescription": "Configure security policies for your organization", + "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", + "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", + "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "subscriptionBadge": "Subscription Required", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", "healthCheckNotAvailable": "Local", diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index 3f99f7f4..42af5174 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -1,12 +1,20 @@ import { Org, User } from "@server/db"; -type CheckOrgAccessPolicyProps = { +export type CheckOrgAccessPolicyProps = { orgId?: string; org?: Org; userId?: string; user?: User; }; +export type CheckOrgAccessPolicyResult = { + allowed: boolean; + error?: string; + policies?: { + requiredTwoFactor?: boolean; + }; +}; + export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps ): Promise<{ diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index b26af280..22c11f16 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -51,7 +52,9 @@ export async function verifyOrgAccess( userId }); - if (!policyCheck.success || policyCheck.error) { + logger.debug("Org check policy result", { policyCheck }); + + if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index 31c1936f..addf6f81 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -17,28 +17,26 @@ import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import license from "#private/license/license"; import { eq } from "drizzle-orm"; - -type CheckOrgAccessPolicyProps = { - orgId?: string; - org?: Org; - userId?: string; - user?: User; -}; +import { + CheckOrgAccessPolicyProps, + CheckOrgAccessPolicyResult +} from "@server/lib/checkOrgAccessPolicy"; +import { UserType } from "@server/types/UserTypes"; export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps -): Promise<{ - success: boolean; - error?: string; -}> { +): Promise { const userId = props.userId || props.user?.userId; const orgId = props.orgId || props.org?.orgId; if (!orgId) { - return { success: false, error: "Organization ID is required" }; + return { + allowed: false, + error: "Organization ID is required" + }; } if (!userId) { - return { success: false, error: "User ID is required" }; + return { allowed: false, error: "User ID is required" }; } if (build === "saas") { @@ -46,7 +44,7 @@ export async function checkOrgAccessPolicy( const subscribed = tier === TierId.STANDARD; // if not subscribed, don't check the policies if (!subscribed) { - return { success: true }; + return { allowed: true }; } } @@ -54,7 +52,7 @@ export async function checkOrgAccessPolicy( const isUnlocked = await license.isUnlocked(); // if not licensed, don't check the policies if (!isUnlocked) { - return { success: true }; + return { allowed: true }; } } @@ -67,7 +65,7 @@ export async function checkOrgAccessPolicy( .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { - return { success: false, error: "Organization not found" }; + return { allowed: false, error: "Organization not found" }; } } @@ -78,18 +76,22 @@ export async function checkOrgAccessPolicy( .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { - return { success: false, error: "User not found" }; + return { allowed: false, error: "User not found" }; } } // now check the policies + const policies: CheckOrgAccessPolicyResult["policies"] = {}; - if (!props.org.requireTwoFactor && !props.user.twoFactorEnabled) { - return { - success: false, - error: "Two-factor authentication is required" - }; + // only applies to internal users + if (props.user.type === UserType.Internal && props.org.requireTwoFactor) { + policies.requiredTwoFactor = props.user.twoFactorEnabled || false; } - return { success: true }; + const allowed = Object.values(policies).every((v) => v === true); + + return { + allowed, + policies + }; } diff --git a/server/routers/external.ts b/server/routers/external.ts index 8bd72f62..67daae77 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -606,6 +606,7 @@ authenticated.post( ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); +authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); authenticated.post( "/user/:userId/2fa", @@ -675,8 +676,6 @@ authenticated.post( idp.updateOidcIdp ); - - authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); @@ -705,7 +704,6 @@ authenticated.get( idp.listIdpOrgPolicies ); - authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts new file mode 100644 index 00000000..1a0c024c --- /dev/null +++ b/server/routers/org/checkOrgUserAccess.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, idp, idpOidcConfig } from "@server/db"; +import { roles, userOrgs, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { OpenAPITags, registry } from "@server/openApi"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; + +async function queryUser(orgId: string, userId: string) { + const [user] = await db + .select({ + orgId: userOrgs.orgId, + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + roleId: userOrgs.roleId, + roleName: roles.name, + isOwner: userOrgs.isOwner, + isAdmin: roles.isAdmin, + twoFactorEnabled: users.twoFactorEnabled, + autoProvisioned: userOrgs.autoProvisioned, + idpId: users.idpId, + idpName: idp.name, + idpType: idp.type, + idpVariant: idpOidcConfig.variant, + idpAutoProvision: idp.autoProvision + }) + .from(userOrgs) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin(users, eq(userOrgs.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + return user; +} + +export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; + +const paramsSchema = z.object({ + userId: z.string(), + orgId: z.string() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user/{userId}/check", + description: "Check a user's access in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function checkOrgUserAccess( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + logger.debug("here0 ") + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + + if (userId !== req.user?.userId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to check this user's access" + ) + ); + } + + let user; + user = await queryUser(orgId, userId); + + if (!user) { + const [fullUser] = await db + .select() + .from(users) + .where(eq(users.email, userId)) + .limit(1); + + if (fullUser) { + user = await queryUser(orgId, fullUser.userId); + } + } + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found in org` + ) + ); + } + + const policyCheck = await checkOrgAccessPolicy({ + orgId, + userId + }); + + // if we get here, the user has an org join, we just don't know if they pass the policies + return response(res, { + data: policyCheck, + success: true, + error: false, + message: "User access checked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 7887fcac..8ce01e92 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -8,3 +8,4 @@ export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; export * from "./applyBlueprint"; +export * from "./checkOrgUserAccess"; diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 64075cab..3a0d60df 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs } from "@server/db"; +import { orgs, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,11 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; +import { UserType } from "@server/types/UserTypes"; +import license from "#dynamic/license/license"; +import { getOrgTierData } from "#dynamic/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; const updateOrgParamsSchema = z .object({ @@ -18,7 +23,8 @@ const updateOrgParamsSchema = z const updateOrgBodySchema = z .object({ - name: z.string().min(1).max(255).optional() + name: z.string().min(1).max(255).optional(), + requireTwoFactor: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -71,10 +77,30 @@ export async function updateOrg( const { orgId } = parsedParams.data; + const isLicensed = await isLicensedOrSubscribed(orgId); + if (!isLicensed) { + parsedBody.data.requireTwoFactor = undefined; + } + + if ( + req.user && + req.user.type === UserType.Internal && + parsedBody.data.requireTwoFactor === true && + !req.user.twoFactorEnabled + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You must enable two-factor authentication for your account before enforcing it for all users" + ) + ); + } + const updatedOrg = await db .update(orgs) .set({ - name: parsedBody.data.name + name: parsedBody.data.name, + requireTwoFactor: parsedBody.data.requireTwoFactor }) .where(eq(orgs.orgId, orgId)) .returning(); @@ -102,3 +128,22 @@ export async function updateOrg( ); } } + +async function isLicensedOrSubscribed(orgId: string): Promise { + if (build === "enterprise") { + const isUnlocked = await license.isUnlocked(); + if (!isUnlocked) { + return false; + } + } + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return false; + } + } + + return true; +} diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 605e5ca6..53aab82f 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -10,11 +10,10 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { generateSessionToken } from "@server/auth/sessions/app"; import config from "@server/lib/config"; -import { - encodeHexLowerCase -} from "@oslojs/encoding"; +import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; const getExchangeTokenParams = z .object({ @@ -74,6 +73,22 @@ export async function getExchangeToken( ); } + // check org policy here + const hasAccess = await checkOrgAccessPolicy({ + orgId: resource[0].orgId, + userId: req.user!.userId + }); + + if (!hasAccess.allowed || hasAccess.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (hasAccess.error || "Unknown error") + ) + ); + } + const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(ssoSession)) ); diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index 0f67fc83..c307efcb 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -1,7 +1,11 @@ -import { internal } from "@app/lib/api"; +import { formatAxiosError, internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; -import { GetOrgResponse } from "@server/routers/org"; +import { + CheckOrgUserAccessResponse, + GetOrgResponse, + ListUserOrgsResponse +} from "@server/routers/org"; import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; @@ -11,6 +15,9 @@ import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvide import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { pullEnv } from "@app/lib/pullEnv"; import { build } from "@server/build"; +import OrgPolicyResult from "@app/components/OrgPolicyResult"; +import UserProvider from "@app/providers/UserProvider"; +import { Layout } from "@app/components/Layout"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -32,25 +39,46 @@ export default async function OrgLayout(props: { redirect(`/`); } + let accessRes: CheckOrgUserAccessResponse | null = null; try { - const getOrgUser = cache(() => - internal.get>( - `/org/${orgId}/user/${user.userId}`, + const checkOrgAccess = cache(() => + internal.get>( + `/org/${orgId}/user/${user.userId}/check`, cookie ) ); - const orgUser = await getOrgUser(); - } catch { + const res = await checkOrgAccess(); + accessRes = res.data.data; + } catch (e) { redirect(`/`); } - try { - const getOrg = cache(() => - internal.get>(`/org/${orgId}`, cookie) + if (!accessRes?.allowed) { + // For non-admin users, show the member resources portal + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + return ( + + + + + ); - await getOrg(); - } catch { - redirect(`/`); } let subscriptionStatus = null; diff --git a/src/app/[orgId]/policy/page.tsx b/src/app/[orgId]/policy/page.tsx new file mode 100644 index 00000000..ea8d32ba --- /dev/null +++ b/src/app/[orgId]/policy/page.tsx @@ -0,0 +1,3 @@ +export async function OrgPolicyPage() { + return
Org Policy Page
; +} diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index b301fd32..b0f77959 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -42,11 +42,15 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; - +import { SwitchInput } from "@app/components/SwitchInput"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { Badge } from "@app/components/ui/badge"; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional() + subnet: z.string().optional(), + requireTwoFactor: z.boolean().optional() }); type GeneralFormValues = z.infer; @@ -60,6 +64,8 @@ export default function GeneralPage() { const { user } = useUserContext(); const t = useTranslations(); const { env } = useEnvContext(); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); @@ -69,7 +75,8 @@ export default function GeneralPage() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: org?.org.name, - subnet: org?.org.subnet || "" // Add default value for subnet + subnet: org?.org.subnet || "", // Add default value for subnet + requireTwoFactor: org?.org.requireTwoFactor || false }, mode: "onChange" }); @@ -129,11 +136,15 @@ export default function GeneralPage() { setLoadingSave(true); try { - // Update organization - await api.post(`/org/${org?.org.orgId}`, { + const reqData = { name: data.name - // subnet: data.subnet // Include subnet in the API request - }); + } as any; + if (build !== "oss") { + reqData.requireTwoFactor = data.requireTwoFactor || false; + } + + // Update organization + await api.post(`/org/${org?.org.orgId}`, reqData); // Also save auth page settings if they have unsaved changes if ( @@ -168,9 +179,7 @@ export default function GeneralPage() { }} dialog={
-

- {t("orgQuestionRemove")} -

+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

} @@ -241,45 +250,122 @@ export default function GeneralPage() { - {(build === "saas") && ( - - )} + {/* Security Settings Section */} + + +
+ + {t("securitySettings")} + + {build === "enterprise" && !isUnlocked() ? ( + + {build === "enterprise" + ? t("licenseBadge") + : t("subscriptionBadge")} + + ) : null} +
+ + {t("securitySettingsDescription")} + +
+ + +
+ + { + const isEnterpriseNotLicensed = + build === "enterprise" && + !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && + !subscriptionStatus?.isSubscribed(); + const isDisabled = + isEnterpriseNotLicensed || + isSaasNotSubscribed; + const shouldDisableToggle = isDisabled; - {/* Save Button */} -
+ return ( + +
+ + { + if ( + !shouldDisableToggle + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {isDisabled + ? t( + "requireTwoFactorDisabledDescription" + ) + : t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + + + + + + + {build === "saas" && } + +
+ {build !== "saas" && ( + + )}
- - {build !== "saas" && ( - - - - {t("orgDangerZone")} - - - {t("orgDangerZoneDescription")} - - - - - - - )} ); } diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx index 21d601ed..28f7754b 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -56,6 +56,9 @@ import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { DomainRow } from "../../../../../../components/DomainsTable"; import { toASCII, toUnicode } from "punycode"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useUserContext } from "@app/hooks/useUserContext"; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -65,6 +68,9 @@ export default function GeneralForm() { const router = useRouter(); const t = useTranslations(); const [editDomainOpen, setEditDomainOpen] = useState(false); + const {licenseStatus } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); + const {user} = useUserContext(); const { env } = useEnvContext(); diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index c6231c69..26d8bbea 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -22,6 +22,8 @@ import { headers } from "next/headers"; import { GetLoginPageResponse } from "@server/routers/loginPage/types"; import { GetOrgTierResponse } from "@server/routers/billing/types"; import { TierId } from "@server/lib/billing/tiers"; +import { CheckOrgUserAccessResponse } from "@server/routers/org"; +import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; export const dynamic = "force-dynamic"; @@ -136,6 +138,34 @@ export default async function ResourceAuthPage(props: { ); } + const cookie = await authCookieHeader(); + + // Check org policy compliance before proceeding + let orgPolicyCheck: CheckOrgUserAccessResponse | null = null; + if (user && authInfo.orgId) { + try { + const policyRes = await internal.get< + AxiosResponse + >(`/org/${authInfo.orgId}/user/${user.userId}/check`, cookie); + + orgPolicyCheck = policyRes.data.data; + } catch (e) { + console.error(formatAxiosError(e)); + } + } + + // If user is not compliant with org policies, show policy requirements + if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) { + return ( +
+ +
+ ); + } + if (!hasAuth) { // no authentication so always go straight to the resource redirect(redirectUrl); @@ -151,7 +181,7 @@ export default async function ResourceAuthPage(props: { >( `/resource/${authInfo.resourceId}/get-exchange-token`, {}, - await authCookieHeader() + cookie ); if (res.data.data.requestToken) { diff --git a/src/components/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx new file mode 100644 index 00000000..efc3d532 --- /dev/null +++ b/src/components/OrgPolicyRequired.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Shield, ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +type OrgPolicyRequiredProps = { + orgId: string; + policies: { + requiredTwoFactor?: boolean; + }; +}; + +export default function OrgPolicyRequired({ + orgId, + policies +}: OrgPolicyRequiredProps) { + const t = useTranslations(); + + const policySteps = []; + + if (policies?.requiredTwoFactor === false) { + policySteps.push(t("enableTwoFactorAuthentication")); + } + + return ( + + +
+ +
+ + {t("additionalSecurityRequired")} + + + {t("organizationRequiresAdditionalSteps")} + +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx new file mode 100644 index 00000000..18c20e39 --- /dev/null +++ b/src/components/OrgPolicyResult.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState } from "react"; +import { CheckOrgUserAccessResponse } from "@server/routers/org"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { CheckCircle2, XCircle, Shield } from "lucide-react"; +import Enable2FaDialog from "./Enable2FaDialog"; +import { useTranslations } from "next-intl"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useRouter } from "next/navigation"; + +type OrgPolicyResultProps = { + orgId: string; + userId: string; + accessRes: CheckOrgUserAccessResponse; +}; + +type PolicyItem = { + id: string; + name: string; + description: string; + compliant: boolean; + action?: () => void; + actionText?: string; +}; + +export default function OrgPolicyResult({ + orgId, + userId, + accessRes +}: OrgPolicyResultProps) { + const [show2FaDialog, setShow2FaDialog] = useState(false); + const t = useTranslations(); + const { user } = useUserContext(); + const router = useRouter(); + + // Determine if user is compliant with 2FA policy + const isTwoFactorCompliant = user?.twoFactorEnabled || false; + const policyKeys = Object.keys(accessRes.policies || {}); + + const policies: PolicyItem[] = []; + + // Only add 2FA policy if the organization has it enforced + if (policyKeys.includes("requiredTwoFactor")) { + policies.push({ + id: "two-factor", + name: t("twoFactorAuthentication"), + description: t("twoFactorDescription"), + compliant: isTwoFactorCompliant, + action: !isTwoFactorCompliant + ? () => setShow2FaDialog(true) + : undefined, + actionText: !isTwoFactorCompliant ? t("enableTwoFactor") : undefined + }); + + // policies.push({ + // id: "reauth-required", + // name: "Re-authentication", + // description: + // "It's been 30 days since you last verified your identity. Please log out and log back in to continue.", + // compliant: false, + // action: () => {}, + // actionText: "Log Out" + // }); + // + // policies.push({ + // id: "password-rotation", + // name: "Password Rotation", + // description: + // "It's been 30 days since you last changed your password. Please update your password to continue.", + // compliant: false, + // action: () => {}, + // actionText: "Change Password" + // }); + } + + const nonCompliantPolicies = policies.filter((policy) => !policy.compliant); + const allCompliant = + policies.length === 0 || nonCompliantPolicies.length === 0; + + // Calculate progress + const completedPolicies = policies.filter( + (policy) => policy.compliant + ).length; + const totalPolicies = policies.length; + const progressPercentage = + totalPolicies > 0 ? (completedPolicies / totalPolicies) * 100 : 100; + + // If no policies are enforced, show a simple success message + if (policies.length === 0) { + return ( +
+ +

+ {t("accessGranted")} +

+

+ {t("noSecurityRequirements")} +

+
+ ); + } + + return ( + <> + + + + {t("securityRequirements")} + + + {allCompliant + ? t("allRequirementsMet") + : t("completeRequirementsToContinue")} + + + + {/* Progress Bar */} +
+
+ + {completedPolicies} of {totalPolicies} steps + completed + + {Math.round(progressPercentage)}% +
+ +
+ + + {policies.map((policy) => ( +
+
+ {policy.compliant ? ( + + ) : ( + + )} +
+
+

+ {policy.name} +

+

+ {policy.description} +

+ {policy.action && policy.actionText && ( +
+ +
+ )} +
+
+ ))} +
+
+ + {allCompliant && ( +
+

+ {t("allRequirementsMet")} +

+

+ {t("youCanNowAccessOrganization")} +

+
+ )} + + { + setShow2FaDialog(val); + router.refresh(); + }} + /> + + ); +} From 39d6b93d4231382d44ba645485079ef47283ed4e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Oct 2025 16:14:21 -0700 Subject: [PATCH 03/14] enforce max session length --- messages/en-US.json | 9 +- server/auth/sessions/app.ts | 3 +- server/db/pg/schema/schema.ts | 6 +- server/db/sqlite/schema/schema.ts | 6 +- server/lib/checkOrgAccessPolicy.ts | 9 +- server/middlewares/verifyOrgAccess.ts | 3 +- server/private/lib/checkOrgAccessPolicy.ts | 46 +++++++- server/routers/org/checkOrgUserAccess.ts | 4 +- server/routers/org/updateOrg.ts | 7 +- server/routers/resource/getExchangeToken.ts | 3 +- src/app/[orgId]/settings/general/page.tsx | 117 +++++++++++++++++++- src/components/OrgPolicyResult.tsx | 117 +++++++++----------- 12 files changed, 249 insertions(+), 81 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f7b98eac..41f03850 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1341,7 +1341,7 @@ "twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactor": "Two-Factor Authentication", "twoFactorAuthentication": "Two-Factor Authentication", - "twoFactorDescription": "Add an extra layer of security to your account with two-factor authentication", + "twoFactorDescription": "This organization requires two-factor authentication.", "enableTwoFactor": "Enable Two-Factor Authentication", "organizationSecurityPolicy": "Organization Security Policy", "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", @@ -1349,6 +1349,9 @@ "allRequirementsMet": "All requirements have been met", "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", "youCanNowAccessOrganization": "You can now access this organization", + "reauthenticationRequired": "Session Length", + "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "securityKeyAdd": "Add Security Key", "securityKeyRegisterTitle": "Register New Security Key", @@ -1746,6 +1749,10 @@ "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", + "maxSessionLength": "Maximum Session Length", + "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", + "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", + "selectSessionLength": "Select session length", "subscriptionBadge": "Subscription Required", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index e846396d..0e3da100 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -39,7 +39,8 @@ export async function createSession( const session: Session = { sessionId: sessionId, userId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime() + expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), + issuedAt: new Date().getTime() }; await db.insert(sessions).values(session); return session; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 520c14eb..9e094ba3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -26,7 +26,8 @@ export const orgs = pgTable("orgs", { name: varchar("name").notNull(), subnet: varchar("subnet"), createdAt: text("createdAt"), - requireTwoFactor: boolean("requireTwoFactor").default(false) + requireTwoFactor: boolean("requireTwoFactor"), + maxSessionLengthHours: integer("maxSessionLengthHours") }); export const orgDomains = pgTable("orgDomains", { @@ -226,7 +227,8 @@ export const sessions = pgTable("session", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), - expiresAt: bigint("expiresAt", { mode: "number" }).notNull() + expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), + issuedAt: bigint("expiresAt", { mode: "number" }) }); export const newtSessions = pgTable("newtSession", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5868aa7d..877cf5c5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -19,7 +19,8 @@ export const orgs = sqliteTable("orgs", { name: text("name").notNull(), subnet: text("subnet"), createdAt: text("createdAt"), - requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }) + requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), + maxSessionLengthHours: integer("maxSessionLengthHours") // hours }); export const userDomains = sqliteTable("userDomains", { @@ -333,7 +334,8 @@ export const sessions = sqliteTable("session", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), - expiresAt: integer("expiresAt").notNull() + expiresAt: integer("expiresAt").notNull(), + issuedAt: integer("issuedAt") }); export const newtSessions = sqliteTable("newtSession", { diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index 42af5174..14a49b4a 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -1,10 +1,12 @@ -import { Org, User } from "@server/db"; +import { Org, Session, User } from "@server/db"; export type CheckOrgAccessPolicyProps = { orgId?: string; org?: Org; userId?: string; user?: User; + sessionId?: string; + session?: Session; }; export type CheckOrgAccessPolicyResult = { @@ -12,6 +14,11 @@ export type CheckOrgAccessPolicyResult = { error?: string; policies?: { requiredTwoFactor?: boolean; + maxSessionLength?: { + compliant: boolean; + maxSessionLengthHours: number; + sessionAgeHours: number; + } }; }; diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 22c11f16..441dd126 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -49,7 +49,8 @@ export async function verifyOrgAccess( const policyCheck = await checkOrgAccessPolicy({ orgId, - userId + userId, + session: req.session }); logger.debug("Org check policy result", { policyCheck }); diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index addf6f81..da82e930 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -12,7 +12,7 @@ */ import { build } from "@server/build"; -import { db, Org, orgs, User, users } from "@server/db"; +import { db, Org, orgs, sessions, User, users } from "@server/db"; import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import license from "#private/license/license"; @@ -28,6 +28,7 @@ export async function checkOrgAccessPolicy( ): Promise { const userId = props.userId || props.user?.userId; const orgId = props.orgId || props.org?.orgId; + const sessionId = props.sessionId || props.session?.sessionId; if (!orgId) { return { @@ -38,6 +39,9 @@ export async function checkOrgAccessPolicy( if (!userId) { return { allowed: false, error: "User ID is required" }; } + if (!sessionId) { + return { allowed: false, error: "Session ID is required" }; + } if (build === "saas") { const { tier } = await getOrgTierData(orgId); @@ -80,6 +84,17 @@ export async function checkOrgAccessPolicy( } } + if (!props.session) { + const [sessionQuery] = await db + .select() + .from(sessions) + .where(eq(sessions.sessionId, sessionId)); + props.session = sessionQuery; + if (!props.session) { + return { allowed: false, error: "Session not found" }; + } + } + // now check the policies const policies: CheckOrgAccessPolicyResult["policies"] = {}; @@ -88,7 +103,34 @@ export async function checkOrgAccessPolicy( policies.requiredTwoFactor = props.user.twoFactorEnabled || false; } - const allowed = Object.values(policies).every((v) => v === true); + if (props.org.maxSessionLengthHours) { + const sessionIssuedAt = props.session.issuedAt; // may be null + const maxSessionLengthHours = props.org.maxSessionLengthHours; + + if (sessionIssuedAt) { + const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000; + const sessionAgeMs = Date.now() - sessionIssuedAt; + policies.maxSessionLength = { + compliant: sessionAgeMs <= maxSessionLengthMs, + maxSessionLengthHours, + sessionAgeHours: sessionAgeMs / (60 * 60 * 1000) + }; + } else { + policies.maxSessionLength = { + compliant: false, + maxSessionLengthHours, + sessionAgeHours: maxSessionLengthHours + }; + } + } + + let allowed = true; + if (policies.requiredTwoFactor === false) { + allowed = false; + } + if (policies.maxSessionLength && policies.maxSessionLength.compliant === false) { + allowed = false; + } return { allowed, diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts index 1a0c024c..d9f0364e 100644 --- a/server/routers/org/checkOrgUserAccess.ts +++ b/server/routers/org/checkOrgUserAccess.ts @@ -68,7 +68,6 @@ export async function checkOrgUserAccess( next: NextFunction ): Promise { try { - logger.debug("here0 ") const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( @@ -116,7 +115,8 @@ export async function checkOrgUserAccess( const policyCheck = await checkOrgAccessPolicy({ orgId, - userId + userId, + session: req.session }); // if we get here, the user has an org join, we just don't know if they pass the policies diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 3a0d60df..06661bd6 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -24,7 +24,8 @@ const updateOrgParamsSchema = z const updateOrgBodySchema = z .object({ name: z.string().min(1).max(255).optional(), - requireTwoFactor: z.boolean().optional() + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -80,6 +81,7 @@ export async function updateOrg( const isLicensed = await isLicensedOrSubscribed(orgId); if (!isLicensed) { parsedBody.data.requireTwoFactor = undefined; + parsedBody.data.maxSessionLengthHours = undefined; } if ( @@ -100,7 +102,8 @@ export async function updateOrg( .update(orgs) .set({ name: parsedBody.data.name, - requireTwoFactor: parsedBody.data.requireTwoFactor + requireTwoFactor: parsedBody.data.requireTwoFactor, + maxSessionLengthHours: parsedBody.data.maxSessionLengthHours }) .where(eq(orgs.orgId, orgId)) .returning(); diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 53aab82f..b4a1a591 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -76,7 +76,8 @@ export async function getExchangeToken( // check org policy here const hasAccess = await checkOrgAccessPolicy({ orgId: resource[0].orgId, - userId: req.user!.userId + userId: req.user!.userId, + session: req.session }); if (!hasAccess.allowed || hasAccess.error) { diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index b0f77959..743cdfb6 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -19,6 +19,13 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; import { z } from "zod"; import { useForm } from "react-hook-form"; @@ -46,11 +53,24 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { Badge } from "@app/components/ui/badge"; + +// Session length options in hours +const SESSION_LENGTH_OPTIONS = [ + { value: null, label: "Unenforced" }, + { value: 72, label: "3 days" }, // 3 * 24 = 72 hours + { value: 168, label: "7 days" }, // 7 * 24 = 168 hours + { value: 336, label: "14 days" }, // 14 * 24 = 336 hours + { value: 720, label: "30 days" }, // 30 * 24 = 720 hours + { value: 2160, label: "90 days" }, // 90 * 24 = 2160 hours + { value: 4320, label: "180 days" } // 180 * 24 = 4320 hours +]; + // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), subnet: z.string().optional(), - requireTwoFactor: z.boolean().optional() + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional() }); type GeneralFormValues = z.infer; @@ -76,7 +96,8 @@ export default function GeneralPage() { defaultValues: { name: org?.org.name, subnet: org?.org.subnet || "", // Add default value for subnet - requireTwoFactor: org?.org.requireTwoFactor || false + requireTwoFactor: org?.org.requireTwoFactor || false, + maxSessionLengthHours: org?.org.maxSessionLengthHours || null }, mode: "onChange" }); @@ -141,6 +162,7 @@ export default function GeneralPage() { } as any; if (build !== "oss") { reqData.requireTwoFactor = data.requireTwoFactor || false; + reqData.maxSessionLengthHours = data.maxSessionLengthHours; } // Update organization @@ -337,6 +359,97 @@ export default function GeneralPage() { ); }} /> + { + const isEnterpriseNotLicensed = + build === "enterprise" && + !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && + !subscriptionStatus?.isSubscribed(); + const isDisabled = + isEnterpriseNotLicensed || + isSaasNotSubscribed; + + return ( + + + {t("maxSessionLength")} + + + + + + + {isDisabled + ? t( + "maxSessionLengthDisabledDescription" + ) + : t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx index 18c20e39..93eb46a3 100644 --- a/src/components/OrgPolicyResult.tsx +++ b/src/components/OrgPolicyResult.tsx @@ -16,6 +16,8 @@ import Enable2FaDialog from "./Enable2FaDialog"; import { useTranslations } from "next-intl"; import { useUserContext } from "@app/hooks/useUserContext"; import { useRouter } from "next/navigation"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; type OrgPolicyResultProps = { orgId: string; @@ -41,15 +43,18 @@ export default function OrgPolicyResult({ const t = useTranslations(); const { user } = useUserContext(); const router = useRouter(); - - // Determine if user is compliant with 2FA policy - const isTwoFactorCompliant = user?.twoFactorEnabled || false; - const policyKeys = Object.keys(accessRes.policies || {}); + let requireedSteps = 0; + let completedSteps = 0; + const { env } = useEnvContext(); + const api = createApiClient({ env }); const policies: PolicyItem[] = []; - - // Only add 2FA policy if the organization has it enforced - if (policyKeys.includes("requiredTwoFactor")) { + if ( + accessRes.policies?.requiredTwoFactor === false || + accessRes.policies?.requiredTwoFactor === true + ) { + const isTwoFactorCompliant = + accessRes.policies?.requiredTwoFactor === true; policies.push({ id: "two-factor", name: t("twoFactorAuthentication"), @@ -60,54 +65,51 @@ export default function OrgPolicyResult({ : undefined, actionText: !isTwoFactorCompliant ? t("enableTwoFactor") : undefined }); - - // policies.push({ - // id: "reauth-required", - // name: "Re-authentication", - // description: - // "It's been 30 days since you last verified your identity. Please log out and log back in to continue.", - // compliant: false, - // action: () => {}, - // actionText: "Log Out" - // }); - // - // policies.push({ - // id: "password-rotation", - // name: "Password Rotation", - // description: - // "It's been 30 days since you last changed your password. Please update your password to continue.", - // compliant: false, - // action: () => {}, - // actionText: "Change Password" - // }); + requireedSteps += 1; + if (isTwoFactorCompliant) { + completedSteps += 1; + } } - const nonCompliantPolicies = policies.filter((policy) => !policy.compliant); - const allCompliant = - policies.length === 0 || nonCompliantPolicies.length === 0; + // Add max session length policy if the organization has it enforced + if (accessRes.policies?.maxSessionLength) { + const maxSessionPolicy = accessRes.policies?.maxSessionLength; + const maxDays = Math.round(maxSessionPolicy.maxSessionLengthHours / 24); + const daysAgo = Math.round(maxSessionPolicy.sessionAgeHours / 24); + + policies.push({ + id: "max-session-length", + name: t("reauthenticationRequired"), + description: t("reauthenticationDescription", { + maxDays, + daysAgo + }), + compliant: maxSessionPolicy.compliant, + action: !maxSessionPolicy.compliant + ? async () => { + try { + await api.post("/auth/logout", undefined); + router.push("/auth/login"); + } catch (error) { + console.error("Error during logout:", error); + router.push("/auth/login"); + } + } + : undefined, + actionText: !maxSessionPolicy.compliant + ? t("reauthenticateNow") + : undefined + }); + requireedSteps += 1; + if (maxSessionPolicy.compliant) { + completedSteps += 1; + } + } - // Calculate progress - const completedPolicies = policies.filter( - (policy) => policy.compliant - ).length; - const totalPolicies = policies.length; const progressPercentage = - totalPolicies > 0 ? (completedPolicies / totalPolicies) * 100 : 100; + requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100; - // If no policies are enforced, show a simple success message - if (policies.length === 0) { - return ( -
- -

- {t("accessGranted")} -

-

- {t("noSecurityRequirements")} -

-
- ); - } + const allCompliant = completedSteps === requireedSteps; return ( <> @@ -123,12 +125,10 @@ export default function OrgPolicyResult({ - {/* Progress Bar */}
- {completedPolicies} of {totalPolicies} steps - completed + {completedSteps} of {requireedSteps} steps completed {Math.round(progressPercentage)}%
@@ -172,17 +172,6 @@ export default function OrgPolicyResult({ - {allCompliant && ( -
-

- {t("allRequirementsMet")} -

-

- {t("youCanNowAccessOrganization")} -

-
- )} - { From 1e70e4289bf0b4e8d832714bc96adf470e95e1a4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Oct 2025 17:11:39 -0700 Subject: [PATCH 04/14] add password expiry enforcement --- cli/commands/setAdminCredentials.ts | 3 +- messages/en-US.json | 15 + server/db/pg/schema/schema.ts | 8 +- server/db/sqlite/schema/schema.ts | 6 +- server/lib/checkOrgAccessPolicy.ts | 5 + server/private/lib/checkOrgAccessPolicy.ts | 32 +- server/routers/auth/changePassword.ts | 49 +- server/routers/auth/resetPassword.ts | 7 +- server/routers/auth/setServerAdmin.ts | 3 +- server/routers/auth/signup.ts | 8 +- server/routers/external.ts | 10 +- server/routers/org/updateOrg.ts | 7 +- src/app/[orgId]/settings/general/page.tsx | 164 ++++-- src/components/ChangePasswordDialog.tsx | 87 +++ src/components/ChangePasswordForm.tsx | 647 +++++++++++++++++++++ src/components/OrgPolicyResult.tsx | 37 ++ src/components/ProfileIcon.tsx | 11 + 17 files changed, 1028 insertions(+), 71 deletions(-) create mode 100644 src/components/ChangePasswordDialog.tsx create mode 100644 src/components/ChangePasswordForm.tsx diff --git a/cli/commands/setAdminCredentials.ts b/cli/commands/setAdminCredentials.ts index 91a6bcf7..f8b1bd40 100644 --- a/cli/commands/setAdminCredentials.ts +++ b/cli/commands/setAdminCredentials.ts @@ -90,7 +90,8 @@ export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = { passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, - emailVerified: true + emailVerified: true, + lastPasswordChange: new Date().getTime() }); console.log("Server admin created"); diff --git a/messages/en-US.json b/messages/en-US.json index 41f03850..9fa8e16c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -911,6 +911,18 @@ "passwordResetCodeDescription": "Check your email for the reset code.", "passwordNew": "New Password", "passwordNewConfirm": "Confirm New Password", + "changePassword": "Change Password", + "changePasswordDescription": "Update your account password", + "oldPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "changePasswordError": "Failed to change password", + "changePasswordErrorDescription": "An error occurred while changing your password", + "changePasswordSuccess": "Password Changed Successfully", + "changePasswordSuccessDescription": "Your password has been updated successfully", + "passwordExpiryRequired": "Password Expiry Required", + "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", + "changePasswordNow": "Change Password Now", "pincodeAuth": "Authenticator Code", "pincodeSubmit2": "Submit Code", "passwordResetSubmit": "Request Reset", @@ -1753,6 +1765,9 @@ "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", "selectSessionLength": "Select session length", + "passwordExpiryDays": "Password Expiry", + "passwordExpiryDescription": "Set the number of days before users are required to change their password.", + "selectPasswordExpiry": "Select password expiry", "subscriptionBadge": "Subscription Required", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 9e094ba3..a1aaecf1 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -27,7 +27,8 @@ export const orgs = pgTable("orgs", { subnet: varchar("subnet"), createdAt: text("createdAt"), requireTwoFactor: boolean("requireTwoFactor"), - maxSessionLengthHours: integer("maxSessionLengthHours") + maxSessionLengthHours: integer("maxSessionLengthHours"), + passwordExpiryDays: integer("passwordExpiryDays") }); export const orgDomains = pgTable("orgDomains", { @@ -201,7 +202,8 @@ export const users = pgTable("user", { dateCreated: varchar("dateCreated").notNull(), termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"), termsVersion: varchar("termsVersion"), - serverAdmin: boolean("serverAdmin").notNull().default(false) + serverAdmin: boolean("serverAdmin").notNull().default(false), + lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) }); export const newts = pgTable("newt", { @@ -228,7 +230,7 @@ export const sessions = pgTable("session", { .notNull() .references(() => users.userId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), - issuedAt: bigint("expiresAt", { mode: "number" }) + issuedAt: bigint("issuedAt", { mode: "number" }) }); export const newtSessions = pgTable("newtSession", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 877cf5c5..1ab7d285 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -20,7 +20,8 @@ export const orgs = sqliteTable("orgs", { subnet: text("subnet"), createdAt: text("createdAt"), requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), - maxSessionLengthHours: integer("maxSessionLengthHours") // hours + maxSessionLengthHours: integer("maxSessionLengthHours"), // hours + passwordExpiryDays: integer("passwordExpiryDays") // days }); export const userDomains = sqliteTable("userDomains", { @@ -229,7 +230,8 @@ export const users = sqliteTable("user", { termsVersion: text("termsVersion"), serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + lastPasswordChange: integer("lastPasswordChange") }); export const securityKeys = sqliteTable("webauthnCredentials", { diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index 14a49b4a..c74283cb 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -18,6 +18,11 @@ export type CheckOrgAccessPolicyResult = { compliant: boolean; maxSessionLengthHours: number; sessionAgeHours: number; + }; + passwordAge?: { + compliant: boolean; + maxPasswordAgeDays: number; + passwordAgeDays: number; } }; }; diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index da82e930..bc2d9cca 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -98,11 +98,12 @@ export async function checkOrgAccessPolicy( // now check the policies const policies: CheckOrgAccessPolicyResult["policies"] = {}; - // only applies to internal users + // only applies to internal users; oidc users 2fa is managed by the IDP if (props.user.type === UserType.Internal && props.org.requireTwoFactor) { policies.requiredTwoFactor = props.user.twoFactorEnabled || false; } + // applies to all users if (props.org.maxSessionLengthHours) { const sessionIssuedAt = props.session.issuedAt; // may be null const maxSessionLengthHours = props.org.maxSessionLengthHours; @@ -124,11 +125,38 @@ export async function checkOrgAccessPolicy( } } + // only applies to internal users; oidc users don't have passwords + if (props.user.type === UserType.Internal && props.org.passwordExpiryDays) { + if (props.user.lastPasswordChange) { + const passwordExpiryDays = props.org.passwordExpiryDays; + const passwordAgeMs = Date.now() - props.user.lastPasswordChange; + const passwordAgeDays = passwordAgeMs / (24 * 60 * 60 * 1000); + + policies.passwordAge = { + compliant: passwordAgeDays <= passwordExpiryDays, + maxPasswordAgeDays: passwordExpiryDays, + passwordAgeDays: passwordAgeDays + }; + } else { + policies.passwordAge = { + compliant: false, + maxPasswordAgeDays: props.org.passwordExpiryDays, + passwordAgeDays: props.org.passwordExpiryDays // Treat as expired + }; + } + } + let allowed = true; if (policies.requiredTwoFactor === false) { allowed = false; } - if (policies.maxSessionLength && policies.maxSessionLength.compliant === false) { + if ( + policies.maxSessionLength && + policies.maxSessionLength.compliant === false + ) { + allowed = false; + } + if (policies.passwordAge && policies.passwordAge.compliant === false) { allowed = false; } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 64efb696..6c9d0d7c 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -5,7 +5,6 @@ import { fromError } from "zod-validation-error"; import { z } from "zod"; import { db } from "@server/db"; import { User, users } from "@server/db"; -import { eq } from "drizzle-orm"; import { response } from "@server/lib/response"; import { hashPassword, @@ -15,6 +14,8 @@ 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 { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; @@ -32,6 +33,46 @@ 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, @@ -109,11 +150,13 @@ export async function changePassword( await db .update(users) .set({ - passwordHash: hash + passwordHash: hash, + lastPasswordChange: new Date().getTime() }) .where(eq(users.userId, user.userId)); - await invalidateAllSessions(user.userId); + // Invalidate all sessions except the current one + await invalidateAllSessionsExceptCurrent(user.userId, req.session.sessionId); // TODO: send email to user confirming password change diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 05293727..14b4236b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -19,10 +19,7 @@ import { passwordSchema } from "@server/auth/passwordSchema"; export const resetPasswordBody = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), token: z.string(), // reset secret code newPassword: passwordSchema, code: z.string().optional() // 2fa code @@ -152,7 +149,7 @@ export async function resetPassword( await db.transaction(async (trx) => { await trx .update(users) - .set({ passwordHash }) + .set({ passwordHash, lastPasswordChange: new Date().getTime() }) .where(eq(users.userId, resetRequest[0].userId)); await trx diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts index 716feca4..307f5504 100644 --- a/server/routers/auth/setServerAdmin.ts +++ b/server/routers/auth/setServerAdmin.ts @@ -98,7 +98,8 @@ export async function setServerAdmin( passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, - emailVerified: true + emailVerified: true, + lastPasswordChange: new Date().getTime() }); }); diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 3e9a7aaa..e836d109 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -23,10 +23,7 @@ import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; import { build } from "@server/build"; -import resend, { - AudienceIds, - moveEmailToAudience -} from "#dynamic/lib/resend"; +import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend"; export const signupBodySchema = z.object({ email: z.string().toLowerCase().email(), @@ -183,7 +180,8 @@ export async function signup( passwordHash, dateCreated: moment().toISOString(), termsAcceptedTimestamp: termsAcceptedTimestamp || null, - termsVersion: "1" + termsVersion: "1", + lastPasswordChange: new Date().getTime() }); // give the user their default permissions: diff --git a/server/routers/external.ts b/server/routers/external.ts index 67daae77..74ad3b31 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -973,11 +973,11 @@ authRouter.post( auth.requestEmailVerificationCode ); -// authRouter.post( -// "/change-password", -// verifySessionUserMiddleware, -// auth.changePassword -// ); +authRouter.post( + "/change-password", + verifySessionUserMiddleware, + auth.changePassword +); authRouter.post( "/reset-password/request", diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 06661bd6..88cd2df2 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -25,7 +25,8 @@ const updateOrgBodySchema = z .object({ name: z.string().min(1).max(255).optional(), requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional() + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -82,6 +83,7 @@ export async function updateOrg( if (!isLicensed) { parsedBody.data.requireTwoFactor = undefined; parsedBody.data.maxSessionLengthHours = undefined; + parsedBody.data.passwordExpiryDays = undefined; } if ( @@ -103,7 +105,8 @@ export async function updateOrg( .set({ name: parsedBody.data.name, requireTwoFactor: parsedBody.data.requireTwoFactor, - maxSessionLengthHours: parsedBody.data.maxSessionLengthHours + maxSessionLengthHours: parsedBody.data.maxSessionLengthHours, + passwordExpiryDays: parsedBody.data.passwordExpiryDays }) .where(eq(orgs.orgId, orgId)) .returning(); diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 743cdfb6..e15a98ac 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -65,12 +65,23 @@ const SESSION_LENGTH_OPTIONS = [ { value: 4320, label: "180 days" } // 180 * 24 = 4320 hours ]; +// Password expiry options in days +const PASSWORD_EXPIRY_OPTIONS = [ + { value: null, label: "Never Expire" }, + { value: 30, label: "30 days" }, + { value: 60, label: "60 days" }, + { value: 90, label: "90 days" }, + { value: 180, label: "180 days" }, + { value: 365, label: "1 year" } +]; + // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), subnet: z.string().optional(), requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional() + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional() }); type GeneralFormValues = z.infer; @@ -87,6 +98,14 @@ export default function GeneralPage() { const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const subscriptionStatus = useSubscriptionStatusContext(); + // Check if security features are disabled due to licensing/subscription + const isSecurityFeatureDisabled = () => { + const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); + const isSaasNotSubscribed = + build === "saas" && !subscriptionStatus?.isSubscribed(); + return isEnterpriseNotLicensed || isSaasNotSubscribed; + }; + const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); const authPageSettingsRef = useRef(null); @@ -97,7 +116,8 @@ export default function GeneralPage() { name: org?.org.name, subnet: org?.org.subnet || "", // Add default value for subnet requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null + maxSessionLengthHours: org?.org.maxSessionLengthHours || null, + passwordExpiryDays: org?.org.passwordExpiryDays || null }, mode: "onChange" }); @@ -163,6 +183,7 @@ export default function GeneralPage() { if (build !== "oss") { reqData.requireTwoFactor = data.requireTwoFactor || false; reqData.maxSessionLengthHours = data.maxSessionLengthHours; + reqData.passwordExpiryDays = data.passwordExpiryDays; } // Update organization @@ -303,16 +324,8 @@ export default function GeneralPage() { control={form.control} name="requireTwoFactor" render={({ field }) => { - const isEnterpriseNotLicensed = - build === "enterprise" && - !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && - !subscriptionStatus?.isSubscribed(); const isDisabled = - isEnterpriseNotLicensed || - isSaasNotSubscribed; - const shouldDisableToggle = isDisabled; + isSecurityFeatureDisabled(); return ( @@ -328,13 +341,13 @@ export default function GeneralPage() { "requireTwoFactorForAllUsers" )} disabled={ - shouldDisableToggle + isDisabled } onCheckedChange={( val ) => { if ( - !shouldDisableToggle + !isDisabled ) { form.setValue( "requireTwoFactor", @@ -347,13 +360,9 @@ export default function GeneralPage() {
- {isDisabled - ? t( - "requireTwoFactorDisabledDescription" - ) - : t( - "requireTwoFactorDescription" - )} + {t( + "requireTwoFactorDescription" + )} ); @@ -363,15 +372,8 @@ export default function GeneralPage() { control={form.control} name="maxSessionLengthHours" render={({ field }) => { - const isEnterpriseNotLicensed = - build === "enterprise" && - !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && - !subscriptionStatus?.isSubscribed(); const isDisabled = - isEnterpriseNotLicensed || - isSaasNotSubscribed; + isSecurityFeatureDisabled(); return ( @@ -384,10 +386,13 @@ export default function GeneralPage() { field.value?.toString() || "null" } - onValueChange={(value) => { + onValueChange={( + value + ) => { if (!isDisabled) { const numValue = - value === "null" + value === + "null" ? null : parseInt( value, @@ -403,11 +408,9 @@ export default function GeneralPage() { > @@ -438,13 +441,90 @@ export default function GeneralPage() { - {isDisabled - ? t( - "maxSessionLengthDisabledDescription" - ) - : t( - "maxSessionLengthDescription" - )} + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = + isSecurityFeatureDisabled(); + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "passwordExpiryDescription" + )} ); diff --git a/src/components/ChangePasswordDialog.tsx b/src/components/ChangePasswordDialog.tsx new file mode 100644 index 00000000..85a55ab2 --- /dev/null +++ b/src/components/ChangePasswordDialog.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import ChangePasswordForm from "@app/components/ChangePasswordForm"; +import { useTranslations } from "next-intl"; + +type ChangePasswordDialogProps = { + open: boolean; + setOpen: (val: boolean) => void; +}; + +export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDialogProps) { + const t = useTranslations(); + const [currentStep, setCurrentStep] = useState(1); + const [loading, setLoading] = useState(false); + const formRef = useRef<{ handleSubmit: () => void }>(null); + + function reset() { + setCurrentStep(1); + setLoading(false); + } + + const handleSubmit = () => { + if (formRef.current) { + formRef.current.handleSubmit(); + } + }; + + return ( + { + setOpen(val); + reset(); + }} + > + + + + {t('changePassword')} + + + {t('changePasswordDescription')} + + + + setOpen(false)} + onStepChange={setCurrentStep} + onLoadingChange={setLoading} + /> + + + + + + {(currentStep === 1 || currentStep === 2) && ( + + )} + + + + ); +} diff --git a/src/components/ChangePasswordForm.tsx b/src/components/ChangePasswordForm.tsx new file mode 100644 index 00000000..5d1395bc --- /dev/null +++ b/src/components/ChangePasswordForm.tsx @@ -0,0 +1,647 @@ +"use client"; + +import { useState, forwardRef, useImperativeHandle, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { CheckCircle2, Check, X } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { AxiosResponse } from "axios"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { useTranslations } from "next-intl"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "./ui/input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import { ChangePasswordResponse } from "@server/routers/auth"; +import { cn } from "@app/lib/cn"; + +// Password strength calculation +const calculatePasswordStrength = (password: string) => { + const requirements = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password), + special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password) + }; + + const score = Object.values(requirements).filter(Boolean).length; + let strength: "weak" | "medium" | "strong" = "weak"; + let color = "bg-red-500"; + let percentage = 0; + + if (score >= 5) { + strength = "strong"; + color = "bg-green-500"; + percentage = 100; + } else if (score >= 3) { + strength = "medium"; + color = "bg-yellow-500"; + percentage = 60; + } else if (score >= 1) { + strength = "weak"; + color = "bg-red-500"; + percentage = 30; + } + + return { requirements, strength, color, percentage, score }; +}; + +type ChangePasswordFormProps = { + onComplete?: () => void; + onCancel?: () => void; + isDialog?: boolean; + submitButtonText?: string; + cancelButtonText?: string; + showCancelButton?: boolean; + onStepChange?: (step: number) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +const ChangePasswordForm = forwardRef< + { handleSubmit: () => void }, + ChangePasswordFormProps +>( + ( + { + onComplete, + onCancel, + isDialog = false, + submitButtonText, + cancelButtonText, + showCancelButton = false, + onStepChange, + onLoadingChange + }, + ref + ) => { + const [step, setStep] = useState(1); + const [loading, setLoading] = useState(false); + const [newPasswordValue, setNewPasswordValue] = useState(""); + const [confirmPasswordValue, setConfirmPasswordValue] = useState(""); + + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const passwordStrength = calculatePasswordStrength(newPasswordValue); + const doPasswordsMatch = + newPasswordValue.length > 0 && + confirmPasswordValue.length > 0 && + newPasswordValue === confirmPasswordValue; + + // Notify parent of step and loading changes + useEffect(() => { + onStepChange?.(step); + }, [step, onStepChange]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + const passwordSchema = z.object({ + oldPassword: z.string().min(1, { message: t("passwordRequired") }), + newPassword: z.string().min(8, { message: t("passwordRequirementsChars") }), + confirmPassword: z.string().min(1, { message: t("passwordRequired") }) + }).refine((data) => data.newPassword === data.confirmPassword, { + message: t("passwordsDoNotMatch"), + path: ["confirmPassword"], + }); + + const mfaSchema = z.object({ + code: z.string().length(6, { message: t("pincodeInvalid") }) + }); + + const passwordForm = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { + oldPassword: "", + newPassword: "", + confirmPassword: "" + } + }); + + const mfaForm = useForm({ + resolver: zodResolver(mfaSchema), + defaultValues: { + code: "" + } + }); + + const changePassword = async (values: z.infer) => { + setLoading(true); + + const endpoint = `/auth/change-password`; + const payload = { + oldPassword: values.oldPassword, + newPassword: values.newPassword + }; + + const res = await api + .post>(endpoint, payload) + .catch((e) => { + toast({ + title: t("changePasswordError"), + description: formatAxiosError( + e, + t("changePasswordErrorDescription") + ), + variant: "destructive" + }); + }); + + if (res && res.data) { + if (res.data.data?.codeRequested) { + setStep(2); + } else { + setStep(3); + } + } + + setLoading(false); + }; + + const confirmMfa = async (values: z.infer) => { + setLoading(true); + + const endpoint = `/auth/change-password`; + const passwordValues = passwordForm.getValues(); + const payload = { + oldPassword: passwordValues.oldPassword, + newPassword: passwordValues.newPassword, + code: values.code + }; + + const res = await api + .post>(endpoint, payload) + .catch((e) => { + toast({ + title: t("changePasswordError"), + description: formatAxiosError( + e, + t("changePasswordErrorDescription") + ), + variant: "destructive" + }); + }); + + if (res && res.data) { + setStep(3); + } + + setLoading(false); + }; + + const handleSubmit = () => { + if (step === 1) { + passwordForm.handleSubmit(changePassword)(); + } else if (step === 2) { + mfaForm.handleSubmit(confirmMfa)(); + } + }; + + const handleComplete = () => { + if (onComplete) { + onComplete(); + } + }; + + useImperativeHandle(ref, () => ({ + handleSubmit + })); + + return ( +
+ {step === 1 && ( +
+ +
+ ( + + + {t("oldPassword")} + + + + + + + )} + /> + + ( + +
+ + {t("newPassword")} + + {passwordStrength.strength === + "strong" && ( + + )} +
+ +
+ { + field.onChange(e); + setNewPasswordValue( + e.target.value + ); + }} + className={cn( + passwordStrength.strength === + "strong" && + "border-green-500 focus-visible:ring-green-500", + passwordStrength.strength === + "medium" && + "border-yellow-500 focus-visible:ring-yellow-500", + passwordStrength.strength === + "weak" && + newPasswordValue.length > + 0 && + "border-red-500 focus-visible:ring-red-500" + )} + autoComplete="new-password" + /> +
+
+ + {newPasswordValue.length > 0 && ( +
+ {/* Password Strength Meter */} +
+
+ + {t("passwordStrength")} + + + {t( + `passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}` + )} + +
+ +
+ + {/* Requirements Checklist */} +
+
+ {t("passwordRequirements")} +
+
+
+ {passwordStrength + .requirements + .length ? ( + + ) : ( + + )} + + {t( + "passwordRequirementLengthText" + )} + +
+
+ {passwordStrength + .requirements + .uppercase ? ( + + ) : ( + + )} + + {t( + "passwordRequirementUppercaseText" + )} + +
+
+ {passwordStrength + .requirements + .lowercase ? ( + + ) : ( + + )} + + {t( + "passwordRequirementLowercaseText" + )} + +
+
+ {passwordStrength + .requirements + .number ? ( + + ) : ( + + )} + + {t( + "passwordRequirementNumberText" + )} + +
+
+ {passwordStrength + .requirements + .special ? ( + + ) : ( + + )} + + {t( + "passwordRequirementSpecialText" + )} + +
+
+
+
+ )} + + {/* Only show FormMessage when not showing our custom requirements */} + {newPasswordValue.length === 0 && ( + + )} +
+ )} + /> + + ( + +
+ + {t("confirmNewPassword")} + + {doPasswordsMatch && ( + + )} +
+ +
+ { + field.onChange(e); + setConfirmPasswordValue( + e.target.value + ); + }} + className={cn( + doPasswordsMatch && + "border-green-500 focus-visible:ring-green-500", + confirmPasswordValue.length > + 0 && + !doPasswordsMatch && + "border-red-500 focus-visible:ring-red-500" + )} + autoComplete="new-password" + /> +
+
+ {confirmPasswordValue.length > 0 && + !doPasswordsMatch && ( +

+ {t("passwordsDoNotMatch")} +

+ )} + {/* Only show FormMessage when field is empty */} + {confirmPasswordValue.length === 0 && ( + + )} +
+ )} + /> +
+
+ + )} + + {step === 2 && ( +
+
+

{t("otpAuth")}

+

+ {t("otpAuthDescription")} +

+
+ +
+ + ( + + +
+ { + field.onChange(value); + if ( + value.length === 6 + ) { + mfaForm.handleSubmit( + confirmMfa + )(); + } + }} + > + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ )} + + {step === 3 && ( +
+ +

+ {t("changePasswordSuccess")} +

+

{t("changePasswordSuccessDescription")}

+
+ )} + + {/* Action buttons - only show when not in dialog */} + {!isDialog && ( +
+ {showCancelButton && onCancel && ( + + )} + {(step === 1 || step === 2) && ( + + )} + {step === 3 && ( + + )} +
+ )} +
+ ); + } +); + +export default ChangePasswordForm; \ No newline at end of file diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx index 93eb46a3..bedf1905 100644 --- a/src/components/OrgPolicyResult.tsx +++ b/src/components/OrgPolicyResult.tsx @@ -13,6 +13,7 @@ import { import { Progress } from "@/components/ui/progress"; import { CheckCircle2, XCircle, Shield } from "lucide-react"; import Enable2FaDialog from "./Enable2FaDialog"; +import ChangePasswordDialog from "./ChangePasswordDialog"; import { useTranslations } from "next-intl"; import { useUserContext } from "@app/hooks/useUserContext"; import { useRouter } from "next/navigation"; @@ -40,6 +41,7 @@ export default function OrgPolicyResult({ accessRes }: OrgPolicyResultProps) { const [show2FaDialog, setShow2FaDialog] = useState(false); + const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false); const t = useTranslations(); const { user } = useUserContext(); const router = useRouter(); @@ -106,6 +108,33 @@ export default function OrgPolicyResult({ } } + // Add password age policy if the organization has it enforced + if (accessRes.policies?.passwordAge) { + const passwordAgePolicy = accessRes.policies.passwordAge; + const maxDays = passwordAgePolicy.maxPasswordAgeDays; + const daysAgo = Math.round(passwordAgePolicy.passwordAgeDays); + + policies.push({ + id: "password-age", + name: t("passwordExpiryRequired"), + description: t("passwordExpiryDescription", { + maxDays, + daysAgo + }), + compliant: passwordAgePolicy.compliant, + action: !passwordAgePolicy.compliant + ? () => setShowChangePasswordDialog(true) + : undefined, + actionText: !passwordAgePolicy.compliant + ? t("changePasswordNow") + : undefined + }); + requireedSteps += 1; + if (passwordAgePolicy.compliant) { + completedSteps += 1; + } + } + const progressPercentage = requireedSteps === 0 ? 100 : (completedSteps / requireedSteps) * 100; @@ -179,6 +208,14 @@ export default function OrgPolicyResult({ router.refresh(); }} /> + + { + setShowChangePasswordDialog(val); + router.refresh(); + }} + /> ); } diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index fc7803a0..5789a83c 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -22,6 +22,7 @@ import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; import SecurityKeyForm from "./SecurityKeyForm"; import Enable2FaDialog from "./Enable2FaDialog"; +import ChangePasswordDialog from "./ChangePasswordDialog"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; import LocaleSwitcher from "@app/components/LocaleSwitcher"; @@ -41,6 +42,7 @@ export default function ProfileIcon() { const [openEnable2fa, setOpenEnable2fa] = useState(false); const [openDisable2fa, setOpenDisable2fa] = useState(false); const [openSecurityKey, setOpenSecurityKey] = useState(false); + const [openChangePassword, setOpenChangePassword] = useState(false); const t = useTranslations(); @@ -78,6 +80,10 @@ export default function ProfileIcon() { open={openSecurityKey} setOpen={setOpenSecurityKey} /> + @@ -132,6 +138,11 @@ export default function ProfileIcon() { > {t("securityKeyManage")} + setOpenChangePassword(true)} + > + {t("changePassword")} + )} From 460df46abcbdbd581267e0d4d7a728932ce394aa Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Oct 2025 17:18:34 -0700 Subject: [PATCH 05/14] update translation and send password reset email --- messages/en-US.json | 7 ++++++- server/routers/auth/changePassword.ts | 14 +++++++++++++- src/app/[orgId]/settings/general/page.tsx | 20 +++++++++----------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9fa8e16c..bdecc6e8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1766,8 +1766,13 @@ "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", "selectSessionLength": "Select session length", "passwordExpiryDays": "Password Expiry", - "passwordExpiryDescription": "Set the number of days before users are required to change their password.", + "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", "selectPasswordExpiry": "Select password expiry", + "30Days": "30 days", + "60Days": "60 days", + "90Days": "90 days", + "180Days": "180 days", + "1Year": "1 year", "subscriptionBadge": "Subscription Required", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 6c9d0d7c..0164316e 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -18,6 +18,9 @@ import { sessions, resourceSessions } from "@server/db"; import { and, eq, ne, inArray } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; +import { sendEmail } from "@server/emails"; +import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword"; +import config from "@server/lib/config"; export const changePasswordBody = z .object({ @@ -158,7 +161,16 @@ export async function changePassword( // Invalidate all sessions except the current one await invalidateAllSessionsExceptCurrent(user.userId, req.session.sessionId); - // TODO: send email to user confirming password change + try { + const email = user.email!; + await sendEmail(ConfirmPasswordReset({ email }), { + from: config.getNoReplyEmail(), + to: email, + subject: "Password Reset Confirmation" + }); + } catch (e) { + logger.error("Failed to send password reset confirmation email", e); + } return response(res, { data: null, diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index e15a98ac..a210364f 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -65,14 +65,14 @@ const SESSION_LENGTH_OPTIONS = [ { value: 4320, label: "180 days" } // 180 * 24 = 4320 hours ]; -// Password expiry options in days +// Password expiry options in days - will be translated in component const PASSWORD_EXPIRY_OPTIONS = [ - { value: null, label: "Never Expire" }, - { value: 30, label: "30 days" }, - { value: 60, label: "60 days" }, - { value: 90, label: "90 days" }, - { value: 180, label: "180 days" }, - { value: 365, label: "1 year" } + { value: null, labelKey: "neverExpire" }, + { value: 30, labelKey: "30Days" }, + { value: 60, labelKey: "60Days" }, + { value: 90, labelKey: "90Days" }, + { value: 180, labelKey: "180Days" }, + { value: 365, labelKey: "1Year" } ]; // Schema for general organization settings @@ -511,9 +511,7 @@ export default function GeneralPage() { : option.value.toString() } > - { - option.label - } + {t(option.labelKey)} ) )} @@ -523,7 +521,7 @@ export default function GeneralPage() { {t( - "passwordExpiryDescription" + "editPasswordExpiryDescription" )} From 9ce81b34c91b9f4ce4c65ac9db6bb01be0ffe30f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Oct 2025 17:30:39 -0700 Subject: [PATCH 06/14] add confirm dialog to update security settings --- messages/en-US.json | 4 +++ server/routers/org/updateOrg.ts | 14 -------- src/app/[orgId]/settings/general/page.tsx | 42 +++++++++++++++++++++++ src/components/ConfirmDeleteDialog.tsx | 21 ++++++++---- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index bdecc6e8..68572da4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1774,6 +1774,10 @@ "180Days": "180 days", "1Year": "1 year", "subscriptionBadge": "Subscription Required", + "securityPolicyChangeWarning": "Security Policy Change Warning", + "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", + "securityPolicyChangeConfirmMessage": "I confirm", + "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageUpdated": "Auth page updated successfully", "healthCheckNotAvailable": "Local", diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 88cd2df2..ea7e3eeb 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -86,20 +86,6 @@ export async function updateOrg( parsedBody.data.passwordExpiryDays = undefined; } - if ( - req.user && - req.user.type === UserType.Internal && - parsedBody.data.requireTwoFactor === true && - !req.user.twoFactorEnabled - ) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "You must enable two-factor authentication for your account before enforcing it for all users" - ) - ); - } - const updatedOrg = await db .update(orgs) .set({ diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index a210364f..1d2508f0 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -108,6 +108,7 @@ export default function GeneralPage() { const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = useState(false); const authPageSettingsRef = useRef(null); const form = useForm({ @@ -122,6 +123,23 @@ export default function GeneralPage() { mode: "onChange" }); + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org?.org.requireTwoFactor || false, + maxSessionLengthHours: org?.org.maxSessionLengthHours || null, + passwordExpiryDays: org?.org.passwordExpiryDays || null + }; + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== initialSecurityValues.passwordExpiryDays + ); + }; + async function deleteOrg() { setLoadingDelete(true); try { @@ -174,6 +192,16 @@ export default function GeneralPage() { } async function onSubmit(data: GeneralFormValues) { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(data); + } + + async function performSave(data: GeneralFormValues) { setLoadingSave(true); try { @@ -231,6 +259,20 @@ export default function GeneralPage() { string={org?.org.name || ""} title={t("orgDelete")} /> + +

{t("securityPolicyChangeDescription")}

+
+ } + buttonText={t("saveSettings")} + onConfirm={() => performSave(form.getValues())} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 499d0887..8dd039ab 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -54,6 +54,7 @@ type InviteUserFormProps = { dialog: React.ReactNode; buttonText: string; onConfirm: () => Promise; + warningText?: string; }; export default function InviteUserForm({ @@ -63,7 +64,8 @@ export default function InviteUserForm({ title, onConfirm, buttonText, - dialog + dialog, + warningText }: InviteUserFormProps) { const [loading, setLoading] = useState(false); @@ -86,13 +88,20 @@ export default function InviteUserForm({ function reset() { form.reset(); - setLoading(false); } async function onSubmit(values: z.infer) { setLoading(true); - await onConfirm(); - reset(); + try { + await onConfirm(); + setOpen(false); + reset(); + } catch (error) { + // Handle error if needed + console.error("Confirmation failed:", error); + } finally { + setLoading(false); + } } return ( @@ -111,8 +120,8 @@ export default function InviteUserForm({
{dialog} -
- {t("cannotbeUndone")} +
+ {warningText || t("cannotbeUndone")}
From 8973726f6331ef26feb02277d424e1c16b832df9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 25 Oct 2025 17:15:37 -0700 Subject: [PATCH 07/14] add org policy check to verify session --- server/private/lib/checkOrgAccessPolicy.ts | 7 ++ server/private/routers/hybrid.ts | 92 +++++++++++++++++++++- server/routers/badger/verifySession.ts | 16 +++- src/app/[orgId]/policy/page.tsx | 3 - 4 files changed, 113 insertions(+), 5 deletions(-) delete mode 100644 src/app/[orgId]/policy/page.tsx diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index bc2d9cca..5c212ac8 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -95,6 +95,13 @@ export async function checkOrgAccessPolicy( } } + if (props.session.userId !== props.user.userId) { + return { + allowed: false, + error: "Session does not belong to the user" + }; + } + // now check the policies const policies: CheckOrgAccessPolicyResult["policies"] = {}; diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index df99df92..cf17cce3 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -73,6 +73,8 @@ import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; // Zod schemas for request validation const getResourceByDomainParamsSchema = z @@ -300,7 +302,8 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path; + encryptionKeyPath = + privateConfig.getRawPrivateConfig().server.encryption_key_path; if (!fs.existsSync(encryptionKeyPath)) { throw new Error( @@ -1582,3 +1585,90 @@ hybridRouter.post( } } ); + +const getOrgAccessPolicyParamsSchema = z + .object({ + orgId: z.string().min(1), + userId: z.string().min(1) + }) + .strict(); + +const getOrgAccessPolicyBodySchema = z + .object({ + sessionId: z.string().min(1) + }) + .strict(); + +hybridRouter.get( + "/org/:orgId/user/:userId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgAccessPolicyParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = getOrgAccessPolicyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + const { sessionId } = parsedBody.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const accessPolicy = await checkOrgAccessPolicy({ + orgId, + userId, + sessionId + }); + + return response(res, { + data: accessPolicy, + success: true, + error: false, + message: "Org access policy retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get org login page" + ) + ); + } + } +); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 523163e6..e3a34eb1 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -37,6 +37,7 @@ import { getCountryCodeForIp } from "@server/lib/geoip"; import { getOrgTierData } from "#dynamic/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -313,7 +314,8 @@ export async function verifyResourceSession( return allowed(res); } - if ( // we dont want to redirect if this is the only auth method and we did not pass here + if ( + // we dont want to redirect if this is the only auth method and we did not pass here !sso && !pincode && !password && @@ -589,6 +591,18 @@ async function isUserAllowedToAccessResource( return null; } + const accessPolicy = await checkOrgAccessPolicy({ + orgId: resource.orgId, + userId: user.userId, + sessionId: session.sessionId + }); + if (!accessPolicy.allowed || accessPolicy.error) { + logger.debug(`User not allowed by org access policy because`, { + accessPolicy + }); + return null; + } + const roleResourceAccess = await getRoleResourceAccess( resource.resourceId, userOrgRole.roleId diff --git a/src/app/[orgId]/policy/page.tsx b/src/app/[orgId]/policy/page.tsx deleted file mode 100644 index ea8d32ba..00000000 --- a/src/app/[orgId]/policy/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export async function OrgPolicyPage() { - return
Org Policy Page
; -} From 1227b3c11a4f74082fc6d78c1b40de7d0f4568ca Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 25 Oct 2025 17:21:21 -0700 Subject: [PATCH 08/14] use alert instead of badge for unlock status --- messages/en-US.json | 1 + src/app/[orgId]/settings/general/page.tsx | 32 ++++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 68572da4..f75d95c6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1743,6 +1743,7 @@ "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", "subscriptionRequiredToUse": "A subscription is required to use this feature.", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", "idpDisabled": "Identity providers are disabled.", "orgAuthPageDisabled": "Organization auth page is disabled.", "domainRestartedDescription": "Domain verification restarted successfully", diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 1d2508f0..45d3c397 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -53,6 +53,7 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { Badge } from "@app/components/ui/badge"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ @@ -338,23 +339,30 @@ export default function GeneralPage() { {/* Security Settings Section */} -
- - {t("securitySettings")} - - {build === "enterprise" && !isUnlocked() ? ( - - {build === "enterprise" - ? t("licenseBadge") - : t("subscriptionBadge")} - - ) : null} -
+ + {t("securitySettings")} + {t("securitySettingsDescription")}
+ {build == "saas" && !subscriptionStatus?.isSubscribed() ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} +
Date: Sun, 26 Oct 2025 16:52:15 -0700 Subject: [PATCH 09/14] enforce resource session length --- server/auth/sessions/resource.ts | 3 +- server/db/pg/schema/schema.ts | 3 +- server/db/queries/verifySessionQueries.ts | 10 ++- server/db/sqlite/schema/schema.ts | 3 +- server/lib/checkOrgAccessPolicy.ts | 9 ++- server/private/lib/checkOrgAccessPolicy.ts | 47 +++++++++--- server/private/routers/hybrid.ts | 89 ---------------------- server/routers/badger/verifySession.ts | 38 +++++++-- 8 files changed, 90 insertions(+), 112 deletions(-) diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 31ab2b38..9a5b2b5f 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -50,7 +50,8 @@ export async function createResourceSession(opts: { doNotExtend: opts.doNotExtend || false, accessTokenId: opts.accessTokenId || null, isRequestToken: opts.isRequestToken || false, - userSessionId: opts.userSessionId || null + userSessionId: opts.userSessionId || null, + issuedAt: new Date().getTime() }; await db.insert(resourceSessions).values(session); diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index a1aaecf1..f6450d07 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -447,7 +447,8 @@ export const resourceSessions = pgTable("resourceSessions", { { onDelete: "cascade" } - ) + ), + issuedAt: bigint("issuedAt", { mode: "number" }) }); export const resourceWhitelist = pgTable("resourceWhitelist", { diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 8944a491..85bd7cc7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,4 @@ -import { db, loginPage, LoginPage, loginPageOrg } from "@server/db"; +import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db"; import { Resource, ResourcePassword, @@ -23,6 +23,7 @@ export type ResourceWithAuth = { pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + org: Org; }; export type UserSessionWithUser = { @@ -51,6 +52,10 @@ export async function getResourceByDomain( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .innerJoin( + orgs, + eq(orgs.orgId, resources.orgId) + ) .where(eq(resources.fullDomain, domain)) .limit(1); @@ -62,7 +67,8 @@ export async function getResourceByDomain( resource: result.resources, pincode: result.resourcePincode, password: result.resourcePassword, - headerAuth: result.resourceHeaderAuth + headerAuth: result.resourceHeaderAuth, + org: result.orgs }; } diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 1ab7d285..40d22bdf 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -587,7 +587,8 @@ export const resourceSessions = sqliteTable("resourceSessions", { { onDelete: "cascade" } - ) + ), + issuedAt: integer("issuedAt") }); export const resourceWhitelist = sqliteTable("resourceWhitelist", { diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index c74283cb..5ffe4702 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -1,4 +1,4 @@ -import { Org, Session, User } from "@server/db"; +import { Org, ResourceSession, Session, User } from "@server/db"; export type CheckOrgAccessPolicyProps = { orgId?: string; @@ -27,6 +27,13 @@ export type CheckOrgAccessPolicyResult = { }; }; +export async function enforceResourceSessionLength( + resourceSession: ResourceSession, + org: Org +): Promise<{ valid: boolean; error?: string }> { + return { valid: true }; +} + export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps ): Promise<{ diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index 5c212ac8..2137cd72 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -12,7 +12,14 @@ */ import { build } from "@server/build"; -import { db, Org, orgs, sessions, User, users } from "@server/db"; +import { + db, + Org, + orgs, + ResourceSession, + sessions, + users +} from "@server/db"; import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import license from "#private/license/license"; @@ -23,6 +30,35 @@ import { } from "@server/lib/checkOrgAccessPolicy"; import { UserType } from "@server/types/UserTypes"; +export async function enforceResourceSessionLength( + resourceSession: ResourceSession, + org: Org +): Promise<{ valid: boolean; error?: string }> { + if (org.maxSessionLengthHours) { + const sessionIssuedAt = resourceSession.issuedAt; // may be null + const maxSessionLengthHours = org.maxSessionLengthHours; + + if (sessionIssuedAt) { + const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000; + const sessionAgeMs = Date.now() - sessionIssuedAt; + + if (sessionAgeMs > maxSessionLengthMs) { + return { + valid: false, + error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` + }; + } + } else { + return { + valid: false, + error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` + }; + } + } + + return { valid: true }; +} + export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps ): Promise { @@ -43,15 +79,6 @@ export async function checkOrgAccessPolicy( return { allowed: false, error: "Session ID is required" }; } - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - // if not subscribed, don't check the policies - if (!subscribed) { - return { allowed: true }; - } - } - if (build === "enterprise") { const isUnlocked = await license.isUnlocked(); // if not licensed, don't check the policies diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index cf17cce3..9da80e87 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -73,8 +73,6 @@ import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; -import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; -import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; // Zod schemas for request validation const getResourceByDomainParamsSchema = z @@ -1585,90 +1583,3 @@ hybridRouter.post( } } ); - -const getOrgAccessPolicyParamsSchema = z - .object({ - orgId: z.string().min(1), - userId: z.string().min(1) - }) - .strict(); - -const getOrgAccessPolicyBodySchema = z - .object({ - sessionId: z.string().min(1) - }) - .strict(); - -hybridRouter.get( - "/org/:orgId/user/:userId/access", - async (req: Request, res: Response, next: NextFunction) => { - try { - const parsedParams = getOrgAccessPolicyParamsSchema.safeParse( - req.params - ); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const parsedBody = getOrgAccessPolicyBodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { orgId, userId } = parsedParams.data; - const { sessionId } = parsedBody.data; - const remoteExitNode = req.remoteExitNode; - - if (!remoteExitNode?.exitNodeId) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Remote exit node not found" - ) - ); - } - - if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { - // If the exit node is not allowed for the org, return an error - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Exit node not allowed for this organization" - ) - ); - } - - const accessPolicy = await checkOrgAccessPolicy({ - orgId, - userId, - sessionId - }); - - return response(res, { - data: accessPolicy, - success: true, - error: false, - message: "Org access policy retrieved successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to get org login page" - ) - ); - } - } -); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index e3a34eb1..758ffa1b 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -16,12 +16,14 @@ import { } from "@server/db/queries/verifySessionQueries"; import { LoginPage, + Org, Resource, ResourceAccessToken, ResourceHeaderAuth, ResourcePassword, ResourcePincode, - ResourceRule + ResourceRule, + resourceSessions } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr } from "@server/lib/ip"; @@ -37,7 +39,10 @@ import { getCountryCodeForIp } from "@server/lib/geoip"; import { getOrgTierData } from "#dynamic/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; -import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { + checkOrgAccessPolicy, + enforceResourceSessionLength +} from "#dynamic/lib/checkOrgAccessPolicy"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -142,6 +147,7 @@ export async function verifyResourceSession( pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + org: Org; } | undefined = cache.get(resourceCacheKey); @@ -380,6 +386,22 @@ export async function verifyResourceSession( } if (resourceSession) { + // only run this check if not SSO sesion; SSO session length is checked later + if (!(resourceSessions.userSessionId && sso)) { + const accessPolicy = await enforceResourceSessionLength( + resourceSession, + resourceData.org + ); + + if (!accessPolicy.valid) { + logger.debug( + "Resource session invalid due to org policy:", + accessPolicy.error + ); + return notAllowed(res, redirectPath, resource.orgId); + } + } + if (pincode && resourceSession.pincodeId) { logger.debug( "Resource allowed because pincode session is valid" @@ -422,7 +444,8 @@ export async function verifyResourceSession( if (allowedUserData === undefined) { allowedUserData = await isUserAllowedToAccessResource( resourceSession.userSessionId, - resource + resource, + resourceData.org ); cache.set(userAccessCacheKey, allowedUserData); @@ -564,7 +587,8 @@ function allowed(res: Response, userData?: BasicUserData) { async function isUserAllowedToAccessResource( userSessionId: string, - resource: Resource + resource: Resource, + org: Org ): Promise { const result = await getUserSessionWithUser(userSessionId); @@ -592,9 +616,9 @@ async function isUserAllowedToAccessResource( } const accessPolicy = await checkOrgAccessPolicy({ - orgId: resource.orgId, - userId: user.userId, - sessionId: session.sessionId + org, + user, + session }); if (!accessPolicy.allowed || accessPolicy.error) { logger.debug(`User not allowed by org access policy because`, { From cbf9c5361e55defa38e7608be34e6851f0666c4d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 26 Oct 2025 17:08:35 -0700 Subject: [PATCH 10/14] redirect to org login via query param --- .../routers/loginPage/loadLoginPage.ts | 7 ++--- src/app/auth/login/page.tsx | 28 +++++++++++++++++++ src/components/OrgPolicyResult.tsx | 4 +-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 133336b6..8f41848d 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -25,7 +25,7 @@ import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; const querySchema = z.object({ resourceId: z.coerce.number().int().positive().optional(), idpId: z.coerce.number().int().positive().optional(), - orgId: z.coerce.number().int().positive().optional(), + orgId: z.string().min(1).optional(), fullDomain: z.string().min(1) }); @@ -87,9 +87,8 @@ export async function loadLoginPage( ); } - const { resourceId, idpId, fullDomain } = parsedQuery.data; + let { resourceId, idpId, fullDomain, orgId } = parsedQuery.data; - let orgId; if (resourceId) { const [resource] = await db .select() @@ -118,7 +117,7 @@ export async function loadLoginPage( orgId = idpOrgLink.orgId; } else if (parsedQuery.data.orgId) { - orgId = parsedQuery.data.orgId.toString(); + orgId = parsedQuery.data.orgId; } const loginPage = await query(orgId, fullDomain); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index f148e21b..11543345 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -13,6 +13,7 @@ import { AxiosResponse } from "axios"; import { ListIdpsResponse } from "@server/routers/idp"; import { getTranslations } from "next-intl/server"; import { build } from "@server/build"; +import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; export const dynamic = "force-dynamic"; @@ -33,6 +34,33 @@ export default async function Page(props: { redirect("/"); } + // Check for orgId and redirect to org-specific login page if found + const orgId = searchParams.orgId as string | undefined; + let loginPageDomain: string | undefined; + if (orgId) { + try { + const res = await priv.get>( + `/login-page?orgId=${orgId}` + ); + + if (res && res.status === 200 && res.data.data.fullDomain) { + loginPageDomain = res.data.data.fullDomain; + } + } catch (e) { + console.debug("No custom login page found for org", orgId); + } + } + + if (loginPageDomain) { + const redirectUrl = searchParams.redirect as string | undefined; + + let url = `https://${loginPageDomain}/auth/org`; + if (redirectUrl) { + url += `?redirect=${redirectUrl}`; + } + redirect(url); + } + let redirectUrl: string | undefined = undefined; if (searchParams.redirect) { redirectUrl = cleanRedirect(searchParams.redirect as string); diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx index bedf1905..bc46aabf 100644 --- a/src/components/OrgPolicyResult.tsx +++ b/src/components/OrgPolicyResult.tsx @@ -91,10 +91,10 @@ export default function OrgPolicyResult({ ? async () => { try { await api.post("/auth/logout", undefined); - router.push("/auth/login"); + router.push(`/auth/login?orgId=${orgId}`); } catch (error) { console.error("Error during logout:", error); - router.push("/auth/login"); + router.push(`/auth/login?orgId=${orgId}`); } } : undefined, From 9fbea4a38059db9a38837d3c41e3ca3484cba7cc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 26 Oct 2025 17:12:47 -0700 Subject: [PATCH 11/14] move enterprise/subscription required alert to component --- src/app/[orgId]/settings/general/page.tsx | 19 ++----------- src/components/SecurityFeaturesAlert.tsx | 33 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 src/components/SecurityFeaturesAlert.tsx diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 45d3c397..d96dbb65 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -52,8 +52,7 @@ import { build } from "@server/build"; import { SwitchInput } from "@app/components/SwitchInput"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { Badge } from "@app/components/ui/badge"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ @@ -347,21 +346,7 @@ export default function GeneralPage() { - {build == "saas" && !subscriptionStatus?.isSubscribed() ? ( - - - {t("subscriptionRequiredToUse")} - - - ) : null} - - {build == "enterprise" && !isUnlocked() ? ( - - - {t("licenseRequiredToUse")} - - - ) : null} + diff --git a/src/components/SecurityFeaturesAlert.tsx b/src/components/SecurityFeaturesAlert.tsx new file mode 100644 index 00000000..2531659b --- /dev/null +++ b/src/components/SecurityFeaturesAlert.tsx @@ -0,0 +1,33 @@ +"use client"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { build } from "@server/build"; +import { useTranslations } from "next-intl"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; + +export function SecurityFeaturesAlert() { + const t = useTranslations(); + const { isUnlocked } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); + + return ( + <> + {build === "saas" && !subscriptionStatus?.isSubscribed() ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build === "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + ); +} + From 4cfd1b1ff54843418c96bc43191396d26279fd6a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 27 Oct 2025 09:45:12 -0700 Subject: [PATCH 12/14] always check resource session length --- server/routers/badger/verifySession.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 758ffa1b..2f88cc46 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -387,19 +387,17 @@ export async function verifyResourceSession( if (resourceSession) { // only run this check if not SSO sesion; SSO session length is checked later - if (!(resourceSessions.userSessionId && sso)) { - const accessPolicy = await enforceResourceSessionLength( - resourceSession, - resourceData.org - ); + const accessPolicy = await enforceResourceSessionLength( + resourceSession, + resourceData.org + ); - if (!accessPolicy.valid) { - logger.debug( - "Resource session invalid due to org policy:", - accessPolicy.error - ); - return notAllowed(res, redirectPath, resource.orgId); - } + if (!accessPolicy.valid) { + logger.debug( + "Resource session invalid due to org policy:", + accessPolicy.error + ); + return notAllowed(res, redirectPath, resource.orgId); } if (pincode && resourceSession.pincodeId) { From eeab92719a35d14362efdeef60924ecf06eac89d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 27 Oct 2025 09:52:25 -0700 Subject: [PATCH 13/14] add smaller time values and update translations --- messages/en-US.json | 14 +++++++++++++ src/app/[orgId]/settings/general/page.tsx | 24 +++++++++++++---------- src/components/OrgPolicyResult.tsx | 20 +++++++++++++------ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index f75d95c6..7374de9e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1363,6 +1363,7 @@ "youCanNowAccessOrganization": "You can now access this organization", "reauthenticationRequired": "Session Length", "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", + "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "securityKeyAdd": "Add Security Key", @@ -1766,10 +1767,23 @@ "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", "selectSessionLength": "Select session length", + "unenforced": "Unenforced", + "1Hour": "1 hour", + "3Hours": "3 hours", + "6Hours": "6 hours", + "12Hours": "12 hours", + "1DaySession": "1 day", + "3Days": "3 days", + "7Days": "7 days", + "14Days": "14 days", + "30DaysSession": "30 days", + "90DaysSession": "90 days", + "180DaysSession": "180 days", "passwordExpiryDays": "Password Expiry", "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", "selectPasswordExpiry": "Select password expiry", "30Days": "30 days", + "1Day": "1 day", "60Days": "60 days", "90Days": "90 days", "180Days": "180 days", diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index d96dbb65..b9f44141 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -56,18 +56,24 @@ import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ - { value: null, label: "Unenforced" }, - { value: 72, label: "3 days" }, // 3 * 24 = 72 hours - { value: 168, label: "7 days" }, // 7 * 24 = 168 hours - { value: 336, label: "14 days" }, // 14 * 24 = 336 hours - { value: 720, label: "30 days" }, // 30 * 24 = 720 hours - { value: 2160, label: "90 days" }, // 90 * 24 = 2160 hours - { value: 4320, label: "180 days" } // 180 * 24 = 4320 hours + { value: null, labelKey: "unenforced" }, + { value: 1, labelKey: "1Hour" }, + { value: 3, labelKey: "3Hours" }, + { value: 6, labelKey: "6Hours" }, + { value: 12, labelKey: "12Hours" }, + { value: 24, labelKey: "1DaySession" }, + { value: 72, labelKey: "3Days" }, + { value: 168, labelKey: "7Days" }, + { value: 336, labelKey: "14Days" }, + { value: 720, labelKey: "30DaysSession" }, + { value: 2160, labelKey: "90DaysSession" }, + { value: 4320, labelKey: "180DaysSession" } ]; // Password expiry options in days - will be translated in component const PASSWORD_EXPIRY_OPTIONS = [ { value: null, labelKey: "neverExpire" }, + { value: 1, labelKey: "1Day" }, { value: 30, labelKey: "30Days" }, { value: 60, labelKey: "60Days" }, { value: 90, labelKey: "90Days" }, @@ -465,9 +471,7 @@ export default function GeneralPage() { : option.value.toString() } > - { - option.label - } + {t(option.labelKey)} ) )} diff --git a/src/components/OrgPolicyResult.tsx b/src/components/OrgPolicyResult.tsx index bc46aabf..793a41e0 100644 --- a/src/components/OrgPolicyResult.tsx +++ b/src/components/OrgPolicyResult.tsx @@ -76,16 +76,24 @@ export default function OrgPolicyResult({ // Add max session length policy if the organization has it enforced if (accessRes.policies?.maxSessionLength) { const maxSessionPolicy = accessRes.policies?.maxSessionLength; - const maxDays = Math.round(maxSessionPolicy.maxSessionLengthHours / 24); - const daysAgo = Math.round(maxSessionPolicy.sessionAgeHours / 24); + const maxHours = maxSessionPolicy.maxSessionLengthHours; + + // Use hours if less than 24, otherwise convert to days + const useHours = maxHours < 24; + const maxTime = useHours ? maxHours : Math.round(maxHours / 24); + + const descriptionKey = useHours + ? "reauthenticationDescriptionHours" + : "reauthenticationDescription"; + + const description = useHours + ? t(descriptionKey, { maxHours }) + : t(descriptionKey, { maxDays: maxTime }); policies.push({ id: "max-session-length", name: t("reauthenticationRequired"), - description: t("reauthenticationDescription", { - maxDays, - daysAgo - }), + description, compliant: maxSessionPolicy.compliant, action: !maxSessionPolicy.compliant ? async () => { From 380c86898c9056807470b55b6b2be2bd541ab20a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Oct 2025 10:43:44 -0700 Subject: [PATCH 14/14] Fix lint --- server/lib/checkOrgAccessPolicy.ts | 9 +++------ server/private/routers/loginPage/loadLoginPage.ts | 2 +- server/routers/resource/getExchangeToken.ts | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/server/lib/checkOrgAccessPolicy.ts b/server/lib/checkOrgAccessPolicy.ts index 5ffe4702..d08d2af9 100644 --- a/server/lib/checkOrgAccessPolicy.ts +++ b/server/lib/checkOrgAccessPolicy.ts @@ -23,7 +23,7 @@ export type CheckOrgAccessPolicyResult = { compliant: boolean; maxPasswordAgeDays: number; passwordAgeDays: number; - } + }; }; }; @@ -36,9 +36,6 @@ export async function enforceResourceSessionLength( export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps -): Promise<{ - success: boolean; - error?: string; -}> { - return { success: true }; +): Promise { + return { allowed: true }; } diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 8f41848d..daa531ca 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -87,7 +87,7 @@ export async function loadLoginPage( ); } - let { resourceId, idpId, fullDomain, orgId } = parsedQuery.data; + const { resourceId, idpId, fullDomain, orgId } = parsedQuery.data; if (resourceId) { const [resource] = await db diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 5e5f4980..1ed461c9 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -76,7 +76,7 @@ export async function getExchangeToken( // check org policy here const hasAccess = await checkOrgAccessPolicy({ - orgId: resource[0].orgId, + orgId: resource.orgId, userId: req.user!.userId, session: req.session });