Add Sign in with passkey Button (#14577)
* Sign in with passkey (PoC)
* 💄 Added "Login with Passkey" Button
* refactor: Improve error response when WebAuthn challenge fails
* signinResponse should be placed under the SigninWithPasskeyResponse object.
* Frontend fix
* Fix: Rate limiting key for passkey signin
Use specific rate limiting key: 'signin-with-passkey' for passkey sign-in API to avoid collisions with signin rate-limit.
* Refactor: enhance Passkey sign-in flow and error handling
- Increased the rate limit for Passkey sign-in attempts to accommodate the two API calls needed per sign-in.
- Improved error messages and handling in both the `WebAuthnService` and the `SigninWithPasskeyApiService`, providing more context and better usability.
- Updated error messages to provide more specific and helpful details to the user.
These changes aim to enhance the Passkey sign-in experience by providing more robust error handling, improving security by limiting API calls, and delivering a more user-friendly interface.
* Refactor: Streamline 2FA flow and remove redundant Passkey button.
- Separate the flow of 1FA and 2FA.
- Remove duplicate passkey buttons
* Fix: Add error messages to MkSignin
* chore: Hide passkey button if the entered user does not use passkey login
* Update CHANGELOG.md
* Refactor: Rename functions and Add comments
* Update locales/ja-JP.yml
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
* Fix: Update translation
- update index.d.ts
- update ko-KR.yml, en-US.yml
- Fix: Reflect Changed i18n key on MkSignin
---------
Co-authored-by: Squarecat-meow <kw7551@gmail.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
parent
fde94f638b
commit
d8dd1683c9
|
@ -5,6 +5,7 @@
|
||||||
- Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように
|
- Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように
|
||||||
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680)
|
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680)
|
||||||
- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように
|
- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように
|
||||||
|
- Feat: パスキーでログインボタンを実装 (#14574)
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
|
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
|
||||||
|
|
16
locales/index.d.ts
vendored
16
locales/index.d.ts
vendored
|
@ -5116,6 +5116,22 @@ export interface Locale extends ILocale {
|
||||||
* {n}件の変更があります
|
* {n}件の変更があります
|
||||||
*/
|
*/
|
||||||
"thereAreNChanges": ParameterizedString<"n">;
|
"thereAreNChanges": ParameterizedString<"n">;
|
||||||
|
/**
|
||||||
|
* パスキーでログイン
|
||||||
|
*/
|
||||||
|
"signinWithPasskey": string;
|
||||||
|
/**
|
||||||
|
* 登録されていないパスキーです。
|
||||||
|
*/
|
||||||
|
"unknownWebAuthnKey": string;
|
||||||
|
/**
|
||||||
|
* パスキーの検証に失敗しました。
|
||||||
|
*/
|
||||||
|
"passkeyVerificationFailed": string;
|
||||||
|
/**
|
||||||
|
* パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。
|
||||||
|
*/
|
||||||
|
"passkeyVerificationSucceededButPasswordlessLoginDisabled": string;
|
||||||
"_delivery": {
|
"_delivery": {
|
||||||
/**
|
/**
|
||||||
* 配信状態
|
* 配信状態
|
||||||
|
|
|
@ -1275,6 +1275,10 @@ performance: "パフォーマンス"
|
||||||
modified: "変更あり"
|
modified: "変更あり"
|
||||||
discard: "破棄"
|
discard: "破棄"
|
||||||
thereAreNChanges: "{n}件の変更があります"
|
thereAreNChanges: "{n}件の変更があります"
|
||||||
|
signinWithPasskey: "パスキーでログイン"
|
||||||
|
unknownWebAuthnKey: "登録されていないパスキーです。"
|
||||||
|
passkeyVerificationFailed: "パスキーの検証に失敗しました。"
|
||||||
|
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
|
||||||
|
|
||||||
_delivery:
|
_delivery:
|
||||||
status: "配信状態"
|
status: "配信状態"
|
||||||
|
|
|
@ -164,6 +164,86 @@ export class WebAuthnService {
|
||||||
return authenticationOptions;
|
return authenticationOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate Passkey Auth (Without specifying user)
|
||||||
|
* @returns authenticationOptions
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async initiateSignInWithPasskeyAuthentication(context: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||||
|
const relyingParty = await this.getRelyingParty();
|
||||||
|
|
||||||
|
const authenticationOptions = await generateAuthenticationOptions({
|
||||||
|
rpID: relyingParty.rpId,
|
||||||
|
userVerification: 'preferred',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.redisClient.setex(`webauthn:challenge:${context}`, 90, authenticationOptions.challenge);
|
||||||
|
|
||||||
|
return authenticationOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Webauthn AuthenticationCredential
|
||||||
|
* @throws IdentifiableError
|
||||||
|
* @returns If the challenge is successful, return the user ID. Otherwise, return null.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise<MiUser['id'] | null> {
|
||||||
|
const challenge = await this.redisClient.get(`webauthn:challenge:${context}`);
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redisClient.del(`webauthn:challenge:${context}`);
|
||||||
|
|
||||||
|
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||||
|
id: response.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'Unknown Webauthn key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const relyingParty = await this.getRelyingParty();
|
||||||
|
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
|
response: response,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: relyingParty.origin,
|
||||||
|
expectedRPID: relyingParty.rpId,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: key.id,
|
||||||
|
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
|
||||||
|
counter: key.counter,
|
||||||
|
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
|
||||||
|
},
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userSecurityKeysRepository.update({
|
||||||
|
id: response.id,
|
||||||
|
}, {
|
||||||
|
lastUsed: new Date(),
|
||||||
|
counter: authenticationInfo.newCounter,
|
||||||
|
credentialDeviceType: authenticationInfo.credentialDeviceType,
|
||||||
|
credentialBackedUp: authenticationInfo.credentialBackedUp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return key.userId;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
||||||
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
||||||
|
|
|
@ -46,6 +46,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
|
||||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||||
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
||||||
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
||||||
|
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -71,6 +72,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
|
||||||
AuthenticateService,
|
AuthenticateService,
|
||||||
RateLimiterService,
|
RateLimiterService,
|
||||||
SigninApiService,
|
SigninApiService,
|
||||||
|
SigninWithPasskeyApiService,
|
||||||
SigninService,
|
SigninService,
|
||||||
SignupApiService,
|
SignupApiService,
|
||||||
StreamingApiServerService,
|
StreamingApiServerService,
|
||||||
|
|
|
@ -8,6 +8,7 @@ import cors from '@fastify/cors';
|
||||||
import multipart from '@fastify/multipart';
|
import multipart from '@fastify/multipart';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
|
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
@ -17,6 +18,7 @@ import endpoints from './endpoints.js';
|
||||||
import { ApiCallService } from './ApiCallService.js';
|
import { ApiCallService } from './ApiCallService.js';
|
||||||
import { SignupApiService } from './SignupApiService.js';
|
import { SignupApiService } from './SignupApiService.js';
|
||||||
import { SigninApiService } from './SigninApiService.js';
|
import { SigninApiService } from './SigninApiService.js';
|
||||||
|
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
|
||||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -37,6 +39,7 @@ export class ApiServerService {
|
||||||
private apiCallService: ApiCallService,
|
private apiCallService: ApiCallService,
|
||||||
private signupApiService: SignupApiService,
|
private signupApiService: SignupApiService,
|
||||||
private signinApiService: SigninApiService,
|
private signinApiService: SigninApiService,
|
||||||
|
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
|
||||||
) {
|
) {
|
||||||
//this.createServer = this.createServer.bind(this);
|
//this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
@ -131,6 +134,12 @@ export class ApiServerService {
|
||||||
};
|
};
|
||||||
}>('/signin', (request, reply) => this.signinApiService.signin(request, reply));
|
}>('/signin', (request, reply) => this.signinApiService.signin(request, reply));
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Body: {
|
||||||
|
credential?: AuthenticationResponseJSON;
|
||||||
|
};
|
||||||
|
}>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply));
|
||||||
|
|
||||||
fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply));
|
fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply));
|
||||||
|
|
||||||
fastify.get('/v1/instance/peers', async (request, reply) => {
|
fastify.get('/v1/instance/peers', async (request, reply) => {
|
||||||
|
|
173
packages/backend/src/server/api/SigninWithPasskeyApiService.ts
Normal file
173
packages/backend/src/server/api/SigninWithPasskeyApiService.ts
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { IsNull } from 'typeorm';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type {
|
||||||
|
SigninsRepository,
|
||||||
|
UserProfilesRepository,
|
||||||
|
UsersRepository,
|
||||||
|
} from '@/models/_.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||||
|
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import type { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { RateLimiterService } from './RateLimiterService.js';
|
||||||
|
import { SigninService } from './SigninService.js';
|
||||||
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SigninWithPasskeyApiService {
|
||||||
|
private logger: Logger;
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
@Inject(DI.signinsRepository)
|
||||||
|
private signinsRepository: SigninsRepository,
|
||||||
|
|
||||||
|
private idService: IdService,
|
||||||
|
private rateLimiterService: RateLimiterService,
|
||||||
|
private signinService: SigninService,
|
||||||
|
private webAuthnService: WebAuthnService,
|
||||||
|
private loggerService: LoggerService,
|
||||||
|
) {
|
||||||
|
this.logger = this.loggerService.getLogger('PasskeyAuth');
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async signin(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Body: {
|
||||||
|
credential?: AuthenticationResponseJSON;
|
||||||
|
context?: string;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
reply.header('Access-Control-Allow-Origin', this.config.url);
|
||||||
|
reply.header('Access-Control-Allow-Credentials', 'true');
|
||||||
|
|
||||||
|
const body = request.body;
|
||||||
|
const credential = body['credential'];
|
||||||
|
|
||||||
|
function error(status: number, error: { id: string }) {
|
||||||
|
reply.code(status);
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail = async (userId: MiUser['id'], status?: number, failure?: { id: string }) => {
|
||||||
|
// Append signin history
|
||||||
|
await this.signinsRepository.insert({
|
||||||
|
id: this.idService.gen(),
|
||||||
|
userId: userId,
|
||||||
|
ip: request.ip,
|
||||||
|
headers: request.headers as any,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
|
||||||
|
// NOTE: 1 Sign-in require 2 API calls
|
||||||
|
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(429);
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: 'Too many failed attempts to sign in. Try again later.',
|
||||||
|
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
|
||||||
|
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate Passkey Auth challenge with context
|
||||||
|
if (!credential) {
|
||||||
|
const context = randomUUID();
|
||||||
|
this.logger.info(`Initiate Passkey challenge: context: ${context}`);
|
||||||
|
const authChallengeOptions = {
|
||||||
|
option: await this.webAuthnService.initiateSignInWithPasskeyAuthentication(context),
|
||||||
|
context: context,
|
||||||
|
};
|
||||||
|
reply.code(200);
|
||||||
|
return authChallengeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = body.context;
|
||||||
|
if (!context || typeof context !== 'string') {
|
||||||
|
// If try Authentication without context
|
||||||
|
return error(400, {
|
||||||
|
id: '1658cc2e-4495-461f-aee4-d403cdf073c1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Try Sign-in with Passkey: context: ${context}`);
|
||||||
|
|
||||||
|
let authorizedUserId: MiUser['id'] | null;
|
||||||
|
try {
|
||||||
|
authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Passkey challenge Verify error! : ${err}`);
|
||||||
|
const errorId = (err as IdentifiableError).id;
|
||||||
|
return error(403, {
|
||||||
|
id: errorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authorizedUserId) {
|
||||||
|
return error(403, {
|
||||||
|
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user
|
||||||
|
const user = await this.usersRepository.findOneBy({
|
||||||
|
id: authorizedUserId,
|
||||||
|
host: IsNull(),
|
||||||
|
}) as MiLocalUser | null;
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return error(403, {
|
||||||
|
id: '652f899f-66d4-490e-993e-6606c8ec04c3',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isSuspended) {
|
||||||
|
return error(403, {
|
||||||
|
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
|
||||||
|
// Authentication was successful, but passwordless login is not enabled
|
||||||
|
if (!profile.usePasswordLessLogin) {
|
||||||
|
return await fail(user.id, 403, {
|
||||||
|
id: '2d84773e-f7b7-4d0b-8f72-bb69b584c912',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const signinResponse = this.signinService.signin(request, reply, user);
|
||||||
|
return {
|
||||||
|
signinResponse: signinResponse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #prefix>@</template>
|
<template #prefix>@</template>
|
||||||
<template #suffix>@{{ host }}</template>
|
<template #suffix>@{{ host }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkInput v-if="!user || user && !user.usePasswordLessLogin" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required data-cy-signin-password>
|
<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required data-cy-signin-password>
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
<template #prefix><i class="ti ti-lock"></i></template>
|
||||||
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
|
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
|
||||||
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
||||||
<p>{{ i18n.ts.useSecurityKey }}</p>
|
<p>{{ i18n.ts.useSecurityKey }}</p>
|
||||||
<MkButton v-if="!queryingKey" @click="queryKey">
|
<MkButton v-if="!queryingKey" @click="query2FaKey">
|
||||||
{{ i18n.ts.retry }}
|
{{ i18n.ts.retry }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -45,10 +45,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="twofa-group totp-group _gaps">
|
<div class="twofa-group totp-group _gaps">
|
||||||
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
|
|
||||||
<template #label>{{ i18n.ts.password }}</template>
|
|
||||||
<template #prefix><i class="ti ti-lock"></i></template>
|
|
||||||
</MkInput>
|
|
||||||
<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
|
<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
|
||||||
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
|
<template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
|
||||||
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
|
<template #prefix><i v-if="isBackupCode" class="ti ti-key"></i><i v-else class="ti ti-123"></i></template>
|
||||||
|
@ -57,6 +53,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!totpLogin && usePasswordLessLogin" :class="$style.orHr">
|
||||||
|
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="!totpLogin && usePasswordLessLogin" class="twofa-group tap-group">
|
||||||
|
<MkButton v-if="!queryingKey" type="submit" :disabled="signing" style="margin: auto auto;" rounded large primary @click="onPasskeyLogin">
|
||||||
|
<i class="ti ti-device-usb" style="font-size: medium;"></i>
|
||||||
|
{{ signing ? i18n.ts.loggingIn : i18n.ts.signinWithPasskey }}
|
||||||
|
</MkButton>
|
||||||
|
<p v-if="queryingKey">{{ i18n.ts.useSecurityKey }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
@ -66,13 +72,15 @@ import { defineAsyncComponent, ref } from 'vue';
|
||||||
import { toUnicode } from 'punycode/';
|
import { toUnicode } from 'punycode/';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
|
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
|
||||||
|
import { SigninWithPasskeyResponse } from 'misskey-js/entities.js';
|
||||||
import { query, extractDomain } from '@@/js/url.js';
|
import { query, extractDomain } from '@@/js/url.js';
|
||||||
|
import { host as configHost } from '@@/js/config.js';
|
||||||
|
import MkDivider from './MkDivider.vue';
|
||||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import { host as configHost } from '@@/js/config.js';
|
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { login } from '@/account.js';
|
import { login } from '@/account.js';
|
||||||
|
@ -80,6 +88,7 @@ import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
const signing = ref(false);
|
const signing = ref(false);
|
||||||
const user = ref<Misskey.entities.UserDetailed | null>(null);
|
const user = ref<Misskey.entities.UserDetailed | null>(null);
|
||||||
|
const usePasswordLessLogin = ref<Misskey.entities.UserDetailed['usePasswordLessLogin']>(true);
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
const token = ref('');
|
const token = ref('');
|
||||||
|
@ -88,6 +97,7 @@ const totpLogin = ref(false);
|
||||||
const isBackupCode = ref(false);
|
const isBackupCode = ref(false);
|
||||||
const queryingKey = ref(false);
|
const queryingKey = ref(false);
|
||||||
let credentialRequest: CredentialRequestOptions | null = null;
|
let credentialRequest: CredentialRequestOptions | null = null;
|
||||||
|
const passkey_context = ref('');
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'login', v: any): void;
|
(ev: 'login', v: any): void;
|
||||||
|
@ -110,8 +120,10 @@ function onUsernameChange(): void {
|
||||||
username: username.value,
|
username: username.value,
|
||||||
}).then(userResponse => {
|
}).then(userResponse => {
|
||||||
user.value = userResponse;
|
user.value = userResponse;
|
||||||
|
usePasswordLessLogin.value = userResponse.usePasswordLessLogin;
|
||||||
}, () => {
|
}, () => {
|
||||||
user.value = null;
|
user.value = null;
|
||||||
|
usePasswordLessLogin.value = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,7 +133,7 @@ function onLogin(res: any): Promise<void> | void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queryKey(): Promise<void> {
|
async function query2FaKey(): Promise<void> {
|
||||||
if (credentialRequest == null) return;
|
if (credentialRequest == null) return;
|
||||||
queryingKey.value = true;
|
queryingKey.value = true;
|
||||||
await webAuthnRequest(credentialRequest)
|
await webAuthnRequest(credentialRequest)
|
||||||
|
@ -150,6 +162,47 @@ async function queryKey(): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPasskeyLogin(): void {
|
||||||
|
signing.value = true;
|
||||||
|
if (webAuthnSupported()) {
|
||||||
|
misskeyApi('signin-with-passkey', {})
|
||||||
|
.then((res: SigninWithPasskeyResponse) => {
|
||||||
|
totpLogin.value = false;
|
||||||
|
signing.value = false;
|
||||||
|
queryingKey.value = true;
|
||||||
|
passkey_context.value = res.context ?? '';
|
||||||
|
credentialRequest = parseRequestOptionsFromJSON({
|
||||||
|
publicKey: res.option,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => queryPasskey())
|
||||||
|
.catch(loginFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPasskey(): Promise<void> {
|
||||||
|
if (credentialRequest == null) return;
|
||||||
|
queryingKey.value = true;
|
||||||
|
console.log('Waiting passkey auth...');
|
||||||
|
await webAuthnRequest(credentialRequest)
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Passkey Auth fail!: ', err);
|
||||||
|
queryingKey.value = false;
|
||||||
|
return Promise.reject(null);
|
||||||
|
}).then(credential => {
|
||||||
|
credentialRequest = null;
|
||||||
|
queryingKey.value = false;
|
||||||
|
signing.value = true;
|
||||||
|
return misskeyApi('signin-with-passkey', {
|
||||||
|
credential: credential.toJSON(),
|
||||||
|
context: passkey_context.value,
|
||||||
|
});
|
||||||
|
}).then((res: SigninWithPasskeyResponse) => {
|
||||||
|
emit('login', res.signinResponse);
|
||||||
|
return onLogin(res.signinResponse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onSubmit(): void {
|
function onSubmit(): void {
|
||||||
signing.value = true;
|
signing.value = true;
|
||||||
if (!totpLogin.value && user.value && user.value.twoFactorEnabled) {
|
if (!totpLogin.value && user.value && user.value.twoFactorEnabled) {
|
||||||
|
@ -164,7 +217,7 @@ function onSubmit(): void {
|
||||||
publicKey: res,
|
publicKey: res,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then(() => queryKey())
|
.then(() => query2FaKey())
|
||||||
.catch(loginFailed);
|
.catch(loginFailed);
|
||||||
} else {
|
} else {
|
||||||
totpLogin.value = true;
|
totpLogin.value = true;
|
||||||
|
@ -212,6 +265,30 @@ function loginFailed(err: any): void {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.loginFailed,
|
||||||
|
text: i18n.ts.unknownWebAuthnKey,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.loginFailed,
|
||||||
|
text: i18n.ts.passkeyVerificationFailed,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '2d84773e-f7b7-4d0b-8f72-bb69b584c912': {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.loginFailed,
|
||||||
|
text: i18n.ts.passkeyVerificationSucceededButPasswordlessLoginDisabled,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
os.alert({
|
os.alert({
|
||||||
|
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkModalWindow
|
<MkModalWindow
|
||||||
ref="dialog"
|
ref="dialog"
|
||||||
:width="400"
|
:width="400"
|
||||||
:height="430"
|
:height="450"
|
||||||
@close="onClose"
|
@close="onClose"
|
||||||
@closed="emit('closed')"
|
@closed="emit('closed')"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1160,6 +1160,10 @@ export type Endpoints = Overwrite<Endpoints_2, {
|
||||||
req: SigninRequest;
|
req: SigninRequest;
|
||||||
res: SigninResponse;
|
res: SigninResponse;
|
||||||
};
|
};
|
||||||
|
'signin-with-passkey': {
|
||||||
|
req: SigninWithPasskeyRequest;
|
||||||
|
res: SigninWithPasskeyResponse;
|
||||||
|
};
|
||||||
'admin/roles/create': {
|
'admin/roles/create': {
|
||||||
req: Overwrite<AdminRolesCreateRequest, {
|
req: Overwrite<AdminRolesCreateRequest, {
|
||||||
policies: PartialRolePolicyOverride;
|
policies: PartialRolePolicyOverride;
|
||||||
|
@ -1191,6 +1195,8 @@ declare namespace entities {
|
||||||
SignupPendingRequest,
|
SignupPendingRequest,
|
||||||
SignupPendingResponse,
|
SignupPendingResponse,
|
||||||
SigninRequest,
|
SigninRequest,
|
||||||
|
SigninWithPasskeyRequest,
|
||||||
|
SigninWithPasskeyResponse,
|
||||||
SigninResponse,
|
SigninResponse,
|
||||||
PartialRolePolicyOverride,
|
PartialRolePolicyOverride,
|
||||||
EmptyRequest,
|
EmptyRequest,
|
||||||
|
@ -3029,6 +3035,19 @@ type SigninResponse = {
|
||||||
i: string;
|
i: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type SigninWithPasskeyRequest = {
|
||||||
|
credential?: object;
|
||||||
|
context?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type SigninWithPasskeyResponse = {
|
||||||
|
option?: object;
|
||||||
|
context?: string;
|
||||||
|
signinResponse?: SigninResponse;
|
||||||
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type SignupPendingRequest = {
|
type SignupPendingRequest = {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
|
@ -5,6 +5,8 @@ import {
|
||||||
PartialRolePolicyOverride,
|
PartialRolePolicyOverride,
|
||||||
SigninRequest,
|
SigninRequest,
|
||||||
SigninResponse,
|
SigninResponse,
|
||||||
|
SigninWithPasskeyRequest,
|
||||||
|
SigninWithPasskeyResponse,
|
||||||
SignupPendingRequest,
|
SignupPendingRequest,
|
||||||
SignupPendingResponse,
|
SignupPendingResponse,
|
||||||
SignupRequest,
|
SignupRequest,
|
||||||
|
@ -82,6 +84,10 @@ export type Endpoints = Overwrite<
|
||||||
req: SigninRequest;
|
req: SigninRequest;
|
||||||
res: SigninResponse;
|
res: SigninResponse;
|
||||||
},
|
},
|
||||||
|
'signin-with-passkey': {
|
||||||
|
req: SigninWithPasskeyRequest;
|
||||||
|
res: SigninWithPasskeyResponse;
|
||||||
|
}
|
||||||
'admin/roles/create': {
|
'admin/roles/create': {
|
||||||
req: Overwrite<AdminRolesCreateRequest, { policies: PartialRolePolicyOverride }>;
|
req: Overwrite<AdminRolesCreateRequest, { policies: PartialRolePolicyOverride }>;
|
||||||
res: AdminRolesCreateResponse;
|
res: AdminRolesCreateResponse;
|
||||||
|
|
|
@ -271,6 +271,17 @@ export type SigninRequest = {
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SigninWithPasskeyRequest = {
|
||||||
|
credential?: object;
|
||||||
|
context?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SigninWithPasskeyResponse = {
|
||||||
|
option?: object;
|
||||||
|
context?: string;
|
||||||
|
signinResponse?: SigninResponse;
|
||||||
|
};
|
||||||
|
|
||||||
export type SigninResponse = {
|
export type SigninResponse = {
|
||||||
id: User['id'],
|
id: User['id'],
|
||||||
i: string,
|
i: string,
|
||||||
|
|
Loading…
Reference in a new issue