Skip to content

Commit

Permalink
メールアドレスログインを実装
Browse files Browse the repository at this point in the history
  • Loading branch information
mattyatea committed Dec 7, 2024
1 parent e7afbd1 commit 64e1351
Show file tree
Hide file tree
Showing 14 changed files with 259 additions and 27 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_twofactor_enable from './endpoints/users/get-twofactor-enable.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 @@ -776,6 +777,7 @@ const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-
const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default };
const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default };
const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default };
const $users_twofactor_enable: Provider = { provide: 'ep:users/get-twofactor-enable', useClass: ep___users_get_twofactor_enable.default };
const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default };
const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default };
const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default };
Expand Down Expand Up @@ -1175,6 +1177,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$users_show,
$users_stats,
$users_achievements,
$users_twofactor_enable,
$users_updateMemo,
$fetchRss,
$fetchExternalResources,
Expand Down Expand Up @@ -1563,6 +1566,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$users_searchByUsernameAndHost,
$users_search,
$users_show,
$users_twofactor_enable,
$users_stats,
$users_achievements,
$users_updateMemo,
Expand Down
43 changes: 31 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/types';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { randomUUID } from 'node:crypto';

@Injectable()
export class SigninApiService {
Expand Down Expand Up @@ -121,23 +122,43 @@ export class SigninApiService {
return;
}

let user: MiLocalUser | null = null;
let profile: MiUserProfile | null = null;
// Fetch user
const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull(),
}) as MiLocalUser;
if (username.includes('@')) {
profile = await this.userProfilesRepository.findOneBy({
email: username.toLowerCase(),
emailVerified: true,
}) as MiUserProfile;

user = await this.usersRepository.findOneBy({
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
id: profile?.userId,
}) as MiLocalUser;
} else {
user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull(),
}) as MiLocalUser;

if (user !== null) {
profile = await this.userProfilesRepository.findOneByOrFail({
userId: user.id,
}) as MiUserProfile;
}
}

if (user == null) {
if (user == null || profile == null) {
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 +169,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_twofactor_enable from './endpoints/users/get-twofactor-enable.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-twofactor-enable', ep___users_get_twofactor_enable],
['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,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

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';

export const meta = {
tags: ['users'],

requireCredential: false,
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' },
},
required: ['email'],
} 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 userProfile = await this.userProfilesRepository.findOneBy({
email: ps.email,
});

return userProfile ? {
twoFactorEnabled: userProfile.twoFactorEnabled,
usePasswordLessLogin: userProfile.usePasswordLessLogin,
securityKeys: userProfile.twoFactorEnabled
? await this.userSecurityKeysRepository.countBy({ userId: userProfile.userId }).then(result => result >= 1)
: false,
} : {
twoFactorEnabled: false,
usePasswordLessLogin: false,
securityKeys: false,
};
});
}
}

6 changes: 3 additions & 3 deletions 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 Expand Up @@ -340,9 +340,9 @@ export async function openAccountMenu(opts: {
}
}

export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
export function getAccountWithSigninDialog(emailMode = false): Promise<{ id: string, token: string } | null> {
return new Promise((resolve) => {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { emailMode }, {
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
await addAccount(res.id, res.i);
resolve({ id: res.id, token: res.i });
Expand Down
62 changes: 52 additions & 10 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 && !emailMode" :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="emailMode ? i18n.ts.emailAddress : i18n.ts.username" type="text" :pattern="emailMode ? '^[a-zA-Z0-9_@.]+$' : '^[a-zA-Z0-9_]+$'" :spellcheck="false" :autocomplete="emailMode ? 'email webauthn' : 'username webauthn'" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
<template v-if="!emailMode" #prefix>@</template>
<template v-if="!emailMode" #suffix>@{{ host }}</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>
<div class="_margin">
<button v-if="!emailMode" class="_textButton" type="button" @click="emailAddressLogin">{{ i18n.ts.emailAddressLogin }}</button>
<button v-if="emailMode" class="_textButton" type="button" @click="usernameLogin">{{ i18n.ts.usernameLogin }}</button>
</div>
<div class="_margin">
<button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button>
</div>
</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="(!emailMode && !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 @@ -64,14 +72,14 @@ import MkInfo from '@/components/MkInfo.vue';
import { host as configHost } from '@/config.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { login } from '@/account.js';
import { getAccountWithSigninDialog, login } from '@/account.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';

const signing = ref(false);
const userAbortController = ref<AbortController>();
const user = ref<Misskey.entities.UserDetailed | null>(null);
const user = ref<Credential | null>(null);
const username = ref('');
const password = ref('');
const token = ref('');
Expand Down Expand Up @@ -99,6 +107,7 @@ const captchaFailed = computed((): boolean => {

const emit = defineEmits<{
(ev: 'login', v: any): void;
(ev: 'close', v: any): void;
}>();

const props = defineProps({
Expand All @@ -117,9 +126,31 @@ const props = defineProps({
required: false,
default: '',
},
emailMode: {
type: Boolean,
required: false,
default: false,
},
});

function onUsernameChange(): void {
type Credential = {
twoFactorEnabled: boolean;
usePasswordLessLogin: boolean;
securityKeys: boolean;
};

async function onUsernameChange(): Promise<void> {
if (props.emailMode) {
let twofactorEnable = await misskeyApi('users/get-twofactor-enable', {
email: username.value,
});
user.value = {
twoFactorEnabled: twofactorEnable.twoFactorEnabled,
usePasswordLessLogin: twofactorEnable.usePasswordLessLogin,
securityKeys: twofactorEnable.securityKeys,
};
return;
}
if (userAbortController.value) {
userAbortController.value.abort();
}
Expand Down Expand Up @@ -168,7 +199,7 @@ async function queryKey(): Promise<void> {
});
}

function onSubmit(): void {
async function onSubmit(): Promise<void> {
signing.value = true;
if (!totpLogin.value && user.value?.twoFactorEnabled) {
if (webAuthnSupported() && user.value.securityKeys) {
Expand Down Expand Up @@ -257,6 +288,17 @@ function resetPassword(): void {
os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
}, 'closed');
}

function emailAddressLogin(): void {
getAccountWithSigninDialog(true);
emit('close', {});
}

function usernameLogin(): void {
getAccountWithSigninDialog();
emit('close', {});
}

</script>

<style lang="scss" module>
Expand Down
6 changes: 4 additions & 2 deletions packages/frontend/src/components/MkSigninDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>{{ i18n.ts.login }}</template>

<MkSpacer :marginMin="20" :marginMax="28">
<MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/>
<MkSignin :autoSet="autoSet" :message="message" :emailMode="emailMode" @login="onLogin" @close="onClose"/>
</MkSpacer>
</MkModalWindow>
</template>
Expand All @@ -27,10 +27,12 @@ import { i18n } from '@/i18n.js';

withDefaults(defineProps<{
autoSet?: boolean;
message?: string,
message?: string;
emailMode?: boolean;
}>(), {
autoSet: false,
message: '',
emailMode: false,
});

const emit = defineEmits<{
Expand Down
Loading

0 comments on commit 64e1351

Please sign in to comment.