From a4d474abe4cacefb295f29695e210f1936a60319 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 9 Dec 2024 13:35:57 +0100 Subject: [PATCH] check if session is valid according to loginsettings (forceMFA) --- apps/login/readme.md | 1 + apps/login/src/app/login/route.ts | 145 ++++++++++++-------- apps/login/src/components/sessions-list.tsx | 1 + 3 files changed, 87 insertions(+), 60 deletions(-) diff --git a/apps/login/readme.md b/apps/login/readme.md index bc946cf0..5aaa30d1 100644 --- a/apps/login/readme.md +++ b/apps/login/readme.md @@ -394,3 +394,4 @@ Timebased features like the multifactor init prompt or password expiry, are not - Lockout Settings - Password Expiry Settings - Login Settings: multifactor init prompt +- forceMFA on login settings is not checked for IDPs diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index bfcef38e..23e96135 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -5,6 +5,7 @@ import { createCallback, getActiveIdentityProviders, getAuthRequest, + getLoginSettings, getOrgsByDomain, listSessions, startIdentityProviderFlow, @@ -37,7 +38,32 @@ const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/; const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/; -function isSessionValid(session: Session): boolean { +/** + * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) + * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); + **/ +async function isSessionValid( + session: Session, + checkLoginSettings?: boolean, +): Promise { + let mfaValid = true; + if (checkLoginSettings && session.factors?.user?.organizationId) { + const loginSettings = await getLoginSettings( + session.factors?.user?.organizationId, + ); + if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) { + const otpEmail = session.factors.otpEmail?.verifiedAt; + const otpSms = session.factors.otpSms?.verifiedAt; + const totp = session.factors.totp?.verifiedAt; + const webAuthN = session.factors.webAuthN?.verifiedAt; + + // must have one single check + mfaValid = !!(otpEmail || otpSms || totp || webAuthN); + } else { + mfaValid = true; + } + } + const validPassword = session?.factors?.password?.verifiedAt; const validPasskey = session?.factors?.webAuthN?.verifiedAt; const validIDP = session?.factors?.intent?.verifiedAt; @@ -46,43 +72,44 @@ function isSessionValid(session: Session): boolean { ? timestampDate(session.expirationDate) > new Date() : true; - const validFactors = !!( - (validPassword || validPasskey || validIDP) && - stillValid - ); + const validFactors = !!(validPassword || validPasskey || validIDP); - return stillValid && validFactors; + return stillValid && validFactors && mfaValid; } -function findValidSession( +async function findValidSession( sessions: Session[], authRequest: AuthRequest, -): Session | undefined { - const validSessionsWithHint = sessions - .filter((s) => { - if (authRequest.hintUserId) { - return s.factors?.user?.id === authRequest.hintUserId; - } - if (authRequest.loginHint) { - return s.factors?.user?.loginName === authRequest.loginHint; - } - return true; - }) - .filter(isSessionValid); +): Promise { + const sessionsWithHint = sessions.filter((s) => { + if (authRequest.hintUserId) { + return s.factors?.user?.id === authRequest.hintUserId; + } + if (authRequest.loginHint) { + return s.factors?.user?.loginName === authRequest.loginHint; + } + return true; + }); - if (validSessionsWithHint.length === 0) { + if (sessionsWithHint.length === 0) { return undefined; } // sort by change date descending - validSessionsWithHint.sort((a, b) => { + sessionsWithHint.sort((a, b) => { const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0; const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0; return dateB - dateA; }); - // return most recently changed session - return sessions[0]; + // return the first valid session according to settings + for (const session of sessionsWithHint) { + if (await isSessionValid(session, true)) { + return session; + } + } + + return undefined; } export async function GET(request: NextRequest) { @@ -103,53 +130,51 @@ export async function GET(request: NextRequest) { sessions = await loadSessions(ids); } - /** - * TODO: before automatically redirecting to the callbackUrl, check if the session is still valid - * possible scenaio: - * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) - * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); - **/ - if (authRequestId && sessionId) { console.log( `Login with session: ${sessionId} and authRequest: ${authRequestId}`, ); - let selectedSession = sessions.find((s) => s.id === sessionId); + const selectedSession = sessions.find((s) => s.id === sessionId); if (selectedSession && selectedSession.id) { console.log(`Found session ${selectedSession.id}`); - const cookie = sessionCookies.find( - (cookie) => cookie.id === selectedSession?.id, - ); - if (cookie && cookie.id && cookie.token) { - const session = { - sessionId: cookie?.id, - sessionToken: cookie?.token, - }; + const isValid = await isSessionValid(selectedSession, true); - // works not with _rsc request - try { - const { callbackUrl } = await createCallback( - create(CreateCallbackRequestSchema, { - authRequestId, - callbackKind: { - case: "session", - value: create(SessionSchema, session), - }, - }), - ); - if (callbackUrl) { - return NextResponse.redirect(callbackUrl); - } else { - return NextResponse.json( - { error: "An error occurred!" }, - { status: 500 }, + if (isValid) { + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { callbackUrl } = await createCallback( + create(CreateCallbackRequestSchema, { + authRequestId, + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), ); + if (callbackUrl) { + return NextResponse.redirect(callbackUrl); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error) { + return NextResponse.json({ error }, { status: 500 }); } - } catch (error) { - return NextResponse.json({ error }, { status: 500 }); } } } @@ -314,7 +339,7 @@ export async function GET(request: NextRequest) { * This means that the user should not be prompted to enter their password again. * Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction **/ - const selectedSession = findValidSession(sessions, authRequest); + const selectedSession = await findValidSession(sessions, authRequest); if (!selectedSession || !selectedSession.id) { return NextResponse.json( @@ -351,7 +376,7 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(callbackUrl); } else { // check for loginHint, userId hint and valid sessions - let selectedSession = findValidSession(sessions, authRequest); + let selectedSession = await findValidSession(sessions, authRequest); if (!selectedSession || !selectedSession.id) { return gotoAccounts(); diff --git a/apps/login/src/components/sessions-list.tsx b/apps/login/src/components/sessions-list.tsx index 00b21ec8..09393bae 100644 --- a/apps/login/src/components/sessions-list.tsx +++ b/apps/login/src/components/sessions-list.tsx @@ -29,6 +29,7 @@ export function SessionsList({ sessions, authRequestId }: Props) { : 0; return dateB - dateA; }) + // TODO: add sorting to move invalid sessions to the bottom .map((session, index) => { return (