enhance(frontend): サインイン画面の改善 (#14658)

* wip

* Update MkSignin.vue

* Update MkSignin.vue

* wip

* Update CHANGELOG.md

* enhance(frontend): サインイン画面の改善

* Update Changelog

* 14655の変更取り込み

* spdx

* fix

* fix

* fix

* 🎨

* 🎨

* 🎨

* 🎨

* Captchaがリセットされない問題を修正

* 次の処理をsignin apiから読み取るように

* Add Comments

* fix

* fix test

* attempt to fix test

* fix test

* fix test

* fix test

* fix

* fix test

* fix: 一部のエラーがちゃんと出るように

* Update Changelog

* 🎨

* 🎨

* remove border

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
かっこかり 2024-10-04 15:23:33 +09:00 committed by GitHub
parent e344650278
commit 975c2e7bc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1161 additions and 489 deletions

View file

@ -4,6 +4,8 @@
- サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません) - サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません)
- ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。 - ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。
- なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能ですUI上で初期パスワードの入力欄を空欄にすると続行できます - なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能ですUI上で初期パスワードの入力欄を空欄にすると続行できます
- ユーザーデータを読み込む際の型が一部変更されました。
- `twoFactorEnabled`, `usePasswordLessLogin`, `securityKeys`: 自分とモデレーター以外のユーザーからは取得できなくなりました
### General ### General
- Feat: サーバー初期設定時に初期パスワードを設定できるように - Feat: サーバー初期設定時に初期パスワードを設定できるように
@ -14,9 +16,11 @@
### Client ### Client
- Enhance: デザインの調整 - Enhance: デザインの調整
- Enhance: ログイン画面の認証フローを改善
### Server ### Server
- Enhance: セキュリティ向上のため、ログイン時にメール通知を行うように - Enhance: セキュリティ向上のため、ログイン時にメール通知を行うように
- Enhance: 自分とモデレーター以外のユーザーから二要素認証関連のデータが取得できないように
## 2024.9.0 ## 2024.9.0

View file

@ -123,8 +123,13 @@ describe('After user signup', () => {
cy.intercept('POST', '/api/signin').as('signin'); cy.intercept('POST', '/api/signin').as('signin');
cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-username] input').type('alice');
// Enterキーでサインインできるかの確認も兼ねる cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-username] input').type('alice{enter}');
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
// Enterキーで続行できるかの確認も兼ねる
cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
cy.wait('@signin'); cy.wait('@signin');
@ -139,8 +144,9 @@ describe('After user signup', () => {
cy.visitHome(); cy.visitHome();
cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-username] input').type('alice');
cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type('alice{enter}');
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする // TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi); cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);

View file

@ -58,7 +58,9 @@ Cypress.Commands.add('login', (username, password) => {
cy.intercept('POST', '/api/signin').as('signin'); cy.intercept('POST', '/api/signin').as('signin');
cy.get('[data-cy-signin]').click(); cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-username] input').type(username); cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`); cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
cy.wait('@signin').as('signedIn'); cy.wait('@signin').as('signedIn');

4
locales/index.d.ts vendored
View file

@ -3714,6 +3714,10 @@ export interface Locale extends ILocale {
* *
*/ */
"incorrectPassword": string; "incorrectPassword": string;
/**
*
*/
"incorrectTotp": string;
/** /**
* {choice} * {choice}
*/ */

View file

@ -924,6 +924,7 @@ followersVisibility: "フォロワーの公開範囲"
continueThread: "さらにスレッドを見る" continueThread: "さらにスレッドを見る"
deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" deleteAccountConfirm: "アカウントが削除されます。よろしいですか?"
incorrectPassword: "パスワードが間違っています。" incorrectPassword: "パスワードが間違っています。"
incorrectTotp: "ワンタイムパスワードが間違っているか、期限切れになっています。"
voteConfirm: "「{choice}」に投票しますか?" voteConfirm: "「{choice}」に投票しますか?"
hide: "隠す" hide: "隠す"
useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示" useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示"

View file

@ -545,11 +545,6 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility, followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility, followingVisibility: profile!.followingVisibility,
twoFactorEnabled: profile!.twoFactorEnabled,
usePasswordLessLogin: profile!.usePasswordLessLogin,
securityKeys: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
: false,
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id, id: role.id,
name: role.name, name: role.name,
@ -564,6 +559,14 @@ export class UserEntityService implements OnModuleInit {
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
} : {}), } : {}),
...(isDetailed && (isMe || iAmModerator) ? {
twoFactorEnabled: profile!.twoFactorEnabled,
usePasswordLessLogin: profile!.usePasswordLessLogin,
securityKeys: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
: false,
} : {}),
...(isDetailed && isMe ? { ...(isDetailed && isMe ? {
avatarId: user.avatarId, avatarId: user.avatarId,
bannerId: user.bannerId, bannerId: user.bannerId,

View file

@ -346,21 +346,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false, nullable: false, optional: false,
enum: ['public', 'followers', 'private'], enum: ['public', 'followers', 'private'],
}, },
twoFactorEnabled: {
type: 'boolean',
nullable: false, optional: false,
default: false,
},
usePasswordLessLogin: {
type: 'boolean',
nullable: false, optional: false,
default: false,
},
securityKeys: {
type: 'boolean',
nullable: false, optional: false,
default: false,
},
roles: { roles: {
type: 'array', type: 'array',
nullable: false, optional: false, nullable: false, optional: false,
@ -382,6 +367,18 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'string', type: 'string',
nullable: false, optional: true, nullable: false, optional: true,
}, },
twoFactorEnabled: {
type: 'boolean',
nullable: false, optional: true,
},
usePasswordLessLogin: {
type: 'boolean',
nullable: false, optional: true,
},
securityKeys: {
type: 'boolean',
nullable: false, optional: true,
},
//#region relations //#region relations
isFollowing: { isFollowing: {
type: 'boolean', type: 'boolean',
@ -630,6 +627,21 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false, nullable: false, optional: false,
ref: 'RolePolicies', ref: 'RolePolicies',
}, },
twoFactorEnabled: {
type: 'boolean',
nullable: false, optional: false,
default: false,
},
usePasswordLessLogin: {
type: 'boolean',
nullable: false, optional: false,
default: false,
},
securityKeys: {
type: 'boolean',
nullable: false, optional: false,
default: false,
},
//#region secrets //#region secrets
email: { email: {
type: 'string', type: 'string',

View file

@ -12,6 +12,7 @@ import type {
MiMeta, MiMeta,
SigninsRepository, SigninsRepository,
UserProfilesRepository, UserProfilesRepository,
UserSecurityKeysRepository,
UsersRepository, UsersRepository,
} from '@/models/_.js'; } from '@/models/_.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -25,9 +26,27 @@ import { CaptchaService } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { RateLimiterService } from './RateLimiterService.js'; import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js'; import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
/**
* next
*
* - `captcha`: CAPTCHAを求める
* - `password`:
* - `totp`:
* - `passkey`: WebAuthn認証を求めるWebAuthnに対応していないブラウザの場合はワンタイムパスワード
*/
type SigninErrorResponse = {
id: string;
next?: 'captcha' | 'password' | 'totp';
} | {
id: string;
next: 'passkey';
authRequest: PublicKeyCredentialRequestOptionsJSON;
};
@Injectable() @Injectable()
export class SigninApiService { export class SigninApiService {
constructor( constructor(
@ -43,6 +62,9 @@ export class SigninApiService {
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.signinsRepository) @Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository, private signinsRepository: SigninsRepository,
@ -60,7 +82,7 @@ export class SigninApiService {
request: FastifyRequest<{ request: FastifyRequest<{
Body: { Body: {
username: string; username: string;
password: string; password?: string;
token?: string; token?: string;
credential?: AuthenticationResponseJSON; credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string; 'hcaptcha-response'?: string;
@ -79,7 +101,7 @@ export class SigninApiService {
const password = body['password']; const password = body['password'];
const token = body['token']; const token = body['token'];
function error(status: number, error: { id: string }) { function error(status: number, error: SigninErrorResponse) {
reply.code(status); reply.code(status);
return { error }; return { error };
} }
@ -103,11 +125,6 @@ export class SigninApiService {
return; return;
} }
if (typeof password !== 'string') {
reply.code(400);
return;
}
if (token != null && typeof token !== 'string') { if (token != null && typeof token !== 'string') {
reply.code(400); reply.code(400);
return; return;
@ -132,11 +149,36 @@ export class SigninApiService {
} }
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1);
if (password == null) {
reply.code(403);
if (profile.twoFactorEnabled) {
return {
error: {
id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
next: 'password',
},
} satisfies { error: SigninErrorResponse };
} else {
return {
error: {
id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
next: 'captcha',
},
} satisfies { error: SigninErrorResponse };
}
}
if (typeof password !== 'string') {
reply.code(400);
return;
}
// Compare password // Compare password
const same = await bcrypt.compare(password, profile.password!); const same = await bcrypt.compare(password, profile.password!);
const fail = async (status?: number, failure?: { id: string }) => { const fail = async (status?: number, failure?: SigninErrorResponse) => {
// Append signin history // Append signin history
await this.signinsRepository.insert({ await this.signinsRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
@ -217,7 +259,7 @@ export class SigninApiService {
id: '93b86c4b-72f9-40eb-9815-798928603d1e', id: '93b86c4b-72f9-40eb-9815-798928603d1e',
}); });
} }
} else { } else if (securityKeysAvailable) {
if (!same && !profile.usePasswordLessLogin) { if (!same && !profile.usePasswordLessLogin) {
return await fail(403, { return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
@ -226,8 +268,28 @@ export class SigninApiService {
const authRequest = await this.webAuthnService.initiateAuthentication(user.id); const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
reply.code(200); reply.code(403);
return authRequest; return {
error: {
id: '06e661b9-8146-4ae3-bde5-47138c0ae0c4',
next: 'passkey',
authRequest,
},
} satisfies { error: SigninErrorResponse };
} else {
if (!same || !profile.twoFactorEnabled) {
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
} else {
reply.code(403);
return {
error: {
id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
next: 'totp',
},
} satisfies { error: SigninErrorResponse };
}
} }
// never get here // never get here
} }

View file

@ -136,13 +136,7 @@ describe('2要素認証', () => {
keyName: string, keyName: string,
credentialId: Buffer, credentialId: Buffer,
requestOptions: PublicKeyCredentialRequestOptionsJSON, requestOptions: PublicKeyCredentialRequestOptionsJSON,
}): { }): misskey.entities.SigninRequest => {
username: string,
password: string,
credential: AuthenticationResponseJSON,
'g-recaptcha-response'?: string | null,
'hcaptcha-response'?: string | null,
} => {
// AuthenticatorAssertionResponse.authenticatorData // AuthenticatorAssertionResponse.authenticatorData
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
const authenticatorData = Buffer.concat([ const authenticatorData = Buffer.concat([
@ -202,11 +196,16 @@ describe('2要素認証', () => {
}, alice); }, alice);
assert.strictEqual(doneResponse.status, 200); assert.strictEqual(doneResponse.status, 200);
const usersShowResponse = await api('users/show', { const signinWithoutTokenResponse = await api('signin', {
username, ...signinParam(),
}, alice); });
assert.strictEqual(usersShowResponse.status, 200); assert.strictEqual(signinWithoutTokenResponse.status, 403);
assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); assert.deepStrictEqual(signinWithoutTokenResponse.body, {
error: {
id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
next: 'totp',
},
});
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -253,26 +252,28 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName); assert.strictEqual(keyDoneResponse.body.name, keyName);
const usersShowResponse = await api('users/show', {
username,
});
assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
}); });
assert.strictEqual(signinResponse.status, 200); const signinResponseBody = signinResponse.body as unknown as {
assert.strictEqual(signinResponse.body.i, undefined); error: {
assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined); id: string;
assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined); next: 'passkey';
assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url')); authRequest: PublicKeyCredentialRequestOptionsJSON;
};
};
assert.strictEqual(signinResponse.status, 403);
assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4');
assert.strictEqual(signinResponseBody.error.next, 'passkey');
assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined);
assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined);
assert.strictEqual(signinResponseBody.error.authRequest.allowCredentials && signinResponseBody.error.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url'));
const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ const signinResponse2 = await api('signin', signinWithSecurityKeyParam({
keyName, keyName,
credentialId, credentialId,
requestOptions: signinResponse.body, requestOptions: signinResponseBody.error.authRequest,
} as any)); }));
assert.strictEqual(signinResponse2.status, 200); assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined); assert.notEqual(signinResponse2.body.i, undefined);
@ -315,24 +316,32 @@ describe('2要素認証', () => {
}, alice); }, alice);
assert.strictEqual(passwordLessResponse.status, 204); assert.strictEqual(passwordLessResponse.status, 204);
const usersShowResponse = await api('users/show', { const iResponse = await api('i', {}, alice);
username, assert.strictEqual(iResponse.status, 200);
}); assert.strictEqual(iResponse.body.usePasswordLessLogin, true);
assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
password: '', password: '',
}); });
assert.strictEqual(signinResponse.status, 200); const signinResponseBody = signinResponse.body as unknown as {
assert.strictEqual(signinResponse.body.i, undefined); error: {
id: string;
next: 'passkey';
authRequest: PublicKeyCredentialRequestOptionsJSON;
};
};
assert.strictEqual(signinResponse.status, 403);
assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4');
assert.strictEqual(signinResponseBody.error.next, 'passkey');
assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined);
assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined);
const signinResponse2 = await api('signin', { const signinResponse2 = await api('signin', {
...signinWithSecurityKeyParam({ ...signinWithSecurityKeyParam({
keyName, keyName,
credentialId, credentialId,
requestOptions: signinResponse.body, requestOptions: signinResponseBody.error.authRequest,
} as any), } as any),
password: '', password: '',
}); });
@ -424,11 +433,11 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
// テストの実行順によっては複数残ってるので全部消す // テストの実行順によっては複数残ってるので全部消す
const iResponse = await api('i', { const beforeIResponse = await api('i', {
}, alice); }, alice);
assert.strictEqual(iResponse.status, 200); assert.strictEqual(beforeIResponse.status, 200);
assert.ok(iResponse.body.securityKeysList); assert.ok(beforeIResponse.body.securityKeysList);
for (const key of iResponse.body.securityKeysList) { for (const key of beforeIResponse.body.securityKeysList) {
const removeKeyResponse = await api('i/2fa/remove-key', { const removeKeyResponse = await api('i/2fa/remove-key', {
token: otpToken(registerResponse.body.secret), token: otpToken(registerResponse.body.secret),
password, password,
@ -437,11 +446,9 @@ describe('2要素認証', () => {
assert.strictEqual(removeKeyResponse.status, 200); assert.strictEqual(removeKeyResponse.status, 200);
} }
const usersShowResponse = await api('users/show', { const afterIResponse = await api('i', {}, alice);
username, assert.strictEqual(afterIResponse.status, 200);
}); assert.strictEqual(afterIResponse.body.securityKeys, false);
assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false);
const signinResponse = await api('signin', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -468,11 +475,9 @@ describe('2要素認証', () => {
}, alice); }, alice);
assert.strictEqual(doneResponse.status, 200); assert.strictEqual(doneResponse.status, 200);
const usersShowResponse = await api('users/show', { const iResponse = await api('i', {}, alice);
username, assert.strictEqual(iResponse.status, 200);
}); assert.strictEqual(iResponse.body.twoFactorEnabled, true);
assert.strictEqual(usersShowResponse.status, 200);
assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true);
const unregisterResponse = await api('i/2fa/unregister', { const unregisterResponse = await api('i/2fa/unregister', {
token: otpToken(registerResponse.body.secret), token: otpToken(registerResponse.body.secret),

View file

@ -83,9 +83,6 @@ describe('ユーザー', () => {
publicReactions: user.publicReactions, publicReactions: user.publicReactions,
followingVisibility: user.followingVisibility, followingVisibility: user.followingVisibility,
followersVisibility: user.followersVisibility, followersVisibility: user.followersVisibility,
twoFactorEnabled: user.twoFactorEnabled,
usePasswordLessLogin: user.usePasswordLessLogin,
securityKeys: user.securityKeys,
roles: user.roles, roles: user.roles,
memo: user.memo, memo: user.memo,
}); });
@ -149,6 +146,9 @@ describe('ユーザー', () => {
achievements: user.achievements, achievements: user.achievements,
loggedInDays: user.loggedInDays, loggedInDays: user.loggedInDays,
policies: user.policies, policies: user.policies,
twoFactorEnabled: user.twoFactorEnabled,
usePasswordLessLogin: user.usePasswordLessLogin,
securityKeys: user.securityKeys,
...(security ? { ...(security ? {
email: user.email, email: user.email,
emailVerified: user.emailVerified, emailVerified: user.emailVerified,
@ -343,9 +343,6 @@ describe('ユーザー', () => {
assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.followingVisibility, 'public'); assert.strictEqual(response.followingVisibility, 'public');
assert.strictEqual(response.followersVisibility, 'public'); assert.strictEqual(response.followersVisibility, 'public');
assert.strictEqual(response.twoFactorEnabled, false);
assert.strictEqual(response.usePasswordLessLogin, false);
assert.strictEqual(response.securityKeys, false);
assert.deepStrictEqual(response.roles, []); assert.deepStrictEqual(response.roles, []);
assert.strictEqual(response.memo, null); assert.strictEqual(response.memo, null);
@ -385,6 +382,9 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.loggedInDays, 0);
assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); assert.deepStrictEqual(response.policies, DEFAULT_POLICIES);
assert.strictEqual(response.twoFactorEnabled, false);
assert.strictEqual(response.usePasswordLessLogin, false);
assert.strictEqual(response.securityKeys, false);
assert.notStrictEqual(response.email, undefined); assert.notStrictEqual(response.email, undefined);
assert.strictEqual(response.emailVerified, false); assert.strictEqual(response.emailVerified, false);
assert.deepStrictEqual(response.securityKeysList, []); assert.deepStrictEqual(response.securityKeysList, []);
@ -618,6 +618,9 @@ describe('ユーザー', () => {
{ label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator }, { label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator },
// @ts-expect-error UserDetailedNotMe doesn't include isModerator // @ts-expect-error UserDetailedNotMe doesn't include isModerator
{ label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined }, { label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined },
{ label: '自分から見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => alice, selector: (user: misskey.entities.MeDetailed) => user.twoFactorEnabled, expected: () => false },
{ label: '自分以外から見た場合に二要素認証関連のプロパティがセットされていない', user: () => alice, me: () => bob, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => undefined },
{ label: 'モデレーターから見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => false },
{ label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced }, { label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced },
// FIXME: 落ちる // FIXME: 落ちる
//{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended }, //{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended },

View file

@ -0,0 +1,206 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper" data-cy-signin-page-input>
<div :class="$style.root">
<div :class="$style.avatar">
<i class="ti ti-user"></i>
</div>
<!-- ログイン画面メッセージ -->
<MkInfo v-if="message">
{{ message }}
</MkInfo>
<!-- 外部サーバーへの転送 -->
<div v-if="openOnRemote" class="_gaps_m">
<div class="_gaps_s">
<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)">
{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i>
</MkButton>
<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)">
{{ i18n.ts.specifyServerHost }}
</button>
</div>
<div :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
</div>
<!-- username入力 -->
<form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
<MkButton type="submit" large primary rounded style="margin: 0 auto;" data-cy-signin-page-input-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</form>
<!-- パスワードレスログイン -->
<div :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
<div>
<MkButton type="submit" style="margin: auto auto;" large rounded primary gradate @click="emit('passkeyClick', $event)">
<i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }}
</MkButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { toUnicode } from 'punycode/';
import { query, extractDomain } from '@@/js/url.js';
import { host as configHost } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
const props = withDefaults(defineProps<{
message?: string,
openOnRemote?: OpenOnRemoteOptions,
}>(), {
message: '',
openOnRemote: undefined,
});
const emit = defineEmits<{
(ev: 'usernameSubmitted', v: string): void;
(ev: 'passkeyClick', v: MouseEvent): void;
}>();
const host = toUnicode(configHost);
const username = ref('');
//#region Open on remote
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
switch (options.type) {
case 'web':
case 'lookup': {
let _path: string;
if (options.type === 'lookup') {
// TODO: v2024.7.0URL
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
} else {
_path = options.path;
}
if (targetHost) {
window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
} else {
window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
}
break;
}
case 'share': {
const params = query(options.params);
if (targetHost) {
window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
} else {
window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
}
break;
}
}
}
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
const { canceled, result: hostTemp } = await os.inputText({
title: i18n.ts.inputHostName,
placeholder: 'misskey.example.com',
});
if (canceled) return;
let targetHost: string | null = hostTemp;
//
targetHost = extractDomain(targetHost ?? '');
if (targetHost == null) {
os.alert({
type: 'error',
title: i18n.ts.invalidValue,
text: i18n.ts.tryAgain,
});
return;
}
openRemote(options, targetHost);
}
//#endregion
</script>
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
gap: 20px;
}
.wrapper {
display: flex;
align-items: center;
width: 100%;
min-height: 336px;
> .root {
width: 100%;
}
}
.avatar {
margin: 0 auto;
background-color: color-mix(in srgb, var(--fg), transparent 85%);
color: color-mix(in srgb, var(--fg), transparent 25%);
text-align: center;
height: 64px;
width: 64px;
font-size: 24px;
line-height: 64px;
border-radius: 50%;
}
.instanceManualSelectButton {
display: block;
text-align: center;
opacity: .7;
font-size: .8em;
&:hover {
text-decoration: underline;
}
}
.orHr {
position: relative;
margin: .4em auto;
width: 100%;
height: 1px;
background: var(--divider);
}
.orMsg {
position: absolute;
top: -.6em;
display: inline-block;
padding: 0 1em;
background: var(--panel);
font-size: 0.8em;
color: var(--fgOnPanel);
margin: 0;
left: 50%;
transform: translateX(-50%);
}
</style>

View file

@ -0,0 +1,92 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper">
<div class="_gaps" :class="$style.root">
<div class="_gaps_s">
<div :class="$style.passkeyIcon">
<i class="ti ti-fingerprint"></i>
</div>
<div :class="$style.passkeyDescription">{{ i18n.ts.useSecurityKey }}</div>
</div>
<MkButton large primary rounded :disabled="queryingKey" style="margin: 0 auto;" @click="queryKey">{{ i18n.ts.retry }}</MkButton>
<MkButton v-if="isPerformingPasswordlessLogin !== true" transparent rounded :disabled="queryingKey" style="margin: 0 auto;" @click="emit('useTotp')">{{ i18n.ts.useTotp }}</MkButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { get as webAuthnRequest } from '@github/webauthn-json/browser-ponyfill';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
const props = defineProps<{
credentialRequest: CredentialRequestOptions;
isPerformingPasswordlessLogin?: boolean;
}>();
const emit = defineEmits<{
(ev: 'done', credential: AuthenticationPublicKeyCredential): void;
(ev: 'useTotp'): void;
}>();
const queryingKey = ref(true);
async function queryKey() {
queryingKey.value = true;
await webAuthnRequest(props.credentialRequest)
.catch(() => {
return Promise.reject(null);
})
.then((credential) => {
emit('done', credential);
})
.finally(() => {
queryingKey.value = false;
});
}
onMounted(() => {
queryKey();
});
</script>
<style lang="scss" module>
.wrapper {
display: flex;
align-items: center;
width: 100%;
min-height: 336px;
> .root {
width: 100%;
}
}
.passkeyIcon {
margin: 0 auto;
background-color: var(--accentedBg);
color: var(--accent);
text-align: center;
height: 64px;
width: 64px;
font-size: 24px;
line-height: 64px;
border-radius: 50%;
}
.passkeyDescription {
text-align: center;
font-size: 1.1em;
}
</style>

View file

@ -0,0 +1,181 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper" data-cy-signin-page-password>
<div class="_gaps" :class="$style.root">
<div :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined }"></div>
<div :class="$style.welcomeBackMessage">
<I18n :src="i18n.ts.welcomeBackWithName" tag="span">
<template #name><Mfm :text="user.name ?? user.username" :plain="true"/></template>
</I18n>
</div>
<!-- password入力 -->
<form class="_gaps_s" @submit.prevent="onSubmit">
<!-- ブラウザ オートコンプリート用 -->
<input type="hidden" name="username" autocomplete="username" :value="user.username">
<MkInput v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true" required autofocus 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>
<div v-if="needCaptcha">
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
</div>
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</form>
</div>
</div>
</template>
<script lang="ts">
export type PwResponse = {
password: string;
captcha: {
hCaptchaResponse: string | null;
mCaptchaResponse: string | null;
reCaptchaResponse: string | null;
turnstileResponse: string | null;
};
};
</script>
<script setup lang="ts">
import { ref, computed, useTemplateRef, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkCaptcha from '@/components/MkCaptcha.vue';
const props = defineProps<{
user: Misskey.entities.UserDetailed;
needCaptcha: boolean;
}>();
const emit = defineEmits<{
(ev: 'passwordSubmitted', v: PwResponse): void;
}>();
const password = ref('');
const hCaptcha = useTemplateRef('hcaptcha');
const mCaptcha = useTemplateRef('mcaptcha');
const reCaptcha = useTemplateRef('recaptcha');
const turnstile = useTemplateRef('turnstile');
const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const captchaFailed = computed((): boolean => {
return (
(instance.enableHcaptcha && !hCaptchaResponse.value) ||
(instance.enableMcaptcha && !mCaptchaResponse.value) ||
(instance.enableRecaptcha && !reCaptchaResponse.value) ||
(instance.enableTurnstile && !turnstileResponse.value)
);
});
function resetPassword(): void {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
closed: () => dispose(),
});
}
function onSubmit() {
emit('passwordSubmitted', {
password: password.value,
captcha: {
hCaptchaResponse: hCaptchaResponse.value,
mCaptchaResponse: mCaptchaResponse.value,
reCaptchaResponse: reCaptchaResponse.value,
turnstileResponse: turnstileResponse.value,
},
});
}
function resetCaptcha() {
hCaptcha.value?.reset();
mCaptcha.value?.reset();
reCaptcha.value?.reset();
turnstile.value?.reset();
}
defineExpose({
resetCaptcha,
});
</script>
<style lang="scss" module>
.wrapper {
display: flex;
align-items: center;
width: 100%;
min-height: 336px;
> .root {
width: 100%;
}
}
.avatar {
margin: 0 auto 0 auto;
width: 64px;
height: 64px;
background: #ddd;
background-position: center;
background-size: cover;
border-radius: 100%;
}
.welcomeBackMessage {
text-align: center;
font-size: 1.1em;
}
.instanceManualSelectButton {
display: block;
text-align: center;
opacity: .7;
font-size: .8em;
&:hover {
text-decoration: underline;
}
}
.orHr {
position: relative;
margin: .4em auto;
width: 100%;
height: 1px;
background: var(--divider);
}
.orMsg {
position: absolute;
top: -.6em;
display: inline-block;
padding: 0 1em;
background: var(--panel);
font-size: 0.8em;
color: var(--fgOnPanel);
margin: 0;
left: 50%;
transform: translateX(-50%);
}
</style>

View file

@ -0,0 +1,74 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.wrapper">
<div class="_gaps" :class="$style.root">
<div class="_gaps_s">
<div :class="$style.totpIcon">
<i class="ti ti-key"></i>
</div>
<div :class="$style.totpDescription">{{ i18n.ts['2fa'] }}</div>
</div>
<!-- totp入力 -->
<form class="_gaps_s" @submit.prevent="emit('totpSubmitted', token)">
<MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required autofocus :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>
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
</MkInput>
<MkButton type="submit" large primary rounded style="margin: 0 auto;">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
const emit = defineEmits<{
(ev: 'totpSubmitted', token: string): void;
}>();
const token = ref('');
const isBackupCode = ref(false);
</script>
<style lang="scss" module>
.wrapper {
display: flex;
align-items: center;
width: 100%;
min-height: 336px;
> .root {
width: 100%;
}
}
.totpIcon {
margin: 0 auto;
background-color: var(--accentedBg);
color: var(--accent);
text-align: center;
height: 64px;
width: 64px;
font-size: 24px;
line-height: 64px;
border-radius: 50%;
}
.totpDescription {
text-align: center;
font-size: 1.1em;
}
</style>

View file

@ -4,269 +4,275 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> <div :class="$style.signinRoot">
<div class="_gaps_m"> <Transition
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div> mode="out-in"
<MkInfo v-if="message"> :enterActiveClass="$style.transition_enterActive"
{{ message }} :leaveActiveClass="$style.transition_leaveActive"
</MkInfo> :enterFromClass="$style.transition_enterFrom"
<div v-if="openOnRemote" class="_gaps_m"> :leaveToClass="$style.transition_leaveTo"
<div class="_gaps_s">
<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)"> :inert="waiting"
{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i> >
</MkButton> <!-- 1. 外部サーバーへの転送username入力パスキー -->
<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)"> <XInput
{{ i18n.ts.specifyServerHost }} v-if="page === 'input'"
</button> key="input"
</div> :message="message"
<div :class="$style.orHr"> :openOnRemote="openOnRemote"
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
@usernameSubmitted="onUsernameSubmitted"
@passkeyClick="onPasskeyLogin"
/>
<!-- 2. パスワード入力 -->
<XPassword
v-else-if="page === 'password'"
key="password"
ref="passwordPageEl"
:user="userInfo!"
:needCaptcha="needCaptcha"
@passwordSubmitted="onPasswordSubmitted"
/>
<!-- 3. ワンタイムパスワード -->
<XTotp
v-else-if="page === 'totp'"
key="totp"
@totpSubmitted="onTotpSubmitted"
/>
<!-- 4. パスキー -->
<XPasskey
v-else-if="page === 'passkey'"
key="passkey"
:credentialRequest="credentialRequest!"
:isPerformingPasswordlessLogin="doingPasskeyFromInputPage"
@done="onPasskeyDone"
@useTotp="onUseTotp"
/>
</Transition>
<div v-if="waiting" :class="$style.waitingRoot">
<MkLoading/>
</div> </div>
</div> </div>
<div v-if="!totpLogin" class="normal-signin _gaps_m">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
</MkInput>
<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>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkButton type="submit" large primary rounded :disabled="captchaFailed || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
<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="query2FaKey">
{{ i18n.ts.retry }}
</MkButton>
</div>
<div v-if="user && user.securityKeys" :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
<div class="twofa-group totp-group _gaps">
<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>
<template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
</MkInput>
<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> </template>
<script lang="ts" setup> <script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'; import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue';
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, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
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 * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { login } from '@/account.js'; import { login } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js'; import * as os from '@/os.js';
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
const signing = ref(false); import XInput from '@/components/MkSignin.input.vue';
const user = ref<Misskey.entities.UserDetailed | null>(null); import XPassword, { type PwResponse } from '@/components/MkSignin.password.vue';
const usePasswordLessLogin = ref<Misskey.entities.UserDetailed['usePasswordLessLogin']>(true); import XTotp from '@/components/MkSignin.totp.vue';
const username = ref(''); import XPasskey from '@/components/MkSignin.passkey.vue';
const password = ref('');
const token = ref('');
const host = ref(toUnicode(configHost));
const totpLogin = ref(false);
const isBackupCode = ref(false);
const queryingKey = ref(false);
let credentialRequest: CredentialRequestOptions | null = null;
const passkey_context = ref('');
const hcaptcha = ref<Captcha | undefined>();
const mcaptcha = ref<Captcha | undefined>();
const recaptcha = ref<Captcha | undefined>();
const turnstile = ref<Captcha | undefined>();
const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null);
const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null);
const captchaFailed = computed((): boolean => { import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
return ( import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
instance.enableHcaptcha && !hCaptchaResponse.value ||
instance.enableMcaptcha && !mCaptchaResponse.value ||
instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value);
});
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'login', v: any): void; (ev: 'login', v: Misskey.entities.SigninResponse): void;
}>(); }>();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
withAvatar?: boolean;
autoSet?: boolean; autoSet?: boolean;
message?: string, message?: string,
openOnRemote?: OpenOnRemoteOptions, openOnRemote?: OpenOnRemoteOptions,
}>(), { }>(), {
withAvatar: true,
autoSet: false, autoSet: false,
message: '', message: '',
openOnRemote: undefined, openOnRemote: undefined,
}); });
function onUsernameChange(): void { const page = ref<'input' | 'password' | 'totp' | 'passkey'>('input');
misskeyApi('users/show', { const waiting = ref(false);
username: username.value,
}).then(userResponse => {
user.value = userResponse;
usePasswordLessLogin.value = userResponse.usePasswordLessLogin;
}, () => {
user.value = null;
usePasswordLessLogin.value = true;
});
}
function onLogin(res: any): Promise<void> | void { const passwordPageEl = useTemplateRef('passwordPageEl');
if (props.autoSet) { const needCaptcha = ref(false);
return login(res.i);
}
}
async function query2FaKey(): Promise<void> { const userInfo = ref<null | Misskey.entities.UserDetailed>(null);
if (credentialRequest == null) return; const password = ref('');
queryingKey.value = true;
await webAuthnRequest(credentialRequest) //#region Passkey Passwordless
.catch(() => { const credentialRequest = shallowRef<CredentialRequestOptions | null>(null);
queryingKey.value = false; const passkeyContext = ref('');
return Promise.reject(null); const doingPasskeyFromInputPage = ref(false);
}).then(credential => {
credentialRequest = null;
queryingKey.value = false;
signing.value = true;
return misskeyApi('signin', {
username: username.value,
password: password.value,
credential: credential.toJSON(),
});
}).then(res => {
emit('login', res);
return onLogin(res);
}).catch(err => {
if (err === null) return;
os.alert({
type: 'error',
text: i18n.ts.signinFailed,
});
signing.value = false;
});
}
function onPasskeyLogin(): void { function onPasskeyLogin(): void {
signing.value = true;
if (webAuthnSupported()) { if (webAuthnSupported()) {
doingPasskeyFromInputPage.value = true;
waiting.value = true;
misskeyApi('signin-with-passkey', {}) misskeyApi('signin-with-passkey', {})
.then(res => { .then((res) => {
totpLogin.value = false; passkeyContext.value = res.context ?? '';
signing.value = false; credentialRequest.value = parseRequestOptionsFromJSON({
queryingKey.value = true;
passkey_context.value = res.context ?? '';
credentialRequest = parseRequestOptionsFromJSON({
publicKey: res.option, publicKey: res.option,
}); });
page.value = 'passkey';
waiting.value = false;
}) })
.then(() => queryPasskey()) .catch(onLoginFailed);
.catch(loginFailed);
} }
} }
async function queryPasskey(): Promise<void> { function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
if (credentialRequest == null) return; waiting.value = true;
queryingKey.value = true;
console.log('Waiting passkey auth...'); if (doingPasskeyFromInputPage.value) {
await webAuthnRequest(credentialRequest) misskeyApi('signin-with-passkey', {
.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(), credential: credential.toJSON(),
context: passkey_context.value, context: passkeyContext.value,
}); }).then((res) => {
}).then(res => { if (res.signinResponse == null) {
onLoginFailed();
return;
}
emit('login', res.signinResponse); emit('login', res.signinResponse);
return onLogin(res.signinResponse); }).catch(onLoginFailed);
} else if (userInfo.value != null) {
tryLogin({
username: userInfo.value.username,
password: password.value,
credential: credential.toJSON(),
});
}
}
function onUseTotp(): void {
page.value = 'totp';
}
//#endregion
async function onUsernameSubmitted(username: string) {
waiting.value = true;
userInfo.value = await misskeyApi('users/show', {
username,
}).catch(() => null);
await tryLogin({
username,
}); });
} }
function onSubmit(): void { async function onPasswordSubmitted(pw: PwResponse) {
signing.value = true; waiting.value = true;
if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { password.value = pw.password;
if (webAuthnSupported() && user.value.securityKeys) {
misskeyApi('signin', { if (userInfo.value == null) {
username: username.value, await os.alert({
password: password.value, type: 'error',
}).then(res => { title: i18n.ts.noSuchUser,
totpLogin.value = true; text: i18n.ts.signinFailed,
signing.value = false;
credentialRequest = parseRequestOptionsFromJSON({
publicKey: res,
}); });
}) waiting.value = false;
.then(() => query2FaKey()) return;
.catch(loginFailed);
} else { } else {
totpLogin.value = true; await tryLogin({
signing.value = false; username: userInfo.value.username,
password: pw.password,
'hcaptcha-response': pw.captcha.hCaptchaResponse,
'm-captcha-response': pw.captcha.mCaptchaResponse,
'g-recaptcha-response': pw.captcha.reCaptchaResponse,
'turnstile-response': pw.captcha.turnstileResponse,
});
} }
}
async function onTotpSubmitted(token: string) {
waiting.value = true;
if (userInfo.value == null) {
await os.alert({
type: 'error',
title: i18n.ts.noSuchUser,
text: i18n.ts.signinFailed,
});
waiting.value = false;
return;
} else { } else {
misskeyApi('signin', { await tryLogin({
username: username.value, username: userInfo.value.username,
password: password.value, password: password.value,
'hcaptcha-response': hCaptchaResponse.value, token,
'm-captcha-response': mCaptchaResponse.value, });
'g-recaptcha-response': reCaptchaResponse.value, }
'turnstile-response': turnstileResponse.value, }
token: user.value?.twoFactorEnabled ? token.value : undefined,
}).then(res => { async function tryLogin(req: Partial<Misskey.entities.SigninRequest>): Promise<Misskey.entities.SigninResponse> {
const _req = {
username: req.username ?? userInfo.value?.username,
...req,
};
function assertIsSigninRequest(x: Partial<Misskey.entities.SigninRequest>): x is Misskey.entities.SigninRequest {
return x.username != null;
}
if (!assertIsSigninRequest(_req)) {
throw new Error('Invalid request');
}
return await misskeyApi('signin', _req).then(async (res) => {
emit('login', res); emit('login', res);
onLogin(res); await onLoginSucceeded(res);
}).catch(loginFailed); return res;
}).catch((err) => {
onLoginFailed(err);
return Promise.reject(err);
});
}
async function onLoginSucceeded(res: Misskey.entities.SigninResponse) {
if (props.autoSet) {
await login(res.i);
} }
} }
function loginFailed(err: any): void { function onLoginFailed(err?: any): void {
hcaptcha.value?.reset?.(); const id = err?.id ?? null;
mcaptcha.value?.reset?.();
recaptcha.value?.reset?.();
turnstile.value?.reset?.();
switch (err.id) { if (typeof err === 'object' && 'next' in err) {
switch (err.next) {
case 'captcha': {
page.value = 'password';
break;
}
case 'password': {
page.value = 'password';
break;
}
case 'totp': {
page.value = 'totp';
break;
}
case 'passkey': {
if (webAuthnSupported() && 'authRequest' in err) {
credentialRequest.value = parseRequestOptionsFromJSON({
publicKey: err.authRequest,
});
page.value = 'passkey';
} else {
page.value = 'totp';
}
break;
}
}
} else {
switch (id) {
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
os.alert({ os.alert({
type: 'error', type: 'error',
@ -295,6 +301,14 @@ function loginFailed(err: any): void {
}); });
break; break;
} }
case 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f': {
os.alert({
type: 'error',
title: i18n.ts.loginFailed,
text: i18n.ts.incorrectTotp,
});
break;
}
case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': { case '36b96a7d-b547-412d-aeed-2d611cdc8cdc': {
os.alert({ os.alert({
type: 'error', type: 'error',
@ -303,6 +317,14 @@ function loginFailed(err: any): void {
}); });
break; break;
} }
case '93b86c4b-72f9-40eb-9815-798928603d1e': {
os.alert({
type: 'error',
title: i18n.ts.loginFailed,
text: i18n.ts.passkeyVerificationFailed,
});
break;
}
case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': { case 'b18c89a7-5b5e-4cec-bb5b-0419f332d430': {
os.alert({ os.alert({
type: 'error', type: 'error',
@ -328,114 +350,56 @@ function loginFailed(err: any): void {
}); });
} }
} }
totpLogin.value = false;
signing.value = false;
} }
function resetPassword(): void { if (doingPasskeyFromInputPage.value === true) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, { doingPasskeyFromInputPage.value = false;
closed: () => dispose(), page.value = 'input';
password.value = '';
}
passwordPageEl.value?.resetCaptcha();
nextTick(() => {
waiting.value = false;
}); });
} }
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { onBeforeUnmount(() => {
switch (options.type) { password.value = '';
case 'web': userInfo.value = null;
case 'lookup': {
let _path: string;
if (options.type === 'lookup') {
// TODO: v2024.7.0URL
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
} else {
_path = options.path;
}
if (targetHost) {
window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
} else {
window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
}
break;
}
case 'share': {
const params = query(options.params);
if (targetHost) {
window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
} else {
window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
}
break;
}
}
}
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
const { canceled, result: hostTemp } = await os.inputText({
title: i18n.ts.inputHostName,
placeholder: 'misskey.example.com',
}); });
if (canceled) return;
let targetHost: string | null = hostTemp;
//
targetHost = extractDomain(targetHost);
if (targetHost == null) {
os.alert({
type: 'error',
title: i18n.ts.invalidValue,
text: i18n.ts.tryAgain,
});
return;
}
openRemote(options, targetHost);
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.avatar { .transition_enterActive,
margin: 0 auto 0 auto; .transition_leaveActive {
width: 64px; transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
height: 64px; }
background: #ddd; .transition_enterFrom {
background-position: center; opacity: 0;
background-size: cover; transform: translateX(50px);
border-radius: 100%; }
.transition_leaveTo {
opacity: 0;
transform: translateX(-50px);
} }
.instanceManualSelectButton { .signinRoot {
display: block; overflow-x: hidden;
text-align: center; overflow-x: clip;
opacity: .7;
font-size: .8em;
&:hover {
text-decoration: underline;
}
}
.orHr {
position: relative; position: relative;
margin: .4em auto;
width: 100%;
height: 1px;
background: var(--divider);
} }
.orMsg { .waitingRoot {
position: absolute; position: absolute;
top: -.6em; top: 0;
display: inline-block; left: 0;
padding: 0 1em; width: 100%;
background: var(--panel); height: 100%;
font-size: 0.8em; background-color: color-mix(in srgb, var(--panel), transparent 50%);
color: var(--fgOnPanel); display: flex;
margin: 0; justify-content: center;
left: 50%; align-items: center;
transform: translateX(-50%); z-index: 1;
} }
</style> </style>

