Merge branch 'develop' into feat-setup-initial-password

This commit is contained in:
かっこかり 2024-09-26 12:46:43 +09:00 committed by GitHub
commit ace9e4ea7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 546 additions and 35 deletions

View File

@ -5,20 +5,24 @@
ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`initialPassword`をランダムな値に設定し、ユーザーに通知するようにしてください。
### General
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
- 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください
- Feat: パスキーでログインボタンを実装 (#14574)
- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445)
- Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680)
- Feat: サーバー初期設定時に初期パスワードを設定できるように
- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように
- Enhance: 依存関係の更新
- Enhance: l10nの更新
### Client
- Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能
- 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください
- Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように
- Enhance: アイコンデコレーション管理画面にプレビューを追加
- Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく
- Enhance: ScratchpadにUIインスペクターを追加
- Enhance: Play編集画面の項目の並びを少しリデザイン
- Enhance: 各種メニューをドロワー表示するかどうか設定可能に
- Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正
- Fix: コントロールパネル内のAp requests内のチャートの表示がおかしかった問題を修正
- Fix: 月の違う同じ日はセパレータが表示されないのを修正

20
locales/index.d.ts vendored
View File

@ -2070,6 +2070,10 @@ export interface Locale extends ILocale {
*
*/
"menuStyle": string;
/**
*
*/
"style": string;
/**
*
*/
@ -5130,6 +5134,22 @@ export interface Locale extends ILocale {
* {n}
*/
"thereAreNChanges": ParameterizedString<"n">;
/**
*
*/
"signinWithPasskey": string;
/**
*
*/
"unknownWebAuthnKey": string;
/**
*
*/
"passkeyVerificationFailed": string;
/**
*
*/
"passkeyVerificationSucceededButPasswordlessLoginDisabled": string;
"_delivery": {
/**
*

View File

@ -513,6 +513,7 @@ aboutX: "{x}について"
emojiStyle: "絵文字のスタイル"
native: "ネイティブ"
menuStyle: "メニューのスタイル"
style: "スタイル"
drawer: "ドロワー"
popup: "ポップアップ"
showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する"
@ -1278,6 +1279,10 @@ performance: "パフォーマンス"
modified: "変更あり"
discard: "破棄"
thereAreNChanges: "{n}件の変更があります"
signinWithPasskey: "パスキーでログイン"
unknownWebAuthnKey: "登録されていないパスキーです。"
passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
_delivery:
status: "配信状態"

View File

@ -55,6 +55,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js';
import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -146,6 +147,7 @@ type Option = {
@Injectable()
export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController();
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
constructor(
@Inject(DI.config)
@ -215,7 +217,9 @@ export class NoteCreateService implements OnApplicationShutdown {
private instanceChart: InstanceChart,
private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
) { }
) {
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
}
@bindThis
public async create(user: {
@ -509,7 +513,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
this.updateNotesCountQueue.enqueue(i.id, 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
@ -1028,12 +1032,23 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
public dispose(): void {
this.#shutdownController.abort();
private collapseNotesCount(oldValue: number, newValue: number) {
return oldValue + newValue;
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
}
@bindThis
public async dispose(): Promise<void> {
this.#shutdownController.abort();
await this.updateNotesCountQueue.performAllNow();
}
@bindThis
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
await this.dispose();
}
}

View File

@ -164,6 +164,86 @@ export class WebAuthnService {
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
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);

View File

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type Job<V> = {
value: V;
timer: NodeJS.Timeout;
};
// TODO: redis使えるようにする
export class CollapsedQueue<K, V> {
private jobs: Map<K, Job<V>> = new Map();
constructor(
private timeout: number,
private collapse: (oldValue: V, newValue: V) => V,
private perform: (key: K, value: V) => Promise<void>,
) {}
enqueue(key: K, value: V) {
if (this.jobs.has(key)) {
const old = this.jobs.get(key)!;
const merged = this.collapse(old.value, value);
this.jobs.set(key, { ...old, value: merged });
} else {
const timer = setTimeout(() => {
const job = this.jobs.get(key)!;
this.jobs.delete(key);
this.perform(key, job.value);
}, this.timeout);
this.jobs.set(key, { value, timer });
}
}
async performAllNow() {
const entries = [...this.jobs.entries()];
this.jobs.clear();
for (const [_key, job] of entries) {
clearTimeout(job.timer);
}
await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value)));
}
}

View File

@ -4,7 +4,7 @@
*/
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import httpSignature from '@peertube/http-signature';
import * as Bull from 'bullmq';
import type Logger from '@/logger.js';
@ -25,14 +25,22 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js';
import { ApInboxService } from '@/core/activitypub/ApInboxService.js';
import { bindThis } from '@/decorators.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type { InboxJobData } from '../types.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { MiNote } from '@/models/Note.js';
import { MiMeta } from '@/models/Meta.js';
import { DI } from '@/di-symbols.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type { InboxJobData } from '../types.js';
type UpdateInstanceJob = {
latestRequestReceivedAt: Date,
shouldUnsuspend: boolean,
};
@Injectable()
export class InboxProcessorService {
export class InboxProcessorService implements OnApplicationShutdown {
private logger: Logger;
private updateInstanceQueue: CollapsedQueue<MiNote['id'], UpdateInstanceJob>;
constructor(
@Inject(DI.meta)
@ -51,6 +59,7 @@ export class InboxProcessorService {
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('inbox');
this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
}
@bindThis
@ -187,11 +196,9 @@ export class InboxProcessorService {
// Update stats
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
this.federatedInstanceService.update(i.id, {
this.updateInstanceQueue.enqueue(i.id, {
latestRequestReceivedAt: new Date(),
isNotResponding: false,
// もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined,
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
});
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
@ -227,4 +234,36 @@ export class InboxProcessorService {
}
return 'ok';
}
@bindThis
public collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) {
const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt
? newJob.latestRequestReceivedAt
: oldJob.latestRequestReceivedAt;
const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend;
return {
latestRequestReceivedAt,
shouldUnsuspend,
};
}
@bindThis
public async performUpdateInstance(id: string, job: UpdateInstanceJob) {
await this.federatedInstanceService.update(id, {
latestRequestReceivedAt: new Date(),
isNotResponding: false,
// もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
suspensionState: job.shouldUnsuspend ? 'none' : undefined,
});
}
@bindThis
public async dispose(): Promise<void> {
await this.updateInstanceQueue.performAllNow();
}
@bindThis
async onApplicationShutdown(signal?: string) {
await this.dispose();
}
}

View File

@ -46,6 +46,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@Module({
imports: [
@ -71,6 +72,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
AuthenticateService,
RateLimiterService,
SigninApiService,
SigninWithPasskeyApiService,
SigninService,
SignupApiService,
StreamingApiServerService,

View File

@ -8,6 +8,7 @@ import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import { ModuleRef } from '@nestjs/core';
import { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { Config } from '@/config.js';
import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
@ -17,6 +18,7 @@ import endpoints from './endpoints.js';
import { ApiCallService } from './ApiCallService.js';
import { SignupApiService } from './SignupApiService.js';
import { SigninApiService } from './SigninApiService.js';
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@Injectable()
@ -37,6 +39,7 @@ export class ApiServerService {
private apiCallService: ApiCallService,
private signupApiService: SignupApiService,
private signinApiService: SigninApiService,
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
) {
//this.createServer = this.createServer.bind(this);
}
@ -131,6 +134,12 @@ export class ApiServerService {
};
}>('/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.get('/v1/instance/peers', async (request, reply) => {

View 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,
};
}
}

View File

@ -13,6 +13,8 @@ html(class='embed')
meta(name='referrer' content='origin')
meta(name='theme-color' content= themeColor || '#86b300')
meta(name='theme-color-orig' content= themeColor || '#86b300')
meta(property='og:site_name' content= instanceName || 'Misskey')
meta(property='instance_url' content= instanceUrl)
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')

View File

@ -229,6 +229,7 @@ function onMousedown(evt: MouseEvent): void {
}
&.danger {
font-weight: bold;
color: #ff2a2a;
&.primary {

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="modal"
v-slot="{ type, maxHeight }"
:zPriority="'middle'"
:preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:preferType="defaultStore.state.emojiPickerStyle"
:hasInteractionWithOtherFocusTrappedEls="true"
:transparentBg="true"
:manualShowing="manualShowing"

View File

@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</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 #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</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="user && user.securityKeys" class="twofa-group tap-group">
<p>{{ i18n.ts.useSecurityKey }}</p>
<MkButton v-if="!queryingKey" @click="queryKey">
<MkButton v-if="!queryingKey" @click="query2FaKey">
{{ i18n.ts.retry }}
</MkButton>
</div>
@ -45,10 +45,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
<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'">
<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>
@ -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>
</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>
</form>
</template>
@ -66,13 +72,15 @@ import { defineAsyncComponent, ref } from 'vue';
import { toUnicode } from 'punycode/';
import * as Misskey from 'misskey-js';
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 { host as configHost } from '@@/js/config.js';
import MkDivider from './MkDivider.vue';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
import { host as configHost } from '@@/js/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { login } from '@/account.js';
@ -80,6 +88,7 @@ import { i18n } from '@/i18n.js';
const signing = ref(false);
const user = ref<Misskey.entities.UserDetailed | null>(null);
const usePasswordLessLogin = ref<Misskey.entities.UserDetailed['usePasswordLessLogin']>(true);
const username = ref('');
const password = ref('');
const token = ref('');
@ -88,6 +97,7 @@ const totpLogin = ref(false);
const isBackupCode = ref(false);
const queryingKey = ref(false);
let credentialRequest: CredentialRequestOptions | null = null;
const passkey_context = ref('');
const emit = defineEmits<{
(ev: 'login', v: any): void;
@ -110,8 +120,10 @@ function onUsernameChange(): void {
username: username.value,
}).then(userResponse => {
user.value = userResponse;
usePasswordLessLogin.value = userResponse.usePasswordLessLogin;
}, () => {
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;
queryingKey.value = true;
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 {
signing.value = true;
if (!totpLogin.value && user.value && user.value.twoFactorEnabled) {
@ -164,7 +217,7 @@ function onSubmit(): void {
publicKey: res,
});
})
.then(() => queryKey())
.then(() => query2FaKey())
.catch(loginFailed);
} else {
totpLogin.value = true;
@ -212,6 +265,30 @@ function loginFailed(err: any): void {
});
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: {
console.error(err);
os.alert({

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow
ref="dialog"
:width="400"
:height="430"
:height="450"
@close="onClose"
@closed="emit('closed')"
>

View File

@ -371,6 +371,7 @@ const patrons = [
'塩キャベツ',
'はとぽぷさん',
'100の人 (エスパー・イーシア)',
'ケモナーのケシン',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@ -113,10 +113,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<option :value="4">{{ i18n.ts.large }}+</option>
</MkRadios>
<MkSwitch v-model="emojiPickerUseDrawerForMobile">
{{ i18n.ts.useDrawerReactionPickerForMobile }}
<MkSelect v-model="emojiPickerStyle">
<template #label>{{ i18n.ts.style }}</template>
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
</MkSwitch>
<option value="auto">{{ i18n.ts.auto }}</option>
<option value="popup">{{ i18n.ts.popup }}</option>
<option value="drawer">{{ i18n.ts.drawer }}</option>
</MkSelect>
</div>
</FormSection>
</div>
@ -128,7 +131,7 @@ import Sortable from 'vuedraggable';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import FormSection from '@/components/form/section.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import * as os from '@/os.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
@ -146,7 +149,7 @@ const pinnedEmojis: Ref<string[]> = ref(deepClone(defaultStore.state.pinnedEmoji
const emojiPickerScale = computed(defaultStore.makeGetterSetter('emojiPickerScale'));
const emojiPickerWidth = computed(defaultStore.makeGetterSetter('emojiPickerWidth'));
const emojiPickerHeight = computed(defaultStore.makeGetterSetter('emojiPickerHeight'));
const emojiPickerUseDrawerForMobile = computed(defaultStore.makeGetterSetter('emojiPickerUseDrawerForMobile'));
const emojiPickerStyle = computed(defaultStore.makeGetterSetter('emojiPickerStyle'));
const removeReaction = (reaction: string, ev: MouseEvent) => remove(pinnedEmojisForReaction, reaction, ev);
const chooseReaction = (ev: MouseEvent) => pickEmoji(pinnedEmojisForReaction, ev);

View File

@ -87,7 +87,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'emojiPickerScale',
'emojiPickerWidth',
'emojiPickerHeight',
'emojiPickerUseDrawerForMobile',
'emojiPickerStyle',
'defaultSideView',
'menuDisplay',
'reportError',

View File

@ -304,9 +304,9 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: 2,
},
emojiPickerUseDrawerForMobile: {
emojiPickerStyle: {
where: 'device',
default: true,
default: 'auto' as 'auto' | 'popup' | 'drawer',
},
recentlyUsedEmojis: {
where: 'device',

View File

@ -1160,6 +1160,10 @@ export type Endpoints = Overwrite<Endpoints_2, {
req: SigninRequest;
res: SigninResponse;
};
'signin-with-passkey': {
req: SigninWithPasskeyRequest;
res: SigninWithPasskeyResponse;
};
'admin/roles/create': {
req: Overwrite<AdminRolesCreateRequest, {
policies: PartialRolePolicyOverride;
@ -1191,6 +1195,8 @@ declare namespace entities {
SignupPendingRequest,
SignupPendingResponse,
SigninRequest,
SigninWithPasskeyRequest,
SigninWithPasskeyResponse,
SigninResponse,
PartialRolePolicyOverride,
EmptyRequest,
@ -3029,6 +3035,19 @@ type SigninResponse = {
i: string;
};
// @public (undocumented)
type SigninWithPasskeyRequest = {
credential?: object;
context?: string;
};
// @public (undocumented)
type SigninWithPasskeyResponse = {
option?: object;
context?: string;
signinResponse?: SigninResponse;
};
// @public (undocumented)
type SignupPendingRequest = {
code: string;

View File

@ -5,6 +5,8 @@ import {
PartialRolePolicyOverride,
SigninRequest,
SigninResponse,
SigninWithPasskeyRequest,
SigninWithPasskeyResponse,
SignupPendingRequest,
SignupPendingResponse,
SignupRequest,
@ -82,6 +84,10 @@ export type Endpoints = Overwrite<
req: SigninRequest;
res: SigninResponse;
},
'signin-with-passkey': {
req: SigninWithPasskeyRequest;
res: SigninWithPasskeyResponse;
}
'admin/roles/create': {
req: Overwrite<AdminRolesCreateRequest, { policies: PartialRolePolicyOverride }>;
res: AdminRolesCreateResponse;

View File

@ -271,6 +271,17 @@ export type SigninRequest = {
token?: string;
};
export type SigninWithPasskeyRequest = {
credential?: object;
context?: string;
};
export type SigninWithPasskeyResponse = {
option?: object;
context?: string;
signinResponse?: SigninResponse;
};
export type SigninResponse = {
id: User['id'],
i: string,