-
Notifications
You must be signed in to change notification settings - Fork 2.7k
2FA #2505
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
2FA #2505
Changes from all commits
ce90e3e
d11315e
97b78a1
6359115
f14a8d8
7b4fcdd
c960a35
614a62b
ab25023
e3d6f12
3e1972a
069fcb3
d359138
a1fcdc1
a210567
db64a38
088c38d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,8 @@ export const GET = withSession(async ({ session }) => { | |
| defaultPartnerId: true, | ||
| passwordHash: true, | ||
| createdAt: true, | ||
| twoFactorConfirmedAt: true, | ||
| twoFactorRecoveryCodes: true, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. recovecy access |
||
| }, | ||
| }), | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| "use client"; | ||
|
|
||
| import { errorCodes } from "@/ui/auth/login/login-form"; | ||
| import { Button, Input, useMediaQuery } from "@dub/ui"; | ||
| import { signIn } from "next-auth/react"; | ||
| import { FormEvent, useState } from "react"; | ||
| import { toast } from "sonner"; | ||
|
|
||
| export const TwoFactorChallengeForm = () => { | ||
| const { isMobile } = useMediaQuery(); | ||
| const [code, setCode] = useState(""); | ||
| const [loading, setLoading] = useState(false); | ||
|
|
||
| const submit = async (e: FormEvent<HTMLFormElement>) => { | ||
| e.preventDefault(); | ||
| setLoading(true); | ||
|
|
||
| const response = await signIn("two-factor-challenge", { | ||
| code, | ||
| redirect: false, | ||
| callbackUrl: "/", | ||
| }); | ||
|
|
||
| setLoading(false); | ||
|
|
||
| if (!response) { | ||
| return; | ||
| } | ||
|
|
||
| if (!response.ok && response.error) { | ||
| if (errorCodes[response.error]) { | ||
| toast.error(errorCodes[response.error]); | ||
| } else { | ||
| toast.error(response.error); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="flex w-full flex-col gap-3"> | ||
| <form onSubmit={submit}> | ||
| <div className="flex flex-col gap-6"> | ||
| <label> | ||
| <span className="text-content-emphasis mb-2 block text-sm font-medium leading-none"> | ||
| Authentication code | ||
| </span> | ||
| <Input | ||
| type="text" | ||
| autoFocus={!isMobile} | ||
| value={code} | ||
| placeholder="012345" | ||
| pattern="[0-9]*" | ||
| onChange={(e) => setCode(e.target.value)} | ||
| maxLength={6} | ||
| /> | ||
| </label> | ||
| <Button | ||
| type="submit" | ||
| text={loading ? "Verifying..." : "Verify code"} | ||
| disabled={code.length < 6} | ||
| loading={loading} | ||
| /> | ||
| </div> | ||
| </form> | ||
| </div> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { TWO_FA_COOKIE_NAME } from "@/lib/auth/constants"; | ||
| import { AuthLayout } from "@/ui/layout/auth-layout"; | ||
| import { constructMetadata } from "@dub/utils"; | ||
| import { cookies } from "next/headers"; | ||
| import { redirect } from "next/navigation"; | ||
| import { TwoFactorChallengeForm } from "./form"; | ||
|
|
||
| export const metadata = constructMetadata({ | ||
| title: `Two-factor challenge for ${process.env.NEXT_PUBLIC_APP_NAME}`, | ||
| }); | ||
|
|
||
| export default function TwoFactorChallengePage() { | ||
| const cookie = cookies().get(TWO_FA_COOKIE_NAME); | ||
|
|
||
| if (!cookie) { | ||
| redirect("/login"); | ||
| } | ||
|
|
||
| return ( | ||
| <AuthLayout> | ||
| <div className="w-full max-w-sm"> | ||
| <h3 className="text-center text-xl font-semibold"> | ||
| Two-factor authentication | ||
| </h3> | ||
|
|
||
| <div className="mt-8"> | ||
| <TwoFactorChallengeForm /> | ||
| </div> | ||
| </div> | ||
| </AuthLayout> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,30 +2,39 @@ | |
|
|
||
| import useUser from "@/lib/swr/use-user"; | ||
| import { RequestSetPassword } from "./request-set-password"; | ||
| import { TwoFactorAuth } from "./two-factor-auth"; | ||
| import { UpdatePassword } from "./update-password"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export default function SecurityPageClient() { | ||
| const { loading, user } = useUser(); | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className="rounded-lg border border-neutral-200 bg-white"> | ||
| <div className="flex flex-col gap-3 border-b border-neutral-200 p-5 sm:p-10"> | ||
| <h2 className="text-xl font-medium">Password</h2> | ||
| <div className="h-3 w-56 rounded-full bg-neutral-100"></div> | ||
| </div> | ||
| <div className="p-5 sm:p-10"> | ||
| <div className="flex justify-between gap-2"> | ||
| <div className="h-3 w-56 rounded-full bg-neutral-100"></div> | ||
| <div className="h-3 w-56 rounded-full bg-neutral-100"></div> | ||
| </div> | ||
| <div className="mt-5 h-3 rounded-full bg-neutral-100"></div> | ||
| </div> | ||
| // if (loading) { | ||
| // return ( | ||
| // <div className="rounded-lg border border-neutral-200 bg-white"> | ||
| // <div className="flex flex-col gap-3 border-b border-neutral-200 p-5 sm:p-10"> | ||
| // <h2 className="text-xl font-medium">Password</h2> | ||
| // <div className="h-3 w-56 rounded-full bg-neutral-100"></div> | ||
| // </div> | ||
| // <div className="p-5 sm:p-10"> | ||
| // <div className="flex justify-between gap-2"> | ||
| // <div className="h-3 w-56 rounded-full bg-neutral-100"></div> | ||
| // <div className="h-3 w-56 rounded-full bg-neutral-100"></div> | ||
| // </div> | ||
| // <div className="mt-5 h-3 rounded-full bg-neutral-100"></div> | ||
| // </div> | ||
| // </div> | ||
| // ); | ||
| // } | ||
|
Comment on lines
+13
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Consider the implications of removing loading state. Commenting out the loading state might cause the page to render before user data is available, potentially leading to:
Consider keeping a minimal loading state or ensure child components handle their own loading states properly. 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-10"> | ||
| <div> | ||
| {user?.hasPassword ? <UpdatePassword /> : <RequestSetPassword />} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return <>{user?.hasPassword ? <UpdatePassword /> : <RequestSetPassword />}</>; | ||
| <TwoFactorAuth /> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| "use client"; | ||
|
|
||
| import { enableTwoFactorAuthAction } from "@/lib/actions/auth/enable-two-factor-auth"; | ||
| import useUser from "@/lib/swr/use-user"; | ||
| import { useDisableTwoFactorAuthModal } from "@/ui/modals/disable-two-factor-auth-modal"; | ||
| import { useEnableTwoFactorAuthModal } from "@/ui/modals/enable-two-factor-auth-modal"; | ||
| import { Button } from "@dub/ui"; | ||
| import { useAction } from "next-safe-action/hooks"; | ||
| import { useState } from "react"; | ||
| import { toast } from "sonner"; | ||
|
|
||
| export const TwoFactorAuth = () => { | ||
| const { user, loading, mutate } = useUser(); | ||
| const [secret, setSecret] = useState(""); | ||
| const [qrCodeUrl, setQrCodeUrl] = useState(""); | ||
|
|
||
| const { EnableTwoFactorAuthModal, setShowEnableTwoFactorAuthModal } = | ||
| useEnableTwoFactorAuthModal({ | ||
| secret, | ||
| qrCodeUrl, | ||
| onSuccess: () => { | ||
| setSecret(""); | ||
| setQrCodeUrl(""); | ||
| mutate(); | ||
| }, | ||
| }); | ||
|
|
||
| const { DisableTwoFactorAuthModal, setShowDisableTwoFactorAuthModal } = | ||
| useDisableTwoFactorAuthModal(); | ||
|
|
||
| const { executeAsync: enable2FA, isPending: isEnabling } = useAction( | ||
| enableTwoFactorAuthAction, | ||
| { | ||
| onSuccess: async ({ data }) => { | ||
| if (!data) { | ||
| toast.error("Failed to enable 2FA. Please try again."); | ||
| return; | ||
| } | ||
|
|
||
| setSecret(data.secret); | ||
| setQrCodeUrl(data.qrCodeUrl); | ||
| setShowEnableTwoFactorAuthModal(true); | ||
| }, | ||
| onError({ error }) { | ||
| toast.error(error.serverError); | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| return ( | ||
| <> | ||
| <EnableTwoFactorAuthModal /> | ||
| <DisableTwoFactorAuthModal /> | ||
| <div className="rounded-lg border border-neutral-200 bg-white"> | ||
| <div className="flex flex-col gap-3 border-b border-neutral-200 p-5 sm:p-10"> | ||
| <h2 className="text-xl font-medium">Two-factor Authentication</h2> | ||
| <p className="pb-2 text-sm text-neutral-500"> | ||
| Once two-factor is enabled you will have to provide two methods of | ||
| authentication in order to sign in into your account. | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="flex flex-wrap justify-between gap-4 px-5 py-4 sm:px-10"> | ||
| <div className="flex w-full items-center justify-between rounded-lg border border-neutral-200 bg-white p-5"> | ||
| <div> | ||
| <div className="font-semibold text-neutral-900"> | ||
| Authenticator App (TOTP) | ||
| </div> | ||
| <div className="text-sm text-neutral-500"> | ||
| Generate codes using an app like Google Authenticator or Okta | ||
| Verify. | ||
| </div> | ||
| </div> | ||
|
|
||
| <Button | ||
| text={ | ||
| loading | ||
| ? "Loading..." | ||
| : user?.twoFactorConfirmedAt | ||
| ? "Disable Two-factor" | ||
| : "Enable Two-factor" | ||
| } | ||
| variant={user?.twoFactorConfirmedAt ? "danger" : "primary"} | ||
| type="button" | ||
| className="ml-4 w-fit" | ||
| loading={isEnabling} | ||
| disabled={loading} | ||
| onClick={async () => { | ||
| if (user?.twoFactorConfirmedAt) { | ||
| setShowDisableTwoFactorAuthModal(true); | ||
| } else { | ||
| await enable2FA(); | ||
| } | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,82 @@ | ||||||||||
| "use server"; | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
|
||||||||||
| import { getTOTPInstance } from "@/lib/auth/totp"; | ||||||||||
| import { ratelimit } from "@/lib/upstash/ratelimit"; | ||||||||||
| import { sendEmail } from "@dub/email"; | ||||||||||
| import TwoFactorEnabled from "@dub/email/templates/two-factor-enabled"; | ||||||||||
| import { prisma } from "@dub/prisma"; | ||||||||||
| import { waitUntil } from "@vercel/functions"; | ||||||||||
| import { z } from "zod"; | ||||||||||
| import { authUserActionClient } from "../safe-action"; | ||||||||||
|
|
||||||||||
| const schema = z.object({ | ||||||||||
| token: z.string().length(6, "Code must be 6 digits"), | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| // Confirm 2FA for an user | ||||||||||
| export const confirmTwoFactorAuthAction = authUserActionClient | ||||||||||
| .schema(schema) | ||||||||||
| .action(async ({ ctx, parsedInput }) => { | ||||||||||
| const { token } = parsedInput; | ||||||||||
| const { user } = ctx; | ||||||||||
|
|
||||||||||
| const currentUser = await prisma.user.findUniqueOrThrow({ | ||||||||||
| where: { | ||||||||||
| id: user.id, | ||||||||||
| }, | ||||||||||
| select: { | ||||||||||
| twoFactorSecret: true, | ||||||||||
| twoFactorConfirmedAt: true, | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| if (currentUser.twoFactorConfirmedAt) { | ||||||||||
| throw new Error("2FA is already enabled for your account."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (!currentUser.twoFactorSecret) { | ||||||||||
| throw new Error("No 2FA secret found. Please try enabling 2FA again."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const { success } = await ratelimit(5, "1 h").limit( | ||||||||||
| `2fa-confirm:${user.id}`, | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| if (!success) { | ||||||||||
| throw new Error("Too many 2FA attempts. Please try again later."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const totp = getTOTPInstance({ | ||||||||||
| secret: currentUser.twoFactorSecret, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| const delta = totp.validate({ | ||||||||||
| token, | ||||||||||
| window: 1, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| // If delta is null, the token is invalid | ||||||||||
| // If delta is a number, the token is valid (0 = current step, 1 = next step, -1 = previous step) | ||||||||||
| if (delta === null) { | ||||||||||
| throw new Error("Invalid 2FA code entered. Please try again."); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Update the user's record to confirm 2FA is enabled | ||||||||||
| await prisma.user.update({ | ||||||||||
| where: { | ||||||||||
| id: user.id, | ||||||||||
| }, | ||||||||||
| data: { | ||||||||||
| twoFactorConfirmedAt: new Date(), | ||||||||||
| }, | ||||||||||
| }); | ||||||||||
|
|
||||||||||
| waitUntil( | ||||||||||
| sendEmail({ | ||||||||||
| subject: "Two Factor authentication enabled", | ||||||||||
| email: user.email, | ||||||||||
| react: TwoFactorEnabled({ email: user.email }), | ||||||||||
| variant: "notifications", | ||||||||||
| }), | ||||||||||
| ); | ||||||||||
| }); | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| "use server"; | ||
|
|
||
| import { sendEmail } from "@dub/email"; | ||
| import TwoFactorDisabled from "@dub/email/templates/two-factor-disabled"; | ||
| import { prisma } from "@dub/prisma"; | ||
| import { waitUntil } from "@vercel/functions"; | ||
| import { authUserActionClient } from "../safe-action"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2 authentication factor |
||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
![Uploading organization-members.csv …]()
 => { | ||
| const { user } = ctx; | ||
|
|
||
| const currentUser = await prisma.user.findUniqueOrThrow({ | ||
| where: { | ||
| id: user.id, | ||
| }, | ||
| }); | ||
|
|
||
| if (!currentUser.twoFactorConfirmedAt) { | ||
| throw new Error("2FA is not enabled for your account."); | ||
| } | ||
|
|
||
| await prisma.user.update({ | ||
| where: { | ||
| id: user.id, | ||
| }, | ||
| data: { | ||
| twoFactorSecret: null, | ||
| twoFactorConfirmedAt: null, | ||
| twoFactorRecoveryCodes: null, | ||
| }, | ||
| }); | ||
|
|
||
| waitUntil( | ||
| sendEmail({ | ||
| subject: "Two Factor authentication disabled", | ||
| email: user.email, | ||
| react: TwoFactorDisabled({ email: user.email }), | ||
| variant: "notifications", | ||
| }), | ||
| ); | ||
| }, | ||
| ); | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
access my acooumt in to github log in