Support password-less login with WebAuthn (#5112)
* Support password-less login with WebAuthn * Fix initial value of usePasswordLessLogin
This commit is contained in:
parent
e97dd13e81
commit
047a46d966
|
@ -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: "閲覧注意"
|
||||||
|
|
|
@ -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"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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.'
|
||||||
|
|
|
@ -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 ? {
|
||||||
|
|
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue