From 80c55fbc764c0755eda4a68ebc070d9d30f5bfaa Mon Sep 17 00:00:00 2001 From: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com> Date: Sun, 7 Jul 2019 01:38:36 +0900 Subject: [PATCH] Support password-less login with WebAuthn (#5112) * Support password-less login with WebAuthn * Fix initial value of usePasswordLessLogin --- locales/ja-JP.yml | 1 + migration/1562422242907-PasswordLessLogin.ts | 13 +++++++ .../common/views/components/settings/2fa.vue | 16 +++++++++ .../app/common/views/components/signin.vue | 6 +++- src/models/entities/user-profile.ts | 5 +++ src/models/repositories/user.ts | 2 +- .../api/endpoints/i/2fa/password-less.ts | 21 +++++++++++ src/server/api/private/signin.ts | 36 ++++++++++++++----- 8 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 migration/1562422242907-PasswordLessLogin.ts create mode 100644 src/server/api/endpoints/i/2fa/password-less.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6666e630a..5daf7e7e3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1112,6 +1112,7 @@ desktop/views/components/settings.2fa.vue: register-security-key: "キーの登録を完了" something-went-wrong: "わー! キーを登録する際に問題が発生しました:" key-unregistered: "キーが削除されました" + use-password-less-login: "パスワードなしのログインを使用" common/views/components/media-image.vue: sensitive: "閲覧注意" diff --git a/migration/1562422242907-PasswordLessLogin.ts b/migration/1562422242907-PasswordLessLogin.ts new file mode 100644 index 000000000..e789a3433 --- /dev/null +++ b/migration/1562422242907-PasswordLessLogin.ts @@ -0,0 +1,13 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class PasswordLessLogin1562422242907 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD COLUMN "usePasswordLessLogin" boolean DEFAULT false NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "usePasswordLessLogin"`); + } + +} diff --git a/src/client/app/common/views/components/settings/2fa.vue b/src/client/app/common/views/components/settings/2fa.vue index eb645898e..813a91b5c 100644 --- a/src/client/app/common/views/components/settings/2fa.vue +++ b/src/client/app/common/views/components/settings/2fa.vue @@ -28,6 +28,10 @@ + + {{ $t('use-password-less-login') }} + + {{ $t('something-went-wrong') }} {{ registration.error }} {{ $t('register') }} @@ -80,6 +84,7 @@ export default Vue.extend({ return { data: null, supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, registration: null, keyName: '', token: null @@ -112,6 +117,9 @@ export default Vue.extend({ if (canceled) return; this.$root.api('i/2fa/unregister', { password: password + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); }).then(() => { this.$notify(this.$t('unregistered')); this.$store.state.i.twoFactorEnabled = false; @@ -157,6 +165,9 @@ export default Vue.extend({ return this.$root.api('i/2fa/remove-key', { password, credentialId: key.id + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); }).then(() => { this.$notify(this.$t('key-unregistered')); }); @@ -213,6 +224,11 @@ export default Vue.extend({ this.registration.stage = -1; }); }); + }, + updatePasswordLessLogin() { + this.$root.api('i/2fa/password-less', { + value: !!this.usePasswordLessLogin + }); } } }); diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index 8498a1dc3..f76f989d6 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -7,7 +7,7 @@ - + {{ $t('password') }} @@ -28,6 +28,10 @@

{{ $t('enter-2fa-code') }}

+ + {{ $t('password') }} + + {{ $t('@.2fa') }} diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index 6f960f1b7..4a588ebfb 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -81,6 +81,11 @@ export class UserProfile { }) public securityKeysAvailable: boolean; + @Column('boolean', { + default: false, + }) + public usePasswordLessLogin: 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.' diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index cc89b674c..06da74197 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -156,6 +156,7 @@ export class UserRepository extends Repository { detail: true }), twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled ? UserSecurityKeys.count({ userId: user.id @@ -208,7 +209,6 @@ export class UserRepository extends Repository { select: ['id', 'name', 'lastUsed'] }) : [] - } : {}), ...(relation ? { diff --git a/src/server/api/endpoints/i/2fa/password-less.ts b/src/server/api/endpoints/i/2fa/password-less.ts new file mode 100644 index 000000000..19e75ca1c --- /dev/null +++ b/src/server/api/endpoints/i/2fa/password-less.ts @@ -0,0 +1,21 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserProfiles } from '../../../../../models'; + +export const meta = { + requireCredential: true, + + secure: true, + + params: { + value: { + validator: $.boolean + } + } +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value + }); +}); diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index bc9346d08..67afed760 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -72,19 +72,25 @@ export default async (ctx: Koa.BaseContext) => { } } - if (!same) { - await fail(403, { - error: 'incorrect password' - }); - return; - } - if (!profile.twoFactorEnabled) { - signin(ctx, user); + if (same) { + signin(ctx, user); + } else { + await fail(403, { + error: 'incorrect password' + }); + } return; } if (token) { + if (!same) { + await fail(403, { + error: 'incorrect password' + }); + return; + } + const verified = (speakeasy as any).totp.verify({ secret: profile.twoFactorSecret, encoding: 'base32', @@ -101,6 +107,13 @@ export default async (ctx: Koa.BaseContext) => { return; } } else if (body.credentialId) { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + error: 'incorrect password' + }); + return; + } + const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); const clientData = JSON.parse(clientDataJSON.toString('utf-8')); const challenge = await AttestationChallenges.findOne({ @@ -163,6 +176,13 @@ export default async (ctx: Koa.BaseContext) => { return; } } else { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + error: 'incorrect password' + }); + return; + } + const keys = await UserSecurityKeys.find({ userId: user.id });