Support password-less login with WebAuthn (#5112)

* Support password-less login with WebAuthn

* Fix initial value of usePasswordLessLogin
This commit is contained in:
Satsuki Yanagi 2019-07-07 01:38:36 +09:00 committed by syuilo
parent e97dd13e81
commit 047a46d966
8 changed files with 90 additions and 10 deletions

View File

@ -1112,6 +1112,7 @@ desktop/views/components/settings.2fa.vue:
register-security-key: "キーの登録を完了" register-security-key: "キーの登録を完了"
something-went-wrong: "わー! キーを登録する際に問題が発生しました:" something-went-wrong: "わー! キーを登録する際に問題が発生しました:"
key-unregistered: "キーが削除されました" key-unregistered: "キーが削除されました"
use-password-less-login: "パスワードなしのログインを使用"
common/views/components/media-image.vue: common/views/components/media-image.vue:
sensitive: "閲覧注意" sensitive: "閲覧注意"

View File

@ -0,0 +1,13 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class PasswordLessLogin1562422242907 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "user_profile" ADD COLUMN "usePasswordLessLogin" boolean DEFAULT false NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "usePasswordLessLogin"`);
}
}

View File

@ -28,6 +28,10 @@
</div> </div>
</div> </div>
<ui-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">
{{ $t('use-password-less-login') }}
</ui-switch>
<ui-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</ui-info> <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> <ui-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button>
@ -80,6 +84,7 @@ export default Vue.extend({
return { return {
data: null, data: null,
supportsCredentials: !!navigator.credentials, supportsCredentials: !!navigator.credentials,
usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
registration: null, registration: null,
keyName: '', keyName: '',
token: null token: null
@ -112,6 +117,9 @@ export default Vue.extend({
if (canceled) return; if (canceled) return;
this.$root.api('i/2fa/unregister', { this.$root.api('i/2fa/unregister', {
password: password password: password
}).then(() => {
this.usePasswordLessLogin = false;
this.updatePasswordLessLogin();
}).then(() => { }).then(() => {
this.$notify(this.$t('unregistered')); this.$notify(this.$t('unregistered'));
this.$store.state.i.twoFactorEnabled = false; this.$store.state.i.twoFactorEnabled = false;
@ -157,6 +165,9 @@ export default Vue.extend({
return this.$root.api('i/2fa/remove-key', { return this.$root.api('i/2fa/remove-key', {
password, password,
credentialId: key.id credentialId: key.id
}).then(() => {
this.usePasswordLessLogin = false;
this.updatePasswordLessLogin();
}).then(() => { }).then(() => {
this.$notify(this.$t('key-unregistered')); this.$notify(this.$t('key-unregistered'));
}); });
@ -213,6 +224,11 @@ export default Vue.extend({
this.registration.stage = -1; this.registration.stage = -1;
}); });
}); });
},
updatePasswordLessLogin() {
this.$root.api('i/2fa/password-less', {
value: !!this.usePasswordLessLogin
});
} }
} }
}); });

View File

@ -7,7 +7,7 @@
<template #prefix>@</template> <template #prefix>@</template>
<template #suffix>@{{ host }}</template> <template #suffix>@{{ host }}</template>
</ui-input> </ui-input>
<ui-input v-model="password" type="password" :with-password-toggle="true" required> <ui-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required>
<span>{{ $t('password') }}</span> <span>{{ $t('password') }}</span>
<template #prefix><fa icon="lock"/></template> <template #prefix><fa icon="lock"/></template>
</ui-input> </ui-input>
@ -28,6 +28,10 @@
</div> </div>
<div class="twofa-group totp-group"> <div class="twofa-group totp-group">
<p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p> <p style="margin-bottom:0;">{{ $t('enter-2fa-code') }}</p>
<ui-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required>
<span>{{ $t('password') }}</span>
<template #prefix><fa icon="lock"/></template>
</ui-input>
<ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> <ui-input v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
<span>{{ $t('@.2fa') }}</span> <span>{{ $t('@.2fa') }}</span>
<template #prefix><fa icon="gavel"/></template> <template #prefix><fa icon="gavel"/></template>

View File

@ -81,6 +81,11 @@ export class UserProfile {
}) })
public securityKeysAvailable: boolean; public securityKeysAvailable: boolean;
@Column('boolean', {
default: false,
})
public usePasswordLessLogin: boolean;
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
comment: 'The password hash of the User. It will be null if the origin of the user is local.' comment: 'The password hash of the User. It will be null if the origin of the user is local.'

View File

@ -156,6 +156,7 @@ export class UserRepository extends Repository<User> {
detail: true detail: true
}), }),
twoFactorEnabled: profile!.twoFactorEnabled, twoFactorEnabled: profile!.twoFactorEnabled,
usePasswordLessLogin: profile!.usePasswordLessLogin,
securityKeys: profile!.twoFactorEnabled securityKeys: profile!.twoFactorEnabled
? UserSecurityKeys.count({ ? UserSecurityKeys.count({
userId: user.id userId: user.id
@ -208,7 +209,6 @@ export class UserRepository extends Repository<User> {
select: ['id', 'name', 'lastUsed'] select: ['id', 'name', 'lastUsed']
}) })
: [] : []
} : {}), } : {}),
...(relation ? { ...(relation ? {

View File

@ -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
});
});

View File

@ -72,19 +72,25 @@ export default async (ctx: Koa.BaseContext) => {
} }
} }
if (!same) {
await fail(403, {
error: 'incorrect password'
});
return;
}
if (!profile.twoFactorEnabled) { if (!profile.twoFactorEnabled) {
signin(ctx, user); if (same) {
signin(ctx, user);
} else {
await fail(403, {
error: 'incorrect password'
});
}
return; return;
} }
if (token) { if (token) {
if (!same) {
await fail(403, {
error: 'incorrect password'
});
return;
}
const verified = (speakeasy as any).totp.verify({ const verified = (speakeasy as any).totp.verify({
secret: profile.twoFactorSecret, secret: profile.twoFactorSecret,
encoding: 'base32', encoding: 'base32',
@ -101,6 +107,13 @@ export default async (ctx: Koa.BaseContext) => {
return; return;
} }
} else if (body.credentialId) { } else if (body.credentialId) {
if (!same && !profile.usePasswordLessLogin) {
await fail(403, {
error: 'incorrect password'
});
return;
}
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
const clientData = JSON.parse(clientDataJSON.toString('utf-8')); const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
const challenge = await AttestationChallenges.findOne({ const challenge = await AttestationChallenges.findOne({
@ -163,6 +176,13 @@ export default async (ctx: Koa.BaseContext) => {
return; return;
} }
} else { } else {
if (!same && !profile.usePasswordLessLogin) {
await fail(403, {
error: 'incorrect password'
});
return;
}
const keys = await UserSecurityKeys.find({ const keys = await UserSecurityKeys.find({
userId: user.id userId: user.id
}); });