mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-12-23 13:23:09 +02:00
Implement Webauthn 🎉 (#5088)
* Implement Webauthn 🎉
* Share hexifyAB
* Move hr inside template and add AttestationChallenges janitor daemon
* Apply suggestions from code review
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Add newline at the end of file
* Fix stray newline in promise chain
* Ignore var in try{}catch(){} block
Co-Authored-By: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
* Add missing comma
* Add missing semicolon
* Support more attestation formats
* add support for more key types and linter pass
* Refactor
* Refactor
* credentialId --> id
* Fix
* Improve readability
* Add indexes
* fixes for credentialId->id
* Avoid changing store state
* Fix syntax error and code style
* Remove unused import
* Refactor of getkey API
* Create 1561706992953-webauthn.ts
* Update ja-JP.yml
* Add type annotations
* Fix code style
* Specify depedency version
* Fix code style
* Fix janitor daemon and login requesting 2FA regardless of status
This commit is contained in:
parent
f17e229c1e
commit
fd94b817ab
21 changed files with 1376 additions and 64 deletions
|
@ -601,6 +601,8 @@ common/views/components/signin.vue:
|
|||
signin-with-github: "Sign in with GitHub"
|
||||
signin-with-discord: "Sign in with Discord"
|
||||
login-failed: "Logging in has failed. Make sure you have entered the correct username and password."
|
||||
tap-key: "Activate your security key by tapping or clicking it to login"
|
||||
enter-2fa-code: "Enter your 2FA code below"
|
||||
common/views/components/signup.vue:
|
||||
invitation-code: "Invitation code"
|
||||
invitation-info: "If you do not have an invitation code, please contact an <a href=\"{}\">administrator</a>."
|
||||
|
@ -984,7 +986,7 @@ desktop/views/components/settings.2fa.vue:
|
|||
url: "https://www.google.com/landing/2step/"
|
||||
caution: "If you lose access to your registered device, you won't be able to connect to Misskey anymore!"
|
||||
register: "Register a device"
|
||||
already-registered: "This device is already registered"
|
||||
already-registered: "Your account is currently registered to an authenticator application"
|
||||
unregister: "Unregister"
|
||||
unregistered: "Two-factor authentication has been disabled."
|
||||
enter-password: "Enter the password"
|
||||
|
@ -997,6 +999,15 @@ desktop/views/components/settings.2fa.vue:
|
|||
success: "Settings saved!"
|
||||
failed: "Failed to setup. Please ensure that the token is correct."
|
||||
info: "From the next time you sign in to Misskey, the token displayed on your device will be necessary too, as well as the password."
|
||||
totp-header: "Authenticator App"
|
||||
security-key-header: "Security Keys"
|
||||
security-key: "You can use a hardware security key supporting FIDO2 to log into your account for enhanced security. When you sign-in, you'll need a registered security key or your authenticator app."
|
||||
last-used: "Last used:"
|
||||
activate-key: "Please activate your security key by tapping or clicking it"
|
||||
security-key-name: "Key Name"
|
||||
register-security-key: "Finish Key Registration"
|
||||
something-went-wrong: "Oops! Something went wrong while trying to register your key:"
|
||||
key-unregistered: "Key Removed"
|
||||
common/views/components/media-image.vue:
|
||||
sensitive: "NSFW"
|
||||
click-to-show: "Click to show"
|
||||
|
|
|
@ -646,6 +646,8 @@ common/views/components/signin.vue:
|
|||
signin-with-github: "GitHubでログイン"
|
||||
signin-with-discord: "Discordでログイン"
|
||||
login-failed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
|
||||
tap-key: "セキュリティキーをクリックしてログイン"
|
||||
enter-2fa-code: "認証コードを入力してください"
|
||||
|
||||
common/views/components/signup.vue:
|
||||
invitation-code: "招待コード"
|
||||
|
@ -1100,6 +1102,15 @@ desktop/views/components/settings.2fa.vue:
|
|||
success: "設定が完了しました!"
|
||||
failed: "設定に失敗しました。トークンに誤りがないかご確認ください。"
|
||||
info: "次回サインインからは、同様にパスワードに加えてデバイスに表示されているトークンを入力します。"
|
||||
totp-header: "認証アプリ"
|
||||
security-key-header: "セキュリティキー"
|
||||
security-key: "セキュリティを強化するために、FIDO2をサポートするハードウェアセキュリティキーを使用してアカウントにログインできます。 サインインの際は、登録されたセキュリティキーまたは認証アプリが必要になります。"
|
||||
last-used: "最後の使用:"
|
||||
activate-key: "クリックしてセキュリティキーをアクティベートしてください"
|
||||
security-key-name: "キー名"
|
||||
register-security-key: "キーの登録を完了"
|
||||
something-went-wrong: "わー! キーを登録する際に問題が発生しました:"
|
||||
key-unregistered: "キーが削除されました"
|
||||
|
||||
common/views/components/media-image.vue:
|
||||
sensitive: "閲覧注意"
|
||||
|
|
29
migration/1561706992953-webauthn.ts
Normal file
29
migration/1561706992953-webauthn.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class webauthn1561706992953 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `);
|
||||
await queryRunner.query(`CREATE TABLE "user_security_key" ("id" character varying NOT NULL, "userId" character varying(32) NOT NULL, "publicKey" character varying NOT NULL, "lastUsed" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(30) NOT NULL, CONSTRAINT "PK_3e508571121ab39c5f85d10c166" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44" ON "user_security_key" ("userId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7" ON "user_security_key" ("publicKey") `);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "securityKeysAvailable" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" ADD CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.query(`ALTER TABLE "user_security_key" DROP CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447"`);
|
||||
await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "securityKeysAvailable"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44"`);
|
||||
await queryRunner.query(`DROP TABLE "user_security_key"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
|
||||
await queryRunner.query(`DROP TABLE "attestation_challenge"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -39,6 +39,7 @@
|
|||
"@koa/cors": "3.0.0",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/bull": "3.5.15",
|
||||
"@types/cbor": "2.0.0",
|
||||
"@types/dateformat": "3.0.0",
|
||||
"@types/deep-equal": "1.0.1",
|
||||
"@types/double-ended-queue": "2.1.1",
|
||||
|
@ -104,9 +105,11 @@
|
|||
"autosize": "4.0.2",
|
||||
"autwh": "0.1.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"bootstrap": "4.3.1",
|
||||
"bootstrap-vue": "2.0.0-rc.13",
|
||||
"bull": "3.10.0",
|
||||
"cafy": "15.1.1",
|
||||
"cbor": "4.1.5",
|
||||
"chai": "4.2.0",
|
||||
"chalk": "2.4.2",
|
||||
"cli-highlight": "2.1.1",
|
||||
|
@ -148,6 +151,7 @@
|
|||
"jsdom": "15.1.1",
|
||||
"json5": "2.1.0",
|
||||
"json5-loader": "3.0.0",
|
||||
"jsrsasign": "8.0.12",
|
||||
"katex": "0.10.2",
|
||||
"koa": "2.7.0",
|
||||
"koa-bodyparser": "4.2.1",
|
||||
|
|
|
@ -79,6 +79,7 @@ export async function masterMain() {
|
|||
require('../daemons/server-stats').default();
|
||||
require('../daemons/notes-stats').default();
|
||||
require('../daemons/queue-stats').default();
|
||||
require('../daemons/janitor').default();
|
||||
}
|
||||
|
||||
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
|
||||
|
|
5
src/client/app/common/scripts/2fa.ts
Normal file
5
src/client/app/common/scripts/2fa.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export function hexifyAB(buffer) {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map(item => item.toString(16).padStart(2, 0))
|
||||
.join('');
|
||||
}
|
|
@ -1,11 +1,54 @@
|
|||
<template>
|
||||
<div class="2fa">
|
||||
<div class="2fa totp-section">
|
||||
<p style="margin-top:0;">{{ $t('intro') }}<a :href="$t('url')" target="_blank">{{ $t('detail') }}</a></p>
|
||||
<ui-info warn>{{ $t('caution') }}</ui-info>
|
||||
<p v-if="!data && !$store.state.i.twoFactorEnabled"><ui-button @click="register">{{ $t('register') }}</ui-button></p>
|
||||
<template v-if="$store.state.i.twoFactorEnabled">
|
||||
<h2 class="heading">{{ $t('totp-header') }}</h2>
|
||||
<p>{{ $t('already-registered') }}</p>
|
||||
<ui-button @click="unregister">{{ $t('unregister') }}</ui-button>
|
||||
|
||||
<template v-if="supportsCredentials">
|
||||
<hr class="totp-method-sep">
|
||||
|
||||
<h2 class="heading">{{ $t('security-key-header') }}</h2>
|
||||
<p>{{ $t('security-key') }}</p>
|
||||
<div class="key-list">
|
||||
<div class="key" v-for="key in $store.state.i.securityKeysList">
|
||||
<h3>
|
||||
{{ key.name }}
|
||||
</h3>
|
||||
<div class="last-used">
|
||||
{{ $t('last-used') }}
|
||||
<mk-time :time="key.lastUsed"/>
|
||||
</div>
|
||||
<ui-button @click="unregisterKey(key)">
|
||||
{{ $t('unregister') }}
|
||||
</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info>
|
||||
<ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button>
|
||||
|
||||
<ol v-if="registration && !registration.error">
|
||||
<li v-if="registration.stage >= 0">
|
||||
{{ $t('activate-key') }}
|
||||
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
|
||||
</li>
|
||||
<li v-if="registration.stage >= 1">
|
||||
<ui-form :disabled="registration.stage != 1 || registration.saving">
|
||||
<ui-input v-model="keyName" :max="30">
|
||||
<span>{{ $t('security-key-name') }}</span>
|
||||
</ui-input>
|
||||
<ui-button @click="registerKey" :disabled="this.keyName.length == 0">
|
||||
{{ $t('register-security-key') }}
|
||||
</ui-button>
|
||||
<fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
|
||||
</ui-form>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="data && !$store.state.i.twoFactorEnabled">
|
||||
<ol>
|
||||
|
@ -24,12 +67,21 @@
|
|||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import i18n from '../../../../i18n';
|
||||
import { hostname } from '../../../../config';
|
||||
import { hexifyAB } from '../../../scripts/2fa';
|
||||
|
||||
function stringifyAB(buffer) {
|
||||
return String.fromCharCode.apply(null, new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
i18n: i18n('desktop/views/components/settings.2fa.vue'),
|
||||
data() {
|
||||
return {
|
||||
data: null,
|
||||
supportsCredentials: !!navigator.credentials,
|
||||
registration: null,
|
||||
keyName: '',
|
||||
token: null
|
||||
};
|
||||
},
|
||||
|
@ -76,7 +128,116 @@ export default Vue.extend({
|
|||
}).catch(() => {
|
||||
this.$notify(this.$t('failed'));
|
||||
});
|
||||
},
|
||||
|
||||
registerKey() {
|
||||
this.registration.saving = true;
|
||||
this.$root.api('i/2fa/key-done', {
|
||||
password: this.registration.password,
|
||||
name: this.keyName,
|
||||
challengeId: this.registration.challengeId,
|
||||
// we convert each 16 bits to a string to serialise
|
||||
clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
|
||||
attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
|
||||
}).then(key => {
|
||||
this.registration = null;
|
||||
key.lastUsed = new Date();
|
||||
this.$notify(this.$t('success'));
|
||||
})
|
||||
},
|
||||
|
||||
unregisterKey(key) {
|
||||
this.$root.dialog({
|
||||
title: this.$t('enter-password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
return this.$root.api('i/2fa/remove-key', {
|
||||
password,
|
||||
credentialId: key.id
|
||||
}).then(() => {
|
||||
this.$notify(this.$t('key-unregistered'));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
addSecurityKey() {
|
||||
this.$root.dialog({
|
||||
title: this.$t('enter-password'),
|
||||
input: {
|
||||
type: 'password'
|
||||
}
|
||||
}).then(({ canceled, result: password }) => {
|
||||
if (canceled) return;
|
||||
this.$root.api('i/2fa/register-key', {
|
||||
password
|
||||
}).then(registration => {
|
||||
this.registration = {
|
||||
password,
|
||||
challengeId: registration.challengeId,
|
||||
stage: 0,
|
||||
publicKeyOptions: {
|
||||
challenge: Buffer.from(
|
||||
registration.challenge
|
||||
.replace(/\-/g, "+")
|
||||
.replace(/_/g, "/"),
|
||||
'base64'
|
||||
),
|
||||
rp: {
|
||||
id: hostname,
|
||||
name: 'Misskey'
|
||||
},
|
||||
user: {
|
||||
id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
|
||||
name: this.$store.state.i.username,
|
||||
displayName: this.$store.state.i.name,
|
||||
},
|
||||
pubKeyCredParams: [{alg: -7, type: 'public-key'}],
|
||||
timeout: 60000,
|
||||
attestation: 'direct'
|
||||
},
|
||||
saving: true
|
||||
};
|
||||
return navigator.credentials.create({
|
||||
publicKey: this.registration.publicKeyOptions
|
||||
});
|
||||
}).then(credential => {
|
||||
this.registration.credential = credential;
|
||||
this.registration.saving = false;
|
||||
this.registration.stage = 1;
|
||||
}).catch(err => {
|
||||
console.warn('Error while registering?', err);
|
||||
this.registration.error = err.message;
|
||||
this.registration.stage = -1;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.totp-section
|
||||
.totp-method-sep
|
||||
margin 1.5em 0 1em
|
||||
border none
|
||||
border-top solid var(--lineWidth) var(--faceDivider)
|
||||
|
||||
h2.heading
|
||||
margin 0
|
||||
|
||||
.key
|
||||
padding 1em
|
||||
margin 0.5em 0
|
||||
background #161616
|
||||
border-radius 6px
|
||||
|
||||
h3
|
||||
margin-top 0
|
||||
margin-bottom .3em
|
||||
|
||||
.last-used
|
||||
margin-bottom .5em
|
||||
</style>
|
||||
|
|
|
@ -1,23 +1,40 @@
|
|||
<template>
|
||||
<form class="mk-signin" :class="{ signing }" @submit.prevent="onSubmit">
|
||||
<form class="mk-signin" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
|
||||
<div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
|
||||
<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
|
||||
<span>{{ $t('username') }}</span>
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</ui-input>
|
||||
<ui-input v-model="password" type="password" :with-password-toggle="true" required>
|
||||
<span>{{ $t('password') }}</span>
|
||||
<template #prefix><fa icon="lock"/></template>
|
||||
</ui-input>
|
||||
<ui-input v-if="user && user.twoFactorEnabled" v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
|
||||
<span>{{ $t('@.2fa') }}</span>
|
||||
<template #prefix><fa icon="gavel"/></template>
|
||||
</ui-input>
|
||||
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
|
||||
<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
|
||||
<p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
|
||||
<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
|
||||
<div class="normal-signin" v-if="!totpLogin">
|
||||
<ui-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange">
|
||||
<span>{{ $t('username') }}</span>
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</ui-input>
|
||||
<ui-input v-model="password" type="password" :with-password-toggle="true" required>
|
||||
<span>{{ $t('password') }}</span>
|
||||
<template #prefix><fa icon="lock"/></template>
|
||||
</ui-input>
|
||||
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
|
||||
<p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p>
|
||||
<p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p>
|
||||
<p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p>
|
||||
</div>
|
||||
<div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
|
||||
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
||||
<p>{{ $t('tap-key') }}</p>
|
||||
<ui-button @click="queryKey" v-if="!queryingKey">
|
||||
{{ $t('@.error.retry') }}
|
||||
</ui-button>
|
||||
</div>
|
||||
<div class="or-hr" v-if="user && user.securityKeys">
|
||||
<p class="or-msg">{{ $t('or') }}</p>
|
||||
</div>
|
||||
<div class="twofa-group totp-group">
|
||||
<p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p>
|
||||
<ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
|
||||
<span>{{ $t('@.2fa') }}</span>
|
||||
<template #prefix><fa icon="gavel"/></template>
|
||||
</ui-input>
|
||||
<ui-button type="submit" :disabled="signing">{{ signing ? $t('signing-in') : $t('@.signin') }}</ui-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
@ -26,6 +43,7 @@ import Vue from 'vue';
|
|||
import i18n from '../../../i18n';
|
||||
import { apiUrl, host } from '../../../config';
|
||||
import { toUnicode } from 'punycode';
|
||||
import { hexifyAB } from '../../scripts/2fa';
|
||||
|
||||
export default Vue.extend({
|
||||
i18n: i18n('common/views/components/signin.vue'),
|
||||
|
@ -47,7 +65,11 @@ export default Vue.extend({
|
|||
token: '',
|
||||
apiUrl,
|
||||
host: toUnicode(host),
|
||||
meta: null
|
||||
meta: null,
|
||||
totpLogin: false,
|
||||
credential: null,
|
||||
challengeData: null,
|
||||
queryingKey: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -68,23 +90,87 @@ export default Vue.extend({
|
|||
});
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.signing = true;
|
||||
|
||||
this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
token: this.user && this.user.twoFactorEnabled ? this.token : undefined
|
||||
queryKey() {
|
||||
this.queryingKey = true;
|
||||
return navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: Buffer.from(
|
||||
this.challengeData.challenge
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/'),
|
||||
'base64'
|
||||
),
|
||||
allowCredentials: this.challengeData.securityKeys.map(key => ({
|
||||
id: Buffer.from(key.id, 'hex'),
|
||||
type: 'public-key',
|
||||
transports: ['usb', 'ble', 'nfc']
|
||||
})),
|
||||
timeout: 60 * 1000
|
||||
}
|
||||
}).catch(err => {
|
||||
this.queryingKey = false;
|
||||
console.warn(err);
|
||||
return Promise.reject(null);
|
||||
}).then(credential => {
|
||||
this.queryingKey = false;
|
||||
this.signing = true;
|
||||
return this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
signature: hexifyAB(credential.response.signature),
|
||||
authenticatorData: hexifyAB(credential.response.authenticatorData),
|
||||
clientDataJSON: hexifyAB(credential.response.clientDataJSON),
|
||||
credentialId: credential.id,
|
||||
challengeId: this.challengeData.challengeId
|
||||
});
|
||||
}).then(res => {
|
||||
localStorage.setItem('i', res.i);
|
||||
location.reload();
|
||||
}).catch(() => {
|
||||
}).catch(err => {
|
||||
if(err === null) return;
|
||||
console.error(err);
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('login-failed')
|
||||
});
|
||||
this.signing = false;
|
||||
});
|
||||
},
|
||||
|
||||
onSubmit() {
|
||||
this.signing = true;
|
||||
|
||||
if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
|
||||
if (window.PublicKeyCredential && this.user.securityKeys) {
|
||||
this.$root.api('i/2fa/getkeys', {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
}).then(res => {
|
||||
this.totpLogin = true;
|
||||
this.signing = false;
|
||||
this.challengeData = res;
|
||||
return this.queryKey();
|
||||
});
|
||||
} else {
|
||||
this.totpLogin = true;
|
||||
this.signing = false;
|
||||
}
|
||||
} else {
|
||||
this.$root.api('signin', {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
token: this.user && this.user.twoFactorEnabled ? this.token : undefined
|
||||
}).then(res => {
|
||||
localStorage.setItem('i', res.i);
|
||||
location.reload();
|
||||
}).catch(() => {
|
||||
this.$root.dialog({
|
||||
type: 'error',
|
||||
text: this.$t('login-failed')
|
||||
});
|
||||
this.signing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -94,6 +180,48 @@ export default Vue.extend({
|
|||
.mk-signin
|
||||
color #555
|
||||
|
||||
.or-hr,
|
||||
.or-hr .or-msg,
|
||||
.twofa-group,
|
||||
.twofa-group p
|
||||
color var(--text)
|
||||
|
||||
.tap-group > button
|
||||
margin-bottom 1em
|
||||
|
||||
.securityKeys .or-hr
|
||||
&
|
||||
position relative
|
||||
|
||||
.or-msg
|
||||
&:before
|
||||
right 100%
|
||||
margin-right 0.125em
|
||||
|
||||
&:after
|
||||
left 100%
|
||||
margin-left 0.125em
|
||||
|
||||
&:before, &:after
|
||||
content ""
|
||||
position absolute
|
||||
top 50%
|
||||
width 100%
|
||||
height 2px
|
||||
background #555
|
||||
|
||||
&
|
||||
position relative
|
||||
margin auto
|
||||
left 0
|
||||
right 0
|
||||
top 0
|
||||
bottom 0
|
||||
font-size 1.5em
|
||||
height 1.5em
|
||||
width 3em
|
||||
text-align center
|
||||
|
||||
&.signing
|
||||
&, *
|
||||
cursor wait !important
|
||||
|
|
18
src/daemons/janitor.ts
Normal file
18
src/daemons/janitor.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
const interval = 30 * 60 * 1000;
|
||||
import { AttestationChallenges } from '../models';
|
||||
import { LessThan } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Clean up database occasionally
|
||||
*/
|
||||
export default function() {
|
||||
async function tick() {
|
||||
await AttestationChallenges.delete({
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000))
|
||||
});
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
setInterval(tick, interval);
|
||||
}
|
|
@ -43,6 +43,8 @@ import { Poll } from '../models/entities/poll';
|
|||
import { UserKeypair } from '../models/entities/user-keypair';
|
||||
import { UserPublickey } from '../models/entities/user-publickey';
|
||||
import { UserProfile } from '../models/entities/user-profile';
|
||||
import { UserSecurityKey } from '../models/entities/user-security-key';
|
||||
import { AttestationChallenge } from '../models/entities/attestation-challenge';
|
||||
import { Page } from '../models/entities/page';
|
||||
import { PageLike } from '../models/entities/page-like';
|
||||
|
||||
|
@ -96,6 +98,8 @@ export const entities = [
|
|||
UserGroupJoining,
|
||||
UserGroupInvite,
|
||||
UserNotePining,
|
||||
UserSecurityKey,
|
||||
AttestationChallenge,
|
||||
Following,
|
||||
FollowRequest,
|
||||
Muting,
|
||||
|
@ -146,7 +150,7 @@ export function initDb(justBorrow = false, sync = false, log = false) {
|
|||
options: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
options:{
|
||||
options: {
|
||||
password: config.redis.pass,
|
||||
prefix: config.redis.prefix,
|
||||
db: config.redis.db || 0
|
||||
|
|
46
src/models/entities/attestation-challenge.ts
Normal file
46
src/models/entities/attestation-challenge.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class AttestationChallenge {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@PrimaryColumn(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
comment: 'Hex-encoded sha256 hash of the challenge.'
|
||||
})
|
||||
public challenge: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The date challenge was created for expiry purposes.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('boolean', {
|
||||
comment:
|
||||
'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
|
||||
default: false
|
||||
})
|
||||
public registrationChallenge: boolean;
|
||||
|
||||
constructor(data: Partial<AttestationChallenge>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -76,6 +76,11 @@ export class UserProfile {
|
|||
})
|
||||
public twoFactorEnabled: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public securityKeysAvailable: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: 'The password hash of the User. It will be null if the origin of the user is local.'
|
||||
|
|
48
src/models/entities/user-security-key.ts
Normal file
48
src/models/entities/user-security-key.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class UserSecurityKey {
|
||||
@PrimaryColumn('varchar', {
|
||||
comment: 'Variable-length id given to navigator.credentials.get()'
|
||||
})
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
comment:
|
||||
'Variable-length public key used to verify attestations (hex-encoded).'
|
||||
})
|
||||
public publicKey: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment:
|
||||
'The date of the last time the UserSecurityKey was successfully validated.'
|
||||
})
|
||||
public lastUsed: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
comment: 'User-defined name for this key',
|
||||
length: 30
|
||||
})
|
||||
public name: string;
|
||||
|
||||
constructor(data: Partial<UserSecurityKey>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -37,6 +37,8 @@ import { FollowingRepository } from './repositories/following';
|
|||
import { AbuseUserReportRepository } from './repositories/abuse-user-report';
|
||||
import { AuthSessionRepository } from './repositories/auth-session';
|
||||
import { UserProfile } from './entities/user-profile';
|
||||
import { AttestationChallenge } from './entities/attestation-challenge';
|
||||
import { UserSecurityKey } from './entities/user-security-key';
|
||||
import { HashtagRepository } from './repositories/hashtag';
|
||||
import { PageRepository } from './repositories/page';
|
||||
import { PageLikeRepository } from './repositories/page-like';
|
||||
|
@ -52,6 +54,8 @@ export const PollVotes = getRepository(PollVote);
|
|||
export const Users = getCustomRepository(UserRepository);
|
||||
export const UserProfiles = getRepository(UserProfile);
|
||||
export const UserKeypairs = getRepository(UserKeypair);
|
||||
export const AttestationChallenges = getRepository(AttestationChallenge);
|
||||
export const UserSecurityKeys = getRepository(UserSecurityKey);
|
||||
export const UserPublickeys = getRepository(UserPublickey);
|
||||
export const UserLists = getCustomRepository(UserListRepository);
|
||||
export const UserListJoinings = getRepository(UserListJoining);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import $ from 'cafy';
|
||||
import { EntityRepository, Repository, In } from 'typeorm';
|
||||
import { User, ILocalUser, IRemoteUser } from '../entities/user';
|
||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..';
|
||||
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings } from '..';
|
||||
import { ensure } from '../../prelude/ensure';
|
||||
import config from '../../config';
|
||||
import { SchemaType } from '../../misc/schema';
|
||||
|
@ -156,6 +156,11 @@ export class UserRepository extends Repository<User> {
|
|||
detail: true
|
||||
}),
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? UserSecurityKeys.count({
|
||||
userId: user.id
|
||||
}).then(result => result >= 1)
|
||||
: false,
|
||||
twitter: profile!.twitter ? {
|
||||
id: profile!.twitterUserId,
|
||||
screenName: profile!.twitterScreenName
|
||||
|
@ -195,6 +200,15 @@ export class UserRepository extends Repository<User> {
|
|||
clientData: profile!.clientData,
|
||||
email: profile!.email,
|
||||
emailVerified: profile!.emailVerified,
|
||||
securityKeysList: profile!.twoFactorEnabled
|
||||
? UserSecurityKeys.find({
|
||||
where: {
|
||||
userId: user.id
|
||||
},
|
||||
select: ['id', 'name', 'lastUsed']
|
||||
})
|
||||
: []
|
||||
|
||||
} : {}),
|
||||
|
||||
...(relation ? {
|
||||
|
|
422
src/server/api/2fa.ts
Normal file
422
src/server/api/2fa.ts
Normal file
|
@ -0,0 +1,422 @@
|
|||
import * as crypto from 'crypto';
|
||||
import config from '../../config';
|
||||
import * as jsrsasign from 'jsrsasign';
|
||||
|
||||
const ECC_PRELUDE = Buffer.from([0x04]);
|
||||
const NULL_BYTE = Buffer.from([0]);
|
||||
const PEM_PRELUDE = Buffer.from(
|
||||
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
|
||||
'hex'
|
||||
);
|
||||
|
||||
// Android Safetynet attestations are signed with this cert:
|
||||
const GSR2 = `-----BEGIN CERTIFICATE-----
|
||||
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
|
||||
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
|
||||
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
|
||||
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
|
||||
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
|
||||
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
|
||||
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
|
||||
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
|
||||
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
|
||||
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
|
||||
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
|
||||
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
|
||||
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
|
||||
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
|
||||
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
|
||||
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
|
||||
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
|
||||
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
|
||||
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
|
||||
-----END CERTIFICATE-----\n`;
|
||||
|
||||
function base64URLDecode(source: string) {
|
||||
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function getCertSubject(certificate: string) {
|
||||
const subjectCert = new jsrsasign.X509();
|
||||
subjectCert.readCertPEM(certificate);
|
||||
|
||||
const subjectString = subjectCert.getSubjectString();
|
||||
const subjectFields = subjectString.slice(1).split('/');
|
||||
|
||||
const fields = {} as Record<string, string>;
|
||||
for (const field of subjectFields) {
|
||||
const eqIndex = field.indexOf('=');
|
||||
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function verifyCertificateChain(certificates: string[]) {
|
||||
let valid = true;
|
||||
|
||||
for (let i = 0; i < certificates.length; i++) {
|
||||
const Cert = certificates[i];
|
||||
const certificate = new jsrsasign.X509();
|
||||
certificate.readCertPEM(Cert);
|
||||
|
||||
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
|
||||
|
||||
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex, 0, [0]);
|
||||
const algorithm = certificate.getSignatureAlgorithmField();
|
||||
const signatureHex = certificate.getSignatureValueHex();
|
||||
|
||||
// Verify against CA
|
||||
const Signature = new jsrsasign.crypto.Signature({alg: algorithm});
|
||||
Signature.init(CACert);
|
||||
Signature.updateHex(certStruct);
|
||||
valid = valid && Signature.verify(signatureHex); // true if CA signed the certificate
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
|
||||
if (pemBuffer.length == 65 && pemBuffer[0] == 0x04) {
|
||||
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
|
||||
type = 'PUBLIC KEY';
|
||||
}
|
||||
const cert = pemBuffer.toString('base64');
|
||||
|
||||
const keyParts = [];
|
||||
const max = Math.ceil(cert.length / 64);
|
||||
let start = 0;
|
||||
for (let i = 0; i < max; i++) {
|
||||
keyParts.push(cert.substring(start, start + 64));
|
||||
start += 64;
|
||||
}
|
||||
|
||||
return (
|
||||
`-----BEGIN ${type}-----\n` +
|
||||
keyParts.join('\n') +
|
||||
`\n-----END ${type}-----\n`
|
||||
);
|
||||
}
|
||||
|
||||
export function hash(data: Buffer) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(data)
|
||||
.digest();
|
||||
}
|
||||
|
||||
export function verifyLogin({
|
||||
publicKey,
|
||||
authenticatorData,
|
||||
clientDataJSON,
|
||||
clientData,
|
||||
signature,
|
||||
challenge
|
||||
}: {
|
||||
publicKey: Buffer,
|
||||
authenticatorData: Buffer,
|
||||
clientDataJSON: Buffer,
|
||||
clientData: any,
|
||||
signature: Buffer,
|
||||
challenge: string
|
||||
}) {
|
||||
if (clientData.type != 'webauthn.get') {
|
||||
throw new Error('type is not webauthn.get');
|
||||
}
|
||||
|
||||
if (hash(clientData.challenge).toString('hex') != challenge) {
|
||||
throw new Error('challenge mismatch');
|
||||
}
|
||||
if (clientData.origin != config.scheme + '://' + config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat(
|
||||
[authenticatorData, hash(clientDataJSON)],
|
||||
32 + authenticatorData.length
|
||||
);
|
||||
|
||||
return crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(publicKey), signature);
|
||||
}
|
||||
|
||||
export const procedures = {
|
||||
none: {
|
||||
verify({publicKey}: {publicKey: Map<number, Buffer>}) {
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length != 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length != 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32
|
||||
);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyU2F,
|
||||
valid: true
|
||||
};
|
||||
}
|
||||
},
|
||||
'android-key': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
if (attStmt.alg != -7) {
|
||||
throw new Error('alg mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash
|
||||
]);
|
||||
|
||||
const attCert: Buffer = attStmt.x5c[0];
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length != 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length != 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32
|
||||
);
|
||||
|
||||
if (!attCert.equals(publicKeyData)) {
|
||||
throw new Error('public key mismatch');
|
||||
}
|
||||
|
||||
const isValid = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
publicKey: publicKeyData
|
||||
};
|
||||
}
|
||||
},
|
||||
// what a stupid attestation
|
||||
'android-safetynet': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
const verificationData = hash(
|
||||
Buffer.concat([authenticatorData, clientDataHash])
|
||||
);
|
||||
|
||||
const jwsParts = attStmt.response.toString('utf-8').split('.');
|
||||
|
||||
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
|
||||
const response = JSON.parse(
|
||||
base64URLDecode(jwsParts[1]).toString('utf-8')
|
||||
);
|
||||
const signature = jwsParts[2];
|
||||
|
||||
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
|
||||
throw new Error('invalid nonce');
|
||||
}
|
||||
|
||||
const certificateChain = header.x5c
|
||||
.map(key => PEMString(key))
|
||||
.concat([GSR2]);
|
||||
|
||||
if (getCertSubject(certificateChain[0]).CN != 'attest.android.com') {
|
||||
throw new Error('invalid common name');
|
||||
}
|
||||
|
||||
if (!verifyCertificateChain(certificateChain)) {
|
||||
throw new Error('Invalid certificate chain!');
|
||||
}
|
||||
|
||||
const signatureBase = Buffer.from(
|
||||
jwsParts[0] + '.' + jwsParts[1],
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const valid = crypto
|
||||
.createVerify('sha256')
|
||||
.update(signatureBase)
|
||||
.verify(certificateChain[0], base64URLDecode(signature));
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length != 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length != 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32
|
||||
);
|
||||
return {
|
||||
valid,
|
||||
publicKey: publicKeyData
|
||||
};
|
||||
}
|
||||
},
|
||||
packed: {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash
|
||||
]);
|
||||
|
||||
if (attStmt.x5c) {
|
||||
const attCert = attStmt.x5c[0];
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length != 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length != 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32
|
||||
);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyData
|
||||
};
|
||||
} else if (attStmt.ecdaaKeyId) {
|
||||
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
|
||||
throw new Error('ECDAA-Verify is not supported');
|
||||
} else {
|
||||
if (attStmt.alg != -7) throw new Error('alg mismatch');
|
||||
|
||||
throw new Error('self attestation is not supported');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'fido-u2f': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>,
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer
|
||||
}) {
|
||||
const x5c: Buffer[] = attStmt.x5c;
|
||||
if (x5c.length != 1) {
|
||||
throw new Error('x5c length does not match expectation');
|
||||
}
|
||||
|
||||
const attCert = x5c[0];
|
||||
|
||||
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
|
||||
|
||||
const negTwo: Buffer = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length != 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree: Buffer = publicKey.get(-3);
|
||||
if (!negThree || negThree.length != 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32
|
||||
);
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
NULL_BYTE,
|
||||
rpIdHash,
|
||||
clientDataHash,
|
||||
credentialId,
|
||||
publicKeyU2F
|
||||
]);
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyU2F
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
67
src/server/api/endpoints/i/2fa/getkeys.ts
Normal file
67
src/server/api/endpoints/i/2fa/getkeys.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import $ from 'cafy';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as crypto from 'crypto';
|
||||
import define from '../../../define';
|
||||
import { UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../../../models';
|
||||
import { ensure } from '../../../../../prelude/ensure';
|
||||
import { promisify } from 'util';
|
||||
import { hash } from '../../../2fa';
|
||||
import { genId } from '../../../../../misc/gen-id';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
params: {
|
||||
password: {
|
||||
validator: $.str
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const randomBytes = promisify(crypto.randomBytes);
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
}
|
||||
|
||||
const keys = await UserSecurityKeys.find({
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
if (keys.length === 0) {
|
||||
throw new Error('no keys found');
|
||||
}
|
||||
|
||||
// 32 byte challenge
|
||||
const entropy = await randomBytes(32);
|
||||
const challenge = entropy.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
const challengeId = genId();
|
||||
|
||||
await AttestationChallenges.save({
|
||||
userId: user.id,
|
||||
id: challengeId,
|
||||
challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
|
||||
createdAt: new Date(),
|
||||
registrationChallenge: false
|
||||
});
|
||||
|
||||
return {
|
||||
challenge,
|
||||
challengeId,
|
||||
securityKeys: keys.map(key => ({
|
||||
id: key.id
|
||||
}))
|
||||
};
|
||||
});
|
151
src/server/api/endpoints/i/2fa/key-done.ts
Normal file
151
src/server/api/endpoints/i/2fa/key-done.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import $ from 'cafy';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { promisify } from 'util';
|
||||
import * as cbor from 'cbor';
|
||||
import define from '../../../define';
|
||||
import {
|
||||
UserProfiles,
|
||||
UserSecurityKeys,
|
||||
AttestationChallenges,
|
||||
Users
|
||||
} from '../../../../../models';
|
||||
import { ensure } from '../../../../../prelude/ensure';
|
||||
import config from '../../../../../config';
|
||||
import { procedures, hash } from '../../../2fa';
|
||||
import { publishMainStream } from '../../../../../services/stream';
|
||||
|
||||
const cborDecodeFirst = promisify(cbor.decodeFirst);
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
params: {
|
||||
clientDataJSON: {
|
||||
validator: $.str
|
||||
},
|
||||
attestationObject: {
|
||||
validator: $.str
|
||||
},
|
||||
password: {
|
||||
validator: $.str
|
||||
},
|
||||
challengeId: {
|
||||
validator: $.str
|
||||
},
|
||||
name: {
|
||||
validator: $.str
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8'));
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
throw new Error('2fa not enabled');
|
||||
}
|
||||
|
||||
const clientData = JSON.parse(ps.clientDataJSON);
|
||||
|
||||
if (clientData.type != 'webauthn.create') {
|
||||
throw new Error('not a creation attestation');
|
||||
}
|
||||
if (clientData.origin != config.scheme + '://' + config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
}
|
||||
|
||||
const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
|
||||
|
||||
const attestation = await cborDecodeFirst(ps.attestationObject);
|
||||
|
||||
const rpIdHash = attestation.authData.slice(0, 32);
|
||||
if (!rpIdHashReal.equals(rpIdHash)) {
|
||||
throw new Error('rpIdHash mismatch');
|
||||
}
|
||||
|
||||
const flags = attestation.authData[32];
|
||||
|
||||
// tslint:disable-next-line:no-bitwise
|
||||
if (!(flags & 1)) {
|
||||
throw new Error('user not present');
|
||||
}
|
||||
|
||||
const authData = Buffer.from(attestation.authData);
|
||||
const credentialIdLength = authData.readUInt16BE(53);
|
||||
const credentialId = authData.slice(55, 55 + credentialIdLength);
|
||||
const publicKeyData = authData.slice(55 + credentialIdLength);
|
||||
const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
|
||||
if (publicKey.get(3) != -7) {
|
||||
throw new Error('alg mismatch');
|
||||
}
|
||||
|
||||
if (!procedures[attestation.fmt]) {
|
||||
throw new Error('unsupported fmt');
|
||||
}
|
||||
|
||||
const verificationData = procedures[attestation.fmt].verify({
|
||||
attStmt: attestation.attStmt,
|
||||
authenticatorData: authData,
|
||||
clientDataHash: clientDataJSONHash,
|
||||
credentialId,
|
||||
publicKey,
|
||||
rpIdHash
|
||||
});
|
||||
if (!verificationData.valid) throw new Error('signature invalid');
|
||||
|
||||
const attestationChallenge = await AttestationChallenges.findOne({
|
||||
userId: user.id,
|
||||
id: ps.challengeId,
|
||||
registrationChallenge: true,
|
||||
challenge: hash(clientData.challenge).toString('hex')
|
||||
});
|
||||
|
||||
if (!attestationChallenge) {
|
||||
throw new Error('non-existent challenge');
|
||||
}
|
||||
|
||||
await AttestationChallenges.delete({
|
||||
userId: user.id,
|
||||
id: ps.challengeId
|
||||
});
|
||||
|
||||
// Expired challenge (> 5min old)
|
||||
if (
|
||||
new Date().getTime() - attestationChallenge.createdAt.getTime() >=
|
||||
5 * 60 * 1000
|
||||
) {
|
||||
throw new Error('expired challenge');
|
||||
}
|
||||
|
||||
const credentialIdString = credentialId.toString('hex');
|
||||
|
||||
await UserSecurityKeys.save({
|
||||
userId: user.id,
|
||||
id: credentialIdString,
|
||||
lastUsed: new Date(),
|
||||
name: ps.name,
|
||||
publicKey: verificationData.publicKey.toString('hex')
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true
|
||||
}));
|
||||
|
||||
return {
|
||||
id: credentialIdString,
|
||||
name: ps.name
|
||||
};
|
||||
});
|
60
src/server/api/endpoints/i/2fa/register-key.ts
Normal file
60
src/server/api/endpoints/i/2fa/register-key.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import $ from 'cafy';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import define from '../../../define';
|
||||
import { UserProfiles, AttestationChallenges } from '../../../../../models';
|
||||
import { ensure } from '../../../../../prelude/ensure';
|
||||
import { promisify } from 'util';
|
||||
import * as crypto from 'crypto';
|
||||
import { genId } from '../../../../../misc/gen-id';
|
||||
import { hash } from '../../../2fa';
|
||||
|
||||
const randomBytes = promisify(crypto.randomBytes);
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
params: {
|
||||
password: {
|
||||
validator: $.str
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
}
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
throw new Error('2fa not enabled');
|
||||
}
|
||||
|
||||
// 32 byte challenge
|
||||
const entropy = await randomBytes(32);
|
||||
const challenge = entropy.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
const challengeId = genId();
|
||||
|
||||
await AttestationChallenges.save({
|
||||
userId: user.id,
|
||||
id: challengeId,
|
||||
challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
|
||||
createdAt: new Date(),
|
||||
registrationChallenge: true
|
||||
});
|
||||
|
||||
return {
|
||||
challengeId,
|
||||
challenge
|
||||
};
|
||||
});
|
46
src/server/api/endpoints/i/2fa/remove-key.ts
Normal file
46
src/server/api/endpoints/i/2fa/remove-key.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import $ from 'cafy';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import define from '../../../define';
|
||||
import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models';
|
||||
import { ensure } from '../../../../../prelude/ensure';
|
||||
import { publishMainStream } from '../../../../../services/stream';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
|
||||
secure: true,
|
||||
|
||||
params: {
|
||||
password: {
|
||||
validator: $.str
|
||||
},
|
||||
credentialId: {
|
||||
validator: $.str
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default define(meta, async (ps, user) => {
|
||||
const profile = await UserProfiles.findOne(user.id).then(ensure);
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(ps.password, profile.password!);
|
||||
|
||||
if (!same) {
|
||||
throw new Error('incorrect password');
|
||||
}
|
||||
|
||||
// Make sure we only delete the user's own creds
|
||||
await UserSecurityKeys.delete({
|
||||
userId: user.id,
|
||||
id: ps.credentialId
|
||||
});
|
||||
|
||||
// Publish meUpdated event
|
||||
publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, {
|
||||
detail: true,
|
||||
includeSecrets: true
|
||||
}));
|
||||
|
||||
return {};
|
||||
});
|
|
@ -4,10 +4,11 @@ import * as speakeasy from 'speakeasy';
|
|||
import { publishMainStream } from '../../../services/stream';
|
||||
import signin from '../common/signin';
|
||||
import config from '../../../config';
|
||||
import { Users, Signins, UserProfiles } from '../../../models';
|
||||
import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models';
|
||||
import { ILocalUser } from '../../../models/entities/user';
|
||||
import { genId } from '../../../misc/gen-id';
|
||||
import { ensure } from '../../../prelude/ensure';
|
||||
import { verifyLogin, hash } from '../2fa';
|
||||
|
||||
export default async (ctx: Koa.BaseContext) => {
|
||||
ctx.set('Access-Control-Allow-Origin', config.url);
|
||||
|
@ -51,40 +52,116 @@ export default async (ctx: Koa.BaseContext) => {
|
|||
// Compare password
|
||||
const same = await bcrypt.compare(password, profile.password!);
|
||||
|
||||
if (same) {
|
||||
if (profile.twoFactorEnabled) {
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: 'base32',
|
||||
token: token
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
signin(ctx, user);
|
||||
} else {
|
||||
ctx.throw(403, {
|
||||
error: 'invalid token'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
signin(ctx, user);
|
||||
}
|
||||
} else {
|
||||
ctx.throw(403, {
|
||||
error: 'incorrect password'
|
||||
async function fail(status?: number, failure?: {error: string}) {
|
||||
// Append signin history
|
||||
const record = await Signins.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
ip: ctx.ip,
|
||||
headers: ctx.headers,
|
||||
success: !!(status || failure)
|
||||
});
|
||||
|
||||
// Publish signin event
|
||||
publishMainStream(user.id, 'signin', await Signins.pack(record));
|
||||
|
||||
if (status && failure) {
|
||||
ctx.throw(status, failure);
|
||||
}
|
||||
}
|
||||
|
||||
// Append signin history
|
||||
const record = await Signins.save({
|
||||
id: genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
ip: ctx.ip,
|
||||
headers: ctx.headers,
|
||||
success: same
|
||||
});
|
||||
if (!same) {
|
||||
await fail(403, {
|
||||
error: 'incorrect password'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish signin event
|
||||
publishMainStream(user.id, 'signin', await Signins.pack(record));
|
||||
if (!profile.twoFactorEnabled) {
|
||||
signin(ctx, user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const verified = (speakeasy as any).totp.verify({
|
||||
secret: profile.twoFactorSecret,
|
||||
encoding: 'base32',
|
||||
token: token
|
||||
});
|
||||
|
||||
if (verified) {
|
||||
signin(ctx, user);
|
||||
return;
|
||||
} else {
|
||||
await fail(403, {
|
||||
error: 'invalid token'
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
|
||||
const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
|
||||
const challenge = await AttestationChallenges.findOne({
|
||||
userId: user.id,
|
||||
id: body.challengeId,
|
||||
registrationChallenge: false,
|
||||
challenge: hash(clientData.challenge).toString('hex')
|
||||
});
|
||||
|
||||
if (!challenge) {
|
||||
await fail(403, {
|
||||
error: 'non-existent challenge'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await AttestationChallenges.delete({
|
||||
userId: user.id,
|
||||
id: body.challengeId
|
||||
});
|
||||
|
||||
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
|
||||
await fail(403, {
|
||||
error: 'non-existent challenge'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const securityKey = await UserSecurityKeys.findOne({
|
||||
id: Buffer.from(
|
||||
body.credentialId
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/'),
|
||||
'base64'
|
||||
).toString('hex')
|
||||
});
|
||||
|
||||
if (!securityKey) {
|
||||
await fail(403, {
|
||||
error: 'invalid credentialId'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = verifyLogin({
|
||||
publicKey: Buffer.from(securityKey.publicKey, 'hex'),
|
||||
authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
|
||||
clientDataJSON,
|
||||
clientData,
|
||||
signature: Buffer.from(body.signature, 'hex'),
|
||||
challenge: challenge.challenge
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
signin(ctx, user);
|
||||
} else {
|
||||
await fail(403, {
|
||||
error: 'invalid challenge data'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fail();
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue