diff --git a/locales/en-US.yml b/locales/en-US.yml index ed2221fc5..ece1ec8ca 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -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" diff --git a/locales/index.d.ts b/locales/index.d.ts index 3233091bd..b8a8b9586 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1964,6 +1964,10 @@ export interface Locale extends ILocale { * 弱いパスワード */ "weakPassword": string; + /** + * このパスワードは他のサービスで{n}回以上流出しています + */ + "leakedPassword": ParameterizedString<"n">; /** * 普通のパスワード */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f4899dafd..2317bf88d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -487,6 +487,7 @@ usernameInvalidFormat: "a~z、A~Z、0~9、_が使えます" tooShort: "短すぎます" tooLong: "長すぎます" weakPassword: "弱いパスワード" +leakedPassword: "このパスワードは他のサービスで{n}回以上流出しています" normalPassword: "普通のパスワード" strongPassword: "強いパスワード" passwordMatched: "一致しました" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 8372df7d5..a58b4381e 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -486,6 +486,7 @@ usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있습니다" tooShort: "너무 짧습니다" tooLong: "너무 깁니다" weakPassword: "약한 비밀번호" +leakedPassword: "이 비밀번호는 다른 서비스에서 {n}회 이상 유출되었습니다" normalPassword: "좋은 비밀번호" strongPassword: "강한 비밀번호" passwordMatched: "일치합니다" diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 5f08e416c..0eef175cb 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -45,11 +45,15 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.error }} - - + + @@ -115,7 +119,9 @@ const invitationCode = ref(''); const email = ref(''); const usernameState = ref(null); const emailState = ref(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); const submitting = ref(false); const hCaptchaResponse = ref(null); @@ -124,6 +130,7 @@ const reCaptchaResponse = ref(null); const turnstileResponse = ref(null); const usernameAbortController = ref(null); const emailAbortController = ref(null); +const passwordAbortController = ref(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 { 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 { 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 { .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; + } +} diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 08ff0c58d..bbec8e06e 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -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 *;" />