This commit is contained in:
Thomas Dedek
2021-04-13 15:50:20 +02:00
commit 169d67d7e7
11 changed files with 9085 additions and 0 deletions

11
.ci.run Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env sh
set -o nounset ## set -u : exit the script if you try to use an uninitialised variable
set -o errexit ## set -e : exit the script if any statement returns a non-true return value
set -o xtrace ## set -x : Print command traces before executing command
npm install --quiet
npm run --silent build
# push into archive
tar czf "$ARCHIVE/artifact.tar.gz" -C dist .

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/nbproject
/dist
/node_modules

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "password-reset",
"version": "0.1.0",
"license": "UNLICENSED",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@popperjs/core": "^2.8.2",
"axios": "^0.21.1",
"bootstrap": "^5.0.0-beta3",
"vue": "^2.6.11",
"vue-content-loading": "^1.6.0",
"vue-router": "^3.5.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {
"no-unused-vars": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

10
public/index.html Normal file
View File

@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>ECG Account Service</title>
</head>
<body class="container">
<div id="app"></div>
</body>
</html>

3
src/AppRoot.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view></router-view>
</template>

53
src/PasswordStrength.vue Normal file
View File

@@ -0,0 +1,53 @@
<template>
<div class="progress">
<div class="progress-bar" :class="passwordClass" role="progressbar" :aria-valuenow="passwordStrength"
:style="{ width: (passwordStrength*24)+4 + '%' }"
aria-valuemin="0" aria-valuemax="11"></div>
</div>
</template>
<script>
"use strict";
import zxcvbn from 'zxcvbn';
let cssClasses = {
0: 'bg-danger',
1: 'bg-danger',
2: 'bg-warning',
3: 'bg-warning',
4: 'bg-success'
};
let keywords = ['ecg', 'gwö', 'ecogood', 'gemeinwohl', 'gemeinwohlökonomie'];
export default {
props: {
password: {
type: String,
required: true
}
},
data() {
return {
passwordStrength: 0,
passwordClass: 'bg-danger',
passwordWarning: ''
};
},
watch: {
password: function () {
let result = zxcvbn(this.password, keywords);
this.passwordStrength = result.score;
this.passwordClass = cssClasses[result.score];
this.passwordWarning = result.feedback.warning;
console.log('score/guesses', result.score, result.guesses);
this.$emit('score', result.score);
this.$emit('feedback', result.feedback.warning);
}
}
};
</script>

109
src/RequestReset.vue Normal file
View File

@@ -0,0 +1,109 @@
<template>
<div>
<h1> Password reset</h1>
<p>Here you can request a new password for your existing account.</p>
<div class='mb-lg-5'>
<div v-if="recentRequestTimestamp" class="alert alert-info" role="alert">
<p>A recent reset request has been sent on <mark>{{ recentRequestTimestamp }}</mark>.
You can resend if the email didn't arrive.</p>
<form method="POST" @submit.prevent="recentRequestTimestamp = null">
<button class="btn btn-info" type="submit">⟳ Send again</button>
</form>
</div>
<template v-else>
<div v-if="requestFinished" class="alert alert-success" role="alert">
Your request has been sent! You'll receive the email soon. 📬
</div>
<form v-else method="POST" class="col-md-10 mb-lg-5" @submit.prevent="onSubmit">
<fieldset :disabled="requestIsWorking">
<div class="row g-3">
<div class="col-auto">
<label for="uid" class="col-form-label"><strong>ECG account ID:</strong></label>
</div>
<div class="col-auto">
<div class="input-group">
<input id="uid" type="text" class="form-control" placeholder="firstname.lastname"
aria-label="Recipient's ECG account ID" aria-describedby="button-submit"
v-model="uid" minlength="3" required="true" autocapitalize="none"
:autofocus="'autofocus'">
<button class="btn btn-outline-danger" type="submit" id="button-submit">
<span v-if="!requestIsWorking"> Request</span>
<span v-else>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</span>
</button>
</div>
</div>
</div>
</fieldset>
</form>
<div v-if="requestError" class="alert alert-danger" role="alert">{{ requestError }}</div>
</template>
</div>
<h3 class="text-muted">How is it working?</h3>
<p>
After submitting your ECG account ID you'll be sent an email to your private email address. It
contains a unique link to a web page where you can enter a new password for your account.
</p>
<h6>In the case of problems</h6>
<p>
You can find <a href="https://wiki.ecogood.org/x/DYQjB" target="_blank">
a detailed explanation of the password reset process in the wiki</a>.
</p>
</div>
</template>
<script>
"use strict";
import axios from 'axios';
export default {
data: function () {
return {
requestIsWorking: false,
requestFinished: false,
uid: this.$route.params.uid,
recentRequestTimestamp: localStorage.getItem('timestamp'),
requestError: null
};
},
methods: {
onSubmit: function () {
this.requestIsWorking = true;
let uidEncoded = encodeURIComponent(this.uid);
axios
.post(`password/${uidEncoded}/request-change`)
.then((response) => {
this.requestFinished = true;
this.requestError = null;
localStorage.setItem('timestamp', new Date().toLocaleString());
}, (error) => {
let containsResponse = typeof error.response !== 'undefined';
this.requestError = containsResponse && error.response.status === 404
? "Unknown ECG account ID provided!"
: error.message;
})
.finally(() => {
this.requestIsWorking = false;
});
}
}
};
</script>
<style>
body.container {
max-width: 800px;
}
#button-submit {
min-width: 100px;
}
</style>

