mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 03:23:40 +00:00
register totp, login catch expired session
This commit is contained in:
@@ -17,7 +17,7 @@ export default async function Page({
|
||||
searchParams: 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 branding = await getBrandingSettings(server, organization);
|
||||
@@ -67,6 +67,10 @@ export default async function Page({
|
||||
<TOTPRegister
|
||||
uri={totpResponse.uri as string}
|
||||
secret={totpResponse.secret as string}
|
||||
loginName={loginName}
|
||||
sessionId={sessionId}
|
||||
authRequestId={authRequestId}
|
||||
organization={organization}
|
||||
></TOTPRegister>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,8 @@ export async function GET(request: NextRequest) {
|
||||
if (authRequestId) {
|
||||
console.log(`Login with authRequest: ${authRequestId}`);
|
||||
const { authRequest } = await getAuthRequest(server, { authRequestId });
|
||||
let organization;
|
||||
|
||||
let organization = "";
|
||||
|
||||
if (authRequest?.scope) {
|
||||
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)) {
|
||||
const registerUrl = new URL("/register", request.url);
|
||||
if (authRequest?.id) {
|
||||
@@ -128,15 +141,7 @@ export async function GET(request: NextRequest) {
|
||||
if (authRequest && sessions.length) {
|
||||
// if some accounts are available for selection and select_account is set
|
||||
if (authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)) {
|
||||
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);
|
||||
gotoAccounts();
|
||||
} else if (authRequest.prompt.includes(Prompt.PROMPT_LOGIN)) {
|
||||
// if prompt is login
|
||||
const loginNameUrl = new URL("/loginname", request.url);
|
||||
@@ -200,22 +205,16 @@ export async function GET(request: NextRequest) {
|
||||
authRequestId,
|
||||
session,
|
||||
});
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} else {
|
||||
const accountsUrl = new URL("/accounts", request.url);
|
||||
accountsUrl.searchParams.set("authRequestId", authRequestId);
|
||||
if (organization) {
|
||||
accountsUrl.searchParams.set("organization", organization);
|
||||
if (callbackUrl) {
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} else {
|
||||
gotoAccounts();
|
||||
}
|
||||
return NextResponse.redirect(accountsUrl);
|
||||
} else {
|
||||
gotoAccounts();
|
||||
}
|
||||
} else {
|
||||
const accountsUrl = new URL("/accounts", request.url);
|
||||
accountsUrl.searchParams.set("authRequestId", authRequestId);
|
||||
if (organization) {
|
||||
accountsUrl.searchParams.set("organization", organization);
|
||||
}
|
||||
return NextResponse.redirect(accountsUrl);
|
||||
gotoAccounts();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
24
apps/login/lib/server-actions.ts
Normal file
24
apps/login/lib/server-actions.ts
Normal 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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { GetUserByIDResponse, RegisterTOTPResponse } from "@zitadel/server";
|
||||
import {
|
||||
GetUserByIDResponse,
|
||||
RegisterTOTPResponse,
|
||||
VerifyTOTPRegistrationResponse,
|
||||
} from "@zitadel/server";
|
||||
import {
|
||||
LegalAndSupportSettings,
|
||||
PasswordComplexitySettings,
|
||||
@@ -50,6 +54,8 @@ import {
|
||||
AddOTPSMSResponse,
|
||||
} from "@zitadel/server";
|
||||
|
||||
const SESSION_LIFETIME_S = 3000;
|
||||
|
||||
export const zitadelConfig: ZitadelServerOptions = {
|
||||
name: "zitadel login",
|
||||
apiUrl: process.env.ZITADEL_API_URL ?? "",
|
||||
@@ -124,8 +130,6 @@ export async function registerTOTP(
|
||||
token: token,
|
||||
};
|
||||
|
||||
console.log(token);
|
||||
|
||||
const sessionUser = initializeServer(authConfig);
|
||||
userService = user.getUser(sessionUser);
|
||||
} else {
|
||||
@@ -185,7 +189,7 @@ export async function createSessionFromChecks(
|
||||
checks: checks,
|
||||
challenges,
|
||||
lifetime: {
|
||||
seconds: 300,
|
||||
seconds: SESSION_LIFETIME_S,
|
||||
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(
|
||||
userId: string
|
||||
): Promise<GetUserByIDResponse> {
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
"@zitadel/react": "workspace:*",
|
||||
"@zitadel/server": "workspace:*",
|
||||
"clsx": "1.2.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"moment": "^2.29.4",
|
||||
"next": "13.4.12",
|
||||
"next-themes": "^0.2.1",
|
||||
"nice-grpc": "2.0.1",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.39.5",
|
||||
|
||||
41
apps/login/ui/CopyToClipboard.tsx
Normal file
41
apps/login/ui/CopyToClipboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -95,47 +95,6 @@ export default function LoginOTP({
|
||||
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) {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
@@ -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({
|
||||
uri,
|
||||
secret,
|
||||
}: {
|
||||
uri: string;
|
||||
secret: string;
|
||||
}) {
|
||||
return <div>{uri}</div>;
|
||||
loginName,
|
||||
sessionId,
|
||||
authRequestId,
|
||||
organization,
|
||||
}: 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ export {
|
||||
AddOTPSMSRequest,
|
||||
RegisterTOTPRequest,
|
||||
RegisterTOTPResponse,
|
||||
VerifyTOTPRegistrationRequest,
|
||||
VerifyTOTPRegistrationResponse,
|
||||
} from "./proto/server/zitadel/user/v2beta/user_service";
|
||||
|
||||
export { AuthFactor } from "./proto/server/zitadel/user";
|
||||
|
||||
8331
pnpm-lock.yaml
generated
8331
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user