totp page, totp api

This commit is contained in:
peintnermax
2024-04-04 13:50:54 +02:00
parent 862df4bbde
commit d7f7fef200
6 changed files with 275 additions and 66 deletions

View File

@@ -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<string | number | symbol, string | undefined>;
}) {
const { loginName, authRequestId, sessionId, organization, code, submit } =
searchParams;
const branding = await getBrandingSettings(server, organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Verify 2-Factor</h1>
<p className="ztdl-p">Enter the code from your authenticator app. </p>
<TOTPForm
loginName={loginName}
sessionId={sessionId}
code={code}
authRequestId={authRequestId}
organization={organization}
submit={submit === "true"}
/>
</div>
</DynamicTheme>
);
}

View File

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

View File

@@ -19,6 +19,7 @@ import {
PasswordComplexitySettings, PasswordComplexitySettings,
GetSessionResponse, GetSessionResponse,
VerifyEmailResponse, VerifyEmailResponse,
Checks,
SetSessionResponse, SetSessionResponse,
SetSessionRequest, SetSessionRequest,
ListUsersResponse, ListUsersResponse,
@@ -118,60 +119,15 @@ export async function getPasswordComplexitySettings(
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings); .then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
} }
export async function createSessionForLoginname( export async function createSessionFromChecks(
server: ZitadelServer, server: ZitadelServer,
loginName: string, checks: Checks,
password: string | undefined,
challenges: RequestChallenges | undefined challenges: RequestChallenges | undefined
): Promise<CreateSessionResponse | undefined> { ): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server); const sessionService = session.getSession(server);
return password return sessionService.createSession(
? sessionService.createSession(
{ {
checks: { user: { loginName }, password: { password } }, checks: checks,
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<CreateSessionResponse | undefined> {
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, challenges,
lifetime: { lifetime: {
seconds: 300, seconds: 300,
@@ -209,6 +165,7 @@ export async function setSession(
sessionId: string, sessionId: string,
sessionToken: string, sessionToken: string,
password: string | undefined, password: string | undefined,
totpCode: string | undefined,
webAuthN: { credentialAssertionData: any } | undefined, webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined challenges: RequestChallenges | undefined
): Promise<SetSessionResponse | undefined> { ): Promise<SetSessionResponse | undefined> {
@@ -226,6 +183,10 @@ export async function setSession(
payload.checks.password = { password }; payload.checks.password = { password };
} }
if (totpCode && payload.checks) {
payload.checks.totp = { code: totpCode };
}
if (webAuthN && payload.checks) { if (webAuthN && payload.checks) {
payload.checks.webAuthN = webAuthN; payload.checks.webAuthN = webAuthN;
} }

150
apps/login/ui/TOTPForm.tsx Normal file
View File

@@ -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<Inputs>({
mode: "onBlur",
defaultValues: {
code: code ? code : "",
},
});
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
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 (
<form className="w-full">
<div className="">
<TextInput
type="text"
{...register("code", { required: "This field is required" })}
label="Code"
/>
</div>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit((e) => setCodeAndContinue(e, organization))}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -1,8 +1,7 @@
"use server"; "use server";
import { import {
createSessionForLoginname, createSessionFromChecks,
createSessionForUserId,
createSessionForUserIdAndIdpIntent, createSessionForUserIdAndIdpIntent,
getSession, getSession,
server, server,
@@ -22,10 +21,15 @@ export async function createSessionAndUpdateCookie(
organization?: string, organization?: string,
authRequestId?: string authRequestId?: string
): Promise<Session> { ): Promise<Session> {
const createdSession = await createSessionForLoginname( const createdSession = await createSessionFromChecks(
server, server,
loginName, password
password, ? {
user: { loginName },
password: { password },
// totp: { code: totpCode },
}
: { user: { loginName } },
challenges challenges
); );
@@ -72,10 +76,15 @@ export async function createSessionForUserIdAndUpdateCookie(
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
authRequestId: string | undefined authRequestId: string | undefined
): Promise<Session> { ): Promise<Session> {
const createdSession = await createSessionForUserId( const createdSession = await createSessionFromChecks(
server, server,
userId, password
password, ? {
user: { userId },
password: { password },
// totp: { code: totpCode },
}
: { user: { userId } },
challenges challenges
); );
@@ -177,6 +186,7 @@ export async function setSessionAndUpdateCookie(
password: string | undefined, password: string | undefined,
webAuthN: { credentialAssertionData: any } | undefined, webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
totpCode: string | undefined,
authRequestId: string | undefined authRequestId: string | undefined
): Promise<SessionWithChallenges> { ): Promise<SessionWithChallenges> {
return setSession( return setSession(
@@ -184,6 +194,7 @@ export async function setSessionAndUpdateCookie(
recentCookie.id, recentCookie.id,
recentCookie.token, recentCookie.token,
password, password,
totpCode,
webAuthN, webAuthN,
challenges challenges
).then((updatedSession) => { ).then((updatedSession) => {

View File

@@ -51,6 +51,7 @@ export {
CreateSessionResponse, CreateSessionResponse,
SetSessionResponse, SetSessionResponse,
SetSessionRequest, SetSessionRequest,
Checks,
DeleteSessionResponse, DeleteSessionResponse,
} from "./proto/server/zitadel/session/v2beta/session_service"; } from "./proto/server/zitadel/session/v2beta/session_service";
export { export {