Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sign-in): メールアドレスログインを実装 #836

Merged
merged 7 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5318,6 +5318,14 @@ export interface Locale extends ILocale {
* 選択した項目のみ許可
*/
"consentSelected": string;
/**
* メールアドレスでログイン
*/
"emailAddressLogin": string;
/**
* ユーザー名でログイン
*/
"usernameLogin": string;
"_bubbleGame": {
/**
* 遊び方
Expand Down
2 changes: 2 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,8 @@ pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPoli
consentEssential: "必須項目のみ許可"
consentAll: "全て許可"
consentSelected: "選択した項目のみ許可"
emailAddressLogin: "メールアドレスでログイン"
usernameLogin: "ユーザー名でログイン"

_bubbleGame:
howToPlay: "遊び方"
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/server/api/EndpointsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 23 additions & 12 deletions packages/backend/src/server/api/SigninApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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',
});
}

Expand All @@ -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!);

Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof meta, typeof paramDef> { // 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,
};
});
}
}
2 changes: 1 addition & 1 deletion packages/frontend/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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を同期
Expand Down
34 changes: 26 additions & 8 deletions packages/frontend/src/components/MkSignin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,32 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
<div class="_gaps_m">
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
<div v-show="withAvatar && !loginWithEmailAddress" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
<MkInfo v-if="message">
{{ message }}
</MkInfo>
<div v-if="!totpLogin" class="normal-signin _gaps_m">
<MkInput v-model="username" :debounce="true" :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 v-model="username" :debounce="true" :placeholder="loginWithEmailAddress ? i18n.ts.emailAddress : i18n.ts.username" type="text" :pattern="loginWithEmailAddress ? '^[a-zA-Z0-9_@.]+$' : '^[a-zA-Z0-9_]+$'" :spellcheck="false" :autocomplete="loginWithEmailAddress ? 'email webauthn' : 'username webauthn'" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template #prefix>
<i v-if="loginWithEmailAddress" class="ti ti-mail"></i>
<span v-else>@</span>
</template>
<template v-if="!loginWithEmailAddress" #suffix>@{{ host }}</template>
<template #caption>
<button class="_textButton" type="button" tabindex="-1" @click="loginWithEmailAddress = !loginWithEmailAddress">{{ loginWithEmailAddress ? i18n.ts.usernameLogin : i18n.ts.emailAddressLogin }}</button>
</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>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
<template #caption>
<button class="_textButton" type="button" tabindex="-1" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button>
</template>
</MkInput>
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="!user?.twoFactorEnabled && instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkButton type="submit" large primary rounded :disabled="!user || captchaFailed || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
<MkButton type="submit" large primary rounded :disabled="(!loginWithEmailAddress && !user) || 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">
Expand Down Expand Up @@ -70,6 +78,7 @@ import { instance } from '@/instance.js';
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';

const signing = ref(false);
const loginWithEmailAddress = ref(false);
const userAbortController = ref<AbortController>();
const user = ref<Misskey.entities.UserDetailed | null>(null);
const username = ref('');
Expand Down Expand Up @@ -119,7 +128,9 @@ const props = defineProps({
},
});

function onUsernameChange(): void {
async function onUsernameChange(): Promise<void> {
if (loginWithEmailAddress.value) return;

if (userAbortController.value) {
userAbortController.value.abort();
}
Expand Down Expand Up @@ -168,8 +179,15 @@ async function queryKey(): Promise<void> {
});
}

function onSubmit(): void {
async function onSubmit(): Promise<void> {
signing.value = true;
if (loginWithEmailAddress.value) {
user.value = await misskeyApi('users/get-security-info', {
email: username.value,
password: password.value,
});
}

if (!totpLogin.value && user.value?.twoFactorEnabled) {
if (webAuthnSupported() && user.value.securityKeys) {
misskeyApi('signin', {
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/MkSigninDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { i18n } from '@/i18n.js';

withDefaults(defineProps<{
autoSet?: boolean;
message?: string,
message?: string;
}>(), {
autoSet: false,
message: '',
Expand Down
8 changes: 8 additions & 0 deletions packages/misskey-js/etc/misskey-js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,8 @@ declare namespace entities {
UsersSearchResponse,
UsersShowRequest,
UsersShowResponse,
UsersGetSecurityInfoRequest,
UsersGetSecurityInfoResponse,
UsersStatsRequest,
UsersStatsResponse,
UsersAchievementsRequest,
Expand Down Expand Up @@ -3166,6 +3168,12 @@ type UsersGetFrequentlyRepliedUsersRequest = operations['users___get-frequently-
// @public (undocumented)
type UsersGetFrequentlyRepliedUsersResponse = operations['users___get-frequently-replied-users']['responses']['200']['content']['application/json'];

// @public (undocumented)
type UsersGetSecurityInfoRequest = operations['users___get-security-info']['requestBody']['content']['application/json'];

// @public (undocumented)
type UsersGetSecurityInfoResponse = operations['users___get-security-info']['responses']['200']['content']['application/json'];

// @public (undocumented)
type UsersGetSkebStatusRequest = operations['users___get-skeb-status']['requestBody']['content']['application/json'];

Expand Down
11 changes: 11 additions & 0 deletions packages/misskey-js/src/autogen/apiClientJSDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4167,6 +4167,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;

/**
* No description provided.
*
* **Credential required**: *No*
*/
request<E extends 'users/get-security-info', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;

/**
* Show statistics about a user.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/misskey-js/src/autogen/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,8 @@ import type {
UsersSearchResponse,
UsersShowRequest,
UsersShowResponse,
UsersGetSecurityInfoRequest,
UsersGetSecurityInfoResponse,
UsersStatsRequest,
UsersStatsResponse,
UsersAchievementsRequest,
Expand Down Expand Up @@ -964,6 +966,7 @@ export type Endpoints = {
'users/search-by-username-and-host': { req: UsersSearchByUsernameAndHostRequest; res: UsersSearchByUsernameAndHostResponse };
'users/search': { req: UsersSearchRequest; res: UsersSearchResponse };
'users/show': { req: UsersShowRequest; res: UsersShowResponse };
'users/get-security-info': { req: UsersGetSecurityInfoRequest; res: UsersGetSecurityInfoResponse };
'users/stats': { req: UsersStatsRequest; res: UsersStatsResponse };
'users/achievements': { req: UsersAchievementsRequest; res: UsersAchievementsResponse };
'users/update-memo': { req: UsersUpdateMemoRequest; res: EmptyResponse };
Expand Down
2 changes: 2 additions & 0 deletions packages/misskey-js/src/autogen/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,8 @@ export type UsersSearchRequest = operations['users___search']['requestBody']['co
export type UsersSearchResponse = operations['users___search']['responses']['200']['content']['application/json'];
export type UsersShowRequest = operations['users___show']['requestBody']['content']['application/json'];
export type UsersShowResponse = operations['users___show']['responses']['200']['content']['application/json'];
export type UsersGetSecurityInfoRequest = operations['users___get-security-info']['requestBody']['content']['application/json'];
export type UsersGetSecurityInfoResponse = operations['users___get-security-info']['responses']['200']['content']['application/json'];
export type UsersStatsRequest = operations['users___stats']['requestBody']['content']['application/json'];
export type UsersStatsResponse = operations['users___stats']['responses']['200']['content']['application/json'];
export type UsersAchievementsRequest = operations['users___achievements']['requestBody']['content']['application/json'];
Expand Down
Loading
Loading