Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/central/src/components/form-group.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ except according to the terms contained in the LICENSE file.
<input ref="input" v-model="modelValue" v-bind="$attrs" class="form-control"
:placeholder="requiredLabel(placeholder, required)" :required="required"
v-tooltip.aria-describedby="tooltip" :autocomplete="autocomplete">
<span class="form-label">{{ requiredLabel(placeholder, required) }}</span>
<password-strength v-if="autocomplete === 'new-password'"
:password="modelValue"/>
<span class="form-label">{{ requiredLabel(placeholder, required) }}</span>
<slot name="after"></slot>
</label>
</template>
Expand Down
16 changes: 11 additions & 5 deletions apps/central/src/components/password-strength.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ vue-password-strength-meter 1.7.2, which uses the MIT license.
https://github.com/apertureless/vue-password-strength-meter -->
<template>
<div class="password-strength">
<div :data-score="score"></div>
<div class="inner">
<div :data-score="score"></div>
</div>
</div>
</template>

Expand Down Expand Up @@ -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),
Expand Down
47 changes: 33 additions & 14 deletions apps/central/src/components/user/edit/password.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ except according to the terms contained in the LICENSE file.
autocomplete="current-password"/>
<form-group id="user-edit-password-new-password" v-model="newPassword"
type="password" :placeholder="$t('field.newPassword')" required
:has-error="tooShort || mismatch" autocomplete="new-password"/>
:has-error="tooShort || mismatch || pwned" autocomplete="new-password">
<template #after>
<div v-if="pwned" class="error">
<p>This password has previously been included in a breach.</p>
<p>For more information, see <a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="noopener noreferrer">here</a>.</p>
</div>
</template>
</form-group>
<form-group id="user-edit-password-confirm" v-model="confirm"
type="password" :placeholder="$t('field.passwordConfirm')" required
:has-error="mismatch" autocomplete="new-password"/>
Expand All @@ -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',
Expand All @@ -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'));
Expand All @@ -86,26 +96,35 @@ 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);
}
})();
}
}
};
</script>

<style lang="scss">
#user-edit-password input[autocomplete="username"] { display: none; }
.error { color:#de2a11; font-size:11px; margin:25px 12px -25px; }
</style>

<i18n lang="json5">
Expand Down
47 changes: 47 additions & 0 deletions apps/central/src/util/password.js
Original file line number Diff line number Diff line change
@@ -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();
}
Loading