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
					
				
					 8 changed files with 90 additions and 10 deletions
				
			
		|  | @ -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: "閲覧注意" | ||||
|  |  | |||
							
								
								
									
										13
									
								
								migration/1562422242907-PasswordLessLogin.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								migration/1562422242907-PasswordLessLogin.ts
									
										
									
									
									
										Normal 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"`); | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
|  | @ -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 | ||||
| 			}); | ||||
| 		} | ||||
| 	} | ||||
| }); | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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.' | ||||
|  |  | |||
|  | @ -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 ? { | ||||
|  |  | |||
							
								
								
									
										21
									
								
								src/server/api/endpoints/i/2fa/password-less.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/server/api/endpoints/i/2fa/password-less.ts
									
										
									
									
									
										Normal 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 | ||||
| 	}); | ||||
| }); | ||||
|  | @ -72,6 +72,18 @@ export default async (ctx: Koa.BaseContext) => { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		if (same) { | ||||
| 			signin(ctx, user); | ||||
| 		} else { | ||||
| 			await fail(403, { | ||||
| 				error: 'incorrect password' | ||||
| 			}); | ||||
| 		} | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	if (token) { | ||||
| 		if (!same) { | ||||
| 			await fail(403, { | ||||
| 				error: 'incorrect password' | ||||
|  | @ -79,12 +91,6 @@ export default async (ctx: Koa.BaseContext) => { | |||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 	if (!profile.twoFactorEnabled) { | ||||
| 		signin(ctx, user); | ||||
| 		return; | ||||
| 	} | ||||
| 
 | ||||
| 	if (token) { | ||||
| 		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 | ||||
| 		}); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue