choose factor when multiple, register u2f, verify u2f

This commit is contained in:
peintnermax
2024-04-30 10:39:34 +02:00
parent 88030ff2b9
commit b01ca12e53
13 changed files with 863 additions and 235 deletions

View File

@@ -1,14 +1,68 @@
import { getBrandingSettings, server } from "#/lib/zitadel";
import {
getBrandingSettings,
getSession,
listAuthenticationMethodTypes,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import ChooseSecondFactor from "#/ui/ChooseSecondFactor";
import DynamicTheme from "#/ui/DynamicTheme";
import UserAvatar from "#/ui/UserAvatar";
import {
getMostRecentCookieWithLoginname,
getSessionCookieById,
} from "#/utils/cookies";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, authRequestId, sessionId, organization, code, submit } =
const { loginName, checkAfter, authRequestId, organization, sessionId } =
searchParams;
const sessionFactors = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function loadSessionByLoginname(
loginName?: string,
organization?: string
) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
return listAuthenticationMethodTypes(
response.session.factors.user.id
).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
};
});
}
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById(sessionId, organization);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
return listAuthenticationMethodTypes(
response.session.factors.user.id
).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
};
});
}
});
}
const branding = await getBrandingSettings(server, organization);
return (
@@ -18,7 +72,29 @@ export default async function Page({
<p className="ztdl-p">Choose one of the following second factors.</p>
<div></div>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
></UserAvatar>
)}
{!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert>
)}
{sessionFactors ? (
<ChooseSecondFactor
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
userMethods={sessionFactors.authMethods ?? []}
></ChooseSecondFactor>
) : (
<Alert>No second factors available to setup.</Alert>
)}
</div>
</DynamicTheme>
);

View File

@@ -64,8 +64,6 @@ export default async function Page({
)}
<p className="ztdl-p mb-6 block">{description}</p>
{!sessionFactors && <div className="py-4"></div>}
{!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert>
)}

View File

@@ -1,6 +1,17 @@
import { getBrandingSettings, getLoginSettings, server } from "#/lib/zitadel";
import {
getBrandingSettings,
getLoginSettings,
getSession,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import DynamicTheme from "#/ui/DynamicTheme";
import LoginPasskey from "#/ui/LoginPasskey";
import UserAvatar from "#/ui/UserAvatar";
import {
getMostRecentCookieWithLoginname,
getSessionCookieById,
} from "#/utils/cookies";
export default async function Page({
searchParams,
@@ -9,25 +20,68 @@ export default async function Page({
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, authRequestId, sessionId, organization, code, submit } =
searchParams;
const { loginName, authRequestId, sessionId, organization } = searchParams;
const branding = await getBrandingSettings(server, organization);
const sessionFactors = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function loadSessionByLoginname(
loginName?: string,
organization?: string
) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById(sessionId, organization);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Verify 2-Factor</h1>
<p className="ztdl-p">Verify your account with your device.</p>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
></UserAvatar>
)}
<p className="ztdl-p mb-6 block">
Verify your account with your device.
</p>
<LoginPasskey
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
altPassword={false}
></LoginPasskey>
{!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert>
)}
{(loginName || sessionId) && (
<LoginPasskey
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
altPassword={false}
organization={organization}
login={false} // this sets the userVerificationRequirement to discouraged as its used as second factor
/>
)}
</div>
</DynamicTheme>
);

View File

@@ -1,38 +1,81 @@
import { getBrandingSettings, getSession, server } from "#/lib/zitadel";
import Alert, { AlertType } from "#/ui/Alert";
import DynamicTheme from "#/ui/DynamicTheme";
import RegisterPasskey from "#/ui/RegisterPasskey";
import RegisterU2F from "#/ui/RegisterU2F";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
export default async function Page({
searchParams,
params,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, organization } = searchParams;
const { loginName, organization, authRequestId } = searchParams;
const branding = await getBrandingSettings(server, organization);
const sessionFactors = await loadSession(loginName);
const session = await loadSession(loginName, organization);
async function loadSession(loginName?: string, organization?: string) {
async function loadSession(loginName?: string) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
return { session: response?.session, token: recent.token };
if (response?.session) {
return response.session;
}
});
}
const title = "Use your passkey to confirm it's really you";
const description =
"Your device will ask for your fingerprint, face, or screen lock";
const branding = await getBrandingSettings(server, organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Register Device</h1>
<p className="ztdl-p">
Choose a device to register for 2-Factor Authentication.
</p>
<h1>{title}</h1>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
></UserAvatar>
)}
<p className="ztdl-p mb-6 block">{description}</p>
{/* <Alert type={AlertType.INFO}>
<span>
A passkey is an authentication method on a device like your
fingerprint, Apple FaceID or similar.
<a
className="text-primary-light-500 dark:text-primary-dark-500 hover:text-primary-light-300 hover:dark:text-primary-dark-300"
target="_blank"
href="https://zitadel.com/docs/guides/manage/user/reg-create-user#with-passwordless"
>
Passwordless Authentication
</a>
</span>
</Alert> */}
{!sessionFactors && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{sessionFactors?.id && (
<RegisterU2F
sessionId={sessionFactors.id}
organization={organization}
authRequestId={authRequestId}
/>
)}
</div>
</DynamicTheme>
);

