Browse Source

Added password reset.

KrisVos130 8 years ago
parent
commit
e1f4e3e9a7

+ 1 - 1
backend/logic/actions/stations.js

@@ -399,7 +399,7 @@ module.exports = {
 
 	create: hooks.loginRequired((session, data, cb) => {
 		data._id = data._id.toLowerCase();
-		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth"];
+		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth", "reset_password"];
 		async.waterfall([
 
 			(next) => {

+ 135 - 1
backend/logic/actions/users.js

@@ -537,5 +537,139 @@ module.exports = {
 				message: 'Password successfully updated.'
 			});
 		});
-	})
+	}),
+
+	/**
+	 * Requests a password reset for an email
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} email - the email of the user that requests a password reset
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestPasswordReset: (session, email, cb) => {
+		let code = utils.generateRandomString(8);
+		async.waterfall([
+			(next) => {
+				if (!email || typeof email !== 'string') return next('Invalid code.');
+				email = email.toLowerCase();
+				db.models.user.findOne({"email.address": email}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				if (!user.services.password || !user.services.password.password) return next('User does not have a password set, and probably uses GitHub to log in.');
+				next(null, user);
+			},
+
+			(user, next) => {
+				let expires = new Date();
+				expires.setDate(expires.getDate() + 1);
+				db.models.user.findOneAndUpdate({"email.address": email}, {$set: {"services.password.reset": {code: code, expires}}}, next);
+			},
+
+			(user, next) => {
+				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("REQUEST_PASSWORD_RESET", `Email '${email}' successfully requested a password reset.`);
+				cb({
+					status: 'success',
+					message: 'Successfully requested password reset.'
+				});
+			}
+		});
+	},
+
+	/**
+	 * Verifies a reset code
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} code - the password reset code
+	 * @param {Function} cb - gets called with the result
+	 */
+	verifyPasswordResetCode: (session, code, cb) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code.');
+				db.models.user.findOne({"services.password.reset.code": code}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Invalid code.');
+				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
+				next(null);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
+				cb({
+					status: 'success',
+					message: 'Successfully verified password reset code.'
+				});
+			}
+		});
+	},
+
+	/**
+	 * Changes a user's password with a reset code
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} code - the password reset code
+	 * @param {String} newPassword - the new password reset code
+	 * @param {Function} cb - gets called with the result
+	 */
+	changePasswordWithResetCode: (session, code, newPassword, cb) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code.');
+				db.models.user.findOne({"services.password.reset.code": code}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Invalid code.');
+				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
+				next();
+			},
+
+			(next) => {
+				bcrypt.genSalt(10, next);
+			},
+
+			// hash the password
+			(salt, next) => {
+				bcrypt.hash(sha256(newPassword), salt, next);
+			},
+
+			(hashedPassword, next) => {
+				db.models.user.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, next);
+			}
+		], (err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
+				cb({
+					status: 'success',
+					message: 'Successfully changed password.'
+				});
+			}
+		});
+	}
 };

+ 5 - 1
backend/logic/db/schemas/user.js

