mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
verify unverified webauthn key
This commit is contained in:
1 parent
e9990705e1
commit
dd8a12a00a
7 files changed
+185
-32
No files matched your search
@@ -59,7 +59,10 @@ export const AddNewWebAuthnModal = ({ visible, onClose }: AddNewWebAuthnModalPro
|
||||
confirmLabelLoading="Registering..."
|
||||
disabled={name.length === 0 || isSuccess}
|
||||
loading={isVerifying}
|
||||
onCancel={reset}
|
||||
onCancel={() => {
|
||||
reset()
|
||||
onClose()
|
||||
}}
|
||||
onConfirm={() =>
|
||||
register({
|
||||
friendlyName: name,
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useState } from 'react'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { DATETIME_FORMAT } from 'lib/constants'
|
||||
import { Button, CardContent, CardFooter } from 'ui'
|
||||
import { Badge, Button, CardContent, CardFooter } from 'ui'
|
||||
import { AddNewWebAuthnModal } from './AddNewWebAuthnModal'
|
||||
import DeleteWebAuthnModal from './DeleteWebAuthnModal'
|
||||
import { toast } from 'sonner'
|
||||
import { useMfaWebAuthnChallengeAndVerifyMutation } from 'data/profile/mfa-webauthn-challenge-and-verify-mutation'
|
||||
import type { AuthMFAListFactorsResponse } from '@supabase/auth-js'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
const WebAuthnFactors = ({
|
||||
data,
|
||||
@@ -25,7 +28,27 @@ const WebAuthnFactors = ({
|
||||
const [isAddNewWebAuthnOpen, setIsAddNewWebAuthnOpen] = useState(false)
|
||||
const [factorToBeDeleted, setFactorToBeDeleted] = useState<string | null>(null)
|
||||
|
||||
const webauthnFactors = data?.webauthn ?? []
|
||||
const webauthnFactors = data?.all.filter((factor) => factor.factor_type === 'webauthn') ?? []
|
||||
|
||||
const {
|
||||
mutate: mfaWebAuthnChallengeAndVerify,
|
||||
isLoading: isWebAuthnVerifying,
|
||||
isSuccess: isWebAuthnSuccess,
|
||||
} = useMfaWebAuthnChallengeAndVerifyMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully added a second factor authentication`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to verify factor: ${error?.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleVerifyFactor = async (factorId: string) => {
|
||||
mfaWebAuthnChallengeAndVerify({
|
||||
factorId: factorId,
|
||||
webauthn: { rpId: window.location.hostname, rpOrigins: [window.location.origin] },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,18 +73,30 @@ const WebAuthnFactors = ({
|
||||
<p className="text-sm text-foreground flex items-center space-x-2">
|
||||
<span className="text-foreground-light">Name:</span>{' '}
|
||||
<span>{factor.friendly_name ?? 'No name provided'}</span>
|
||||
{factor.status === 'unverified' && <Badge>Unverified</Badge>}
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Added on {dayjs(factor.updated_at).format(DATETIME_FORMAT)}
|
||||
</p>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={() => setFactorToBeDeleted(factor.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{factor.status === 'unverified' && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="primary"
|
||||
onClick={() => handleVerifyFactor(factor.id)}
|
||||
>
|
||||
{isWebAuthnVerifying ? <Loader2 className="animate-spin" /> : 'Verify'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={() => setFactorToBeDeleted(factor.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -293,7 +293,12 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Link href="/logout">Force sign out and clear cookies</Link>
|
||||
<Link
|
||||
href="/logout"
|
||||
className="text-sm transition text-foreground-light hover:text-foreground"
|
||||
>
|
||||
Force sign out and clear cookies
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
|
||||
@@ -42,7 +42,7 @@ export const useMfaChallengeAndVerifyMutation = ({
|
||||
const refreshFactors = variables.refreshFactors ?? true
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(profileKeys.mfaFactors()),
|
||||
...(refreshFactors ? [queryClient.invalidateQueries(profileKeys.mfaFactors())] : []),
|
||||
queryClient.invalidateQueries(profileKeys.aaLevel()),
|
||||
])
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { auth } from 'lib/gotrue'
|
||||
import { toast } from 'sonner'
|
||||
import { profileKeys } from './keys'
|
||||
|
||||
interface MFAWebAuthnChallengeAndVerifyVariables extends MFAUnenrollParams {
|
||||
refreshFactors?: boolean
|
||||
}
|
||||
|
||||
const mfaUnenroll = async (params: MFAUnenrollParams) => {
|
||||
const { error, data } = await auth.mfa.unenroll(params)
|
||||
|
||||
@@ -19,28 +23,40 @@ export const useMfaUnenrollMutation = ({
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<CustomMFAUnenrollResponse, CustomMFAUnenrollError, MFAUnenrollParams>,
|
||||
UseMutationOptions<
|
||||
CustomMFAUnenrollResponse,
|
||||
CustomMFAUnenrollError,
|
||||
MFAWebAuthnChallengeAndVerifyVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation((vars) => mfaUnenroll(vars), {
|
||||
async onSuccess(data, variables, context) {
|
||||
// when a factor is unenrolled, the aaLevel is bumped down if it's the last factor
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries(profileKeys.mfaFactors()),
|
||||
queryClient.invalidateQueries(profileKeys.aaLevel()),
|
||||
])
|
||||
return useMutation(
|
||||
(vars) => {
|
||||
const { refreshFactors, ...params } = vars
|
||||
return mfaUnenroll(params)
|
||||
},
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
// when a factor is unenrolled, the aaLevel is bumped down if it's the last factor
|
||||
const refreshFactors = variables.refreshFactors ?? true
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to delete factor: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
await Promise.all([
|
||||
...(refreshFactors ? [queryClient.invalidateQueries(profileKeys.mfaFactors())] : []),
|
||||
queryClient.invalidateQueries(profileKeys.aaLevel()),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to delete factor: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import type { AuthMFAVerifyResponse, MFAChallengeWebauthnParams } from '@supabase/auth-js'
|
||||
import { UseMutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { auth } from 'lib/gotrue'
|
||||
import { profileKeys } from './keys'
|
||||
|
||||
const WHITELIST_ERRORS = ['Invalid TOTP code entered']
|
||||
|
||||
interface MFAWebAuthnChallengeAndVerifyVariables extends MFAChallengeWebauthnParams {
|
||||
refreshFactors?: boolean
|
||||
}
|
||||
|
||||
export const mfaWebAuthnChallengeAndVerify = async (params: MFAChallengeWebauthnParams) => {
|
||||
const challenge = await auth.mfa.webauthn.challenge(params)
|
||||
|
||||
if (challenge.error) {
|
||||
throw challenge.error
|
||||
}
|
||||
|
||||
const verify = await auth.mfa.webauthn.verify({
|
||||
factorId: challenge.data.factorId,
|
||||
challengeId: challenge.data.challengeId,
|
||||
webauthn: {
|
||||
rpId: window.location.hostname,
|
||||
rpOrigins: [window.location.origin],
|
||||
type: challenge.data.webauthn.type,
|
||||
credential_response: challenge.data.webauthn.credential_response,
|
||||
},
|
||||
})
|
||||
|
||||
if (verify.error) {
|
||||
throw verify.error
|
||||
}
|
||||
|
||||
return verify.data
|
||||
}
|
||||
|
||||
type CustomMFAVerifyResponse = NonNullable<AuthMFAVerifyResponse['data']>
|
||||
type CustomMFAVerifyError = NonNullable<AuthMFAVerifyResponse['error']>
|
||||
|
||||
export const useMfaWebAuthnChallengeAndVerifyMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseMutationOptions<
|
||||
CustomMFAVerifyResponse,
|
||||
CustomMFAVerifyError,
|
||||
MFAWebAuthnChallengeAndVerifyVariables
|
||||
>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(
|
||||
(vars) => {
|
||||
const { refreshFactors, ...params } = vars
|
||||
return mfaWebAuthnChallengeAndVerify(params)
|
||||
},
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
// when a MFA is added, the aaLevel is bumped up
|
||||
const refreshFactors = variables.refreshFactors ?? true
|
||||
|
||||
await Promise.all([
|
||||
...(refreshFactors ? [queryClient.invalidateQueries(profileKeys.mfaFactors())] : []),
|
||||
queryClient.invalidateQueries(profileKeys.aaLevel()),
|
||||
])
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
toast.error(`Failed to sign in: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
if (!WHITELIST_ERRORS.some((error) => data.message.includes(error))) {
|
||||
Sentry.captureMessage('[CRITICAL] Failed to sign in via MFA: ' + data.message)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { AlertCircle, ChevronRightIcon, Fingerprint, Smartphone } from 'lucide-react'
|
||||
import { TOTPFactors, WebAuthnFactors } from 'components/interfaces/Account'
|
||||
import AccountLayout from 'components/layouts/AccountLayout/AccountLayout'
|
||||
import AppLayout from 'components/layouts/AppLayout/AppLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -25,6 +25,13 @@ import type { NextPageWithLayout } from 'types'
|
||||
import { useState } from 'react'
|
||||
import { useFlag } from 'common'
|
||||
|
||||
const TOTPFactors = dynamic(() =>
|
||||
import('components/interfaces/Account').then((mod) => mod.TOTPFactors)
|
||||
)
|
||||
const WebAuthnFactors = dynamic(() =>
|
||||
import('components/interfaces/Account').then((mod) => mod.WebAuthnFactors)
|
||||
)
|
||||
|
||||
const collapsibleClasses = [
|
||||
'bg-surface-100',
|
||||
'hover:bg-surface-200',
|
||||
|
||||
Reference in new issue
Block a user