diff --git a/locales/index.d.ts b/locales/index.d.ts index 73c563d388de..1bc040ddee32 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5318,6 +5318,14 @@ export interface Locale extends ILocale { * 選択した項目のみ許可 */ "consentSelected": string; + /** + * メールアドレスでログイン + */ + "emailAddressLogin": string; + /** + * ユーザー名でログイン + */ + "usernameLogin": string; "_bubbleGame": { /** * 遊び方 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 923afe654cad..514484dc57ae 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1323,6 +1323,8 @@ pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPoli consentEssential: "必須項目のみ許可" consentAll: "全て許可" consentSelected: "選択した項目のみ許可" +emailAddressLogin: "メールアドレスでログイン" +usernameLogin: "ユーザー名でログイン" _bubbleGame: howToPlay: "遊び方" diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 7b564affeb5d..924eac7a1fb4 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -382,6 +382,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; +import * as ep___users_get_security_info from './endpoints/users/get-security-info.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; @@ -751,6 +752,7 @@ const $users_following: Provider = { provide: 'ep:users/following', useClass: ep const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; const $users_getFollowingBirthdayUsers: Provider = { provide: 'ep:users/get-following-birthday-users', useClass: ep___users_getFollowingBirthdayUsers.default }; const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; +const $users_getSecurityInfo: Provider = { provide: 'ep:users/get-security-info', useClass: ep___users_get_security_info.default }; const $users_getSkebStatus: Provider = { provide: 'ep:users/get-skeb-status', useClass: ep___users_getSkebStatus.default }; const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default }; const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; @@ -1149,6 +1151,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $users_gallery_posts, $users_getFollowingBirthdayUsers, $users_getFrequentlyRepliedUsers, + $users_getSecurityInfo, $users_getSkebStatus, $users_featuredNotes, $users_lists_create, @@ -1539,6 +1542,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $users_gallery_posts, $users_getFollowingBirthdayUsers, $users_getFrequentlyRepliedUsers, + $users_getSecurityInfo, $users_getSkebStatus, $users_featuredNotes, $users_lists_create, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index c8f76550dc9c..ec1ec567d6db 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -3,11 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { + MiUserProfile, SigninsRepository, UserProfilesRepository, UsersRepository, @@ -27,7 +29,6 @@ import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/server'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { randomUUID } from 'node:crypto'; @Injectable() export class SigninApiService { @@ -122,22 +123,34 @@ export class SigninApiService { } // Fetch user - const user = await this.usersRepository.findOneBy({ - usernameLower: username.toLowerCase(), - host: IsNull(), - }) as MiLocalUser; + const profile = await this.userProfilesRepository.findOne({ + relations: ['user'], + where: username.includes('@') ? { + email: username, + emailVerified: true, + user: { + host: IsNull(), + } + } : { + user: { + usernameLower: username.toLowerCase(), + host: IsNull(), + } + } + }); + const user = (profile?.user as MiLocalUser) ?? null; - if (user == null) { + if (!user || !profile) { logger.error('No such user.'); - return error(404, { - id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + return error(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); } if (user.isDeleted && user.isSuspended) { logger.error('No such user. (logical deletion)'); - return error(404, { - id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + return error(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', }); } @@ -148,8 +161,6 @@ export class SigninApiService { }); } - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - // Compare password const same = await bcrypt.compare(password, profile.password!); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index a603a7077c74..987228be66b1 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -381,6 +381,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; +import * as ep___users_get_security_info from './endpoints/users/get-security-info.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; @@ -773,6 +774,7 @@ const eps = [ ['users/search-by-username-and-host', ep___users_searchByUsernameAndHost], ['users/search', ep___users_search], ['users/show', ep___users_show], + ['users/get-security-info', ep___users_get_security_info], ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], ['users/update-memo', ep___users_updateMemo], diff --git a/packages/backend/src/server/api/endpoints/users/get-security-info.ts b/packages/backend/src/server/api/endpoints/users/get-security-info.ts new file mode 100644 index 000000000000..e3fe2b6ffe27 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-security-info.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import bcrypt from 'bcryptjs'; +import ms from 'ms'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + limit: { + duration: ms('1hour'), + max: 30, + }, + + res: { + type: 'object', + properties: { + twoFactorEnabled: { type: 'boolean' }, + usePasswordLessLogin: { type: 'boolean' }, + securityKeys: { type: 'boolean' }, + }, + }, + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + email: { type: 'string' }, + password: { type: 'string' }, + }, + required: ['email', 'password'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneBy({ + email: ps.email, + emailVerified: true, + }); + + const passwordMatched = await bcrypt.compare(ps.password, profile?.password ?? ''); + if (!profile || !passwordMatched) { + return { + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + }; + } + + return { + twoFactorEnabled: profile.twoFactorEnabled, + usePasswordLessLogin: profile.usePasswordLessLogin, + securityKeys: profile.twoFactorEnabled + ? await this.userSecurityKeysRepository.countBy({ userId: profile.userId }).then(result => result >= 1) + : false, + }; + }); + } +} diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index a34a70df0e10..e4f8b707b546 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -5,6 +5,7 @@ import { defineAsyncComponent, reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { set as gtagSet, time as gtagTime } from 'vue-gtag'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -14,7 +15,6 @@ import { apiUrl } from '@/config.js'; import { waiting, popup, popupMenu, success, alert } from '@/os.js'; import { generateClientTransactionId, misskeyApi } from '@/scripts/misskey-api.js'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; -import { set as gtagSet, time as gtagTime } from 'vue-gtag'; import { instance } from '@/instance.js'; // TODO: 他のタブと永続化されたstateを同期 diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index 60d7333df635..feb1f8acf4fa 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -6,24 +6,32 @@ SPDX-License-Identifier: AGPL-3.0-only