From ff9a65e8faa46a101d3ed3dc8915dd1f269ef556 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 8 Sep 2023 14:05:03 +0900
Subject: feat: passkey support (#11804)

https://github.com/MisskeyIO/misskey/pull/149
---
 packages/frontend/src/components/MkSignin.vue | 113 ++++++++++++--------------
 1 file changed, 50 insertions(+), 63 deletions(-)

(limited to 'packages/frontend/src/components/MkSignin.vue')

diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 19f418b48d..247fcb4b29 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 <template>
 <form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
 	<div class="_gaps_m">
-		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null, marginBottom: message ? '1.5em' : null }"></div>
+		<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
 		<MkInfo v-if="message">
 			{{ message }}
 		</MkInfo>
 		<div v-if="!totpLogin" class="normal-signin _gaps_m">
-			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
+			<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
 				<template #prefix>@</template>
 				<template #suffix>@{{ host }}</template>
 			</MkInput>
-			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password" :withPasswordToggle="true" required data-cy-signin-password>
+			<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required data-cy-signin-password>
 				<template #prefix><i class="ti ti-lock"></i></template>
 				<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
 			</MkInput>
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 		</div>
 		<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
 			<div v-if="user && user.securityKeys" class="twofa-group tap-group">
-				<p>{{ i18n.ts.tapSecurityKey }}</p>
+				<p>{{ i18n.ts.useSecurityKey }}</p>
 				<MkButton v-if="!queryingKey" @click="queryKey">
 					{{ i18n.ts.retry }}
 				</MkButton>
@@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<p class="or-msg">{{ i18n.ts.or }}</p>
 			</div>
 			<div class="twofa-group totp-group">
-				<p style="margin-bottom:0;">{{ i18n.ts.twoStepAuthentication }}</p>
+				<p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
 				<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
 					<template #label>{{ i18n.ts.password }}</template>
 					<template #prefix><i class="ti ti-lock"></i></template>
@@ -51,32 +51,29 @@ SPDX-License-Identifier: AGPL-3.0-only
 <script lang="ts" setup>
 import { defineAsyncComponent } from 'vue';
 import { toUnicode } from 'punycode/';
-import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
+import { UserDetailed } from 'misskey-js/built/entities';
+import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
+import { showSuspendedDialog } from '@/scripts/show-suspended-dialog';
 import MkButton from '@/components/MkButton.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkInfo from '@/components/MkInfo.vue';
 import { host as configHost } from '@/config';
-import { byteify, hexify } from '@/scripts/2fa';
 import * as os from '@/os';
 import { login } from '@/account';
-import { instance } from '@/instance';
 import { i18n } from '@/i18n';
 
 let signing = $ref(false);
-let user = $ref(null);
+let user = $ref<UserDetailed | null>(null);
 let username = $ref('');
 let password = $ref('');
 let token = $ref('');
 let host = $ref(toUnicode(configHost));
 let totpLogin = $ref(false);
-let credential = $ref(null);
-let challengeData = $ref(null);
 let queryingKey = $ref(false);
+let credentialRequest = $ref<CredentialRequestOptions | null>(null);
 let hCaptchaResponse = $ref(null);
 let reCaptchaResponse = $ref(null);
 
-const meta = $computed(() => instance);
-
 const emit = defineEmits<{
 	(ev: 'login', v: any): void;
 }>();
@@ -99,7 +96,7 @@ const props = defineProps({
 	},
 });
 