@@ -9,7 +9,11 @@ module.exports = {
 	},
 	services: {
 		password: {
-			password: String
+			password: String,
+			reset: {
+				code: { type: String, min: 8, max: 8 },
+				expires: { type: Date }
+			}
 		},
 		github: {
 			id: Number,

+ 2 - 1
backend/logic/mail/index.js

@@ -9,7 +9,8 @@ let lib = {
 
 	init: (cb) => {
 		lib.schemas = {
-			verifyEmail: require('./schemas/verifyEmail')
+			verifyEmail: require('./schemas/verifyEmail'),
+			resetPasswordRequest: require('./schemas/resetPasswordRequest')
 		};
 
 		cb();

+ 30 - 0
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -0,0 +1,30 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a request password reset email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the password reset code of the recipient
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+module.exports = function(to, username, code, cb) {
+	let data = {
+		from: 'Musare <noreply@musare.com>',
+		to: to,
+		subject: 'Password reset request',
+		html:
+			`
+				Hello there ${username},
+				<br>
+				<br>
+				Someone has requested to reset the password of your account. If this was not you, you can ignore this email.
+				<br>
+				<br>
+				The reset code is <b>${code}</b>. You can enter this code on the page you requested the password reset. This code will expire in 24 hours.
+			`
+	};
+
+	mail.sendMail(data, cb);
+};

+ 1 - 0
frontend/components/Modals/Login.vue

@@ -26,6 +26,7 @@
 					</div>
 					&nbsp;&nbsp;Login with GitHub
 				</a>
+				<a href='/reset_password' v-link="{ path: '/reset_password' }">Forgot password?</a>
 			</footer>
 		</div>
 	</div>

+ 105 - 0
frontend/components/User/ResetPassword.vue

@@ -0,0 +1,105 @@
+<template>
+	<main-header></main-header>
+	<div class="container">
+		<!--Implement Validation-->
+		<h1>Step {{step}}</h1>
+
+
+		<label class="label" v-if="step === 1">Email</label>
+		<div class="control is-grouped" v-if="step === 1">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="email" placeholder="Email" v-model="email">
+			</p>
+			<p class="control">
+				<button class="button is-success" @click="submitEmail()">Request</button>
+			</p>
+		</div>
+		<button @click="step = 2" v-if="step === 1" class="button is-success">Skip this step</button>
+
+
+		<label class="label" v-if="step === 2">Reset code (the code that was sent to your account email address)</label>
+		<div class="control is-grouped" v-if="step === 2">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="text" placeholder="Reset code" v-model="code">
+			</p>
+			<p class="control">
+				<button class="button is-success" @click="verifyCode()">Verify reset code</button>
+			</p>
+		</div>
+
+
+		<label class="label" v-if="step === 3">Change password</label>
+		<div class="control is-grouped" v-if="step === 3">
+			<p class="control is-expanded has-icon has-icon-right">
+				<input class="input" type="password" placeholder="New password" v-model="newPassword">
+			</p>
+			<p class="control">
+				<button class="button is-success" @click="changePassword()">Change password</button>
+			</p>
+		</div>
+	</div>
+	<main-footer></main-footer>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+
+	import MainHeader from '../MainHeader.vue';
+	import MainFooter from '../MainFooter.vue';
+
+	import LoginModal from '../Modals/Login.vue'
+	import io from '../../io'
+
+	export default {
+		data() {
+			return {
+				email: '',
+				code: '',
+				newPassword: '',
+				step: 1
+			}
+		},
+		ready: function() {
+			let _this = this;
+			io.getSocket((socket) => {
+				_this.socket = socket;
+			});
+		},
+		methods: {
+			submitEmail: function () {
+				if (!this.email) return Toast.methods.addToast('Email cannot be empty', 8000);
+				this.socket.emit('users.requestPasswordReset', this.email, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.step = 2;
+					}
+				});
+			},
+			verifyCode: function () {
+				if (!this.code) return Toast.methods.addToast('Code cannot be empty', 8000);
+				this.socket.emit('users.verifyPasswordResetCode', this.code, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.step = 3;
+					}
+				});
+			},
+			changePassword: function () {
+				if (!this.newPassword) return Toast.methods.addToast('Password cannot be empty', 8000);
+				this.socket.emit('users.changePasswordWithResetCode', this.code, this.newPassword, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.$router.go('/login');
+					}
+				});
+			}
+		},
+		components: { MainHeader, MainFooter, LoginModal }
+	}
+</script>
+
+<style lang="scss" scoped>
+	.container {
+		padding: 25px;
+	}
+</style>

+ 4 - 0
frontend/main.js

@@ -13,6 +13,7 @@ import Terms from './components/pages/Terms.vue';
 import Privacy from './components/pages/Privacy.vue';
 import User from './components/User/Show.vue';
 import Settings from './components/User/Settings.vue';
+import ResetPassword from './components/User/ResetPassword.vue';
 import Login from './components/Modals/Login.vue';
 
 Vue.use(VueRouter);
@@ -79,6 +80,9 @@ router.map({
 		component: Settings,
 		loginRequired: true
 	},
+	'/reset_password': {
+		component: ResetPassword
+	},
 	'/login': {
 		component: Login
 	},