Skip to content

Commit

Permalink
Reapply "passkeys!"
Browse files Browse the repository at this point in the history
This reverts commit 9b1e4db.
  • Loading branch information
RiskyMH committed May 8, 2024
1 parent 9b1e4db commit b503248
Show file tree
Hide file tree
Showing 13 changed files with 450 additions and 21 deletions.
77 changes: 76 additions & 1 deletion app/(auth)/login/action.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use server";
import { db, MailboxForUser, User, ResetPasswordToken } from "@/db";
import { db, MailboxForUser, User, ResetPasswordToken, PasskeyCredentials } from "@/db";
import { env } from "@/utils/env";
import { addUserTokenToCookie } from "@/utils/jwt"
import { verifyCredentials, verifyCredentialss } from "@/utils/passkeys";
import { createPasswordHash, verifyPassword } from "@/utils/password";
import { userAuthSchema } from "@/validations/auth"
import { createId } from "@paralleldrive/cuid2";
Expand Down Expand Up @@ -84,6 +85,80 @@ export default async function signIn(data: FormData, callback?: string | null):
}
}

export async function signInPasskey(credential: Credential, callback?: string | null): Promise<{ error?: string | null }> {
console.log(credential)
if (!callback) {
const referer = headers().get("referer")
if (referer) {
callback = new URL(referer).searchParams?.get("from")
} else {
const mailboxId = cookies().get("mailboxId")
if (mailboxId) {
callback = `/mail/${mailboxId.value}`
}
}
}

const cred = await db.query.PasskeyCredentials.findFirst({
where: eq(PasskeyCredentials.credentialId, credential.id)
});
if (cred == null) {
return { error: "Passkey not found" };
}

let verification;

try {
verification = await verifyCredentialss("login", credential, cred);
} catch (error) {
console.error(error);
return { error: "Failed to verify passkey :(" }
}

console.log(verification)
if (!verification.userVerified) {
return { error: "Failed to verify passkey" }
}

// find user
const user = await db.query.User.findFirst({
where: eq(User.id, cred.userId),
columns: {
id: true,
password: true,
}
})

if (!user) {
return { error: "Can't find user" }
}

await addUserTokenToCookie(user)

if (callback) {
redirect(callback)
}

// get the user's mailbox then redirect to it
const mailboxes = await db.query.MailboxForUser.findMany({
where: eq(MailboxForUser.userId, user.id),
columns: {
mailboxId: true,
}
})

const possibleMailbox = cookies().get("mailboxId")?.value
if (possibleMailbox && mailboxes.some(({ mailboxId }) => mailboxId === possibleMailbox)) {
redirect(`/mail/${possibleMailbox}`)
} else {
cookies().set("mailboxId", mailboxes[0].mailboxId, {
path: "/",
expires: new Date("2038-01-19 04:14:07")
});
redirect(`/mail/${mailboxes[0].mailboxId}`)
}
}

