fix passkey retry, cleanup mfa set

This commit is contained in:
peintnermax
2024-09-17 10:12:03 +02:00
parent fdb2711af0
commit 424bdf42f2
4 changed files with 151 additions and 122 deletions

View File

@@ -158,13 +158,19 @@ After updating the session, the user is signed in.
<img src="./screenshots/mfaset.png" alt="/mfa/set" width="400px" /> <img src="./screenshots/mfaset.png" alt="/mfa/set" width="400px" />
This page requests a webAuthN challenge for the user and updates the session afterwards. This page loads login Settings and the authentication methods for a user and shows setup options.
Requests to the APIs made: Requests to the APIs made:
- `getBrandingSettings(org?)` - `getBrandingSettings(org?)`
- `getLoginSettings(user.org)`
- `getSession()` - `getSession()`
- `updateSession()` - `listAuthenticationMethodTypes()`
- `getUserByID()`
When updating the session for the webAuthN challenge, we set `userVerificationRequirement` to `UserVerificationRequirement.REQUIRED` as this will request the webAuthN method as primary method to login. If a user has already setup a certain method, a checkbox is shown alongside the button and the button is disabled.
After updating the session, the user is signed in. OTP Email and OTP SMS only show up if the user has verified email or phone.
If the user chooses a method he is redirected to one of `/otp/time-based/set`, `/u2f/set`, `/otp/email/set`, or `/otp/sms/set`.
At the moment, U2F methods are hidden if a method is already added on the users resource. Reasoning is that the page should only be invoked for prompts. A self service page which shows up multiple u2f factors is implemented at a later stage.
> NOTE: The session and therefore the user factor defines which login settings are checked for available options.

View File

@@ -12,6 +12,7 @@ import BackButton from "@/ui/BackButton";
import ChooseSecondFactorToSetup from "@/ui/ChooseSecondFactorToSetup"; import ChooseSecondFactorToSetup from "@/ui/ChooseSecondFactorToSetup";
import DynamicTheme from "@/ui/DynamicTheme"; import DynamicTheme from "@/ui/DynamicTheme";
import UserAvatar from "@/ui/UserAvatar"; import UserAvatar from "@/ui/UserAvatar";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
export default async function Page({ export default async function Page({
searchParams, searchParams,
@@ -31,6 +32,28 @@ export default async function Page({
? await loadSessionById(sessionId, organization) ? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization); : await loadSessionByLoginname(loginName, organization);
async function getAuthMethodsAndUser(session?: Session) {
const userId = session?.factors?.user?.id;
if (!userId) {
throw Error("Could not get user id from session");
}
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
const humanUser =
user.user?.type.case === "human" ? user.user?.type.value : undefined;
return {
factors: session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: humanUser?.phone?.isVerified ?? false,
emailVerified: humanUser?.email?.isVerified ?? false,
};
});
});
}
async function loadSessionByLoginname( async function loadSessionByLoginname(
loginName?: string, loginName?: string,
organization?: string, organization?: string,
@@ -39,24 +62,7 @@ export default async function Page({
loginName, loginName,
organization, organization,
}).then((session) => { }).then((session) => {
if (session && session.factors?.user?.id) { return getAuthMethodsAndUser(session);
const userId = session.factors.user.id;
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
const humanUser =
user.user?.type.case === "human"
? user.user?.type.value
: undefined;
return {
factors: session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: humanUser?.phone?.isVerified ?? false,
emailVerified: humanUser?.email?.isVerified ?? false,
};
});
});
}
}); });
} }
@@ -65,29 +71,15 @@ export default async function Page({
return getSession({ return getSession({
sessionId: recent.id, sessionId: recent.id,
sessionToken: recent.token, sessionToken: recent.token,
}).then((response) => { }).then((sessionResponse) => {
if (response?.session && response.session.factors?.user?.id) { return getAuthMethodsAndUser(sessionResponse.session);
const userId = response.session.factors.user.id;
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
const humanUser =
user.user?.type.case === "human"
? user.user?.type.value
: undefined;
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: humanUser?.phone?.isVerified ?? false,
emailVerified: humanUser?.email?.isVerified ?? false,
};
});
});
}
}); });
} }
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization); const loginSettings = await getLoginSettings(
sessionWithData.factors?.user?.organizationId,
);
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>

View File

