split up register, session for passkeys

This commit is contained in:
Max Peintner
2023-06-16 13:57:29 +02:00
parent 73d5c6e70c
commit fbbc48e5cd
12 changed files with 556 additions and 50 deletions

View File

@@ -10,6 +10,7 @@ export default async function Page({
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName } = searchParams;
const sessionFactors = await loadSession(loginName);
async function loadSession(loginName?: string) {

View File

@@ -3,24 +3,50 @@ import {
getPasswordComplexitySettings,
server,
} from "#/lib/zitadel";
import RegisterForm from "#/ui/RegisterForm";
import RegisterFormWithoutPassword from "#/ui/RegisterFormWithoutPassword";
import SetPasswordForm from "#/ui/SetPasswordForm";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { firstname, lastname, email } = searchParams;
const setPassword = !!(firstname && lastname && email);
console.log(setPassword);
export default async function Page() {
const legal = await getLegalAndSupportSettings(server);
const passwordComplexitySettings = await getPasswordComplexitySettings(
server
);
return (
console.log(legal);
return setPassword ? (
<div className="flex flex-col items-center space-y-4">
<h1>Set Password</h1>
<p className="ztdl-p">Set the password for your account</p>
{legal && passwordComplexitySettings && (
<SetPasswordForm
passwordComplexitySettings={passwordComplexitySettings}
email={email}
firstname={firstname}
lastname={lastname}
></SetPasswordForm>
)}
</div>
) : (
<div className="flex flex-col items-center space-y-4">
<h1>Register</h1>
<p className="ztdl-p">Create your ZITADEL account.</p>
{legal && passwordComplexitySettings && (
<RegisterForm
<RegisterFormWithoutPassword
legal={legal}
passwordComplexitySettings={passwordComplexitySettings}
></RegisterForm>
></RegisterFormWithoutPassword>
)}
</div>
);

View File

@@ -0,0 +1,27 @@
import {
getLegalAndSupportSettings,
getPasswordComplexitySettings,
server,
} from "#/lib/zitadel";
import RegisterForm from "#/ui/RegisterForm";
export default async function Page() {
const legal = await getLegalAndSupportSettings(server);
const passwordComplexitySettings = await getPasswordComplexitySettings(
server
);
return (
<div className="flex flex-col items-center space-y-4">
<h1>Register</h1>
<p className="ztdl-p">Create your ZITADEL account.</p>
{legal && passwordComplexitySettings && (
<RegisterForm
legal={legal}
passwordComplexitySettings={passwordComplexitySettings}
></RegisterForm>
)}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Button, ButtonVariants } from "#/ui/Button";
import Link from "next/link";
type Props = {
searchParams: { [key: string]: string | string[] | undefined };
};
export default async function Page({ searchParams }: Props) {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Registration successful</h1>
<p className="ztdl-p">You are registered.</p>
{`userId: ${searchParams["userid"]}`}
<Link href="/register">
<Button variant={ButtonVariants.Primary}>back</Button>
</Link>
</div>
);
}

View File

@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
email: email,
firstName,
lastName,
password: password,
password: password ? password : undefined,
});
return NextResponse.json({ userId });
} else {

View File

@@ -35,7 +35,10 @@ export async function POST(request: NextRequest) {
loginName: response.session?.factors?.user?.loginName ?? "",
};
return addSessionToCookie(sessionCookie).then(() => {
return NextResponse.json({ factors: response?.session?.factors });
return NextResponse.json({
sessionId: createdSession.sessionId,
factors: response?.session?.factors,
});
});
} else {
return NextResponse.json(

View File

@@ -2,6 +2,7 @@ import {
ZitadelServer,
ZitadelServerOptions,
user,
management,
settings,
getServers,
initializeServer,
@@ -23,6 +24,8 @@ import {
VerifyPasskeyRegistrationRequest,
VerifyPasskeyRegistrationResponse,
orgMetadata,
SetUserPassword,
SetHumanPasswordResponse,
} from "@zitadel/server";
import { Metadata } from "nice-grpc";
@@ -131,7 +134,7 @@ export type AddHumanUserData = {
firstName: string;
lastName: string;
email: string;
password: string;
password: string | undefined;
};
export async function addHumanUser(
@@ -141,12 +144,18 @@ export async function addHumanUser(
const mgmt = user.getUser(server);
return mgmt
.addHumanUser(
{
email: { email },
username: email,
profile: { firstName, lastName },
password: { password },
},
password
? {
email: { email },
username: email,
profile: { firstName, lastName },
password: { password },
}
: {
email: { email },
username: email,
profile: { firstName, lastName },
},
{}
)
.then((resp: AddHumanUserResponse) => {
@@ -154,6 +163,19 @@ export async function addHumanUser(
});
}
export async function setHumanPassword(
server: ZitadelServer,
userId: string,
password: string
): Promise<string> {
const mgmt = management.getManagement(server);
return mgmt
.setHumanPassword({ userId, password }, {})
.then((resp: SetHumanPasswordResponse) => {
return resp.userId;
});
}
export async function verifyEmail(
server: ZitadelServer,
userId: string,

View File

@@ -0,0 +1,105 @@
"use client";
import { RadioGroup } from "@headlessui/react";
export const methods = [
{
name: "Passkeys",
description: "Authenticate with your device.",
},
{
name: "Password",
description: "Authenticate with a password",
},
];
export default function AuthenticationMethodRadio({
selected,
selectionChanged,
}: {
selected: any;
selectionChanged: (value: any) => void;
}) {
return (
<div className="w-full">
<div className="mx-auto w-full max-w-md">
<RadioGroup value={selected} onChange={selectionChanged}>
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
<div className="grid grid-cols-2 space-x-2">
{methods.map((method) => (
<RadioGroup.Option
key={method.name}
value={method}
className={({ active, checked }) =>
`${
active
? "h-full ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20"
: "h-full "
}
${
checked
? "bg-background-light-400 dark:bg-background-dark-400"
: "bg-background-light-400 dark:bg-background-dark-400"
}
relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-md`
}
>
{({ active, checked }) => (
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-sm">
<RadioGroup.Label
as="p"
className={`font-medium ${checked ? "" : ""}`}
>
{method.name}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={`text-xs text-opacity-80 dark:text-opacity-80 inline ${
checked ? "" : ""
}`}
>
{method.description}
<span aria-hidden="true">&middot;</span>{" "}
</RadioGroup.Description>
</div>
</div>
{checked && (
<div className="shrink-0 text-white">
<CheckIcon className="h-6 w-6" />
</div>
)}
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
</div>
);
}
function CheckIcon(props: any) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<circle
className="fill-current text-black/50 dark:text-white/50"
cx={12}
cy={12}
r={12}
opacity="0.2"
/>
<path
d="M7 13l3 3 7-7"
className="stroke-black dark:stroke-white"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { LegalAndSupportSettings } from "@zitadel/server";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { TextInput } from "./Input";
import { PrivacyPolicyCheckboxes } from "./PrivacyPolicyCheckboxes";
import { FieldValues, useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import AuthenticationMethodRadio, {
methods,
} from "./AuthenticationMethodRadio";
type Inputs =
| {
firstname: string;
lastname: string;
email: string;
}
| FieldValues;
type Props = {
legal: LegalAndSupportSettings;
};
export default function RegisterFormWithoutPassword({ legal }: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [loading, setLoading] = useState<boolean>(false);
const [selected, setSelected] = useState(methods[0]);
const router = useRouter();
async function submitAndRegister(values: Inputs) {
setLoading(true);
const res = await fetch("/registeruser", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: values.email,
firstName: values.firstname,
lastName: values.lastname,
}),
});
if (!res.ok) {
setLoading(false);
throw new Error("Failed to register user");
}
setLoading(false);
return res.json();
}
async function createSessionWithLoginName(loginName: string) {
setLoading(true);
const res = await fetch("/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: loginName,
}),
});
setLoading(false);
if (!res.ok) {
throw new Error("Failed to set user");
}
return res.json();
}
async function submitAndContinue(
value: Inputs,
withPassword: boolean = false
) {
return withPassword
? router.push(`/register?` + new URLSearchParams(value))
: submitAndRegister(value).then((resp: any) => {
createSessionWithLoginName(value.email).then(({ factors }) => {
return router.push(
`/passkey/add?` +
new URLSearchParams({ loginName: factors.user.loginName })
);
});
});
// .then((resp: any) => {
// return router.push(`/verify?userID=${resp.userId}`);
// });
}
const { errors } = formState;
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
return (
<form className="w-full">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="">
<TextInput
type="firstname"
autoComplete="firstname"
required
{...register("firstname", { required: "This field is required" })}
label="First name"
error={errors.firstname?.message as string}
/>
</div>
<div className="">
<TextInput
type="lastname"
autoComplete="lastname"
required
{...register("lastname", { required: "This field is required" })}
label="Last name"
error={errors.lastname?.message as string}
/>
</div>
<div className="col-span-2">
<TextInput
type="email"
autoComplete="email"
required
{...register("email", { required: "This field is required" })}
label="E-mail"
error={errors.email?.message as string}
/>
</div>
</div>
{legal && (
<PrivacyPolicyCheckboxes
legal={legal}
onChange={setTosAndPolicyAccepted}
/>
)}
<div className="py-4">
<AuthenticationMethodRadio
selected={selected}
selectionChanged={setSelected}
/>
</div>
<div className="mt-8 flex w-full flex-row items-center justify-between">
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.back()}
>
back
</Button>
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
onClick={handleSubmit((values) =>
submitAndContinue(values, selected === methods[0] ? false : true)
)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -146,41 +146,6 @@ export default function RegisterPasskey({ sessionId }: Props) {
console.log(data);
return submitVerify(passkeyId, "name", data, sessionId);
// if (this.type === U2FComponentDestination.MFA) {
// this.service
// .verifyMyMultiFactorU2F(base64, this.name)
// .then(() => {
// this.translate
// .get("USER.MFA.U2F_SUCCESS")
// .pipe(take(1))
// .subscribe((msg) => {
// this.toast.showInfo(msg);
// });
// this.dialogRef.close(true);
// this.loading = false;
// })
// .catch((error) => {
// this.loading = false;
// this.toast.showError(error);
// });
// } else if (this.type === U2FComponentDestination.PASSWORDLESS) {
// this.service
// .verifyMyPasswordless(base64, this.name)
// .then(() => {
// this.translate
// .get("USER.PASSWORDLESS.U2F_SUCCESS")
// .pipe(take(1))
// .subscribe((msg) => {
// this.toast.showInfo(msg);
// });
// this.dialogRef.close(true);
// this.loading = false;
// })
// .catch((error) => {
// this.loading = false;
// this.toast.showError(error);
// });
// }
} else {
setLoading(false);
setError("An error on registering passkey");

View File

@@ -0,0 +1,161 @@
"use client";
import {
LegalAndSupportSettings,
PasswordComplexitySettings,
} from "@zitadel/server";
import PasswordComplexity from "./PasswordComplexity";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { TextInput } from "./Input";
import { PrivacyPolicyCheckboxes } from "./PrivacyPolicyCheckboxes";
import { FieldValues, useForm } from "react-hook-form";
import {
lowerCaseValidator,
numberValidator,
symbolValidator,
upperCaseValidator,
} from "#/utils/validators";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
type Inputs =
| {
password: string;
confirmPassword: string;
}
| FieldValues;
type Props = {
passwordComplexitySettings: PasswordComplexitySettings;
email: string;
firstname: string;
lastname: string;
};
export default function SetPasswordForm({
passwordComplexitySettings,
email,
firstname,
lastname,
}: Props) {
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitRegister(values: Inputs) {
setLoading(true);
const res = await fetch("/registeruser", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
firstName: firstname,
lastName: lastname,
password: values.password,
}),
});
if (!res.ok) {
setLoading(false);
throw new Error("Failed to register user");
}
setLoading(false);
return res.json();
}
function submitAndLink(value: Inputs): Promise<boolean | void> {
return submitRegister(value).then((resp: any) => {
return router.push(`/verify?userID=${resp.userId}`);
});
}
const { errors } = formState;
const watchPassword = watch("password", "");
const watchConfirmPassword = watch("confirmPassword", "");
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
const hasMinLength =
passwordComplexitySettings &&
watchPassword?.length >= passwordComplexitySettings.minLength;
const hasSymbol = symbolValidator(watchPassword);
const hasNumber = numberValidator(watchPassword);
const hasUppercase = upperCaseValidator(watchPassword);
const hasLowercase = lowerCaseValidator(watchPassword);
const policyIsValid =
passwordComplexitySettings &&
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
hasMinLength;
return (
<form className="w-full">
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
<div className="">
<TextInput
type="password"
autoComplete="new-password"
required
{...register("password", {
required: "You have to provide a password!",
})}
label="Password"
error={errors.password?.message as string}
/>
</div>
<div className="">
<TextInput
type="password"
required
autoComplete="new-password"
{...register("confirmPassword", {
required: "This field is required",
})}
label="Confirm Password"
error={errors.confirmPassword?.message as string}
/>
</div>
</div>
{passwordComplexitySettings && (
<PasswordComplexity
passwordComplexitySettings={passwordComplexitySettings}
password={watchPassword}
equals={!!watchPassword && watchPassword === watchConfirmPassword}
/>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<Button type="button" variant={ButtonVariants.Secondary}>
back
</Button>
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={
loading ||
!policyIsValid ||
!formState.isValid ||
watchPassword !== watchConfirmPassword
}
onClick={handleSubmit(submitAndLink)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}