register totp, login catch expired session

This commit is contained in:
peintnermax
2024-04-22 15:09:39 +02:00
parent b78e5063cb
commit 4f9e7d7a21
10 changed files with 2721 additions and 5933 deletions

View File

@@ -17,7 +17,7 @@ export default async function Page({
searchParams: Record<string | number | symbol, string | undefined>; searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>; params: Record<string | number | symbol, string | undefined>;
}) { }) {
const { loginName, organization } = searchParams; const { loginName, organization, sessionId, authRequestId } = searchParams;
const { method } = params; const { method } = params;
const branding = await getBrandingSettings(server, organization); const branding = await getBrandingSettings(server, organization);
@@ -67,6 +67,10 @@ export default async function Page({
<TOTPRegister <TOTPRegister
uri={totpResponse.uri as string} uri={totpResponse.uri as string}
secret={totpResponse.secret as string} secret={totpResponse.secret as string}
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
></TOTPRegister> ></TOTPRegister>
)} )}
</div> </div>

View File

@@ -86,7 +86,8 @@ export async function GET(request: NextRequest) {
if (authRequestId) { if (authRequestId) {
console.log(`Login with authRequest: ${authRequestId}`); console.log(`Login with authRequest: ${authRequestId}`);
const { authRequest } = await getAuthRequest(server, { authRequestId }); const { authRequest } = await getAuthRequest(server, { authRequestId });
let organization;
let organization = "";
if (authRequest?.scope) { if (authRequest?.scope) {
const orgScope = authRequest.scope.find((s: string) => const orgScope = authRequest.scope.find((s: string) =>
@@ -112,6 +113,18 @@ export async function GET(request: NextRequest) {
} }
} }
const gotoAccounts = () => {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
};
if (authRequest && authRequest.prompt.includes(Prompt.PROMPT_CREATE)) { if (authRequest && authRequest.prompt.includes(Prompt.PROMPT_CREATE)) {
const registerUrl = new URL("/register", request.url); const registerUrl = new URL("/register", request.url);
if (authRequest?.id) { if (authRequest?.id) {
@@ -128,15 +141,7 @@ export async function GET(request: NextRequest) {
if (authRequest && sessions.length) { if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set // if some accounts are available for selection and select_account is set
if (authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)) { if (authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)) {
const accountsUrl = new URL("/accounts", request.url); gotoAccounts();
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
} else if (authRequest.prompt.includes(Prompt.PROMPT_LOGIN)) { } else if (authRequest.prompt.includes(Prompt.PROMPT_LOGIN)) {
// if prompt is login // if prompt is login
const loginNameUrl = new URL("/loginname", request.url); const loginNameUrl = new URL("/loginname", request.url);
@@ -200,22 +205,16 @@ export async function GET(request: NextRequest) {
authRequestId, authRequestId,
session, session,
}); });
return NextResponse.redirect(callbackUrl); if (callbackUrl) {
} else { return NextResponse.redirect(callbackUrl);
const accountsUrl = new URL("/accounts", request.url); } else {
accountsUrl.searchParams.set("authRequestId", authRequestId); gotoAccounts();
if (organization) {
accountsUrl.searchParams.set("organization", organization);
} }
return NextResponse.redirect(accountsUrl); } else {
gotoAccounts();
} }
} else { } else {
const accountsUrl = new URL("/accounts", request.url); gotoAccounts();
accountsUrl.searchParams.set("authRequestId", authRequestId);
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
} }
} }
} else { } else {

View File

@@ -0,0 +1,24 @@
"use server";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
import { getSession, server, verifyTOTPRegistration } from "./zitadel";
export async function verifyTOTP(
code: string,
loginName?: string,
organization?: string
) {
return getMostRecentCookieWithLoginname(loginName, organization)
.then((recent) => {
return getSession(server, recent.id, recent.token).then((response) => {
return { session: response?.session, token: recent.token };
});
})
.then(({ session, token }) => {
if (session?.factors?.user?.id) {
return verifyTOTPRegistration(code, session.factors.user.id, token);
} else {
throw Error("No user id found in session.");
}
});
}

View File

@@ -1,4 +1,8 @@
import { GetUserByIDResponse, RegisterTOTPResponse } from "@zitadel/server"; import {
GetUserByIDResponse,
RegisterTOTPResponse,
VerifyTOTPRegistrationResponse,
} from "@zitadel/server";
import { import {
LegalAndSupportSettings, LegalAndSupportSettings,
PasswordComplexitySettings, PasswordComplexitySettings,
@@ -50,6 +54,8 @@ import {
AddOTPSMSResponse, AddOTPSMSResponse,
} from "@zitadel/server"; } from "@zitadel/server";
const SESSION_LIFETIME_S = 3000;
export const zitadelConfig: ZitadelServerOptions = { export const zitadelConfig: ZitadelServerOptions = {
name: "zitadel login", name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "", apiUrl: process.env.ZITADEL_API_URL ?? "",
@@ -124,8 +130,6 @@ export async function registerTOTP(
token: token, token: token,
}; };
console.log(token);
const sessionUser = initializeServer(authConfig); const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser); userService = user.getUser(sessionUser);
} else { } else {
@@ -185,7 +189,7 @@ export async function createSessionFromChecks(
checks: checks, checks: checks,
challenges, challenges,
lifetime: { lifetime: {
seconds: 300, seconds: SESSION_LIFETIME_S,
nanos: 0, nanos: 0,
}, },
}, },
@@ -302,6 +306,27 @@ export async function addHumanUser(
); );
} }
export async function verifyTOTPRegistration(
code: string,
userId: string,
token?: string
): Promise<VerifyTOTPRegistrationResponse> {
let userService;
if (token) {
const authConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
token: token,
};
const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser);
} else {
userService = user.getUser(server);
}
return userService.verifyTOTPRegistration({ code, userId }, {});
}
export async function getUserByID( export async function getUserByID(
userId: string userId: string
): Promise<GetUserByIDResponse> { ): Promise<GetUserByIDResponse> {

View File

@@ -40,10 +40,12 @@
"@zitadel/react": "workspace:*", "@zitadel/react": "workspace:*",
"@zitadel/server": "workspace:*", "@zitadel/server": "workspace:*",
"clsx": "1.2.1", "clsx": "1.2.1",
"copy-to-clipboard": "^3.3.3",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "13.4.12", "next": "13.4.12",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nice-grpc": "2.0.1", "nice-grpc": "2.0.1",
"qrcode.react": "^3.1.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "7.39.5", "react-hook-form": "7.39.5",

View File

@@ -0,0 +1,41 @@
"use client";
import {
ClipboardDocumentCheckIcon,
ClipboardIcon,
} from "@heroicons/react/20/solid";
import copy from "copy-to-clipboard";
import { useEffect, useState } from "react";
type Props = {
value: string;
};
export default function CopyToClipboard({ value }: Props) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (copied) {
copy(value);
const to = setTimeout(setCopied, 1000, false);
return () => clearTimeout(to);
}
}, [copied]);
return (
<div className="flex flex-row items-center px-2">
<button
id="tooltip-ctc"
type="button"
className=" text-primary-light-500 dark:text-primary-dark-500"
onClick={() => setCopied(true)}
>
{!copied ? (
<ClipboardIcon className="h-5 w-5" />
) : (
<ClipboardDocumentCheckIcon className="h-5 w-5" />
)}
</button>
</div>
);
}

