Skip to content

Commit

Permalink
feat(sign-in): メールアドレスログインを実装 (#836)
Browse files Browse the repository at this point in the history
Co-authored-by: まっちゃてぃー。 <[email protected]>
  • Loading branch information
u1-liquid and mattyatea authored Dec 21, 2024
1 parent 3ecc340 commit 58513c1
Show file tree
Hide file tree
Showing 14 changed files with 236 additions and 22 deletions.
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

0 comments on commit 58513c1

Please sign in to comment.