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"
|
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
4
locales/index.d.ts
vendored
@ -1964,6 +1964,10 @@ export interface Locale extends ILocale {
|
|||||||
* 弱いパスワード
|
* 弱いパスワード
|
||||||
*/
|
*/
|
||||||
"weakPassword": string;
|
"weakPassword": string;
|
||||||
|
/**
|
||||||
|
* このパスワードは他のサービスで{n}回以上流出しています
|
||||||
|
*/
|
||||||
|
"leakedPassword": ParameterizedString<"n">;
|
||||||
/**
|
/**
|
||||||
* 普通のパスワード
|
* 普通のパスワード
|
||||||
*/
|
*/
|
||||||
|
@ -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: "一致しました"
|
||||||
|
@ -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: "일치합니다"
|
||||||
|
@ -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>
|
||||||
|
@ -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" />
|
||||||
|
Loading…
Reference in New Issue
Block a user