253
src/SetPassword.vue Normal file
View File

@@ -0,0 +1,253 @@
<template>
<div>
<h1> Submit new password</h1>
<div v-if="validationRequestActive" class="alert alert-info">
Validating reset token
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</div>
<template v-else>
<div v-if="!tokenIsValid" class="alert alert-danger" role="alert">
<template v-if="validationRequestError">
<h3>Failed to validate password reset link</h3>
<p>{{ validationRequestError }}</p>
</template>
<template v-else>
<h3>Invalid link provided</h3>
<p>This password reset link isn't valid (anymore). Please
<router-link :to="{ name: 'request' }" tag="a">renew your password reset request</router-link></p>
</template>
</div>
<div v-else>
<div v-if="setRequestFinished" class="alert alert-success" role="alert">
✊ Your password has been set successfully! The change takes effect immediately.
</div>
<form v-else method="POST" class="col-md-10 mb-lg-5" @submit.prevent="onSubmit">
<p>Please provide a new password.</p>
<fieldset :disabled="setRequestIsWorking">
<div class="row">
<label for="password1" class="col-sm-3 col-form-label"><strong>New password:</strong></label>
<div class="col-sm-9">
<input id="password1" type="password" class="form-control" required="true" v-model="password1"
aria-label="New password" ref="password1" minlength="8"
autocomplete="new-password">
</div>
</div>
<div class="mb-3 row">
<label for="password2" class="col-sm-3 col-form-label"><strong>Repeat password:</strong></label>
<div class="col-sm-9">
<input id="password2" type="password" class="form-control" required="true"
aria-label="New password again" v-model="password2"
autocomplete="new-password">
</div>
</div>
<div class="mb-3 row">
<label for="password-quality" class="col-sm-3 col-form-label"><strong>Password quality:</strong></label>
<div class="col-sm-9">
<password-strength :password="password1" class="mt-2"
v-on:score="qualityScore = $event"
v-on:feedback="qualityFeedback = $event"
></password-strength>
</div>
</div>
<div v-if="setRequestError" class="alert alert-danger" role="alert">{{ setRequestError }}</div>
<div class="text-muted float-start">{{ qualityFeedback }}</div>
<div class="text-end" :title="setButtonTitle">
<button class="btn btn-outline-danger" type="submit" id="button-submit"
:disabled="setButtonTitle !== null">
<span v-if="!setRequestIsWorking">⮷ Set</span>
<span v-else>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</span>
</button>
</div>
</fieldset>
</form>
</div>
</template>
<h3 class="text-muted">How to choose a good password?</h3>
<p>As of today (2021) there are three approved strategies:</p>
<ul>
<li>Choose a reasonably complex password using multiple character classes (upper-case, lower-case, special
characters). Downside: They are hard to remember for users and potentionally lead to passwords being
reused across different services.</li>
<li>Choose a long password that consists of a personal sentence which is hard to break for machines and easy
to remember for users</li>
<li>Just for the sake of completeness: Choose a second factor to get the best possible security. This is not
yet supported by the ECG services. But it's on our radar.</li>
</ul>
<p>Although currently not technically prevented it's not allowed to reuse a previous password</p>
<p>Furthermore we recommend using a password manager like KeePass.</p>
<h6>In the case of problems</h6>
<p>
You can find <a href="https://wiki.ecogood.org/x/DYQjB" target="_blank">
a detailed explanation of the password reset process in the wiki</a>.
</p>
</div>
</template>
<script>
"use strict";
import PasswordStrength from './PasswordStrength';
import axios from 'axios';
import zxcvbn from 'zxcvbn';
let cssClasses = {
0: 'bg-danger',
1: 'bg-danger',
2: 'bg-warning',
3: 'bg-warning',
4: 'bg-success'
};
export default {
components: {
PasswordStrength
},
data: function () {
return {
// step 1: validate the given token
token: this.$route.params.token,
validationRequestActive: true,
validationRequestError: null,
tokenIsValid: false,
// step 2: set the new password
password1: "",
password2: "",
setRequestIsWorking: false,
setRequestFinished: false,
setRequestError: null,
qualityScore: 0,
qualityFeedback: null,
};
},
computed: {
setButtonTitle: function () {
if (this.qualityScore < 4) {
return 'The password strength is too low to be accepted';
} else if (this.password1 !== this.password2) {
return "The two provided passwords don't match";
} else {
return null;
}
}
},
methods: {
validateToken: function () {
this.validationRequestActive = true;
let tokenEncoded = encodeURIComponent(this.token);
axios
.get(`password/token/${tokenEncoded}`)
.then(
() => this.tokenIsValid = true,
(error) => {
let containsResponse = typeof error.response !== 'undefined';
this.validationRequestError = containsResponse && error.response.status === 404
? null
: error.message;
})
.catch(() => this.validationRequestError = "validation request error")
.finally(() => this.validationRequestActive = false);
},
onSubmit: function () {
this.setRequestIsWorking = true;
let tokenEncoded = encodeURIComponent(this.token);
let config = {
headers: {
'Content-Type': 'text/plain'
},
responseType: 'text'
};
axios
.post(`password/${tokenEncoded}`, this.password1, config)
.then(() => {
this.setRequestFinished = true;
this.setRequestError = null;
localStorage.removeItem('timestamp');
}, (error) => {
let containsResponse = typeof error.response !== 'undefined';
this.setRequestError = containsResponse && error.response.status === 400
? "Insufficient password strength!"
: error.message;
})
.finally(() => {
this.setRequestIsWorking = false;
});
},
onUserTest: function () {
let uidEncoded = encodeURIComponent(this.uid);
let config = {
headers: {
'Content-Type': 'text/plain'
},
responseType: 'text'
};
this.testResult = null;
this.testIsWorking = true;
axios
.post(`password/test/${uidEncoded}`, this.password, config)
.then((response) => {
this.testRequestFinished = true;
this.testRequestError = null;
this.testResult = true;
}, (error) => {
this.testResult = false;
this.testRequestError = error.message;
})
.finally(() => {
this.testIsWorking = false;
});
},
onScoreUpdate: function (score) {
this.qualityScore = score;
}
},
mounted() {
this.validateToken();
}
};
</script>
<style scoped>
#button-submit {
min-width: 100px;
}
#button-submit:enabled {
animation: pulse 0.3s 1 alternate;
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
</style>

38
src/main.js Normal file
View File

@@ -0,0 +1,38 @@
"use strict";
import 'bootstrap/dist/css/bootstrap.css';
import axios from 'axios';
axios.defaults.baseURL = window.location.host.includes('accounts.ecogood.org')
? '/api'
: '//localhost:8090';
import Vue from 'vue';
import AppRoot from './AppRoot';
import RequestReset from './RequestReset';
import SetPassword from './SetPassword';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
const router = new VueRouter({
routes: [
{
name: 'request',
path: '/:uid?',
component: RequestReset
},
{
name: 'set',
path: '/set/:token',
component: SetPassword,
}
]
});
new Vue({
el: '#app',
render: h => h(AppRoot),
router
});

3
vue.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
runtimeCompiler: true
}

8552
yarn.lock Normal file

File diff suppressed because it is too large Load Diff