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
{{ i18n.ts.invitationCode }}
-
+
{{ i18n.ts.username }}
@
@{{ host }}
@@ -45,11 +45,15 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.error }}
-
- {{ i18n.ts.password }}
+
+
+ {{ i18n.ts.password }} leak checked by ';--hibp?
+
- {{ i18n.ts.weakPassword }}
+ {{ i18n.ts.checking }}
+ {{ i18n.tsx.leakedPassword({ n: leakedCount }) }}
+ {{ i18n.ts.weakPassword }}
{{ i18n.ts.normalPassword }}
{{ i18n.ts.strongPassword }}
@@ -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 *;"
/>