View File

@@ -95,47 +95,6 @@ export default function LoginOTP({
return res.json(); return res.json();
} }
// async function submitLogin(inputs: Inputs) {
// setLoading(true);
// const checks: Checks = {};
// if (method === "email") {
// checks.otpEmail = {
// code: inputs.code,
// };
// }
// if (method === "sms") {
// checks.otpSms = {
// code: inputs.code,
// };
// }
// const res = await fetch("/api/session", {
// method: "PUT",
// headers: {
// "Content-Type": "application/json",
// },
// body: JSON.stringify({
// loginName,
// sessionId,
// organization,
// authRequestId,
// checks,
// }),
// });
// const response = await res.json();
// setLoading(false);
// if (!res.ok) {
// setError(response.details);
// return Promise.reject(response.details);
// }
// return response;
// }
async function submitCode(values: Inputs, organization?: string) { async function submitCode(values: Inputs, organization?: string) {
setLoading(true); setLoading(true);

View File

@@ -1,11 +1,128 @@
import { RegisterTOTPResponse } from "@zitadel/server"; "use client";
import { QRCodeSVG } from "qrcode.react";
import Alert, { AlertType } from "./Alert";
import Link from "next/link";
import CopyToClipboard from "./CopyToClipboard";
import { TextInput } from "./Input";
import { Button, ButtonVariants } from "./Button";
import { Spinner } from "./Spinner";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { verifyTOTP } from "#/lib/server-actions";
import { login } from "@zitadel/server";
type Inputs = {
code: string;
};
type Props = {
uri: string;
secret: string;
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
};
export default function TOTPRegister({ export default function TOTPRegister({
uri, uri,
secret, secret,
}: { loginName,
uri: string; sessionId,
secret: string; authRequestId,
}) { organization,
return <div>{uri}</div>; }: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
code: "",
},
});
async function continueWithCode(values: Inputs) {
return verifyTOTP(values.code, loginName, organization)
.then((response) => {
if (authRequestId && sessionId) {
const params = new URLSearchParams({
sessionId: sessionId,
authRequest: authRequestId,
});
if (organization) {
params.append("organization", organization);
}
return router.push(`/login?` + params);
} else if (loginName) {
const params = new URLSearchParams({
loginName,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
}
})
.catch((e) => {
setError(e.message);
});
}
return (
<div className="flex flex-col items-center ">
{uri && (
<>
<QRCodeSVG
className="rounded-md w-40 h-40 p-2 bg-white my-4"
value={uri}
/>
<div className="mb-4 w-96 flex text-sm my-2 border rounded-lg px-4 py-2 pr-2 border-divider-light dark:border-divider-dark">
<Link href={uri} target="_blank" className="flex-1 overflow-x-auto">
{uri}
</Link>
<CopyToClipboard value={uri}></CopyToClipboard>
</div>
<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(continueWithCode)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
</>
)}
</div>
);
} }

View File

@@ -92,6 +92,8 @@ export {
AddOTPSMSRequest, AddOTPSMSRequest,
RegisterTOTPRequest, RegisterTOTPRequest,
RegisterTOTPResponse, RegisterTOTPResponse,
VerifyTOTPRegistrationRequest,
VerifyTOTPRegistrationResponse,
} from "./proto/server/zitadel/user/v2beta/user_service"; } from "./proto/server/zitadel/user/v2beta/user_service";
export { AuthFactor } from "./proto/server/zitadel/user"; export { AuthFactor } from "./proto/server/zitadel/user";

8331
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff