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

@ -545,11 +545,6 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility,
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 => ({
id: role.id,
name: role.name,
@ -564,6 +559,14 @@ export class UserEntityService implements OnModuleInit {
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 ? {
avatarId: user.avatarId,
bannerId: user.bannerId,

View file

@ -346,21 +346,6 @@ export const packedUserDetailedNotMeOnlySchema = {
nullable: false, optional: false,
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: {
type: 'array',
nullable: false, optional: false,
@ -382,6 +367,18 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'string',
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
isFollowing: {
type: 'boolean',
@ -630,6 +627,21 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false,
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
email: {
type: 'string',

View file

@ -12,6 +12,7 @@ import type {
MiMeta,
SigninsRepository,
UserProfilesRepository,
UserSecurityKeysRepository,
UsersRepository,
} from '@/models/_.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 { RateLimiterService } from './RateLimiterService.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';
/**
* next
*
* - `captcha`: CAPTCHAを求める
* - `password`:
* - `totp`:
* - `passkey`: WebAuthn認証を求めるWebAuthnに対応していないブラウザの場合はワンタイムパスワード
*/
type SigninErrorResponse = {
id: string;
next?: 'captcha' | 'password' | 'totp';
} | {
id: string;
next: 'passkey';
authRequest: PublicKeyCredentialRequestOptionsJSON;
};
@Injectable()
export class SigninApiService {
constructor(
@ -43,6 +62,9 @@ export class SigninApiService {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
@ -60,7 +82,7 @@ export class SigninApiService {
request: FastifyRequest<{
Body: {
username: string;
password: string;
password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string;
@ -79,7 +101,7 @@ export class SigninApiService {
const password = body['password'];
const token = body['token'];
function error(status: number, error: { id: string }) {
function error(status: number, error: SigninErrorResponse) {
reply.code(status);
return { error };
}
@ -103,11 +125,6 @@ export class SigninApiService {
return;
}
if (typeof password !== 'string') {
reply.code(400);
return;
}
if (token != null && typeof token !== 'string') {
reply.code(400);
return;
@ -132,11 +149,36 @@ export class SigninApiService {
}
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
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
await this.signinsRepository.insert({
id: this.idService.gen(),
@ -217,7 +259,7 @@ export class SigninApiService {
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
});
}
} else {
} else if (securityKeysAvailable) {
if (!same && !profile.usePasswordLessLogin) {
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
@ -226,8 +268,28 @@ export class SigninApiService {
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
reply.code(200);
return authRequest;
reply.code(403);
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
}

View file

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

View file

@ -83,9 +83,6 @@ describe('ユーザー', () => {
publicReactions: user.publicReactions,
followingVisibility: user.followingVisibility,
followersVisibility: user.followersVisibility,
twoFactorEnabled: user.twoFactorEnabled,
usePasswordLessLogin: user.usePasswordLessLogin,
securityKeys: user.securityKeys,
roles: user.roles,
memo: user.memo,
});
@ -149,6 +146,9 @@ describe('ユーザー', () => {
achievements: user.achievements,
loggedInDays: user.loggedInDays,
policies: user.policies,
twoFactorEnabled: user.twoFactorEnabled,
usePasswordLessLogin: user.usePasswordLessLogin,
securityKeys: user.securityKeys,
...(security ? {
email: user.email,
emailVerified: user.emailVerified,
@ -343,9 +343,6 @@ describe('ユーザー', () => {
assert.strictEqual(response.publicReactions, true);
assert.strictEqual(response.followingVisibility, '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.strictEqual(response.memo, null);
@ -385,6 +382,9 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0);
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.strictEqual(response.emailVerified, false);
assert.deepStrictEqual(response.securityKeysList, []);
@ -618,6 +618,9 @@ describe('ユーザー', () => {
{ label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator },
// @ts-expect-error UserDetailedNotMe doesn't include isModerator
{ 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 },
// FIXME: 落ちる
//{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended },