enhance(frontend): アカウント作成時にHIBPで流出パスワードをチェックするように (MisskeyIO#617)
Co-authored-by: MurakamiSan <ark@arkjp.net>
This commit is contained in:
parent
37bdae6e89
commit
5fcec3d5dc
@ -487,6 +487,7 @@ usernameInvalidFormat: "You can use upper- and lowercase letters, numbers, and u
|
||||
tooShort: "Too short"
|
||||
tooLong: "Too long"
|
||||
weakPassword: "Weak password"
|
||||
leakedPassword: "This password has been leaked {n} times in other services"
|
||||
normalPassword: "Average password"
|
||||
strongPassword: "Strong password"
|
||||
passwordMatched: "Matches"
|
||||
|
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
@ -1964,6 +1964,10 @@ export interface Locale extends ILocale {
|
||||
* 弱いパスワード
|
||||
*/
|
||||
"weakPassword": string;
|
||||
/**
|
||||
* このパスワードは他のサービスで{n}回以上流出しています
|
||||
*/
|
||||
"leakedPassword": ParameterizedString<"n">;
|
||||
/**
|
||||
* 普通のパスワード
|
||||
*/
|
||||
|
@ -487,6 +487,7 @@ usernameInvalidFormat: "a~z、A~Z、0~9、_が使えます"
|
||||
tooShort: "短すぎます"
|
||||
tooLong: "長すぎます"
|
||||
weakPassword: "弱いパスワード"
|
||||
leakedPassword: "このパスワードは他のサービスで{n}回以上流出しています"
|
||||
normalPassword: "普通のパスワード"
|
||||
strongPassword: "強いパスワード"
|
||||
passwordMatched: "一致しました"
|
||||
|
@ -486,6 +486,7 @@ usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있습니다"
|
||||
tooShort: "너무 짧습니다"
|
||||
tooLong: "너무 깁니다"
|
||||
weakPassword: "약한 비밀번호"
|
||||
leakedPassword: "이 비밀번호는 다른 서비스에서 {n}회 이상 유출되었습니다"
|
||||
normalPassword: "좋은 비밀번호"
|
||||
strongPassword: "강한 비밀번호"
|
||||
passwordMatched: "일치합니다"
|
||||
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #label>{{ i18n.ts.invitationCode }}</template>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
</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 #prefix>@</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>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
|
||||
<template #label>{{ i18n.ts.password }}</template>
|
||||
<MkInput v-model="password" :debounce="true" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
|
||||
<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 == '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 == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
|
||||
</template>
|
||||
@ -115,7 +119,9 @@ const invitationCode = ref<string>('');
|
||||
const email = ref('');
|
||||
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 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 submitting = ref<boolean>(false);
|
||||
const hCaptchaResponse = ref<string | null>(null);
|
||||
@ -124,6 +130,7 @@ const reCaptchaResponse = ref<string | null>(null);
|
||||
const turnstileResponse = ref<string | null>(null);
|
||||
const usernameAbortController = ref<null | AbortController>(null);
|
||||
const emailAbortController = ref<null | AbortController>(null);
|
||||
const passwordAbortController = ref<null | AbortController>(null);
|
||||
|
||||
const shouldDisableSubmitting = computed((): boolean => {
|
||||
return submitting.value ||
|
||||
@ -132,11 +139,12 @@ const shouldDisableSubmitting = computed((): boolean => {
|
||||
instance.enableRecaptcha && !reCaptchaResponse.value ||
|
||||
instance.enableTurnstile && !turnstileResponse.value ||
|
||||
instance.emailRequiredForSignup && emailState.value !== 'ok' ||
|
||||
(passwordStrength.value !== 'medium' && passwordStrength.value !== 'high') ||
|
||||
usernameState.value !== 'ok' ||
|
||||
passwordRetypeState.value !== 'match';
|
||||
});
|
||||
|
||||
function getPasswordStrength(source: string): number {
|
||||
async function getPasswordStrength(source: string): Promise<number> {
|
||||
let strength = 0;
|
||||
let power = 0.018;
|
||||
|
||||
@ -157,6 +165,46 @@ function getPasswordStrength(source: string): number {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@ -226,14 +274,16 @@ function onChangeEmail(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function onChangePassword(): void {
|
||||
async function onChangePassword(): Promise<void> {
|
||||
if (password.value === '') {
|
||||
passwordStrength.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const strength = getPasswordStrength(password.value);
|
||||
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 {
|
||||
@ -304,4 +354,26 @@ async function onSubmit(): Promise<void> {
|
||||
.captcha {
|
||||
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>
|
||||
|
@ -22,7 +22,7 @@
|
||||
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;
|
||||
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 *;"
|
||||
/>
|
||||
<meta property="og:site_name" content="[DEV BUILD] Misskey" />
|
||||
|
Loading…
Reference in New Issue
Block a user