enhance(frontend): アカウント作成時にHIBPで流出パスワードをチェックするように (MisskeyIO#617)

Co-authored-by: MurakamiSan <ark@arkjp.net>
This commit is contained in:
まっちゃとーにゅ 2024-04-28 01:34:22 +09:00 committed by GitHub
parent 37bdae6e89
commit 5fcec3d5dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 88 additions and 9 deletions

View File

@ -487,6 +487,7 @@ usernameInvalidFormat: "You can use upper- and lowercase letters, numbers, and u
tooShort: "Too short" tooShort: "Too short"
tooLong: "Too long" tooLong: "Too long"
weakPassword: "Weak password" weakPassword: "Weak password"
leakedPassword: "This password has been leaked {n} times in other services"
normalPassword: "Average password" normalPassword: "Average password"
strongPassword: "Strong password" strongPassword: "Strong password"
passwordMatched: "Matches" passwordMatched: "Matches"

4
locales/index.d.ts vendored
View File

@ -1964,6 +1964,10 @@ export interface Locale extends ILocale {
* *
*/ */
"weakPassword": string; "weakPassword": string;
/**
* {n}
*/
"leakedPassword": ParameterizedString<"n">;
/** /**
* *
*/ */

View File

@ -487,6 +487,7 @@ usernameInvalidFormat: "a~z、A~Z、0~9、_が使えます"
tooShort: "短すぎます" tooShort: "短すぎます"
tooLong: "長すぎます" tooLong: "長すぎます"
weakPassword: "弱いパスワード" weakPassword: "弱いパスワード"
leakedPassword: "このパスワードは他のサービスで{n}回以上流出しています"
normalPassword: "普通のパスワード" normalPassword: "普通のパスワード"
strongPassword: "強いパスワード" strongPassword: "強いパスワード"
passwordMatched: "一致しました" passwordMatched: "一致しました"

View File

@ -486,6 +486,7 @@ usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있습니다"
tooShort: "너무 짧습니다" tooShort: "너무 짧습니다"
tooLong: "너무 깁니다" tooLong: "너무 깁니다"
weakPassword: "약한 비밀번호" weakPassword: "약한 비밀번호"
leakedPassword: "이 비밀번호는 다른 서비스에서 {n}회 이상 유출되었습니다"
normalPassword: "좋은 비밀번호" normalPassword: "좋은 비밀번호"
strongPassword: "강한 비밀번호" strongPassword: "강한 비밀번호"
passwordMatched: "일치합니다" passwordMatched: "일치합니다"

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.invitationCode }}</template> <template #label>{{ i18n.ts.invitationCode }}</template>
<template #prefix><i class="ti ti-key"></i></template> <template #prefix><i class="ti ti-key"></i></template>
</MkInput> </MkInput>
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" autocomplete="username" required data-cy-signup-username @update:modelValue="onChangeUsername"> <MkInput v-model="username" :debounce="true" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" autocomplete="username" required data-cy-signup-username @update:modelValue="onChangeUsername">
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template> <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix>@</template> <template #prefix>@</template>
<template #suffix>@{{ host }}</template> <template #suffix>@{{ host }}</template>
@ -45,11 +45,15 @@ 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" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword"> <MkInput v-model="password" :debounce="true" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
<template #label>{{ i18n.ts.password }}</template> <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 #prefix><i class="ti ti-lock"></i></template>
<template #caption> <template #caption>
<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span> <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 == '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> <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
</template> </template>
@ -115,7 +119,9 @@ 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<'' | 'low' | 'medium' | 'high'>(''); const passwordStrength = ref<'' | 'wait' | 'low' | 'medium' | 'high'>('');
const isLeaked = ref(false);
const leakedCount = ref(0);
const passwordRetypeState = ref<null | 'match' | 'not-match'>(null); 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);
@ -124,6 +130,7 @@ 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 ||
@ -132,11 +139,12 @@ 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'; passwordRetypeState.value !== 'match';
}); });
function getPasswordStrength(source: string): number { async function getPasswordStrength(source: string): Promise<number> {
let strength = 0; let strength = 0;
let power = 0.018; let power = 0.018;
@ -157,6 +165,46 @@ function getPasswordStrength(source: string): number {
strength = power * source.length; 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)); return Math.max(0, Math.min(1, strength));
} }
@ -226,14 +274,16 @@ function onChangeEmail(): void {
}); });
} }
function onChangePassword(): void { async function onChangePassword(): Promise<void> {
if (password.value === '') { if (password.value === '') {
passwordStrength.value = ''; passwordStrength.value = '';
return; return;
} }
const strength = getPasswordStrength(password.value); const strength = await getPasswordStrength(password.value);
passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
if (passwordRetypeState.value === 'match') onChangePasswordRetype();
} }
function onChangePasswordRetype(): void { function onChangePasswordRetype(): void {
@ -304,4 +354,26 @@ 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

@ -22,7 +22,7 @@
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.pwnedpasswords.com;
frame-src *;" frame-src *;"
/> />
<meta property="og:site_name" content="[DEV BUILD] Misskey" /> <meta property="og:site_name" content="[DEV BUILD] Misskey" />