From 57db64f6bb5feaccfed87fb9109a742e1701d3c5 Mon Sep 17 00:00:00 2001 From: peintnermax Date: Tue, 16 Apr 2024 15:33:14 +0200 Subject: [PATCH] otp methods --- apps/login/app/(login)/mfa/page.tsx | 26 ++++++- apps/login/app/(login)/mfa/set/page.tsx | 48 ++++++------ apps/login/app/(login)/otp/[method]/page.tsx | 61 +++++++++++++++ .../app/(login)/otp/[method]/set/page.tsx | 76 +++++++++++++++++++ apps/login/app/(login)/totp/page.tsx | 32 -------- apps/login/app/(login)/totp/set/page.tsx | 46 ----------- .../app/api/{totp/verify => otp/set}/route.ts | 24 +++++- apps/login/app/api/otp/verify/route.ts | 70 +++++++++++++++++ apps/login/app/api/session/route.ts | 16 +++- apps/login/lib/zitadel.ts | 51 ++++++++++--- apps/login/ui/PasswordForm.tsx | 33 +++++++- apps/login/ui/TOTPForm.tsx | 5 +- apps/login/ui/TOTPRegister.tsx | 12 ++- apps/login/ui/VerifyU2F.tsx | 16 ++++ apps/login/utils/session.ts | 16 +--- packages/zitadel-server/src/index.ts | 10 +++ 16 files changed, 398 insertions(+), 144 deletions(-) create mode 100644 apps/login/app/(login)/otp/[method]/page.tsx create mode 100644 apps/login/app/(login)/otp/[method]/set/page.tsx delete mode 100644 apps/login/app/(login)/totp/page.tsx delete mode 100644 apps/login/app/(login)/totp/set/page.tsx rename apps/login/app/api/{totp/verify => otp/set}/route.ts (77%) create mode 100644 apps/login/app/api/otp/verify/route.ts create mode 100644 apps/login/ui/VerifyU2F.tsx diff --git a/apps/login/app/(login)/mfa/page.tsx b/apps/login/app/(login)/mfa/page.tsx index b0916e9ac75..1f033eb4f3a 100644 --- a/apps/login/app/(login)/mfa/page.tsx +++ b/apps/login/app/(login)/mfa/page.tsx @@ -1,3 +1,25 @@ -export default function Page() { - return
mfa
; +import { getBrandingSettings, server } from "#/lib/zitadel"; +import DynamicTheme from "#/ui/DynamicTheme"; + +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

+ +

Choose one of the following second factors.

+ +
+
+
+ ); } diff --git a/apps/login/app/(login)/mfa/set/page.tsx b/apps/login/app/(login)/mfa/set/page.tsx index cda98ee4a37..8cb7bb79264 100644 --- a/apps/login/app/(login)/mfa/set/page.tsx +++ b/apps/login/app/(login)/mfa/set/page.tsx @@ -1,34 +1,36 @@ -"use client"; +import { getBrandingSettings, server } from "#/lib/zitadel"; import { Button, ButtonVariants } from "#/ui/Button"; +import DynamicTheme from "#/ui/DynamicTheme"; import { TextInput } from "#/ui/Input"; import UserAvatar from "#/ui/UserAvatar"; import { useRouter } from "next/navigation"; -export default function Page() { - const router = useRouter(); +export default async function Page({ + searchParams, +}: { + searchParams: Record; +}) { + const { loginName, authRequestId, sessionId, organization, code, submit } = + searchParams; + + const branding = await getBrandingSettings(server, organization); return ( -
-

Second Factor

-

Please select a second factor.

+ +
+

Verify 2-Factor

- -
- +

Choose one of the following second factors.

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

Verify 2-Factor

+ {method === "time-based" && ( +

Enter the code from your authenticator app.

+ )} + {method === "sms" && ( +

Enter the code you got on your phone.

+ )} + {method === "email" && ( +

Enter the code you got via your email.

+ )} + {method === "u2f" && ( +

Verify your account with your device.

+ )} + + {method && ["time-based", "sms", "email"].includes(method) ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/login/app/(login)/otp/[method]/set/page.tsx b/apps/login/app/(login)/otp/[method]/set/page.tsx new file mode 100644 index 00000000000..33af50b4ccb --- /dev/null +++ b/apps/login/app/(login)/otp/[method]/set/page.tsx @@ -0,0 +1,76 @@ +import { + addOTPEmail, + addOTPSMS, + getBrandingSettings, + getSession, + registerTOTP, + server, +} from "#/lib/zitadel"; +import DynamicTheme from "#/ui/DynamicTheme"; +import TOTPRegister from "#/ui/TOTPRegister"; +import { getMostRecentCookieWithLoginname } from "#/utils/cookies"; + +export default async function Page({ + searchParams, + params, +}: { + searchParams: Record; + params: Record; +}) { + const { loginName, organization } = searchParams; + const { method } = params; + + const branding = await getBrandingSettings(server, organization); + + const totpResponse = await loadSession(loginName, organization).then( + ({ session, token }) => { + if (session && session.factors?.user?.id) { + if (method === "time-based") { + return registerTOTP(session.factors.user.id, token); + } else if (method === "sms") { + return addOTPSMS(session.factors.user.id); + } else if (method === "email") { + return addOTPEmail(session.factors.user.id); + } else { + throw new Error("Invalid method"); + } + } else { + throw new Error("No session found"); + } + } + ); + + async function loadSession(loginName?: string, organization?: string) { + const recent = await getMostRecentCookieWithLoginname( + loginName, + organization + ); + + return getSession(server, recent.id, recent.token).then((response) => { + return { session: response?.session, token: recent.token }; + }); + } + + return ( + +
+

Register TOTP

+

+ Scan the QR Code or navigate to the URL manually. +

+ +
+ {/* {auth &&
{auth.to}
} */} + {totpResponse && + "uri" in totpResponse && + "secret" in totpResponse && ( + + )} +
+
+
+ ); +} diff --git a/apps/login/app/(login)/totp/page.tsx b/apps/login/app/(login)/totp/page.tsx deleted file mode 100644 index 7d8a1d51062..00000000000 --- a/apps/login/app/(login)/totp/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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/(login)/totp/set/page.tsx b/apps/login/app/(login)/totp/set/page.tsx deleted file mode 100644 index 65f2f65183e..00000000000 --- a/apps/login/app/(login)/totp/set/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { - addMyAuthFactorOTP, - getBrandingSettings, - getLoginSettings, - getSession, - server, -} from "#/lib/zitadel"; -import DynamicTheme from "#/ui/DynamicTheme"; -import TOTPRegister from "#/ui/TOTPRegister"; -import { getMostRecentCookieWithLoginname } from "#/utils/cookies"; - -export default async function Page({ - searchParams, -}: { - searchParams: Record; -}) { - const { loginName, organization } = searchParams; - - const branding = await getBrandingSettings(server, organization); - const auth = await getMostRecentCookieWithLoginname( - loginName, - organization - ).then((cookie) => { - if (cookie) { - return addMyAuthFactorOTP(cookie.token); - } else { - throw new Error("No cookie found"); - } - }); - - return ( - -
-

Register TOTP

-

- Scan the QR Code or navigate to the URL manually. -

- -
- {auth &&
{auth.url}
} - -
-
-
- ); -} diff --git a/apps/login/app/api/totp/verify/route.ts b/apps/login/app/api/otp/set/route.ts similarity index 77% rename from apps/login/app/api/totp/verify/route.ts rename to apps/login/app/api/otp/set/route.ts index ae1148c0ef3..38be7023ad2 100644 --- a/apps/login/app/api/totp/verify/route.ts +++ b/apps/login/app/api/otp/set/route.ts @@ -5,13 +5,15 @@ import { getSessionCookieByLoginName, } from "#/utils/cookies"; import { setSessionAndUpdateCookie } from "#/utils/session"; +import { Checks } from "@zitadel/server"; 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 { loginName, sessionId, organization, authRequestId, code, method } = + body; const recentPromise: Promise = sessionId ? getSessionCookieById(sessionId).catch((error) => { @@ -27,12 +29,26 @@ export async function POST(request: NextRequest) { return recentPromise .then((recent) => { + const checks: Checks = {}; + + if (method === "time-based") { + checks.totp = { + code, + }; + } else if (method === "sms") { + checks.otpSms = { + code, + }; + } else if (method === "email") { + checks.otpEmail = { + code, + }; + } + return setSessionAndUpdateCookie( recent, + checks, undefined, - undefined, - undefined, - code, authRequestId ).then((session) => { return NextResponse.json({ diff --git a/apps/login/app/api/otp/verify/route.ts b/apps/login/app/api/otp/verify/route.ts new file mode 100644 index 00000000000..38be7023ad2 --- /dev/null +++ b/apps/login/app/api/otp/verify/route.ts @@ -0,0 +1,70 @@ +import { + SessionCookie, + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "#/utils/cookies"; +import { setSessionAndUpdateCookie } from "#/utils/session"; +import { Checks } from "@zitadel/server"; +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, method } = + 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) => { + const checks: Checks = {}; + + if (method === "time-based") { + checks.totp = { + code, + }; + } else if (method === "sms") { + checks.otpSms = { + code, + }; + } else if (method === "email") { + checks.otpEmail = { + code, + }; + } + + return setSessionAndUpdateCookie( + recent, + checks, + undefined, + 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/app/api/session/route.ts b/apps/login/app/api/session/route.ts index 73f7f70b7a3..ce98f18f73f 100644 --- a/apps/login/app/api/session/route.ts +++ b/apps/login/app/api/session/route.ts @@ -11,7 +11,7 @@ import { createSessionForIdpAndUpdateCookie, setSessionAndUpdateCookie, } from "#/utils/session"; -import { RequestChallenges } from "@zitadel/server"; +import { Checks, RequestChallenges } from "@zitadel/server"; import { NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { @@ -93,12 +93,20 @@ export async function PUT(request: NextRequest) { return recentPromise .then((recent) => { + const checks: Checks = {}; + if (password) { + checks.password = { + password, + }; + } + if (webAuthN) { + checks.webAuthN = webAuthN; + } + return setSessionAndUpdateCookie( recent, - password, - webAuthN, + checks, challenges, - undefined, authRequestId ).then(async (session) => { // if password, check if user has MFA methods diff --git a/apps/login/lib/zitadel.ts b/apps/login/lib/zitadel.ts index ef8ea65229a..8770298e446 100644 --- a/apps/login/lib/zitadel.ts +++ b/apps/login/lib/zitadel.ts @@ -1,3 +1,4 @@ +import { RegisterTOTPResponse } from "@zitadel/server"; import { LegalAndSupportSettings, PasswordComplexitySettings, @@ -45,6 +46,8 @@ import { TextQueryMethod, ListHumanAuthFactorsResponse, AddHumanUserRequest, + AddOTPEmailResponse, + AddOTPSMSResponse, } from "@zitadel/server"; export const zitadelConfig: ZitadelServerOptions = { @@ -90,19 +93,45 @@ export async function verifyMyAuthFactorOTP( return authService.verifyMyAuthFactorOTP({ code }, {}); } -export async function addMyAuthFactorOTP( - token: string -): Promise { - const zitadelConfig: ZitadelServerOptions = { - name: "zitadel login", - apiUrl: process.env.ZITADEL_API_URL ?? "", - token: token, - }; +export async function addOTPEmail( + userId: string +): Promise { + const userService = user.getUser(server); + return userService.addOTPEmail( + { + userId, + }, + {} + ); +} - const server: ZitadelServer = initializeServer(zitadelConfig); +export async function addOTPSMS( + userId: string +): Promise { + const userService = user.getUser(server); + return userService.addOTPSMS({ userId }, {}); +} - const authService = auth.getAuth(server); - return authService.addMyAuthFactorOTP({}, {}); +export async function registerTOTP( + userId: string, + token?: string +): Promise { + let userService; + if (token) { + const authConfig: ZitadelServerOptions = { + name: "zitadel login", + apiUrl: process.env.ZITADEL_API_URL ?? "", + token: token, + }; + + console.log(token); + + const sessionUser = initializeServer(authConfig); + userService = user.getUser(sessionUser); + } else { + userService = user.getUser(server); + } + return userService.registerTOTP({ userId }, {}); } export async function getGeneralSettings( diff --git a/apps/login/ui/PasswordForm.tsx b/apps/login/ui/PasswordForm.tsx index 67201e26557..d528c814079 100644 --- a/apps/login/ui/PasswordForm.tsx +++ b/apps/login/ui/PasswordForm.tsx @@ -7,7 +7,7 @@ import { useForm } from "react-hook-form"; import { useRouter } from "next/navigation"; import { Spinner } from "./Spinner"; import Alert from "./Alert"; -import { LoginSettings } from "@zitadel/server"; +import { LoginSettings, AuthFactor } from "@zitadel/server"; type Inputs = { password: string; @@ -68,11 +68,11 @@ export default function PasswordForm({ } function submitPasswordAndContinue(value: Inputs): Promise { - return submitPassword(value).then((resp: any) => { + return submitPassword(value).then((resp) => { // if user has mfa -> /totp // if mfa is forced -> /mfa/set // if no passwordless -> /passkey/add - if (resp.authFactors?.length >= 1) { + if (resp.authFactors?.length == 1) { const params = new URLSearchParams({ loginName: resp.factors.user.loginName, }); @@ -85,7 +85,32 @@ export default function PasswordForm({ params.append("organization", organization); } - return router.push(`/mfa/set?` + params); + let method; + if ((resp.authFactors as AuthFactor[])[0].otp) { + method = "time-based"; + } else if ((resp.authFactors as AuthFactor[])[0].otpSms) { + method = "sms"; + } else if ((resp.authFactors as AuthFactor[])[0].otpEmail) { + method = "email"; + } else if ((resp.authFactors as AuthFactor[])[0].u2f) { + method = "u2f"; + } + + return router.push(`/otp/${method}?` + params); + } else if (resp.authFactors?.length >= 1) { + const params = new URLSearchParams({ + loginName: resp.factors.user.loginName, + }); + + if (authRequestId) { + params.append("authRequest", authRequestId); + } + + if (organization) { + params.append("organization", organization); + } + + return router.push(`/mfa?` + params); } else if ( resp.factors && !resp.factors.passwordless && // if session was not verified with a passkey diff --git a/apps/login/ui/TOTPForm.tsx b/apps/login/ui/TOTPForm.tsx index 5ed791cfef6..365c3a3bd9d 100644 --- a/apps/login/ui/TOTPForm.tsx +++ b/apps/login/ui/TOTPForm.tsx @@ -16,6 +16,7 @@ type Props = { loginName: string | undefined; sessionId: string | undefined; code: string | undefined; + method: string; authRequestId?: string; organization?: string; submit: boolean; @@ -24,6 +25,7 @@ type Props = { export default function TOTPForm({ loginName, code, + method, authRequestId, organization, submit, @@ -45,6 +47,7 @@ export default function TOTPForm({ let body: any = { code: values.code, + method, }; if (organization) { @@ -55,7 +58,7 @@ export default function TOTPForm({ body.authRequestId = authRequestId; } - const res = await fetch("/api/totp/verify", { + const res = await fetch("/api/otp/verify", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/login/ui/TOTPRegister.tsx b/apps/login/ui/TOTPRegister.tsx index c84cddf46ae..8765352e148 100644 --- a/apps/login/ui/TOTPRegister.tsx +++ b/apps/login/ui/TOTPRegister.tsx @@ -1,3 +1,11 @@ -export default function TOTPRegister() { - return
; +import { RegisterTOTPResponse } from "@zitadel/server"; + +export default function TOTPRegister({ + uri, + secret, +}: { + uri: string; + secret: string; +}) { + return
{uri}
; } diff --git a/apps/login/ui/VerifyU2F.tsx b/apps/login/ui/VerifyU2F.tsx new file mode 100644 index 00000000000..cc38bbc88d6 --- /dev/null +++ b/apps/login/ui/VerifyU2F.tsx @@ -0,0 +1,16 @@ +type Props = { + loginName: string | undefined; + sessionId: string | undefined; + authRequestId?: string; + organization?: string; + submit: boolean; +}; + +export default function VerifyU2F({ + loginName, + authRequestId, + organization, + submit, +}: Props) { + return
Verify U2F
; +} diff --git a/apps/login/utils/session.ts b/apps/login/utils/session.ts index 5335ea62c8a..ac3203ecb3a 100644 --- a/apps/login/utils/session.ts +++ b/apps/login/utils/session.ts @@ -188,24 +188,10 @@ export type SessionWithChallenges = Session & { export async function setSessionAndUpdateCookie( recentCookie: SessionCookie, - password: string | undefined, - webAuthN: { credentialAssertionData: any } | undefined, + checks: Checks, challenges: RequestChallenges | undefined, - totpCode: string | undefined, authRequestId: string | undefined ): Promise { - const checks: Checks = {}; - - if (password) { - checks.password = { password }; - } - if (webAuthN) { - checks.webAuthN = webAuthN; - } - if (totpCode) { - checks.totp = { code: totpCode }; - } - return setSession( server, recentCookie.id, diff --git a/packages/zitadel-server/src/index.ts b/packages/zitadel-server/src/index.ts index b3f0f5e83a2..39aaf7eb827 100644 --- a/packages/zitadel-server/src/index.ts +++ b/packages/zitadel-server/src/index.ts @@ -86,13 +86,23 @@ export { RetrieveIdentityProviderIntentResponse, ListUsersRequest, ListUsersResponse, + AddOTPEmailResponse, + AddOTPEmailRequest, + AddOTPSMSResponse, + AddOTPSMSRequest, + RegisterTOTPRequest, + RegisterTOTPResponse, } from "./proto/server/zitadel/user/v2beta/user_service"; + +export { AuthFactor } from "./proto/server/zitadel/user"; + export { SetHumanPasswordResponse, SetHumanPasswordRequest, GetOrgByDomainGlobalResponse, ListHumanAuthFactorsResponse, } from "./proto/server/zitadel/management"; + export * from "./proto/server/zitadel/idp"; export { type LegalAndSupportSettings } from "./proto/server/zitadel/settings/v2beta/legal_settings"; export { type PasswordComplexitySettings } from "./proto/server/zitadel/settings/v2beta/password_settings";