summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-09-08 14:05:03 +0900
committerGitHub <noreply@github.com>2023-09-08 14:05:03 +0900
commitff9a65e8faa46a101d3ed3dc8915dd1f269ef556 (patch)
treea6b1ae734e61da58b4205cd08a505ce392b317a9 /packages/frontend/src
parentUpdate CHANGELOG.md (diff)
downloadmisskey-ff9a65e8faa46a101d3ed3dc8915dd1f269ef556.tar.gz
misskey-ff9a65e8faa46a101d3ed3dc8915dd1f269ef556.tar.bz2
misskey-ff9a65e8faa46a101d3ed3dc8915dd1f269ef556.zip
feat: passkey support (#11804)
https://github.com/MisskeyIO/misskey/pull/149
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkSignin.vue113
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue51
-rw-r--r--packages/frontend/src/scripts/2fa.ts38
3 files changed, 64 insertions, 138 deletions
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');
}
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index 2f923fcae3..965fd1a500 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -38,16 +38,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.securityKeyAndPasskey }}</template>
<div class="_gaps_s">
<MkInfo>
- {{ i18n.ts._2fa.securityKeyInfo }}<br>
- <br>
- {{ i18n.ts._2fa.chromePasskeyNotSupported }}
+ {{ i18n.ts._2fa.securityKeyInfo }}
</MkInfo>
- <MkInfo v-if="!supportsCredentials" warn>
+ <MkInfo v-if="!webAuthnSupported()" warn>
{{ i18n.ts._2fa.securityKeyNotSupported }}
</MkInfo>
- <MkInfo v-else-if="supportsCredentials && !$i.twoFactorEnabled" warn>
+ <MkInfo v-else-if="webAuthnSupported() && !$i.twoFactorEnabled" warn>
{{ i18n.ts._2fa.registerTOTPBeforeKey }}
</MkInfo>
@@ -75,8 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, defineAsyncComponent } from 'vue';
-import { hostname } from '@/config';
-import { byteify, hexify, stringify } from '@/scripts/2fa';
+import { supported as webAuthnSupported, create as webAuthnCreate, parseCreationOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkSwitch from '@/components/MkSwitch.vue';
@@ -94,10 +91,9 @@ withDefaults(defineProps<{
first: false,
});
-const supportsCredentials = ref(!!navigator.credentials);
-const usePasswordLessLogin = $computed(() => $i!.usePasswordLessLogin);
+const usePasswordLessLogin = $computed(() => $i?.usePasswordLessLogin ?? false);
-async function registerTOTP() {
+async function registerTOTP(): Promise<void> {
const password = await os.inputText({
title: i18n.ts._2fa.registerTOTP,
text: i18n.ts._2fa.passwordToTOTP,
@@ -115,7 +111,7 @@ async function registerTOTP() {
}, {}, 'closed');
}
-function unregisterTOTP() {
+function unregisterTOTP(): void {
os.inputText({
title: i18n.ts.password,
type: 'password',
@@ -133,7 +129,7 @@ function unregisterTOTP() {
});
}
-function renewTOTP() {
+function renewTOTP(): void {
os.confirm({
type: 'question',
title: i18n.ts._2fa.renewTOTP,
@@ -192,8 +188,10 @@ async function addSecurityKey() {
});
if (password.canceled) return;
- const challenge: any = await os.apiWithDialog('i/2fa/register-key', {
- password: password.result,
+ const registrationOptions = parseCreationOptionsFromJSON({
+ publicKey: await os.apiWithDialog('i/2fa/register-key', {
+ password: password.result,
+ }),
});
const name = await os.inputText({
@@ -205,26 +203,8 @@ async function addSecurityKey() {
});
if (name.canceled) return;
- const webAuthnCreation = navigator.credentials.create({
- publicKey: {
- challenge: byteify(challenge.challenge, 'base64'),
- rp: {
- id: hostname,
- name: 'Misskey',
- },
- user: {
- id: byteify($i!.id, 'ascii'),
- name: $i!.username,
- displayName: $i!.name,
- },
- pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
- timeout: 60000,
- attestation: 'direct',
- },
- }) as Promise<PublicKeyCredential & { response: AuthenticatorAttestationResponse; } | null>;
-
const credential = await os.promiseDialog(
- webAuthnCreation,
+ webAuthnCreate(registrationOptions),
null,
() => {}, // ユーザーのキャンセルはrejectなのでエラーダイアログを出さない
i18n.ts._2fa.tapSecurityKey,
@@ -234,10 +214,7 @@ async function addSecurityKey() {
await os.apiWithDialog('i/2fa/key-done', {
password: password.result,
name: name.result,
- challengeId: challenge.challengeId,
- // we convert each 16 bits to a string to serialise
- clientDataJSON: stringify(credential.response.clientDataJSON),
- attestationObject: hexify(credential.response.attestationObject),
+ credential: credential.toJSON(),
});
}
diff --git a/packages/frontend/src/scripts/2fa.ts b/packages/frontend/src/scripts/2fa.ts
deleted file mode 100644
index 2d0498522a..0000000000
--- a/packages/frontend/src/scripts/2fa.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and other misskey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
- switch (encoding) {
- case 'ascii':
- return Uint8Array.from(string, c => c.charCodeAt(0));
- case 'base64':
- return Uint8Array.from(
- atob(
- string
- .replace(/-/g, '+')
- .replace(/_/g, '/'),
- ),
- c => c.charCodeAt(0),
- );
- case 'hex':
- return new Uint8Array(
- string
- .match(/.{1,2}/g)
- .map(byte => parseInt(byte, 16)),
- );
- }
-}
-
-export function hexify(buffer: ArrayBuffer) {
- return Array.from(new Uint8Array(buffer))
- .reduce(
- (str, byte) => str + byte.toString(16).padStart(2, '0'),
- '',
- );
-}
-
-export function stringify(buffer: ArrayBuffer) {
- return String.fromCharCode(... new Uint8Array(buffer));
-}