diff --git a/apps/login/app/(login)/totp/page.tsx b/apps/login/app/(login)/totp/page.tsx new file mode 100644 index 00000000000..7d8a1d51062 --- /dev/null +++ b/apps/login/app/(login)/totp/page.tsx @@ -0,0 +1,32 @@ +import { getBrandingSettings, getLoginSettings, server } from "#/lib/zitadel"; +import DynamicTheme from "#/ui/DynamicTheme"; +import TOTPForm from "#/ui/TOTPForm"; + +export default async function Page({ + searchParams, +}: { + searchParams: Record; +}) { + const { loginName, authRequestId, sessionId, organization, code, submit } = + searchParams; + + const branding = await getBrandingSettings(server, organization); + + return ( + +
+

Verify 2-Factor

+

Enter the code from your authenticator app.

+ + +
+
+ ); +} diff --git a/apps/login/app/api/totp/verify/route.ts b/apps/login/app/api/totp/verify/route.ts new file mode 100644 index 00000000000..ae1148c0ef3 --- /dev/null +++ b/apps/login/app/api/totp/verify/route.ts @@ -0,0 +1,54 @@ +import { + SessionCookie, + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "#/utils/cookies"; +import { setSessionAndUpdateCookie } from "#/utils/session"; +import { NextRequest, NextResponse, userAgent } from "next/server"; + +export async function POST(request: NextRequest) { + const body = await request.json(); + + if (body) { + const { loginName, sessionId, organization, authRequestId, code } = body; + + const recentPromise: Promise = sessionId + ? getSessionCookieById(sessionId).catch((error) => { + return Promise.reject(error); + }) + : loginName + ? getSessionCookieByLoginName(loginName, organization).catch((error) => { + return Promise.reject(error); + }) + : getMostRecentSessionCookie().catch((error) => { + return Promise.reject(error); + }); + + return recentPromise + .then((recent) => { + return setSessionAndUpdateCookie( + recent, + undefined, + undefined, + undefined, + code, + authRequestId + ).then((session) => { + return NextResponse.json({ + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + }); + }); + }) + .catch((error) => { + return NextResponse.json({ details: error }, { status: 500 }); + }); + } else { + return NextResponse.json( + { details: "Request body is missing" }, + { status: 400 } + ); + } +} diff --git a/apps/login/lib/zitadel.ts b/apps/login/lib/zitadel.ts index edaf9cc3647..c9267bb6095 100644 --- a/apps/login/lib/zitadel.ts +++ b/apps/login/lib/zitadel.ts @@ -19,6 +19,7 @@ import { PasswordComplexitySettings, GetSessionResponse, VerifyEmailResponse, + Checks, SetSessionResponse, SetSessionRequest, ListUsersResponse, @@ -118,68 +119,23 @@ export async function getPasswordComplexitySettings( .then((resp: GetPasswordComplexitySettingsResponse) => resp.settings); } -export async function createSessionForLoginname( +export async function createSessionFromChecks( server: ZitadelServer, - loginName: string, - password: string | undefined, + checks: Checks, challenges: RequestChallenges | undefined ): Promise { const sessionService = session.getSession(server); - return password - ? sessionService.createSession( - { - checks: { user: { loginName }, password: { password } }, - challenges, - lifetime: { - seconds: 300, - nanos: 0, - }, - }, - {} - ) - : sessionService.createSession( - { - checks: { user: { loginName } }, - challenges, - lifetime: { - seconds: 300, - nanos: 0, - }, - }, - {} - ); -} - -export async function createSessionForUserId( - server: ZitadelServer, - userId: string, - password: string | undefined, - challenges: RequestChallenges | undefined -): Promise { - const sessionService = session.getSession(server); - return password - ? sessionService.createSession( - { - checks: { user: { userId }, password: { password } }, - challenges, - lifetime: { - seconds: 300, - nanos: 0, - }, - }, - {} - ) - : sessionService.createSession( - { - checks: { user: { userId } }, - challenges, - lifetime: { - seconds: 300, - nanos: 0, - }, - }, - {} - ); + return sessionService.createSession( + { + checks: checks, + challenges, + lifetime: { + seconds: 300, + nanos: 0, + }, + }, + {} + ); } export async function createSessionForUserIdAndIdpIntent( @@ -209,6 +165,7 @@ export async function setSession( sessionId: string, sessionToken: string, password: string | undefined, + totpCode: string | undefined, webAuthN: { credentialAssertionData: any } | undefined, challenges: RequestChallenges | undefined ): Promise { @@ -226,6 +183,10 @@ export async function setSession( payload.checks.password = { password }; } + if (totpCode && payload.checks) { + payload.checks.totp = { code: totpCode }; + } + if (webAuthN && payload.checks) { payload.checks.webAuthN = webAuthN; } diff --git a/apps/login/ui/TOTPForm.tsx b/apps/login/ui/TOTPForm.tsx new file mode 100644 index 00000000000..5ed791cfef6 --- /dev/null +++ b/apps/login/ui/TOTPForm.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, ButtonVariants } from "./Button"; +import { TextInput } from "./Input"; +import { useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; +import { Spinner } from "./Spinner"; +import Alert from "./Alert"; + +type Inputs = { + code: string; +}; + +type Props = { + loginName: string | undefined; + sessionId: string | undefined; + code: string | undefined; + authRequestId?: string; + organization?: string; + submit: boolean; +}; + +export default function TOTPForm({ + loginName, + code, + authRequestId, + organization, + submit, +}: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ? code : "", + }, + }); + + const router = useRouter(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitCode(values: Inputs, organization?: string) { + setLoading(true); + + let body: any = { + code: values.code, + }; + + if (organization) { + body.organization = organization; + } + + if (authRequestId) { + body.authRequestId = authRequestId; + } + + const res = await fetch("/api/totp/verify", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + setLoading(false); + if (!res.ok) { + const response = await res.json(); + + setError(response.message ?? "An internal error occurred"); + return Promise.reject(response.message ?? "An internal error occurred"); + } + return res.json(); + } + + function setCodeAndContinue(values: Inputs, organization?: string) { + return submitCode(values, organization).then((response) => { + if (authRequestId && response && response.sessionId) { + const params = new URLSearchParams({ + sessionId: response.sessionId, + authRequest: authRequestId, + }); + + if (organization) { + params.append("organization", organization); + } + + return router.push(`/login?` + params); + } else { + const params = new URLSearchParams( + authRequestId + ? { + loginName: response.factors.user.loginName, + authRequestId, + } + : { + loginName: response.factors.user.loginName, + } + ); + + if (organization) { + params.append("organization", organization); + } + + return router.push(`/signedin?` + params); + } + }); + } + + const { errors } = formState; + + useEffect(() => { + if (submit && code) { + // When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid. + setCodeAndContinue({ code }, organization); + } + }, []); + + return ( +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ); +} diff --git a/apps/login/utils/session.ts b/apps/login/utils/session.ts index 55f0a14dce5..76023f7c43c 100644 --- a/apps/login/utils/session.ts +++ b/apps/login/utils/session.ts @@ -1,8 +1,7 @@ "use server"; import { - createSessionForLoginname, - createSessionForUserId, + createSessionFromChecks, createSessionForUserIdAndIdpIntent, getSession, server, @@ -22,10 +21,15 @@ export async function createSessionAndUpdateCookie( organization?: string, authRequestId?: string ): Promise { - const createdSession = await createSessionForLoginname( + const createdSession = await createSessionFromChecks( server, - loginName, - password, + password + ? { + user: { loginName }, + password: { password }, + // totp: { code: totpCode }, + } + : { user: { loginName } }, challenges ); @@ -72,10 +76,15 @@ export async function createSessionForUserIdAndUpdateCookie( challenges: RequestChallenges | undefined, authRequestId: string | undefined ): Promise { - const createdSession = await createSessionForUserId( + const createdSession = await createSessionFromChecks( server, - userId, - password, + password + ? { + user: { userId }, + password: { password }, + // totp: { code: totpCode }, + } + : { user: { userId } }, challenges ); @@ -177,6 +186,7 @@ export async function setSessionAndUpdateCookie( password: string | undefined, webAuthN: { credentialAssertionData: any } | undefined, challenges: RequestChallenges | undefined, + totpCode: string | undefined, authRequestId: string | undefined ): Promise { return setSession( @@ -184,6 +194,7 @@ export async function setSessionAndUpdateCookie( recentCookie.id, recentCookie.token, password, + totpCode, webAuthN, challenges ).then((updatedSession) => { diff --git a/packages/zitadel-server/src/index.ts b/packages/zitadel-server/src/index.ts index 6ec282fe75a..33cb28ab097 100644 --- a/packages/zitadel-server/src/index.ts +++ b/packages/zitadel-server/src/index.ts @@ -51,6 +51,7 @@ export { CreateSessionResponse, SetSessionResponse, SetSessionRequest, + Checks, DeleteSessionResponse, } from "./proto/server/zitadel/session/v2beta/session_service"; export {