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: "キーの登録を完了"
something-went-wrong: "わー! キーを登録する際に問題が発生しました:"
key-unregistered: "キーが削除されました"
use-password-less-login: "パスワードなしのログインを使用"
common/views/components/media-image.vue:
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>
<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-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</ui-button>
@ -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
});
}
}
});

View file

@ -7,7 +7,7 @@
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</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>
<template #prefix><fa icon="lock"/></template>
</ui-input>
@ -28,6 +28,10 @@
</div>
<div class="twofa-group totp-group">
<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>
<span>{{ $t('@.2fa') }}</span>
<template #prefix><fa icon="gavel"/></template>

View file

@ -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.'

View file

@ -156,6 +156,7 @@ export class UserRepository extends Repository<User> {
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<User> {
select: ['id', 'name', 'lastUsed']
})
: []
} : {}),
...(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) {
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
});