From 62d102ad8a82ec5d2bfa0cf667e94ccd33f4efa8 Mon Sep 17 00:00:00 2001 From: Kentaro Mizuki <66548698+harsssh@users.noreply.github.com> Date: Sat, 14 Dec 2024 04:21:41 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=A4=E3=83=B3=E5=BE=8C?= =?UTF-8?q?=E3=81=AB=20passkey=20=E3=81=AE=E7=99=BB=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E4=BF=83=E3=81=99=20login=20form=20=E3=81=A7=E7=99=BB=E9=8C=B2?= =?UTF-8?q?=E3=80=81=E8=AA=8D=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/component/auth/LoginForm.tsx | 143 +++++++++++++++++++----- src/client/hook/usePasskey.ts | 81 ++++++++++++++ 2 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 src/client/hook/usePasskey.ts diff --git a/src/client/component/auth/LoginForm.tsx b/src/client/component/auth/LoginForm.tsx index 71e804e..4426775 100644 --- a/src/client/component/auth/LoginForm.tsx +++ b/src/client/component/auth/LoginForm.tsx @@ -1,13 +1,19 @@ +import usePasskey from '@/client/hook/usePasskey' import { hcWithType } from '@/server/client' -import { Button, PasswordInput, TextInput } from '@mantine/core' +import { Button, Divider, Group, PasswordInput, TextInput } from '@mantine/core' import { isNotEmpty, useForm } from '@mantine/form' import { notifications } from '@mantine/notifications' +import { browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser' +import { IconKeyFilled } from '@tabler/icons-react' import { useNavigate } from '@tanstack/react-router' -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' export default function LoginForm() { - const [loading, setLoading] = useState(false) + const [loginLoading, setLoginLoading] = useState(false) + const [passkeyLoading, setPasskeyLoading] = useState(false) const navigate = useNavigate() + const { registerPasskey, loginWithPasskey } = usePasskey() + const manualPasskeyLoginStartedRef = useRef(false) const form = useForm({ mode: 'controlled', @@ -23,8 +29,17 @@ export default function LoginForm() { }) const client = useMemo(() => hcWithType('/api'), []) + const onLoginSuccess = useCallback(async () => { + notifications.show({ + color: 'blue', + title: 'ログインしました', + message: 'トップページに遷移します', + }) + await navigate({ to: '/' }) + }, [navigate]) + const handleLogin = form.onSubmit(async (values) => { - setLoading(true) + setLoginLoading(true) const res = await client.auth.login.$post({ json: { @@ -39,39 +54,107 @@ export default function LoginForm() { message: 'ID またはパスワードが正しくありません', }) form.setFieldValue('password', '') - setLoading(false) + setLoginLoading(false) return } - notifications.show({ - color: 'blue', - title: 'ログインしました', - message: 'トップページに遷移します', - }) - // 遷移するまで loading は解除しない - await navigate({ to: '/' }) + // 下の処理はあえて待たない + onLoginSuccess() + + // 失敗してもログインには影響しないので、UI にエラーを通知したりしない + try { + await registerPasskey() + } catch (e) { + console.log(e) + } }) + const handlePasskeyLogin = async () => { + setPasskeyLoading(true) + manualPasskeyLoginStartedRef.current = true + + try { + await loginWithPasskey({ useBrowserAutofill: false }) + // 遷移するまで loading は解除しない + await onLoginSuccess() + } catch (e) { + notifications.show({ + color: 'red', + title: 'パスキーでのログインに失敗しました', + message: 'もう一度お試しください', + }) + setPasskeyLoading(false) + } + } + + useEffect(() => { + ;(async () => { + if (await browserSupportsWebAuthnAutofill()) { + try { + await loginWithPasskey({ useBrowserAutofill: true }) + await onLoginSuccess() + } catch (e) { + /** + * ボタンから手動でログインを始めるとエラーが発生する + * その場合はエラーを通知しない + * + * effect の再実行は不要なため、passkeyLoading を使った判定はできない + * 依存配列から除いても、render 前の値しか参照できない + */ + if (!manualPasskeyLoginStartedRef.current) { + notifications.show({ + color: 'red', + title: 'パスキーでのログインに失敗しました', + message: 'もう一度お試しください', + }) + } + } + } + })() + }, [onLoginSuccess, loginWithPasskey]) + return ( -
- - + <> + + + + + + + + - - + + + + ) } diff --git a/src/client/hook/usePasskey.ts b/src/client/hook/usePasskey.ts new file mode 100644 index 0000000..d8af8ef --- /dev/null +++ b/src/client/hook/usePasskey.ts @@ -0,0 +1,81 @@ +import { hcWithType } from '@/server/client' +import { startAuthentication, startRegistration } from '@simplewebauthn/browser' +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/types' +import { useCallback, useMemo } from 'react' + +export default function usePasskey() { + const client = useMemo(() => hcWithType('/api'), []) + + // refs: https://simplewebauthn.dev/docs/packages/browser#startregistration + const registerPasskey = useCallback(async () => { + const attestationRes = await client.auth.passkey.attestation.options.$get() + if (!attestationRes.ok) { + throw new Error('Failed to get attestation options') + } + const optionsJSON = await attestationRes.json() + + let registrationResponse: RegistrationResponseJSON + try { + registrationResponse = await startRegistration({ + optionsJSON, + useAutoRegister: true, + }) + } catch (e) { + throw new Error('Failed to start registration') + } + + const verificationRes = await client.auth.passkey.attestation.$post({ + json: registrationResponse, + }) + if (!verificationRes.ok) { + throw new Error('Failed to verify registration response') + } + const { verified } = await verificationRes.json() + + if (!verified) { + throw new Error('Failed to verify registration response') + } + }, [client]) + + // refs: https://simplewebauthn.dev/docs/packages/browser#startauthentication + const loginWithPasskey = useCallback( + async ({ useBrowserAutofill }: { useBrowserAutofill: boolean }) => { + const assertionRes = await client.auth.passkey.assertion.options.$get() + if (!assertionRes.ok) { + throw new Error('Failed to get assertion options') + } + const optionsJSON = await assertionRes.json() + + let assertionResponse: AuthenticationResponseJSON + try { + assertionResponse = await startAuthentication({ + optionsJSON, + useBrowserAutofill, + }) + } catch (e) { + throw new Error('Failed to start authentication') + } + + const verificationRes = await client.auth.passkey.assertion.$post({ + json: assertionResponse, + }) + if (!verificationRes.ok) { + throw new Error('Failed to verify assertion response') + } + const { verified } = await verificationRes.json() + + if (!verified) { + throw new Error('Failed to verify assertion response') + } + }, + [client], + ) + + return { + registerPasskey, + loginWithPasskey, + } +}