瀏覽代碼

Added GitHub/password unlinking and linking.

KrisVos130 8 年之前
父節點
當前提交
3d8db103aa

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

@@ -337,6 +337,7 @@ module.exports = {
 					username: user.username
 				};
 				if (user.services.password && user.services.password.password) data.password = true;
+				if (user.services.github && user.services.github.id) data.github = true;
 				logger.success("FIND_BY_SESSION", `User found. '${user.username}'.`);
 				return cb({
 					status: 'success',
@@ -549,6 +550,211 @@ module.exports = {
 		});
 	}),
 
+	/**
+	 * Requests a password for a session
+	 *
+	 * @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
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	requestPassword: hooks.loginRequired((session, cb, userId) => {
+		let code = utils.generateRandomString(8);
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				if (user.services.password && user.services.password.password) return next('You already have a password set.');
+				next(null, user);
+			},
+
+			(user, next) => {
+				let expires = new Date();
+				expires.setDate(expires.getDate() + 1);
+				db.models.user.findOneAndUpdate({"email.address": user.email.address}, {$set: {"services.password": {set: {code: code, expires}}}}, next);
+			},
+
+			(user, next) => {
+				mail.schemas.passwordRequest(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", `UserId '${userId}' failed to request password. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("REQUEST_PASSWORD", `UserId '${userId}' successfully requested a password.`);
+				cb({
+					status: 'success',
+					message: 'Successfully requested password.'
+				});
+			}
+		});
+	}),
+
+	/**
+	 * Verifies a password code
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} code - the password code
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	verifyPasswordCode: hooks.loginRequired((session, code, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code1.');
+				db.models.user.findOne({"services.password.set.code": code, _id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Invalid code2.');
+				if (user.services.password.set.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_CODE", `Code '${code}' failed to verify. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("VERIFY_PASSWORD_CODE", `Code '${code}' successfully verified.`);
+				cb({
+					status: 'success',
+					message: 'Successfully verified password code.'
+				});
+			}
+		});
+	}),
+
+	/**
+	 * Adds a password to a user with a code
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} code - the password code
+	 * @param {String} newPassword - the new password code
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	changePasswordWithCode: hooks.loginRequired((session, code, newPassword, cb) => {
+		async.waterfall([
+			(next) => {
+				if (!code || typeof code !== 'string') return next('Invalid code1.');
+				db.models.user.findOne({"services.password.set.code": code}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Invalid code2.');
+				if (!user.services.password.set.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.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, 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("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
+				cb({
+					status: 'success',
+					message: 'Successfully added password.'
+				});
+			}
+		});
+	}),
+
+	/**
+	 * Unlinks password from user
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	unlinkPassword: hooks.loginRequired((session, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Not logged in.');
+				if (!user.services.github || !user.services.github.id) return next('You can\'t remove password login without having GitHub login.');
+				db.models.user.update({_id: userId}, {$unset: {"services.password": ''}}, 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("UNLINK_PASSWORD", `Unlinking password failed for userId '${userId}'. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("UNLINK_PASSWORD", `Unlinking password successful for userId '${userId}'.`);
+				cb({
+					status: 'success',
+					message: 'Successfully unlinked password.'
+				});
+			}
+		});
+	}),
+
+	/**
+	 * Unlinks GitHub from user
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	unlinkGitHub: hooks.loginRequired((session, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({_id: userId}, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('Not logged in.');
+				if (!user.services.password || !user.services.password.password) return next('You can\'t remove GitHub login without having password login.');
+				db.models.user.update({_id: userId}, {$unset: {"services.github": ''}}, 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("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${userId}'. '${error}'`);
+				cb({status: 'failure', message: error});
+			} else {
+				logger.success("UNLINK_GITHUB", `Unlinking GitHub successful for userId '${userId}'.`);
+				cb({
+					status: 'success',
+					message: 'Successfully unlinked GitHub.'
+				});
+			}
+		});
+	}),
+
 	/**
 	 * Requests a password reset for an email
 	 *
@@ -560,7 +766,7 @@ module.exports = {
 		let code = utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
-				if (!email || typeof email !== 'string') return next('Invalid code.');
+				if (!email || typeof email !== 'string') return next('Invalid email.');
 				email = email.toLowerCase();
 				db.models.user.findOne({"email.address": email}, next);
 			},

+ 49 - 10
backend/logic/app.js

@@ -2,6 +2,7 @@
 
 const express = require('express');
 const bodyParser = require('body-parser');
+const cookieParser = require('cookie-parser');
 const cors = require('cors');
 const config = require('config');
 const async = require('async');
@@ -29,6 +30,8 @@ const lib = {
 
 		lib.server = app.listen(config.get('serverPort'));
 
+		app.use(cookieParser());
+
 		app.use(bodyParser.json());
 		app.use(bodyParser.urlencoded({ extended: true }));
 
@@ -57,19 +60,44 @@ const lib = {
 			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
 		});
 
+		app.get('/auth/github/link', (req, res) => {
+			let params = [
+				`client_id=${config.get('apis.github.client')}`,
+				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
+				`scope=user:email`,
+				`state=${req.cookies.SID}`
+			].join('&');
+			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+		});
+
 		function redirectOnErr (res, err){
 			return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
 		}
 
 		app.get('/auth/github/authorize/callback', (req, res) => {
 			let code = req.query.code;
-			oauth2.getOAuthAccessToken(code, { redirect_uri }, (err, access_token, refresh_token, results) => {
+			const state = req.query.state;
+			oauth2.getOAuthAccessToken(code, {redirect_uri}, (err, access_token, refresh_token, results) => {
 				if (!err) request.get({
-						url: `https://api.github.com/user?access_token=${access_token}`,
-						headers: { 'User-Agent': 'request' }
-					}, (err, httpResponse, body) => {
-						if (err) return redirectOnErr(res, err.message);
-						body = JSON.parse(body);
+					url: `https://api.github.com/user?access_token=${access_token}`,
+					headers: {'User-Agent': 'request'}
+				}, (err, httpResponse, body) => {
+					if (err) return redirectOnErr(res, err.message);
+					body = JSON.parse(body);
+					if (state) {
+						cache.hget('sessions', state, (err, session) => {
+							if (err) return redirectOnErr(res, err.message);
+							db.models.user.findOne({_id: session.userId}, (err, user) => {
+								if (err) return redirectOnErr(res, err.message);
+								if (!user) return redirectOnErr(res, 'Not logged in.');
+								if (user.services.github && user.services.github.id) return redirectOnErr(res, 'Account already has GitHub linked.');
+								db.models.user.update({_id: user._id}, {$set: {"services.github": {id: body.id, access_token}}}, (err) => {
+									if (err) return redirectOnErr(res, err.message);
+									res.redirect(`${config.get('domain')}/settings`);
+								});
+							});
+						});
+					} else {
 						db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
 							if (err) return redirectOnErr(res, 'err');
 							if (user) {
@@ -81,12 +109,17 @@ const lib = {
 										if (err) return redirectOnErr(res, err.message);
 										let date = new Date();
 										date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-										res.cookie('SID', sessionId, {expires: date, secure: config.get("cookie.secure"), path: "/", domain: config.get("cookie.domain")});
+										res.cookie('SID', sessionId, {
+											expires: date,
+											secure: config.get("cookie.secure"),
+											path: "/",
+											domain: config.get("cookie.domain")
+										});
 										res.redirect(`${config.get('domain')}/`);
 									});
 								});
 							} else {
-								db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i') }, (err, user) => {
+								db.models.user.findOne({username: new RegExp(`^${body.login}$`, 'i')}, (err, user) => {
 									if (err) return redirectOnErr(res, err.message);
 									if (user) return redirectOnErr(res, 'An account with that username already exists.');
 									else request.get({
@@ -122,7 +155,12 @@ const lib = {
 													if (err) return redirectOnErr(res, err.message);
 													let date = new Date();
 													date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-													res.cookie('SID', sessionId, {expires: date, secure: config.get("cookie.secure"), path: "/", domain: config.get("cookie.domain")});
+													res.cookie('SID', sessionId, {
+														expires: date,
+														secure: config.get("cookie.secure"),
+														path: "/",
+														domain: config.get("cookie.domain")
+													});
 													res.redirect(`${config.get('domain')}/`);
 												});
 											});
@@ -131,7 +169,8 @@ const lib = {
 								});
 							}
 						});
-					});
+					}
+				});
 				else return redirectOnErr(res, 'err');
 			});
 		});

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

@@ -13,10 +13,15 @@ module.exports = {
 			reset: {
 				code: { type: String, min: 8, max: 8 },
 				expires: { type: Date }
+			},
+			set: {
+				code: { type: String, min: 8, max: 8 },
+				expires: { type: Date }
 			}
 		},
 		github: {
 			id: Number,
+			access_token: String
 		}
 	},
 	ban: {

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

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

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

@@ -0,0 +1,30 @@
+const config = require('config');
+const mail = require('../index');
+
+/**
+ * Sends a request password email
+ *
+ * @param {String} to - the email address of the recipient
+ * @param {String} username - the username of the recipient
+ * @param {String} code - the password 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 request',
+		html:
+			`
+				Hello there ${username},
+				<br>
+				<br>
+				Someone has requested to add a password to your account. If this was not you, you can ignore this email.
+				<br>
+				<br>
+				The code is <b>${code}</b>. You can enter this code on the page you requested the password on. This code will expire in 24 hours.
+			`
+	};
+
+	mail.sendMail(data, cb);
+};

+ 71 - 1
frontend/components/User/Settings.vue

@@ -31,6 +31,40 @@
 				<button class="button is-success" @click="changePassword()">Change password</button>
 			</p>
 		</div>
+
+
+		<label class="label" v-if="!user.password">Add password</label>
+		<div class="control is-grouped" v-if="!user.password">
+			<button class="button is-success" @click="requestPassword()" v-if="passwordStep === 1">Request password email</button><br>
+
+
+			<p class="control is-expanded has-icon has-icon-right" v-if="passwordStep === 2">
+				<input class="input" type="text" placeholder="Code" v-model="passwordCode">
+			</p>
+			<p class="control is-expanded" v-if="passwordStep === 2">
+				<button class="button is-success" @click="verifyCode()">Verify code</button>
+			</p>
+
+
+			<p class="control is-expanded has-icon has-icon-right" v-if="passwordStep === 3">
+				<input class="input" type="password" placeholder="New password" v-model="setNewPassword">
+			</p>
+			<p class="control is-expanded" v-if="passwordStep === 3">
+				<button class="button is-success" @click="setPassword()">Set password</button>
+			</p>
+		</div>
+		<a href="#" v-if="passwordStep === 1 && !user.password" @click="passwordStep = 2">Skip this step</a>
+
+
+		<a class="button is-github" v-if="!user.github" :href='$parent.serverDomain + "/auth/github/link"'>
+			<div class='icon'>
+				<img class='invert' src='/assets/social/github.svg'/>
+			</div>
+			&nbsp; Link GitHub to account
+		</a>
+
+		<button class="button is-danger" @click="unlinkPassword()" v-if="user.password && user.github">Remove logging in with password</button>
+		<button class="button is-danger" @click="unlinkGitHub()" v-if="user.password && user.github">Remove logging in with GitHub</button>
 	</div>
 	<main-footer></main-footer>
 </template>
@@ -48,7 +82,10 @@
 		data() {
 			return {
 				user: {},
-				newPassword: ''
+				newPassword: '',
+				setNewPassword: '',
+				passwordStep: 1,
+				passwordCode: ''
 			}
 		},
 		ready: function() {
@@ -89,6 +126,39 @@
 					if (res.status !== 'success') Toast.methods.addToast(res.message, 8000);
 					else Toast.methods.addToast('Successfully changed password', 4000);
 				});
+			},
+			requestPassword: function() {
+				this.socket.emit('users.requestPassword', res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.passwordStep = 2;
+					}
+				});
+			},
+			verifyCode: function () {
+				if (!this.passwordCode) return Toast.methods.addToast('Code cannot be empty', 8000);
+				this.socket.emit('users.verifyPasswordCode', this.passwordCode, res => {
+					Toast.methods.addToast(res.message, 8000);
+					if (res.status === 'success') {
+						this.passwordStep = 3;
+					}
+				});
+			},
+			setPassword: function () {
+				if (!this.setNewPassword) return Toast.methods.addToast('Password cannot be empty', 8000);
+				this.socket.emit('users.changePasswordWithCode', this.passwordCode, this.setNewPassword, res => {
+					Toast.methods.addToast(res.message, 8000);
+				});
+			},
+			unlinkPassword: function () {
+				this.socket.emit('users.unlinkPassword', res => {
+					Toast.methods.addToast(res.message, 8000);
+				});
+			},
+			unlinkGitHub: function () {
+				this.socket.emit('users.unlinkGitHub', res => {
+					Toast.methods.addToast(res.message, 8000);
+			});
 			}
 		},
 		components: { MainHeader, MainFooter, LoginModal }