export async function resetPassword(username: string) {
const user = await db.query.User.findFirst({
where: eq(User.username, username),
Expand Down
27 changes: 20 additions & 7 deletions app/(auth)/login/form.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,36 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
import { KeyRoundIcon, Loader2, Loader2Icon } from "lucide-react"
import signIn, { resetPassword } from "./action"
import { FormEvent, useState, useTransition } from "react"
import Link from "next/link"
import { SmartDrawer, SmartDrawerClose, SmartDrawerContent, SmartDrawerDescription, SmartDrawerFooter, SmartDrawerHeader, SmartDrawerTitle, SmartDrawerTrigger } from "@/components/ui/smart-drawer"
import PasskeysLogin from "./passkeys.client"

interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> { }


export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const [isPending, startTransition] = useTransition();
const [hadAnError, setHadAnError] = useState<false | string>(false)
const [loading, setLoading] = useState(false)

async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
// @ts-ignore - the types seem to be wrong with async
setLoading(true)
startTransition(async () => {
const formData = new FormData(event.target as HTMLFormElement)
const signInResult = await signIn(formData)
setLoading(false)

if (signInResult?.error) {
// @ts-expect-error yay types
setHadAnError(event.target?.username?.value ?? "unknown")
return toast.error(signInResult.error)
return void toast.error(signInResult.error)
}

return toast.success("Welcome back!")
return void toast.success("Welcome back!")
});
}

Expand Down Expand Up @@ -73,15 +76,25 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
</div>

<Button disabled={isPending} type="submit">
{isPending && (
{loading && (
<Loader2 className="me-2 h-4 w-4 animate-spin" />
)}
Sign In
</Button>
</div>
</form>

</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t-2" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<PasskeysLogin transition={[isPending, startTransition]} />
<p className="px-8 text-center text-sm text-muted-foreground flex flex-col gap-2">
<Link
href="/register"
Expand Down Expand Up @@ -128,7 +141,7 @@ function ResetPasswordDiag({ username }: { username: string }) {
</SmartDrawerDescription>
</SmartDrawerHeader>
<SmartDrawerFooter className="pt-2 flex">
<SmartDrawerClose className={buttonVariants({variant: "secondary"})}>Close</SmartDrawerClose>
<SmartDrawerClose className={buttonVariants({ variant: "secondary" })}>Close</SmartDrawerClose>
<Button onClick={resetPasswordAction} disabled={isPending} className="gap-2">
{isPending && (
<Loader2 className="me-2 h-4 w-4 animate-spin" />
Expand Down
84 changes: 84 additions & 0 deletions app/(auth)/login/passkeys.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client'

import { useEffect, useState, type TransitionStartFunction } from "react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/utils/tw";
import { Loader2Icon, KeyRoundIcon } from "lucide-react";
import { toast } from "sonner";
import { signInPasskey } from "./action";
import { get, parseRequestOptionsFromJSON, supported } from "@github/webauthn-json/browser-ponyfill";

export default function PasskeysLogin({ transition, challenge = "login" }: { transition: [boolean, TransitionStartFunction], challenge?: string }) {
const [isPending, startTransition] = transition
const [support, setSupport] = useState(false);
useEffect(() => {
setSupport(supported());
}, []);

const [loading, setLoading] = useState(false)

const handleLogin = async (event: any) => {
event.preventDefault();
setLoading(true)
startTransition(async () => {
try {
const credential = await get(
parseRequestOptionsFromJSON({
publicKey: {
challenge: Buffer.from(challenge).toString('base64'),
timeout: 60000,
userVerification: "required",
rpId: window.location.hostname,
}
}),
);
// const credential = await navigator.credentials.get({
// publicKey: {
// challenge: Buffer.from(challenge),
// timeout: 60000,
// userVerification: "required",
// rpId: window.location.hostname,
// },
// });

if (!credential) {
setLoading(false)
return void toast.error("No passkey")
}


const signInResult = await signInPasskey(credential)

if (signInResult?.error) {
setLoading(false)
return void toast.error(signInResult.error)
}

setLoading(false)
return void toast.success("Welcome back!")
} catch (err) {
console.error(err)
setLoading(false)
return void toast.error("Failed to sign in with passkey")
}
})
}

return (
<button
type="button"
className={cn(buttonVariants({ variant: "secondary", className: "gap-2" }))}
onClick={handleLogin}
disabled={isPending || !support}
>
{loading ? (
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRoundIcon className="mr-2 h-4 w-4" />
)}
Passkey
</button>

)

}
5 changes: 2 additions & 3 deletions app/(auth)/login/reset/form.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ export function UserAuthForm({ className, username, token, ...props }: UserAuthF
const [isPending, startTransition] = useTransition();

async function onSubmit(data: FormData) {
// @ts-ignore - the types seem to be wrong with async
startTransition(async () => {
const signInResult = await resetPasswordWithToken(token, data.get("password") as string)

if (signInResult?.error) {
return toast.error(signInResult.error)
return void toast.error(signInResult.error)
}

return toast.success("Now login with your new password!")
return void toast.success("Now login with your new password!")
});
}

Expand Down
5 changes: 2 additions & 3 deletions app/(auth)/onboarding/welcome/components.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ export function Page({ githubStars, action }: any) {
}, [])

async function actionn(data: FormData) {
// @ts-ignore - the types seem to be wrong with async
startTransition(async () => {
const res = await action(data.get("email") as string, true)

if (res?.error) {
return toast.error(res.error)
return void toast.error(res.error)
}

return toast.success("Your backup email has been saved.")
return void toast.success("Your backup email has been saved.")
})
}

Expand Down
5 changes: 2 additions & 3 deletions app/(auth)/register/form.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,15 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {

async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
// @ts-ignore - the types seem to be wrong with async
startTransition(async () => {
const formData = new FormData(event.target as HTMLFormElement)
const signUpResult = await signUp(formData)

if (signUpResult?.error) {
return toast.error(signUpResult.error)
return void toast.error(signUpResult.error)
}

return toast.success("Welcome!")
return void toast.success("Welcome!")
});
}

Expand Down
39 changes: 38 additions & 1 deletion app/(auth)/settings/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import { getCurrentUser } from "@/utils/jwt"
import { createPasswordHash, verifyPassword } from "@/utils/password"
import { db, User, UserNotification } from "@/db";
import { db, PasskeyCredentials, User, UserNotification } from "@/db";
import { and, eq, not } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache"
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { sendNotification } from "@/utils/web-push";
import { userAuthSchema } from "@/validations/auth"
import { env } from "@/utils/env";
import { verifyCredentials } from "@/utils/passkeys";


export async function changeUsername(username: string) {
Expand Down Expand Up @@ -194,3 +195,39 @@ If you did not expect this email or have any questions, please contact us at con

if (redirectHome) redirect("/mail")
}


export async function addPasskey(creds: Credential, name: string) {
const userId = await getCurrentUser()
if (!userId) return
try {
const { credentialID, publicKey } = await verifyCredentials(userId, creds);

await db.insert(PasskeyCredentials)
.values({
userId,
name,
publicKey: publicKey,
credentialId: credentialID
})
} catch (err) {
console.error(err)
return { error: "Failed to verify passkey" }
}

revalidatePath("/settings")
}

export async function deletePasskey(passkeyId: string) {
const userId = await getCurrentUser()
if (!userId) return

await db.delete(PasskeyCredentials)
.where(and(
eq(PasskeyCredentials.id, passkeyId),
eq(PasskeyCredentials.userId, userId)
))
.execute()

revalidatePath("/settings")
}

0 comments on commit b503248

Please sign in to comment.