feat: 2FAのバックアップコードの実装 (#121)

This commit is contained in:
まっちゃとーにゅ
2023-07-30 03:35:42 +09:00
committed by GitHub
parent 99232ed417
commit 2b941ae648
34 changed files with 91 additions and 32 deletions

View File

@@ -0,0 +1,11 @@
export class User2faBackupCodes1690569881926 {
name = 'User2faBackupCodes1690569881926'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "twoFactorBackupSecret" character varying array`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "twoFactorBackupSecret"`);
}
}

View File

@@ -455,6 +455,7 @@ export class UserEntityService implements OnModuleInit {
preventAiLearning: profile!.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
twoFactorBackupCodes: profile?.twoFactorBackupSecret?.length === 20 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
where: { userId: user.id, isSpecified: true },

View File

@@ -91,6 +91,11 @@ export class UserProfile {
})
public twoFactorTempSecret: string | null;
@Column('varchar', {
nullable: true, array: true,
})
public twoFactorBackupSecret: string[] | null;
@Column('varchar', {
length: 128, nullable: true,
})

View File

@@ -320,6 +320,11 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
twoFactorBackupCodes: {
type: 'string',
enum: ['full', 'partial', 'none'],
nullable: false, optional: false,
},
hideOnlineStatus: {
type: 'boolean',
nullable: false, optional: false,

View File

@@ -155,6 +155,13 @@ export class SigninApiService {
});
}
if (profile.twoFactorBackupSecret?.includes(token)) {
await this.userProfilesRepository.update({ userId: profile.userId }, {
twoFactorBackupSecret: profile.twoFactorBackupSecret.filter((secret) => secret !== token),
});
return this.signinService.signin(request, reply, user);
}
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
digits: 6,

View File

@@ -54,8 +54,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new Error('not verified');
}
const backupCodes = Array.from({ length: 20 }, () => {
return new OTPAuth.Secret().base32;
});
await this.userProfilesRepository.update(me.id, {
twoFactorSecret: profile.twoFactorTempSecret,
twoFactorBackupSecret: backupCodes,
twoFactorEnabled: true,
});
@@ -64,6 +69,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
detail: true,
includeSecrets: true,
}));
return {
backupCodes: backupCodes,
};
});
}
}

View File

@@ -42,6 +42,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userProfilesRepository.update(me.id, {
twoFactorSecret: null,
twoFactorBackupSecret: null,
twoFactorEnabled: false,
usePasswordLessLogin: false,
});

View File

@@ -186,7 +186,7 @@ describe('2要素認証', () => {
const doneResponse = await api('/i/2fa/done', {
token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(doneResponse.status, 204);
assert.strictEqual(doneResponse.status, 200);
const usersShowResponse = await api('/users/show', {
username,
@@ -211,7 +211,7 @@ describe('2要素認証', () => {
const doneResponse = await api('/i/2fa/done', {
token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(doneResponse.status, 204);
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
password,
@@ -267,7 +267,7 @@ describe('2要素認証', () => {
const doneResponse = await api('/i/2fa/done', {
token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(doneResponse.status, 204);
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
password,
@@ -324,7 +324,7 @@ describe('2要素認証', () => {
const doneResponse = await api('/i/2fa/done', {
token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(doneResponse.status, 204);
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
password,
@@ -366,7 +366,7 @@ describe('2要素認証', () => {
const doneResponse = await api('/i/2fa/done', {
token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(doneResponse.status, 204);
assert.strictEqual(doneResponse.status, 200);
const registerKeyResponse = await api('/i/2fa/register-key', {
password,
@@ -418,7 +418,7 @@ describe('2要素認証', () => {
const doneResponse = await api('/i/2fa/done', {
token: otpToken(registerResponse.body.secret),
}, alice);
assert.strictEqual(doneResponse.status, 204);
assert.strictEqual(doneResponse.status, 200);
const usersShowResponse = await api('/users/show', {
username,

View File

@@ -148,6 +148,7 @@ describe('ユーザー', () => {
preventAiLearning: user.preventAiLearning,
isExplorable: user.isExplorable,
isDeleted: user.isDeleted,
twoFactorBackupCodes: user.twoFactorBackupCodes,
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes,
hasUnreadMentions: user.hasUnreadMentions,
@@ -394,6 +395,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.preventAiLearning, true);
assert.strictEqual(response.isExplorable, true);
assert.strictEqual(response.isDeleted, false);
assert.strictEqual(response.twoFactorBackupCodes, 'none');
assert.strictEqual(response.hideOnlineStatus, false);
assert.strictEqual(response.hasUnreadSpecifiedNotes, false);
assert.strictEqual(response.hasUnreadMentions, false);

View File

@@ -111,6 +111,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
publicReactions: false,
securityKeys: false,
twoFactorEnabled: false,
twoFactorBackupCodes: 'none',
updatedAt: null,
uri: null,
url: null,

View File

@@ -32,7 +32,7 @@
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
</MkInput>
<MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="one-time-code" :spellcheck="false" required>
<MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
<template #label>{{ i18n.ts.token }}</template>
<template #prefix><i class="ti ti-123"></i></template>
</MkInput>

View File

@@ -3,6 +3,13 @@
<template #label>{{ i18n.ts['2fa'] }}</template>
<div v-if="$i" class="_gaps_s">
<MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodes === 'partial'" warn class="info">
{{ i18n.ts._2fa.twoFactorBackupSecretWarning }}
</MkInfo>
<MkInfo v-if="$i.twoFactorEnabled && $i.twoFactorBackupCodes === 'none'" warn class="info">
{{ i18n.ts._2fa.twoFactorBackupSecretExhausted }}
</MkInfo>
<MkFolder>
<template #icon><i class="ti ti-shield-lock"></i></template>
<template #label>{{ i18n.ts.totp }}</template>
@@ -114,13 +121,13 @@ async function registerTOTP() {
});
if (token.canceled) return;
await os.apiWithDialog('i/2fa/done', {
const { backupCodes } = await os.apiWithDialog('i/2fa/done', {
token: token.result.toString(),
});
await os.alert({
type: 'success',
text: i18n.ts._2fa.step4,
text: i18n.t('_2fa.step4', { codes: backupCodes.join('\n') }),
});
}

View File

@@ -2455,6 +2455,7 @@ type MeDetailed = UserDetailed & {
hasUnreadMessagingMessage: boolean;
hasUnreadNotification: boolean;
hasUnreadSpecifiedNotes: boolean;
twoFactorBackupCodes: 'full' | 'partial' | 'none';
hideOnlineStatus: boolean;
injectFeaturedNote: boolean;
integrations: Record<string, any>;

View File

@@ -95,6 +95,7 @@ export type MeDetailed = UserDetailed & {
hasUnreadMessagingMessage: boolean;
hasUnreadNotification: boolean;
hasUnreadSpecifiedNotes: boolean;
twoFactorBackupCodes: 'full' | 'partial' | 'none';
hideOnlineStatus: boolean;
injectFeaturedNote: boolean;
integrations: Record<string, any>;