次の処理をsignin apiから読み取るように

This commit is contained in:
kakkokari-gtyih 2024-10-03 20:34:05 +09:00
parent 65aac75a33
commit adabff708d
10 changed files with 255 additions and 184 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',

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,18 @@ 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';
type SigninErrorResponse = {
id: string;
next?: 'captcha' | 'password' | 'totp';
} | {
id: string;
next: 'passkey';
authRequest: PublicKeyCredentialRequestOptionsJSON;
};
@Injectable()
export class SigninApiService {
constructor(
@ -43,6 +53,9 @@ export class SigninApiService {
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
@ -60,7 +73,7 @@ export class SigninApiService {
request: FastifyRequest<{
Body: {
username: string;
password: string;
password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string;
@ -79,7 +92,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 +116,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 +140,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 +250,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 +259,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

@ -22,7 +22,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import { get as webAuthnRequest } from '@github/webauthn-json/browser-ponyfill';
import { i18n } from '@/i18n.js';
@ -32,7 +31,6 @@ import MkButton from '@/components/MkButton.vue';
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
const props = defineProps<{
user: Misskey.entities.UserDetailed;
credentialRequest: CredentialRequestOptions;
isPerformingPasswordlessLogin?: boolean;
}>();

View File

@ -23,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
</MkInput>
<div v-if="!user.twoFactorEnabled">
<div v-if="needCaptcha">
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
</div>
<MkButton type="submit" :disabled="!user.twoFactorEnabled && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
<MkButton type="submit" :disabled="needCaptcha && captchaFailed" large primary rounded style="margin: 0 auto;" data-cy-signin-page-password-continue>{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</form>
</div>
</div>
@ -62,6 +62,7 @@ import MkCaptcha from '@/components/MkCaptcha.vue';
const props = defineProps<{
user: Misskey.entities.UserDetailed;
needCaptcha: boolean;
}>();
const emit = defineEmits<{
@ -82,10 +83,11 @@ const turnstileResponse = ref<string | null>(null);
const captchaFailed = computed((): boolean => {
return (
instance.enableHcaptcha && !hCaptchaResponse.value ||
instance.enableMcaptcha && !mCaptchaResponse.value ||
instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value);
(instance.enableHcaptcha && !hCaptchaResponse.value) ||
(instance.enableMcaptcha && !mCaptchaResponse.value) ||
(instance.enableRecaptcha && !reCaptchaResponse.value) ||
(instance.enableTurnstile && !turnstileResponse.value)
);
});
function resetPassword(): void {

View File

@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="passwordPageEl"
:user="userInfo!"
:needCaptcha="needCaptcha"
@passwordSubmitted="onPasswordSubmitted"
/>
@ -49,7 +50,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-else-if="page === 'passkey'"
key="passkey"
:user="userInfo!"
:credentialRequest="credentialRequest!"
:isPerformingPasswordlessLogin="doingPasskeyFromInputPage"
@ -64,14 +64,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script setup lang="ts">
import { ref, shallowRef, useTemplateRef } from 'vue';
import { nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { login } from '@/account.js';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
@ -98,9 +97,11 @@ const props = withDefaults(defineProps<{
});
const page = ref<'input' | 'password' | 'totp' | 'passkey'>('input');
const passwordPageEl = useTemplateRef('passwordPageEl');
const waiting = ref(false);
const passwordPageEl = useTemplateRef('passwordPageEl');
const needCaptcha = ref(false);
const userInfo = ref<null | Misskey.entities.UserDetailed>(null);
const password = ref('');
@ -142,14 +143,11 @@ function onPasskeyDone(credential: AuthenticationPublicKeyCredential): void {
emit('login', res.signinResponse);
}).catch(onLoginFailed);
} else if (userInfo.value != null) {
misskeyApi('signin', {
tryLogin({
username: userInfo.value.username,
password: password.value,
credential: credential.toJSON(),
}).then(async (res) => {
emit('login', res);
await onLoginSucceeded(res);
}).catch(onLoginFailed);
});
}
}
@ -171,17 +169,18 @@ async function onUsernameSubmitted(username: string) {
title: i18n.ts.noSuchUser,
text: i18n.ts.signinFailed,
});
} else if (userInfo.value.usePasswordLessLogin) {
page.value = 'passkey';
} else {
page.value = 'password';
waiting.value = false;
return;
}
waiting.value = false;
await tryLogin({
username,
});
}
async function onPasswordSubmitted(pw: PwResponse) {
waiting.value = true;
password.value = pw.password;
if (userInfo.value == null) {
await os.alert({
@ -189,48 +188,17 @@ async function onPasswordSubmitted(pw: PwResponse) {
title: i18n.ts.noSuchUser,
text: i18n.ts.signinFailed,
});
return;
} else {
if (!userInfo.value.twoFactorEnabled) {
if (
(instance.enableHcaptcha || instance.enableMcaptcha || instance.enableRecaptcha || instance.enableTurnstile) &&
(pw.captcha.hCaptchaResponse == null && pw.captcha.mCaptchaResponse == null && pw.captcha.reCaptchaResponse == null && pw.captcha.turnstileResponse == null)
) {
// 2FACAPTCHACAPTCHA
onLoginFailed();
waiting.value = false;
return;
} else {
await misskeyApi('signin', {
await tryLogin({
username: userInfo.value.username,
password: pw.password,
'h-captcha-response': pw.captcha.hCaptchaResponse,
'hcaptcha-response': pw.captcha.hCaptchaResponse,
'm-captcha-response': pw.captcha.mCaptchaResponse,
'g-recaptcha-response': pw.captcha.reCaptchaResponse,
'turnstile-response': pw.captcha.turnstileResponse,
}).then(async (res) => {
emit('login', res);
await onLoginSucceeded(res);
}).catch(onLoginFailed);
}
} else if (userInfo.value.securityKeys) {
password.value = pw.password;
await misskeyApi('signin', {
username: userInfo.value.username,
password: pw.password,
}).then((res) => {
credentialRequest.value = parseRequestOptionsFromJSON({
publicKey: res,
});
page.value = 'passkey';
waiting.value = false;
}).catch(onLoginFailed);
} else {
password.value = pw.password;
page.value = 'totp';
waiting.value = false;
}
}
}
@ -243,17 +211,35 @@ async function onTotpSubmitted(token: string) {
title: i18n.ts.noSuchUser,
text: i18n.ts.signinFailed,
});
waiting.value = false;
return;
} else {
await misskeyApi('signin', {
await tryLogin({
username: userInfo.value.username,
password: password.value,
token,
}).then(async (res) => {
});
}
}
async function tryLogin(req: Partial<Misskey.entities.SigninRequest>): Promise<Misskey.entities.SigninResponse> {
if (userInfo.value == null) {
throw new Error('No such user');
}
const _req = {
username: userInfo.value.username,
...req,
};
return await misskeyApi('signin', _req).then(async (res) => {
emit('login', res);
await onLoginSucceeded(res);
}).catch(onLoginFailed);
}
return res;
}).catch((err) => {
onLoginFailed(err);
return Promise.reject(err);
});
}
async function onLoginSucceeded(res: Misskey.entities.SigninResponse) {
@ -265,6 +251,33 @@ async function onLoginSucceeded(res: Misskey.entities.SigninResponse) {
function onLoginFailed(err?: any): void {
const id = err?.id ?? null;
if (typeof err === 'object' && 'next' in err) {
switch (err.next) {
case 'captcha': {
page.value = 'password';
break;
}
case 'password': {
page.value = 'password';
break;
}
case 'totp': {
page.value = 'totp';
break;
}
case 'passkey': {
if (webAuthnSupported() && 'authRequest' in err) {
credentialRequest.value = parseRequestOptionsFromJSON({
publicKey: err.authRequest,
});
page.value = 'passkey';
} else {
page.value = 'totp';
}
break;
}
}
} else {
switch (id) {
case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
os.alert({
@ -343,14 +356,23 @@ function onLoginFailed(err?: any): void {
});
}
}
}
if (doingPasskeyFromInputPage.value === true) {
doingPasskeyFromInputPage.value = false;
page.value = 'input';
password.value = '';
}
passwordPageEl.value?.resetCaptcha();
nextTick(() => {
waiting.value = false;
});
}
onBeforeUnmount(() => {
password.value = '';
userInfo.value = null;
});
</script>
<style lang="scss" module>

View File

@ -68,7 +68,6 @@ function onLogin(res) {
height: 100%;
max-height: 450px;
box-sizing: border-box;
text-align: center;
background: var(--panel);
border-radius: var(--radius);
}

View File

@ -3040,7 +3040,7 @@ type Signin = components['schemas']['Signin'];
// @public (undocumented)
type SigninRequest = {
username: string;
password: string;
password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null;

View File

@ -3782,16 +3782,13 @@ export type components = {
followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private';
/** @default false */
twoFactorEnabled: boolean;
/** @default false */
usePasswordLessLogin: boolean;
/** @default false */
securityKeys: boolean;
roles: components['schemas']['RoleLite'][];
followedMessage?: string | null;
memo: string | null;
moderationNote?: string;
twoFactorEnabled?: boolean;
usePasswordLessLogin?: boolean;
securityKeys?: boolean;
isFollowing?: boolean;
isFollowed?: boolean;
hasPendingFollowRequestFromYou?: boolean;

View File

@ -269,7 +269,7 @@ export type SignupPendingResponse = {
export type SigninRequest = {
username: string;
password: string;
password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null;