From b023c744954fd45fc865483a84bd612ab7a31b7b Mon Sep 17 00:00:00 2001 From: Kayne Ruse Date: Thu, 9 May 2019 09:23:10 +1000 Subject: [PATCH] Password recovery completed --- package-lock.json | 20 ++++ package.json | 1 + server/accounts.js | 123 +++++++++++++++++++- server/index.js | 2 + sql/create_database_structure.sql | 9 ++ src/components/app.jsx | 2 + src/components/pages/home.jsx | 69 ++++++++--- src/components/pages/password_reset.jsx | 41 +++++++ src/components/panels/password_recover.jsx | 99 ++++++++++++++++ src/components/panels/password_reset.jsx | 126 +++++++++++++++++++++ 10 files changed, 477 insertions(+), 15 deletions(-) create mode 100644 src/components/pages/password_reset.jsx create mode 100644 src/components/panels/password_recover.jsx create mode 100644 src/components/panels/password_reset.jsx diff --git a/package-lock.json b/package-lock.json index 6cd8aa6..502f626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5317,6 +5317,16 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, + "query-string": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.5.0.tgz", + "integrity": "sha512-TYC4hDjZSvVxLMEucDMySkuAS9UIzSbAiYGyA9GWCjLKB8fQpviFbjd20fD7uejCDxZS+ftSdBKE6DS+xucJFg==", + "requires": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, "querystring": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", @@ -6088,6 +6098,11 @@ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=" }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -6173,6 +6188,11 @@ "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.0.tgz", "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=" }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", diff --git a/package.json b/package.json index e9790e7..f7865f5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "forever": "^1.0.0", "formidable": "^1.2.1", "mysql": "^2.17.1", + "query-string": "^6.5.0", "react": "^16.8.6", "react-dom": "^16.8.6", "react-redux": "^7.0.3", diff --git a/server/accounts.js b/server/accounts.js index a7b6fdc..dd8b01f 100644 --- a/server/accounts.js +++ b/server/accounts.js @@ -265,10 +265,131 @@ function passwordChange(connection) { } } +function passwordRecover(connection) { + return (req, res) => { + //formidable handles forms + let form = formidable.IncomingForm(); + + //parse form + form.parse(req, (err, fields) => { + if (err) throw err; + + //validate email, username and password + if (!validateEmail(fields.email)) { + res.status(400).write('Invalid recover data'); + res.end(); + return; + } + + //ensure that this email is registered to an account + let query = 'SELECT accounts.id FROM accounts WHERE email = ?;'; + connection.query(query, [fields.email], (err, results) => { + if (err) throw err; + + if (results.length !== 1) { + res.status(400).write('Invalid recover data (did you use a registered email?)'); + res.end(); + return; + } + + //create the new recover record + let rand = Math.floor(Math.random() * 100000); + + let query = 'REPLACE INTO passwordRecover (accountId, token) VALUES (?, ?)'; + connection.query(query, [results[0].id, rand], (err) => { + if (err) throw err; + + //build the recovery email + let addr = `http://${process.env.WEB_ADDRESS}/passwordreset?email=${fields.email}&token=${rand}`; + let msg = 'Hello! Please visit the following address to set a new password (if you didn\'t request a password recovery, ignore this email): '; + let msgHtml = `

${msg}${addr}

`; + + //send the verification email + sendmail({ + from: `passwordrecover@${process.env.WEB_ADDRESS}`, + to: fields.email, + subject: 'Password Recovery', + text: msg + addr, + html: msgHtml + }, (err, reply) => { + //final check + if (err) { + res.write(`

Something went wrong (did you use a valid email?)

${err}`) + res.end(); + return; + } + + res.status(200).write('Recovery email sent!'); + res.end(); + }); + }); + }); + }); + } +} + +function passwordReset(connection) { + return (req, res) => { + //formidable handles forms + let form = formidable.IncomingForm(); + + //parse form + form.parse(req, (err, fields) => { + if (err) throw err; + + //validate email, username and password + if (!validateEmail(fields.email) || fields.password.length < 8 || fields.password !== fields.retype) { + res.status(400).write('Invalid reset data (invalid email/password)'); + res.end(); + return; + } + + //get the account based on this email, token + let query = 'SELECT * FROM accounts WHERE email = ? AND id IN (SELECT passwordRecover.accountId FROM passwordRecover WHERE token = ?);'; + connection.query(query, [fields.email, fields.token], (err, results) => { + if (err) throw err; + + //results should be only 1 account + if (results.length !== 1) { + res.status(400).write('Invalid reset data (incorrect parameters/database state)'); + res.end(); + return; + } + + //generate the new salt, hash + bcrypt.genSalt(11, (err, salt) => { + if (err) throw err; + bcrypt.hash(fields.password, salt, (err, hash) => { + if (err) throw err; + + //update the salt, hash + let query = 'UPDATE accounts SET salt = ?, hash = ? WHERE email = ?;'; + connection.query(query, [salt, hash, fields.email], (err) => { + if (err) throw err; + + //delete the recover request from the database + let query = 'DELETE FROM passwordRecover WHERE accountId IN (SELECT id FROM accounts WHERE email = ?);'; + connection.query(query, [fields.email], (err) => { + if (err) throw err; + + res.status(200).write('Password updated!'); + res.end(); + return; + }); + }); + }); + }); + }); + }); + } +} + module.exports = { signup: signup, verify: verify, login: login, logout: logout, - passwordChange: passwordChange + passwordChange: passwordChange, + passwordRecover: passwordRecover, + passwordReset: passwordReset }; \ No newline at end of file diff --git a/server/index.js b/server/index.js index 598bb22..18ef27a 100644 --- a/server/index.js +++ b/server/index.js @@ -21,6 +21,8 @@ app.get('/verify', accounts.verify(connection)); app.post('/login', accounts.login(connection)); app.post('/logout', accounts.logout(connection)); app.post('/passwordchange', accounts.passwordChange(connection)); +app.post('/passwordrecover', accounts.passwordRecover(connection)); +app.post('/passwordreset', accounts.passwordReset(connection)); //static directories app.use('/styles', express.static(path.resolve(__dirname + '/../public/styles')) ); diff --git a/sql/create_database_structure.sql b/sql/create_database_structure.sql index b7f4899..417253e 100644 --- a/sql/create_database_structure.sql +++ b/sql/create_database_structure.sql @@ -27,3 +27,12 @@ CREATE TABLE IF NOT EXISTS sessions ( CONSTRAINT FOREIGN KEY fk_accountId(accountId) REFERENCES accounts(id) ON UPDATE CASCADE ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS passwordRecover ( + id INTEGER UNSIGNED AUTO_INCREMENT PRIMARY KEY UNIQUE, + td TIMESTAMP DEFAULT CURRENT_TIMESTAMP(), + + accountId INTEGER UNSIGNED UNIQUE, + token INTEGER DEFAULT 0, + + CONSTRAINT FOREIGN KEY fk_accountId(accountId) REFERENCES accounts(id) ON UPDATE CASCADE ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/components/app.jsx b/src/components/app.jsx index b8cbfad..f40d848 100644 --- a/src/components/app.jsx +++ b/src/components/app.jsx @@ -3,6 +3,7 @@ import { BrowserRouter, Switch, Route } from 'react-router-dom'; //include pages import Home from './pages/home.jsx'; +import PasswordReset from './pages/password_reset.jsx' import PageNotFound from './pages/page_not_found.jsx'; //other stuff @@ -20,6 +21,7 @@ export default class App extends React.Component { + diff --git a/src/components/pages/home.jsx b/src/components/pages/home.jsx index ba04d88..57af9d3 100644 --- a/src/components/pages/home.jsx +++ b/src/components/pages/home.jsx @@ -8,6 +8,7 @@ import Signup from '../panels/signup.jsx'; import Login from '../panels/login.jsx'; import Logout from '../panels/logout.jsx'; import PasswordChange from '../panels/password_change.jsx'; +import PasswordRecover from '../panels/password_recover.jsx'; class Home extends React.Component { constructor(props) { @@ -15,7 +16,9 @@ class Home extends React.Component { this.state = { changedPassword: false, signedUp: false, - signupMsg: '' + signupMsg: '', + recoverSent: false, + recoverMsg: '' }; } @@ -23,8 +26,16 @@ class Home extends React.Component { //DEBUGGING: well this is goofy let SidePanel; - if (this.props.id) { + if (this.props.id) { //logged in SidePanel = () => { + if (this.state.signedUp) { + this.setState({ signedUp: false }); + } + + if (this.state.recoverSent) { + this.setState({ recoverSent: false }); + } + let PasswordChangePanel; if (!this.state.changedPassword) { @@ -45,27 +56,52 @@ class Home extends React.Component { ); }; - } else { + } else { //not logged in SidePanel = () => { if (this.state.changedPassword) { - this.setState({changedPassword: false}); + this.setState({ changedPassword: false }); } + let SignupPanel; + if (!this.state.signedUp) { - return ( -
+ SignupPanel = () => { + return ( this.setState( {signedUp: true, signupMsg: msg} )} /> - -
- ); + ); + } } else { - return ( -
+ SignupPanel = () => { + return (

{this.state.signupMsg}

- -
- ); + ); + } } + + let RecoverPanel; + + if (!this.state.recoverSent) { + RecoverPanel = () => { + return ( + this.setState( {recoverSent: true, recoverMsg: msg} )} /> + ); + } + } + else { + RecoverPanel = () => { + return ( +

{this.state.recoverMsg}

+ ); + } + } + + return ( +
+ + + +
+ ); }; } @@ -78,6 +114,11 @@ class Home extends React.Component { } } +Home.propTypes = { + id: PropTypes.number.isRequired, + token: PropTypes.number.isRequired +}; + function mapStoreToProps(store) { return { id: store.account.id, diff --git a/src/components/pages/password_reset.jsx b/src/components/pages/password_reset.jsx new file mode 100644 index 0000000..63029f9 --- /dev/null +++ b/src/components/pages/password_reset.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { withRouter, Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import queryString from 'query-string'; + +//panels +import PasswordResetPanel from '../panels/password_reset.jsx'; + +class PasswordReset extends React.Component { + constructor(props) { + super(props); + this.state = { + reset: false, + resetMsg: '', + params: queryString.parse(props.location.search) + } + } + + render() { + let Panel; + + if (!this.state.reset) { + Panel = () => { + return ( this.setState( {reset: true, resetMsg: msg} )}/>); + } + } else { + Panel = () => { + return (

{this.state.resetMsg}

); + } + } + + return ( +
+ + Return Home +
+ ); + } +}; + +export default withRouter(PasswordReset); \ No newline at end of file diff --git a/src/components/panels/password_recover.jsx b/src/components/panels/password_recover.jsx new file mode 100644 index 0000000..3c8a6ea --- /dev/null +++ b/src/components/panels/password_recover.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { validateEmail } from '../../../common/utilities.js'; + +class PasswordRecover extends React.Component { + constructor(props) { + super(props); + this.state = { + email: '', + warning: '' + }; + } + + render() { + let warningStyle = { + display: this.state.warning.length > 0 ? 'flex' : 'none' + }; + + return ( +
+

Recover Password

+ +
+

{this.state.warning}

+
+ +
this.submit(e)}> +
+ + +
+ + +
+
+ ); + } + + submit(e) { + e.preventDefault(); + + if (!this.validateInput()) { + return; + } + + //build the XHR + let form = e.target; + let formData = new FormData(form); + let xhr = new XMLHttpRequest(); + + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + //DEBUGGING + if (this.props.onEmailSent) { + this.props.onEmailSent(xhr.responseText); + } + } + + else if (xhr.status === 400) { + this.setWarning(xhr.responseText); + } + } + }; + + //send the XHR + xhr.open('POST', form.action, true); + xhr.send(formData); + } + + validateInput(e) { + if (!validateEmail(this.state.email)) { + this.setWarning('Invalid Email'); + return false; + } + + return true; + } + + setWarning(s) { + this.setState({ + warning: s + }); + } + + clearInput() { + this.setState({ + email: '', + warning: '' + }); + } + + updateEmail(evt) { + this.setState({ + email: evt.target.value + }); + } +} + +export default PasswordRecover; \ No newline at end of file diff --git a/src/components/panels/password_reset.jsx b/src/components/panels/password_reset.jsx new file mode 100644 index 0000000..ed1434d --- /dev/null +++ b/src/components/panels/password_reset.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { sessionChange } from '../../actions/accounts.js'; +import PropTypes from 'prop-types'; + +class PasswordReset extends React.Component { + constructor(props) { + super(props); + this.state = { + password: '', + retype: '', + warning: '' + }; + } + + render() { + let warningStyle = { + display: this.state.warning.length > 0 ? 'flex' : 'none' + }; + + return ( +
+

Change Password

+ +
+

{this.state.warning}

+
+ +
this.submit(e)}> +
+ + +
+ +
+ + +
+ + +
+
+ ); + } + + submit(e) { + e.preventDefault(); + + if (!this.validateInput()) { + return; + } + + //build the XHR + let form = e.target; + let formData = new FormData(form); + let xhr = new XMLHttpRequest(); + + formData.append('email', this.props.email); + formData.append('token', this.props.token); + + xhr.onreadystatechange = () => { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + if (this.props.onPasswordReset) { + this.props.onPasswordReset(xhr.responseText); + } + } + + else if (xhr.status === 400) { + this.setWarning(xhr.responseText); + } + } + }; + + //send the XHR + xhr.open('POST', form.action, true); + xhr.send(formData); + } + + validateInput(e) { + if (this.state.password.length < 8) { + this.setWarning('Minimum password length is 8 characters'); + return false; + } + + if (this.state.password !== this.state.retype) { + this.setWarning('Passwords do not match'); + return false; + } + + return true; + } + + setWarning(s) { + this.setState({ + warning: s + }); + } + + clearInput() { + this.setState({ + password: '', + retype: '', + warning: '' + }); + } + + updatePassword(evt) { + this.setState({ + password: evt.target.value + }); + } + + updateRetype(evt) { + this.setState({ + retype: evt.target.value + }); + } +} + +PasswordReset.propTypes = { + email: PropTypes.string.isRequired, + token: PropTypes.number.isRequired +}; + +export default PasswordReset; \ No newline at end of file