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")} -

-
- )} - {