-function onUsernameChange() {
+function onUsernameChange(): void {
 	os.api('users/show', {
 		username: username,
 	}).then(userResponse => {
@@ -109,58 +106,46 @@ function onUsernameChange() {
 	});
 }
 
-function onLogin(res) {
+function onLogin(res: any): Promise<void> | void {
 	if (props.autoSet) {
 		return login(res.i);
 	}
 }
 
-function queryKey() {
+async function queryKey(): Promise<void> {
 	queryingKey = true;
-	return navigator.credentials.get({
-		publicKey: {
-			challenge: byteify(challengeData.challenge, 'base64'),
-			allowCredentials: challengeData.securityKeys.map(key => ({
-				id: byteify(key.id, 'hex'),
-				type: 'public-key',
-				transports: ['usb', 'nfc', 'ble', 'internal'],
-			})),
-			timeout: 60 * 1000,
-		},
-	}).catch(() => {
-		queryingKey = false;
-		return Promise.reject(null);
-	}).then(credential => {
-		queryingKey = false;
-		signing = true;
-		return os.api('signin', {
-			username,
-			password,
-			signature: hexify(credential.response.signature),
-			authenticatorData: hexify(credential.response.authenticatorData),
-			clientDataJSON: hexify(credential.response.clientDataJSON),
-			credentialId: credential.id,
-			challengeId: challengeData.challengeId,
-			'hcaptcha-response': hCaptchaResponse,
-			'g-recaptcha-response': reCaptchaResponse,
-		});
-	}).then(res => {
-		emit('login', res);
-		return onLogin(res);
-	}).catch(err => {
-		if (err === null) return;
-		os.alert({
-			type: 'error',
-			text: i18n.ts.signinFailed,
+	await webAuthnRequest(credentialRequest)
+		.catch(() => {
+			queryingKey = false;
+			return Promise.reject(null);
+		}).then(credential => {
+			credentialRequest = null;
+			queryingKey = false;
+			signing = true;
+			return os.api('signin', {
+				username,
+				password,
+				credential: credential.toJSON(),
+				'hcaptcha-response': hCaptchaResponse,
+				'g-recaptcha-response': reCaptchaResponse,
+			});
+		}).then(res => {
+			emit('login', res);
+			return onLogin(res);
+		}).catch(err => {
+			if (err === null) return;
+			os.alert({
+				type: 'error',
+				text: i18n.ts.signinFailed,
+			});
+			signing = false;
 		});
-		signing = false;
-	});
 }
 
-function onSubmit() {
+function onSubmit(): void {
 	signing = true;
 	if (!totpLogin && user && user.twoFactorEnabled) {
-		if (window.PublicKeyCredential && user.securityKeys) {
+		if (webAuthnSupported() && user.securityKeys) {
 			os.api('signin', {
 				username,
 				password,
@@ -169,9 +154,12 @@ function onSubmit() {
 			}).then(res => {
 				totpLogin = true;
 				signing = false;
-				challengeData = res;
-				return queryKey();
-			}).catch(loginFailed);
+				credentialRequest = parseRequestOptionsFromJSON({
+					publicKey: res,
+				});
+			})
+				.then(() => queryKey())
+				.catch(loginFailed);
 		} else {
 			totpLogin = true;
 			signing = false;
@@ -182,7 +170,7 @@ function onSubmit() {
 			password,
 			'hcaptcha-response': hCaptchaResponse,
 			'g-recaptcha-response': reCaptchaResponse,
-			token: user && user.twoFactorEnabled ? token : undefined,
+			token: user?.twoFactorEnabled ? token : undefined,
 		}).then(res => {
 			emit('login', res);
 			onLogin(res);
@@ -190,7 +178,7 @@ function onSubmit() {
 	}
 }
 
-function loginFailed(err) {
+function loginFailed(err: any): void {
 	switch (err.id) {
 		case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
 			os.alert({
@@ -221,7 +209,7 @@ function loginFailed(err) {
 			break;
 		}
 		default: {
-			console.log(err);
+			console.error(err);
 			os.alert({
 				type: 'error',
 				title: i18n.ts.loginFailed,
@@ -230,12 +218,11 @@ function loginFailed(err) {
 		}
 	}
 
-	challengeData = null;
 	totpLogin = false;
 	signing = false;
 }
 
-function resetPassword() {
+function resetPassword(): void {
 	os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
 	}, 'closed');
 }
-- 
cgit v1.2.3-freya