@@ -1,6 +1,9 @@
"use client"; "use client";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import {
LoginSettings,
SecondFactorType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { EMAIL, SMS, TOTP, U2F } from "./AuthMethods"; import { EMAIL, SMS, TOTP, U2F } from "./AuthMethods";
@@ -47,28 +50,37 @@ export default function ChooseSecondFactorToSetup({
return ( return (
<div className="grid grid-cols-1 gap-5 w-full pt-4"> <div className="grid grid-cols-1 gap-5 w-full pt-4">
{loginSettings.secondFactors.map((factor, i) => { {loginSettings.secondFactors.map((factor) => {
return factor === 1 switch (factor) {
? TOTP( case SecondFactorType.OTP:
return TOTP(
userMethods.includes(AuthenticationMethodType.TOTP), userMethods.includes(AuthenticationMethodType.TOTP),
"/otp/time-based/set?" + params, "/otp/time-based/set?" + params,
) );
: factor === 2 case SecondFactorType.U2F:
? U2F( return U2F(
userMethods.includes(AuthenticationMethodType.U2F), userMethods.includes(AuthenticationMethodType.U2F),
"/u2f/set?" + params, "/u2f/set?" + params,
);
case SecondFactorType.OTP_EMAIL:
return (
emailVerified &&
EMAIL(
userMethods.includes(AuthenticationMethodType.OTP_EMAIL),
"/otp/email/set?" + params,
) )
: factor === 3 && emailVerified );
? EMAIL( case SecondFactorType.OTP_SMS:
userMethods.includes(AuthenticationMethodType.OTP_EMAIL), return (
"/otp/email/set?" + params, phoneVerified &&
) SMS(
: factor === 4 && phoneVerified userMethods.includes(AuthenticationMethodType.OTP_SMS),
? SMS( "/otp/sms/set?" + params,
userMethods.includes(AuthenticationMethodType.OTP_SMS), )
"/otp/sms/set?" + params, );
) default:
: null; return null;
}
})} })}
</div> </div>
); );

View File

@@ -50,19 +50,20 @@ export default function LoginPasskey({
const pK = const pK =
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
?.publicKey; ?.publicKey;
if (pK) {
submitLoginAndContinue(pK) if (!pK) {
.then(() => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
} else {
setError("Could not request passkey challenge"); setError("Could not request passkey challenge");
setLoading(false); setLoading(false);
} }
return submitLoginAndContinue(pK)
.then(() => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
}) })
.catch((error) => { .catch((error) => {
setError(error); setError(error);
@@ -135,59 +136,57 @@ export default function LoginPasskey({
publicKey, publicKey,
}) })
.then((assertedCredential: any) => { .then((assertedCredential: any) => {
if (assertedCredential) { if (!assertedCredential) {
const authData = new Uint8Array(
assertedCredential.response.authenticatorData,
);
const clientDataJSON = new Uint8Array(
assertedCredential.response.clientDataJSON,
);
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(
assertedCredential.response.userHandle,
);
const data = {
id: assertedCredential.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: assertedCredential.type,
response: {
authenticatorData: coerceToBase64Url(authData, "authData"),
clientDataJSON: coerceToBase64Url(
clientDataJSON,
"clientDataJSON",
),
signature: coerceToBase64Url(sig, "sig"),
userHandle: coerceToBase64Url(userHandle, "userHandle"),
},
};
return submitLogin(data).then((resp) => {
if (authRequestId && resp && resp.sessionId) {
return router.push(
`/login?` +
new URLSearchParams({
sessionId: resp.sessionId,
authRequest: authRequestId,
}),
);
} else {
const params = new URLSearchParams({});
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
if (resp?.factors?.user?.loginName) {
params.set("loginName", resp.factors.user.loginName);
}
return router.push(`/signedin?` + params);
}
});
} else {
setLoading(false); setLoading(false);
setError("An error on retrieving passkey"); setError("An error on retrieving passkey");
return null; return;
} }
const authData = new Uint8Array(
assertedCredential.response.authenticatorData,
);
const clientDataJSON = new Uint8Array(
assertedCredential.response.clientDataJSON,
);
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(
assertedCredential.response.userHandle,
);
const data = {
id: assertedCredential.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: assertedCredential.type,
response: {
authenticatorData: coerceToBase64Url(authData, "authData"),
clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"),
signature: coerceToBase64Url(sig, "sig"),
userHandle: coerceToBase64Url(userHandle, "userHandle"),
},
};
return submitLogin(data).then((resp) => {
if (authRequestId && resp && resp.sessionId) {
return router.push(
`/login?` +
new URLSearchParams({
sessionId: resp.sessionId,
authRequest: authRequestId,
}),
);
} else {
const params = new URLSearchParams({});
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
if (resp?.factors?.user?.loginName) {
params.set("loginName", resp.factors.user.loginName);
}
return router.push(`/signedin?` + params);
}
});
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
@@ -245,7 +244,27 @@ export default function LoginPasskey({
className="self-end" className="self-end"
variant={ButtonVariants.Primary} variant={ButtonVariants.Primary}
disabled={loading} disabled={loading}
onClick={() => updateSessionForChallenge()} onClick={async () => {
const response = await updateSessionForChallenge();
const pK =
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
?.publicKey;
if (!pK) {
setError("Could not request passkey challenge");
setLoading(false);
}
return submitLoginAndContinue(pK)
.then(() => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
}}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
continue continue