diff --git a/apps/central/src/components/form-group.vue b/apps/central/src/components/form-group.vue index 476746a7c..d28bb1c32 100644 --- a/apps/central/src/components/form-group.vue +++ b/apps/central/src/components/form-group.vue @@ -15,9 +15,9 @@ except according to the terms contained in the LICENSE file. + {{ requiredLabel(placeholder, required) }} - {{ requiredLabel(placeholder, required) }} diff --git a/apps/central/src/components/password-strength.vue b/apps/central/src/components/password-strength.vue index d6c3dfd7d..1b45e15c5 100644 --- a/apps/central/src/components/password-strength.vue +++ b/apps/central/src/components/password-strength.vue @@ -15,7 +15,9 @@ vue-password-strength-meter 1.7.2, which uses the MIT license. https://github.com/apertureless/vue-password-strength-meter --> @@ -46,12 +48,16 @@ const score = computed(() => { @import '../assets/scss/mixins'; .password-strength { + position: relative; + height: 2px; +} + +.inner { background-color: #ddd; - float: right; height: 2px; - margin-bottom: 20px; - margin-top: 10px; - position: relative; + position: absolute; + right: 0; + top: 10px; width: 50%; // Use the borders of two pseduo-elements to create 4 blank spaces (gaps), diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 7da343aa6..fbda317f3 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -24,7 +24,14 @@ except according to the terms contained in the LICENSE file. autocomplete="current-password"/> + :has-error="tooShort || mismatch || pwned" autocomplete="new-password"> + + @@ -46,6 +53,7 @@ import useRequest from '../../../composables/request'; import { apiPaths } from '../../../util/request'; import { noop } from '../../../util/util'; import { useRequestData } from '../../../request-data'; +import { checkPasswordPwnage } from '../../../util/password'; export default { name: 'UserEditPassword', @@ -62,13 +70,15 @@ export default { newPassword: '', tooShort: false, confirm: '', - mismatch: false + mismatch: false, + pwned: false, }; }, methods: { validate() { this.tooShort = false; this.mismatch = false; + this.pwned = false; if (this.newPassword.length < 10) { this.alert.danger(this.$t('alert.passwordTooShort')); @@ -86,19 +96,27 @@ export default { }, submit() { if (!this.validate()) return; - const data = { old: this.oldPassword, new: this.newPassword }; - this.request({ - method: 'PUT', - url: apiPaths.password(this.user.id), - data - }) - .then(() => { - this.alert.success(this.$t('alert.success')); - // The Chrome password manager does not realize that the form was - // submitted. Should we navigate to a different page so that it does? - }) - .catch(noop); + (async () => { + const isPwned = await checkPasswordPwnage(this.newPassword); + if (isPwned) { + this.pwned = true; + } else { + const data = { old: this.oldPassword, new: this.newPassword }; + this.request({ + method: 'PUT', + url: apiPaths.password(this.user.id), + data + }) + .then(() => { + this.alert.success(this.$t('alert.success')); + + // The Chrome password manager does not realize that the form was + // submitted. Should we navigate to a different page so that it does? + }) + .catch(noop); + } + })(); } } }; @@ -106,6 +124,7 @@ export default { diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js new file mode 100644 index 000000000..deaf66c0d --- /dev/null +++ b/apps/central/src/util/password.js @@ -0,0 +1,47 @@ +const maxCacheLength = 10; +const hashCache = []; + +async function getSuffixesFor(prefix) { + const cachedHashes = hashCache.find(cached => cached.prefix === prefix); + if (cachedHashes) return cachedHashes.suffixes; + + try { + const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); + if (!res.ok) throw new Error(`Bad response: ${res.status}`); + + const body = await res.text(); + const suffixes = body.split('\n').map(line => line.split(':')[0]); + + if (hashCache.length === maxCacheLength) hashCache.shift(); + + hashCache.push({ prefix, suffixes }); + + return suffixes; + } catch (err) { + console.log('pwned check failed:', err); // eslint-disable-line no-console + // if we can't check, just let them use it + return []; + } +} + +export async function checkPasswordPwnage(password) { // eslint-disable-line import/prefer-default-export + const hash = await digestMessage(password); // eslint-disable-line no-use-before-define + + const hashPrefix = hash.substring(0, 5); + const hashSuffix = hash.substring(5); + + const suffixes = await getSuffixesFor(hashPrefix); + + return suffixes.includes(hashSuffix); +} + +// from: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string +async function digestMessage(message) { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray + .map(b => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); +} diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index 5ea8dee50..be748dd22 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -28,7 +28,7 @@ const submit = return component.get('#user-edit-password form').trigger('submit'); }; -describe('UserEditPassword', () => { +describe.only('UserEditPassword', () => { beforeEach(mockLogin); it('resets the form if the route changes', () => { @@ -145,7 +145,7 @@ describe('UserEditPassword', () => { .respondWithSuccess()); }); - it('sends the correct request', () => + it.only('sends the correct request', () => mockHttp() .mount(UserEditPassword, mountOptions()) .request(submit)