View File

@@ -0,0 +1,50 @@
import {
createPasskeyRegistrationLink,
getSession,
registerPasskey,
registerU2F,
server,
} from "#/lib/zitadel";
import { getSessionCookieById } from "#/utils/cookies";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { sessionId } = body;
const sessionCookie = await getSessionCookieById(sessionId);
const session = await getSession(
server,
sessionCookie.id,
sessionCookie.token
);
const domain: string = request.nextUrl.hostname;
const userId = session?.session?.factors?.user?.id;
if (userId) {
// TODO: add org context
return createPasskeyRegistrationLink(userId, sessionCookie.token)
.then((resp) => {
const code = resp.code;
return registerU2F(userId, domain).then((resp) => {
return NextResponse.json(resp);
});
})
.catch((error) => {
console.error("error on creating passkey registration link");
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.json(
{ details: "could not get session" },
{ status: 500 }
);
}
} else {
return NextResponse.json({}, { status: 400 });
}
}

View File

@@ -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<SessionCookie> = 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 }
);
}
}

View File

@@ -1,3 +1,4 @@
import { VerifyU2FRegistrationRequest } from "@zitadel/server";
import {
GetUserByIDResponse,
RegisterTOTPResponse,
@@ -348,14 +349,6 @@ export async function getUserByID(
return userService.getUserByID({ userId }, {});
}
export async function listHumanAuthFactors(
server: ZitadelServer,
userId: string
): Promise<ListHumanAuthFactorsResponse> {
const managementService = management.getManagement(server);
return managementService.listHumanAuthFactors({ userId }, {});
}
export async function listUsers(
userName: string,
organizationId: string
@@ -483,16 +476,63 @@ export async function setEmail(
* @returns the newly set email
*/
export async function createPasskeyRegistrationLink(
userId: string
userId: string,
token?: string
): Promise<any> {
const userservice = user.getUser(server);
let userService;
if (token) {
const authConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
token: token,
};
return userservice.createPasskeyRegistrationLink({
const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser);
} else {
userService = user.getUser(server);
}
return userService.createPasskeyRegistrationLink({
userId,
returnCode: {},
});
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @param domain the domain on which the factor is registered
* @returns the newly set email
*/
export async function registerU2F(
userId: string,
domain: string
): Promise<any> {
const userservice = user.getUser(server);
return userservice.registerU2F({
userId,
domain,
});
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @param domain the domain on which the factor is registered
* @returns the newly set email
*/
export async function verifyU2FRegistration(
request: VerifyU2FRegistrationRequest
): Promise<any> {
const userservice = user.getUser(server);
return userservice.verifyU2FRegistration(request, {});
}
/**
*
* @param server

View File

@@ -0,0 +1,184 @@
import clsx from "clsx";
import Link from "next/link";
import { BadgeState, StateBadge } from "./StateBadge";
import { CheckIcon } from "@heroicons/react/24/solid";
const cardClasses = (alreadyAdded: boolean) =>
clsx(
"relative bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 border border-divider-light dark:border-divider-dark transition-all ",
alreadyAdded ? "" : "hover:shadow-lg hover:dark:bg-white/10"
);
export const TOTP = (alreadyAdded: boolean, link: string) => {
return (
<Link href={link} className={cardClasses(alreadyAdded)}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "opacity-50" : ""
)}
>
<svg
className="h-9 w-9 transform -translate-x-[2px] mr-4"
version="1.1"
baseProfile="basic"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 512 512"
xmlSpace="preserve"
>
<path
fill="#1A73E8"
d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"
/>
<path
fill="#EA4335"
d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"
/>
<path
fill="#FBBC04"
d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z"
/>
<path
fill="#34A853"
d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z"
/>
<polygon
fill="#185DB7"
points="309.70001,287 202.29999,287 256,193.98999 "
/>
</svg>{" "}
<span>Authenticator App</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
export const U2F = (alreadyAdded: boolean, link: string) => {
return (
<Link href={link} className={cardClasses(alreadyAdded)}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="w-8 h-8 mr-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
/>
</svg>
<span>Universal Second Factor</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
export const EMAIL = (alreadyAdded: boolean, link: string) => {
return (
<Link href={link} className={cardClasses(alreadyAdded)}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
className="w-8 h-8 mr-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={1.5}
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span>Code via Email</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
export const SMS = (alreadyAdded: boolean, link: string) => {
return (
<Link href={link} className={cardClasses(alreadyAdded)}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
className="w-8 h-8 mr-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
<span>Code via SMS</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
function Setup() {
return (
<div className="transform absolute right-2 top-0">
<StateBadge evenPadding={true} state={BadgeState.Success}>
<CheckIcon className="w-4 h-4" />
</StateBadge>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import {
AuthenticationMethodType,
LoginSettings,
login,
} from "@zitadel/server";
import Link from "next/link";
import { BadgeState, StateBadge } from "./StateBadge";
import clsx from "clsx";
import { CheckIcon } from "@heroicons/react/24/outline";
import { EMAIL, SMS, TOTP, U2F } from "./AuthMethods";
type Props = {
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
userMethods: AuthenticationMethodType[];
};
export default function ChooseSecondFactor({
loginName,
sessionId,
authRequestId,
organization,
userMethods,
}: Props) {
const params = new URLSearchParams({});
if (loginName) {
params.append("loginName", loginName);
}
if (sessionId) {
params.append("sessionId", sessionId);
}
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{userMethods.map((method, i) => {
return (
<div key={"method-" + i}>
{method === 4 && TOTP(false, "")}
{method === 2 && U2F(false, "")}
{method === 3 && EMAIL(false, "")}
{method === 4 && SMS(false, "")}
</div>
);
})}
</div>
);
}
function Setup() {
return (
<div className="transform absolute right-2 top-0">
<StateBadge evenPadding={true} state={BadgeState.Success}>
<CheckIcon className="w-4 h-4" />
</StateBadge>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import Link from "next/link";
import { BadgeState, StateBadge } from "./StateBadge";
import clsx from "clsx";
import { CheckIcon } from "@heroicons/react/24/outline";
import { EMAIL, SMS, TOTP, U2F } from "./AuthMethods";
type Props = {
loginName?: string;
@@ -29,12 +30,6 @@ export default function ChooseSecondFactorToSetup({
userMethods,
checkAfter,
}: Props) {
const cardClasses = (alreadyAdded: boolean) =>
clsx(
"relative bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 border border-divider-light dark:border-divider-dark transition-all ",
alreadyAdded ? "" : "hover:shadow-lg hover:dark:bg-white/10"
);
const params = new URLSearchParams({});
if (loginName) {
@@ -53,201 +48,24 @@ export default function ChooseSecondFactorToSetup({
params.append("checkAfter", "true");
}
const TOTP = (alreadyAdded: boolean) => {
return (
<Link
href={userMethods.includes(4) ? "" : "/otp/time-based/set?" + params}
className={cardClasses(alreadyAdded)}
>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "opacity-50" : ""
)}
>
<svg
className="h-9 w-9 transform -translate-x-[2px] mr-4"
version="1.1"
baseProfile="basic"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 512 512"
xmlSpace="preserve"
>
<path
fill="#1A73E8"
d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"
/>
<path
fill="#EA4335"
d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"
/>
<path
fill="#FBBC04"
d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z"
/>
<path
fill="#34A853"
d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z"
/>
<polygon
fill="#185DB7"
points="309.70001,287 202.29999,287 256,193.98999 "
/>
</svg>{" "}
<span>Authenticator App</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
const U2F = (alreadyAdded: boolean) => {
return (
<Link href={"/u2f/set?" + params} className={cardClasses(alreadyAdded)}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="w-8 h-8 mr-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
/>
</svg>
<span>Universal Second Factor</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
const EMAIL = (alreadyAdded: boolean) => {
return (
<Link
href={"/otp/email/set?" + params}
className={cardClasses(alreadyAdded)}
>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
className="w-8 h-8 mr-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={1.5}
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span>Code via Email</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
const SMS = (alreadyAdded: boolean) => {
return (
<Link
href={"/otp/sms/set?" + params}
className={cardClasses(alreadyAdded)}
>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
className="w-8 h-8 mr-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
<span>Code via SMS</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</Link>
);
};
return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{loginSettings.secondFactors.map((factor, i) => {
return (
<div key={"method-" + i}>
{factor === 1 && TOTP(userMethods.includes(4))}
{factor === 2 && U2F(userMethods.includes(5))}
{factor === 3 && EMAIL(userMethods.includes(7))}
{factor === 4 && SMS(userMethods.includes(6))}
{factor === 1 &&
TOTP(
userMethods.includes(4),
userMethods.includes(4) ? "" : "/otp/time-based/set?" + params
)}
{factor === 2 && U2F(userMethods.includes(5), "/u2f/set?" + params)}
{factor === 3 &&
EMAIL(userMethods.includes(7), "/otp/email/set?" + params)}
{factor === 4 &&
SMS(userMethods.includes(6), "/otp/sms/set?" + params)}
</div>
);
})}
</div>
);
}
function Setup() {
return (
<div className="transform absolute right-2 top-0">
<StateBadge evenPadding={true} state={BadgeState.Success}>
<CheckIcon className="w-4 h-4" />
</StateBadge>
</div>
);
}

View File

@@ -14,6 +14,7 @@ type Props = {
sessionId?: string;
authRequestId?: string;
altPassword: boolean;
login?: boolean;
organization?: string;
};
@@ -23,6 +24,7 @@ export default function LoginPasskey({
authRequestId,
altPassword,
organization,
login = true,
}: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
@@ -31,6 +33,7 @@ export default function LoginPasskey({
const initialized = useRef(false);
// TODO: move this to server side
useEffect(() => {
if (!initialized.current) {
initialized.current = true;
@@ -61,7 +64,9 @@ export default function LoginPasskey({
}
}, []);
async function updateSessionForChallenge() {
async function updateSessionForChallenge(
userVerificationRequirement: number = login ? 1 : 3
) {
setLoading(true);
const res = await fetch("/api/session", {
method: "PUT",
@@ -75,7 +80,11 @@ export default function LoginPasskey({
challenges: {
webAuthN: {
domain: "",
userVerificationRequirement: 1,
// USER_VERIFICATION_REQUIREMENT_UNSPECIFIED = 0;
// USER_VERIFICATION_REQUIREMENT_REQUIRED = 1; - passkey login
// USER_VERIFICATION_REQUIREMENT_PREFERRED = 2;
// USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 3; - mfa
userVerificationRequirement: userVerificationRequirement,
},
},
authRequestId,

View File

@@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import Alert from "./Alert";
import { AuthRequest, RegisterPasskeyResponse } from "@zitadel/server";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
type Inputs = {};
type Props = {
sessionId: string;
authRequestId?: string;
organization?: string;
};
export default function RegisterU2F({
sessionId,
organization,
authRequestId,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitRegister() {
setError("");
setLoading(true);
const res = await fetch("/api/u2f", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sessionId,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
async function submitVerify(
passkeyId: string,
passkeyName: string,
publicKeyCredential: any,
sessionId: string
) {
setLoading(true);
const res = await fetch("/api/u2f/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
passkeyId,
passkeyName,
publicKeyCredential,
sessionId,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
function submitRegisterAndContinue(value: Inputs): Promise<boolean | void> {
return submitRegister().then((resp: RegisterPasskeyResponse) => {
const passkeyId = resp.passkeyId;
if (
resp.publicKeyCredentialCreationOptions &&
resp.publicKeyCredentialCreationOptions.publicKey
) {
resp.publicKeyCredentialCreationOptions.publicKey.challenge =
coerceToArrayBuffer(
resp.publicKeyCredentialCreationOptions.publicKey.challenge,
"challenge"
);
resp.publicKeyCredentialCreationOptions.publicKey.user.id =
coerceToArrayBuffer(
resp.publicKeyCredentialCreationOptions.publicKey.user.id,
"userid"
);
if (
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials
) {
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials.map(
(cred: any) => {
cred.id = coerceToArrayBuffer(
cred.id as string,
"excludeCredentials.id"
);
return cred;
}
);
}
navigator.credentials
.create(resp.publicKeyCredentialCreationOptions)
.then((resp) => {
if (
resp &&
(resp as any).response.attestationObject &&
(resp as any).response.clientDataJSON &&
(resp as any).rawId
) {
const attestationObject = (resp as any).response
.attestationObject;
const clientDataJSON = (resp as any).response.clientDataJSON;
const rawId = (resp as any).rawId;
const data = {
id: resp.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: resp.type,
response: {
attestationObject: coerceToBase64Url(
attestationObject,
"attestationObject"
),
clientDataJSON: coerceToBase64Url(
clientDataJSON,
"clientDataJSON"
),
},
};
return submitVerify(passkeyId, "", data, sessionId).then(() => {
const params = new URLSearchParams();
if (organization) {
params.set("organization", organization);
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
params.set("sessionId", sessionId);
// params.set("altPassword", ${false}); // without setting altPassword this does not allow password
// params.set("loginName", resp.loginName);
router.push("/u2f?" + params);
} else {
router.push("/accounts?" + params);
}
});
} else {
setLoading(false);
setError("An error on registering passkey");
return null;
}
})
.catch((error) => {
console.error(error);
setLoading(false);
setError(error);
return null;
});
}
});
}
const { errors } = formState;
return (
<form className="w-full">
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.back()}
>
back
</Button>
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitRegisterAndContinue)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}