enhance(frontend): パスワード変更時にHIBPで流出パスワードをチェックするように (MisskeyIO#625)

This commit is contained in:
まっちゃとーにゅ 2024-04-30 14:32:30 +09:00 committed by GitHub
parent 86e15db338
commit 5f8d4cf7b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 202 additions and 180 deletions

4
locales/index.d.ts vendored
View File

@ -7904,10 +7904,6 @@ export interface Locale extends ILocale {
* 使 * 使
*/ */
"backupCodesExhaustedWarning": string; "backupCodesExhaustedWarning": string;
/**
*
*/
"moreDetailedGuideHere": string;
/** /**
* *
*/ */

View File

@ -0,0 +1,161 @@
<template>
<MkInput v-model="password" :debounce="true" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
<template #label>
{{ label }} <a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="nofollow noopener"><span :class="$style.hibpLogo">leak checked by <span>';--hibp?</span></span></a>
</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordStrength == 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-if="passwordStrength == 'low' && isLeaked" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.tsx.leakedPassword({ n: leakedCount }) }}</span>
<span v-else-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
<template #label>{{ label }} ({{ i18n.ts.retype }})</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
</template>
</MkInput>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js';
import MkInput from '@/components/MkInput.vue';
defineProps<{
label: string;
}>();
const password = ref<string>('');
const retypedPassword = ref<string>('');
const passwordStrength = ref<'' | 'wait' | 'low' | 'medium' | 'high'>('');
const isLeaked = ref(false);
const leakedCount = ref(0);
const passwordRetypeState = ref<null | 'match' | 'not-match'>(null);
const passwordAbortController = ref<null | AbortController>(null);
const isValid = computed((): boolean => {
return passwordRetypeState.value === 'match'
&& (passwordStrength.value === 'medium' || passwordStrength.value === 'high');
});
defineExpose({
isValid,
password,
});
async function getPasswordStrength(source: string): Promise<number> {
let strength = 0;
let power = 0.018;
//
if (/[a-zA-Z]/.test(source) && /[0-9]/.test(source)) {
power += 0.020;
}
//
if (/[a-z]/.test(source) && /[A-Z]/.test(source)) {
power += 0.015;
}
//
if (/[!\x22\#$%&@'()*+,-./_]/.test(source)) {
power += 0.02;
}
strength = power * source.length;
// check HIBP 3 chars or more
if (passwordAbortController.value != null) {
passwordAbortController.value.abort();
}
if (source.length >= 3) {
passwordStrength.value = 'wait';
passwordAbortController.value = new AbortController();
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(source));
const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
const hashPrefix = hashHex.slice(0, 5).toUpperCase();
const hashSuffix = hashHex.slice(5).toUpperCase();
await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, { signal: passwordAbortController.value.signal })
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(text => {
const lines = text.split('\n');
const line = lines.find(l => l.startsWith(hashSuffix));
if (line) {
leakedCount.value = parseInt(line.split(':')[1]);
isLeaked.value = true;
strength = 0;
} else {
isLeaked.value = false;
}
})
.catch(() => {
leakedCount.value = 0;
isLeaked.value = false;
});
} else {
leakedCount.value = 0;
isLeaked.value = false;
}
return Math.max(0, Math.min(1, strength));
}
async function onChangePassword(): Promise<void> {
if (password.value === '') {
passwordStrength.value = '';
return;
}
const strength = await getPasswordStrength(password.value);
passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
onChangePasswordRetype();
}
function onChangePasswordRetype(): void {
if (retypedPassword.value === '') {
passwordRetypeState.value = null;
return;
}
passwordRetypeState.value = password.value === retypedPassword.value ? 'match' : 'not-match';
}
</script>
<style lang="scss" module>
.hibpLogo {
background: linear-gradient(45deg, #616c70, #626262);
color: #fefefe;
display: inline-flex;
padding-left: 8px;
margin-left: 4px;
font-size: 0.8em;
border-radius: 6px;
overflow: hidden;
align-items: center;
transform: translateY(-1px);
span {
background: linear-gradient(45deg, #255e81, #338cac);
font-size: 1.4em;
padding: 2px 8px;
height: auto;
margin-left: 8px;
font-weight: bold;
}
}
</style>

View File

@ -45,27 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
</template> </template>
</MkInput> </MkInput>
<MkInput v-model="password" :debounce="true" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword"> <MkNewPassword ref="password" :label="i18n.ts.password"/>
<template #label>
{{ i18n.ts.password }} <a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="nofollow noopener"><span :class="$style.hibpLogo">leak checked by <span>';--hibp?</span></span></a>
</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordStrength == 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-if="passwordStrength == 'low' && isLeaked" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.tsx.leakedPassword({ n: leakedCount }) }}</span>
<span v-else-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
</template>
</MkInput>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> <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.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.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
@ -82,11 +62,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed, shallowRef } from 'vue';
import { toUnicode } from 'punycode/'; import { toUnicode } from 'punycode/';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkButton from './MkButton.vue'; import MkButton from './MkButton.vue';
import MkInput from './MkInput.vue'; import MkInput from './MkInput.vue';
import MkNewPassword from '@/components/MkNewPassword.vue';
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
import * as config from '@/config.js'; import * as config from '@/config.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -113,16 +94,11 @@ const recaptcha = ref<Captcha | undefined>();
const turnstile = ref<Captcha | undefined>(); const turnstile = ref<Captcha | undefined>();
const username = ref<string>(''); const username = ref<string>('');
const password = ref<string>(''); const password = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
const retypedPassword = ref<string>('');
const invitationCode = ref<string>(''); const invitationCode = ref<string>('');
const email = ref(''); const email = ref('');
const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null); const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null);
const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null); const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null);
const passwordStrength = ref<'' | 'wait' | 'low' | 'medium' | 'high'>('');
const isLeaked = ref(false);
const leakedCount = ref(0);
const passwordRetypeState = ref<null | 'match' | 'not-match'>(null);
const submitting = ref<boolean>(false); const submitting = ref<boolean>(false);
const hCaptchaResponse = ref<string | null>(null); const hCaptchaResponse = ref<string | null>(null);
const mCaptchaResponse = ref<string | null>(null); const mCaptchaResponse = ref<string | null>(null);
@ -130,7 +106,6 @@ const reCaptchaResponse = ref<string | null>(null);
const turnstileResponse = ref<string | null>(null); const turnstileResponse = ref<string | null>(null);
const usernameAbortController = ref<null | AbortController>(null); const usernameAbortController = ref<null | AbortController>(null);
const emailAbortController = ref<null | AbortController>(null); const emailAbortController = ref<null | AbortController>(null);
const passwordAbortController = ref<null | AbortController>(null);
const shouldDisableSubmitting = computed((): boolean => { const shouldDisableSubmitting = computed((): boolean => {
return submitting.value || return submitting.value ||
@ -139,75 +114,10 @@ const shouldDisableSubmitting = computed((): boolean => {
instance.enableRecaptcha && !reCaptchaResponse.value || instance.enableRecaptcha && !reCaptchaResponse.value ||
instance.enableTurnstile && !turnstileResponse.value || instance.enableTurnstile && !turnstileResponse.value ||
instance.emailRequiredForSignup && emailState.value !== 'ok' || instance.emailRequiredForSignup && emailState.value !== 'ok' ||
(passwordStrength.value !== 'medium' && passwordStrength.value !== 'high') ||
usernameState.value !== 'ok' || usernameState.value !== 'ok' ||
passwordRetypeState.value !== 'match'; !password.value?.isValid;
}); });
async function getPasswordStrength(source: string): Promise<number> {
let strength = 0;
let power = 0.018;
//
if (/[a-zA-Z]/.test(source) && /[0-9]/.test(source)) {
power += 0.020;
}
//
if (/[a-z]/.test(source) && /[A-Z]/.test(source)) {
power += 0.015;
}
//
if (/[!\x22\#$%&@'()*+,-./_]/.test(source)) {
power += 0.02;
}
strength = power * source.length;
// check HIBP 3 chars or more
if (passwordAbortController.value != null) {
passwordAbortController.value.abort();
}
if (source.length >= 3) {
passwordStrength.value = 'wait';
passwordAbortController.value = new AbortController();
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(source));
const hashHex = Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
const hashPrefix = hashHex.slice(0, 5).toUpperCase();
const hashSuffix = hashHex.slice(5).toUpperCase();
await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, { signal: passwordAbortController.value.signal })
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(text => {
const lines = text.split('\n');
const line = lines.find(l => l.startsWith(hashSuffix));
if (line) {
leakedCount.value = parseInt(line.split(':')[1]);
isLeaked.value = true;
strength = 0;
} else {
isLeaked.value = false;
}
})
.catch(() => {
leakedCount.value = 0;
isLeaked.value = false;
});
} else {
leakedCount.value = 0;
isLeaked.value = false;
}
return Math.max(0, Math.min(1, strength));
}
function onChangeUsername(): void { function onChangeUsername(): void {
if (username.value === '') { if (username.value === '') {
usernameState.value = null; usernameState.value = null;
@ -274,35 +184,14 @@ function onChangeEmail(): void {
}); });
} }
async function onChangePassword(): Promise<void> {
if (password.value === '') {
passwordStrength.value = '';
return;
}
const strength = await getPasswordStrength(password.value);
passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
if (passwordRetypeState.value === 'match') onChangePasswordRetype();
}
function onChangePasswordRetype(): void {
if (retypedPassword.value === '') {
passwordRetypeState.value = null;
return;
}
passwordRetypeState.value = password.value === retypedPassword.value ? 'match' : 'not-match';
}
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
if (submitting.value) return; if (!password.value?.isValid || submitting.value) return;
submitting.value = true; submitting.value = true;
try { try {
await misskeyApi('signup', { await misskeyApi('signup', {
username: username.value, username: username.value,
password: password.value, password: password.value.password,
emailAddress: email.value, emailAddress: email.value,
invitationCode: invitationCode.value, invitationCode: invitationCode.value,
'hcaptcha-response': hCaptchaResponse.value, 'hcaptcha-response': hCaptchaResponse.value,
@ -320,7 +209,7 @@ async function onSubmit(): Promise<void> {
} else { } else {
const res = await misskeyApi('signin', { const res = await misskeyApi('signin', {
username: username.value, username: username.value,
password: password.value, password: password.value.password,
}); });
emit('signup', res); emit('signup', res);
@ -354,26 +243,4 @@ async function onSubmit(): Promise<void> {
.captcha { .captcha {
margin: 16px 0; margin: 16px 0;
} }
.hibpLogo {
background: linear-gradient(45deg, #616c70, #626262);
color: #fefefe;
display: inline-flex;
padding-left: 8px;
margin-left: 4px;
font-size: 0.8em;
border-radius: 6px;
overflow: hidden;
align-items: center;
transform: translateY(-1px);
span {
background: linear-gradient(45deg, #255e81, #338cac);
font-size: 1.4em;
padding: 2px 8px;
height: auto;
margin-left: 8px;
font-weight: bold;
}
}
</style> </style>

View File

@ -8,20 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32"> <MkSpacer v-if="token" :contentMax="700" :marginMin="16" :marginMax="32">
<div class="_gaps_m"> <div class="_gaps_m">
<MkInput v-model="password" type="password"> <MkNewPassword ref="newPassword" :label="i18n.ts.newPassword"/>
<template #prefix><i class="ti ti-lock"></i></template> <MkButton primary :disabled="shouldDisableSubmitting" @click="save">{{ i18n.ts.save }}</MkButton>
<template #label>{{ i18n.ts.newPassword }}</template>
</MkInput>
<MkButton primary @click="save">{{ i18n.ts.save }}</MkButton>
</div> </div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent, onMounted, ref, computed } from 'vue'; import { defineAsyncComponent, onMounted, ref, computed, shallowRef } from 'vue';
import MkInput from '@/components/MkInput.vue'; import MkNewPassword from '@/components/MkNewPassword.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -32,12 +28,20 @@ const props = defineProps<{
token?: string; token?: string;
}>(); }>();
const password = ref(''); const newPassword = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
const submitting = ref<boolean>(false);
const shouldDisableSubmitting = computed((): boolean => {
return submitting.value || !newPassword.value?.isValid;
});
async function save() { async function save() {
if (!newPassword.value?.isValid || submitting.value) return;
submitting.value = true;
await os.apiWithDialog('reset-password', { await os.apiWithDialog('reset-password', {
token: props.token, token: props.token,
password: password.value, password: newPassword.value.password,
}); });
mainRouter.push('/'); mainRouter.push('/');
} }

View File

@ -7,7 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<FormSection first> <FormSection first>
<template #label>{{ i18n.ts.password }}</template> <template #label>{{ i18n.ts.password }}</template>
<MkButton primary @click="change()">{{ i18n.ts.changePassword }}</MkButton> <MkFolder key="changePasswordKey">
<template #icon><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.changePassword }}</template>
<div class="_gaps_s">
<MkNewPassword ref="newPassword" :label="i18n.ts.newPassword"/>
<MkButton primary :disabled="!newPassword?.isValid" @click="changePassword">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</FormSection> </FormSection>
<X2fa/> <X2fa/>
@ -40,10 +47,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed, ref, shallowRef } from 'vue';
import X2fa from './2fa.vue'; import X2fa from './2fa.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import FormSlot from '@/components/form/slot.vue'; import FormSlot from '@/components/form/slot.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkNewPassword from '@/components/MkNewPassword.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -51,33 +60,16 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
const changePasswordKey = ref(Date.now());
const newPassword = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
const pagination = { const pagination = {
endpoint: 'i/signin-history' as const, endpoint: 'i/signin-history' as const,
limit: 5, limit: 5,
}; };
async function change() { async function changePassword() {
const { canceled: canceled2, result: newPassword } = await os.inputText({ if (!newPassword.value?.isValid) return;
title: i18n.ts.newPassword,
type: 'password',
autocomplete: 'new-password',
});
if (canceled2) return;
const { canceled: canceled3, result: newPassword2 } = await os.inputText({
title: i18n.ts.newPasswordRetype,
type: 'password',
autocomplete: 'new-password',
});
if (canceled3) return;
if (newPassword !== newPassword2) {
os.alert({
type: 'error',
text: i18n.ts.retypedNotMatch,
});
return;
}
const auth = await os.authenticateDialog(); const auth = await os.authenticateDialog();
if (auth.canceled) return; if (auth.canceled) return;
@ -85,7 +77,9 @@ async function change() {
os.apiWithDialog('i/change-password', { os.apiWithDialog('i/change-password', {
currentPassword: auth.result.password, currentPassword: auth.result.password,
token: auth.result.token, token: auth.result.token,
newPassword, newPassword: newPassword.value.password,
}).then(() => {
changePasswordKey.value = Date.now();
}); });
} }