View file

@ -4,26 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkModalWindow <MkModal
ref="dialog" ref="modal"
:width="400" :preferType="'dialog'"
:height="450" @click="onClose"
@close="onClose"
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.login }}</template> <div :class="$style.root">
<div :class="$style.header">
<MkSpacer :marginMin="20" :marginMax="28"> <div :class="$style.headerText"><i class="ti ti-login-2"></i> {{ i18n.ts.login }}</div>
<button :class="$style.closeButton" class="_button" @click="onClose"><i class="ti ti-x"></i></button>
</div>
<div :class="$style.content">
<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/> <MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
</MkSpacer> </div>
</MkModalWindow> </div>
</MkModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { shallowRef } from 'vue'; import { shallowRef } from 'vue';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import MkSignin from '@/components/MkSignin.vue'; import MkSignin from '@/components/MkSignin.vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModal from '@/components/MkModal.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
withDefaults(defineProps<{ withDefaults(defineProps<{
@ -42,15 +45,62 @@ const emit = defineEmits<{
(ev: 'cancelled'): void; (ev: 'cancelled'): void;
}>(); }>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const modal = shallowRef<InstanceType<typeof MkModal>>();
function onClose() { function onClose() {
emit('cancelled'); emit('cancelled');
if (dialog.value) dialog.value.close(); if (modal.value) modal.value.close();
} }
function onLogin(res) { function onLogin(res) {
emit('done', res); emit('done', res);
if (dialog.value) dialog.value.close(); if (modal.value) modal.value.close();
} }
</script> </script>
<style lang="scss" module>
.root {
overflow: auto;
margin: auto;
position: relative;
width: 100%;
max-width: 400px;
height: 100%;
max-height: 450px;
box-sizing: border-box;
background: var(--panel);
border-radius: var(--radius);
}
.header {
position: sticky;
top: 0;
left: 0;
width: 100%;
height: 50px;
box-sizing: border-box;
display: flex;
align-items: center;
font-weight: bold;
backdrop-filter: var(--blur, blur(15px));
background: var(--acrylicBg);
z-index: 1;
}
.headerText {
padding: 0 20px;
box-sizing: border-box;
}
.closeButton {
margin-left: auto;
padding: 16px;
font-size: 16px;
line-height: 16px;
}
.content {
padding: 32px;
box-sizing: border-box;
}
</style>

View file

@ -3040,7 +3040,7 @@ type Signin = components['schemas']['Signin'];
// @public (undocumented) // @public (undocumented)
type SigninRequest = { type SigninRequest = {
username: string; username: string;
password: string; password?: string;
token?: string; token?: string;
credential?: AuthenticationResponseJSON; credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null; 'hcaptcha-response'?: string | null;

View file

@ -3782,16 +3782,13 @@ export type components = {
followingVisibility: 'public' | 'followers' | 'private'; followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */ /** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private'; followersVisibility: 'public' | 'followers' | 'private';
/** @default false */
twoFactorEnabled: boolean;
/** @default false */
usePasswordLessLogin: boolean;
/** @default false */
securityKeys: boolean;
roles: components['schemas']['RoleLite'][]; roles: components['schemas']['RoleLite'][];
followedMessage?: string | null; followedMessage?: string | null;
memo: string | null; memo: string | null;
moderationNote?: string; moderationNote?: string;
twoFactorEnabled?: boolean;
usePasswordLessLogin?: boolean;
securityKeys?: boolean;
isFollowing?: boolean; isFollowing?: boolean;
isFollowed?: boolean; isFollowed?: boolean;
hasPendingFollowRequestFromYou?: boolean; hasPendingFollowRequestFromYou?: boolean;
@ -3972,6 +3969,12 @@ export type components = {
}[]; }[];
loggedInDays: number; loggedInDays: number;
policies: components['schemas']['RolePolicies']; policies: components['schemas']['RolePolicies'];
/** @default false */
twoFactorEnabled: boolean;
/** @default false */
usePasswordLessLogin: boolean;
/** @default false */
securityKeys: boolean;
email?: string | null; email?: string | null;
emailVerified?: boolean | null; emailVerified?: boolean | null;
securityKeysList?: { securityKeysList?: {

View file

@ -269,7 +269,7 @@ export type SignupPendingResponse = {
export type SigninRequest = { export type SigninRequest = {
username: string; username: string;
password: string; password?: string;
token?: string; token?: string;
credential?: AuthenticationResponseJSON; credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null; 'hcaptcha-response'?: string | null;