Email verification (#369)
* Re-introduce Email Verification code Revert "Revert "Email verification"" This reverts commit d154d8bff259350523a0f139e844db96c43d2ee1. * Uses MJML to generate Reset Password email * Sends Password Reset and Email Confirmation emails using MJML template * Sends verified status along with user data * API endpoint for resending email verification confirmation * Displays verification status on Account page and allows resending * Send back error string * Passes email address through to sign/verify helper * Uses enum-style object to set verified state * Sends minimal info when user verifies since it can be done without login * Provides /verify UI and sends confirmation token to API * Better name for JWT secret token env var * Adds mail config variables to Readme * Encrypts email address in JWT The JWT sent as the token in the Confirm Password URL can be unencoded by anyone, although it's signature can only be verified by us. To ensure that no passwords are leaked, we encrypt the email address before creating the token. * Removes unused mail templates * Resets verified flag when email is changed and sends another email * Moves email confirmation functions next to each other * Extracts random token generator to helper * Moves email confirmation actions into Redux - updates the AccountForm label with a message to check inbox - show status when verifying email token * Uses generated token stored in DB for email confirmation * Sets email confirmation status to verified if logging in from Github * Sends email using new method on account creation * Fixes linting errors * Removes replyTo config
This commit is contained in:
parent
7403b2b2d6
commit
1dc0c22cb7
21 changed files with 554 additions and 43 deletions
|
@ -55,6 +55,10 @@ The automatic redirection to HTTPS is turned off by default in development. If y
|
||||||
S3_BUCKET=<your-s3-bucket>
|
S3_BUCKET=<your-s3-bucket>
|
||||||
GITHUB_ID=<your-github-client-id>
|
GITHUB_ID=<your-github-client-id>
|
||||||
GITHUB_SECRET=<your-github-client-secret>
|
GITHUB_SECRET=<your-github-client-secret>
|
||||||
|
EMAIL_SENDER=<email-address-to-send-from>
|
||||||
|
MAILGUN_KEY=<mailgun-api-key>
|
||||||
|
MAILGUN_DOMAIN=<mailgun-domain>
|
||||||
|
EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production
|
||||||
```
|
```
|
||||||
For production, you will need to have real Github and Amazon credentions. Refer to [this gist](https://gist.github.com/catarak/70c9301f0fd1ac2d6b58de03f61997e3) for creating an S3 bucket for testing.
|
For production, you will need to have real Github and Amazon credentions. Refer to [this gist](https://gist.github.com/catarak/70c9301f0fd1ac2d6b58de03f61997e3) for creating an S3 bucket for testing.
|
||||||
|
|
||||||
|
|
|
@ -102,6 +102,11 @@ export const RESET_PASSWORD_INITIATE = 'RESET_PASSWORD_INITIATE';
|
||||||
export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET';
|
export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET';
|
||||||
export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN';
|
export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN';
|
||||||
|
|
||||||
|
export const EMAIL_VERIFICATION_INITIATE = 'EMAIL_VERIFICATION_INITIATE';
|
||||||
|
export const EMAIL_VERIFICATION_VERIFY = 'EMAIL_VERIFICATION_VERIFY';
|
||||||
|
export const EMAIL_VERIFICATION_VERIFIED = 'EMAIL_VERIFICATION_VERIFIED';
|
||||||
|
export const EMAIL_VERIFICATION_INVALID = 'EMAIL_VERIFICATION_INVALID';
|
||||||
|
|
||||||
// eventually, handle errors more specifically and better
|
// eventually, handle errors more specifically and better
|
||||||
export const ERROR = 'ERROR';
|
export const ERROR = 'ERROR';
|
||||||
|
|
||||||
|
|
|
@ -130,6 +130,41 @@ export function initiateResetPassword(formValues) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initiateVerification() {
|
||||||
|
return (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: ActionTypes.EMAIL_VERIFICATION_INITIATE
|
||||||
|
});
|
||||||
|
axios.post(`${ROOT_URL}/verify/send`, {}, { withCredentials: true })
|
||||||
|
.then(() => {
|
||||||
|
// do nothing
|
||||||
|
})
|
||||||
|
.catch(response => dispatch({
|
||||||
|
type: ActionTypes.ERROR,
|
||||||
|
message: response.data
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEmailConfirmation(token) {
|
||||||
|
return (dispatch) => {
|
||||||
|
dispatch({
|
||||||
|
type: ActionTypes.EMAIL_VERIFICATION_VERIFY,
|
||||||
|
state: 'checking',
|
||||||
|
});
|
||||||
|
return axios.get(`${ROOT_URL}/verify?t=${token}`, {}, { withCredentials: true })
|
||||||
|
.then(response => dispatch({
|
||||||
|
type: ActionTypes.EMAIL_VERIFICATION_VERIFIED,
|
||||||
|
message: response.data,
|
||||||
|
}))
|
||||||
|
.catch(response => dispatch({
|
||||||
|
type: ActionTypes.EMAIL_VERIFICATION_INVALID,
|
||||||
|
message: response.data
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function resetPasswordReset() {
|
export function resetPasswordReset() {
|
||||||
return {
|
return {
|
||||||
type: ActionTypes.RESET_PASSWORD_RESET
|
type: ActionTypes.RESET_PASSWORD_RESET
|
||||||
|
|
|
@ -4,11 +4,19 @@ import { domOnlyProps } from '../../../utils/reduxFormUtils';
|
||||||
function AccountForm(props) {
|
function AccountForm(props) {
|
||||||
const {
|
const {
|
||||||
fields: { username, email, currentPassword, newPassword },
|
fields: { username, email, currentPassword, newPassword },
|
||||||
|
user,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
initiateVerification,
|
||||||
submitting,
|
submitting,
|
||||||
invalid,
|
invalid,
|
||||||
pristine
|
pristine
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const handleInitiateVerification = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
initiateVerification();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form" onSubmit={handleSubmit(props.updateSettings)}>
|
<form className="form" onSubmit={handleSubmit(props.updateSettings)}>
|
||||||
<p className="form__field">
|
<p className="form__field">
|
||||||
|
@ -22,6 +30,26 @@ function AccountForm(props) {
|
||||||
/>
|
/>
|
||||||
{email.touched && email.error && <span className="form-error">{email.error}</span>}
|
{email.touched && email.error && <span className="form-error">{email.error}</span>}
|
||||||
</p>
|
</p>
|
||||||
|
{
|
||||||
|
user.verified !== 'verified' &&
|
||||||
|
(
|
||||||
|
<p className="form__context">
|
||||||
|
<span className="form__status">Unconfirmed.</span>
|
||||||
|
{
|
||||||
|
user.emailVerificationInitiate === true ?
|
||||||
|
(
|
||||||
|
<span className="form__status"> Confirmation sent, check your email.</span>
|
||||||
|
) :
|
||||||
|
(
|
||||||
|
<button
|
||||||
|
className="form__action"
|
||||||
|
onClick={handleInitiateVerification}
|
||||||
|
>Resend confirmation email</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
<p className="form__field">
|
<p className="form__field">
|
||||||
<label htmlFor="username" className="form__label">User Name</label>
|
<label htmlFor="username" className="form__label">User Name</label>
|
||||||
<input
|
<input
|
||||||
|
@ -75,9 +103,14 @@ AccountForm.propTypes = {
|
||||||
username: PropTypes.object.isRequired,
|
username: PropTypes.object.isRequired,
|
||||||
email: PropTypes.object.isRequired,
|
email: PropTypes.object.isRequired,
|
||||||
currentPassword: PropTypes.object.isRequired,
|
currentPassword: PropTypes.object.isRequired,
|
||||||
newPassword: PropTypes.object.isRequired
|
newPassword: PropTypes.object.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
user: PropTypes.shape({
|
||||||
|
verified: PropTypes.number.isRequired,
|
||||||
|
emailVerificationInitiate: PropTypes.bool.isRequired,
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
handleSubmit: PropTypes.func.isRequired,
|
handleSubmit: PropTypes.func.isRequired,
|
||||||
|
initiateVerification: PropTypes.func.isRequired,
|
||||||
updateSettings: PropTypes.func.isRequired,
|
updateSettings: PropTypes.func.isRequired,
|
||||||
submitting: PropTypes.bool,
|
submitting: PropTypes.bool,
|
||||||
invalid: PropTypes.bool,
|
invalid: PropTypes.bool,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux';
|
||||||
import { browserHistory } from 'react-router';
|
import { browserHistory } from 'react-router';
|
||||||
import InlineSVG from 'react-inlinesvg';
|
import InlineSVG from 'react-inlinesvg';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { updateSettings } from '../actions';
|
import { updateSettings, initiateVerification } from '../actions';
|
||||||
import AccountForm from '../components/AccountForm';
|
import AccountForm from '../components/AccountForm';
|
||||||
import { validateSettings } from '../../../utils/reduxFormUtils';
|
import { validateSettings } from '../../../utils/reduxFormUtils';
|
||||||
import GithubButton from '../components/GithubButton';
|
import GithubButton from '../components/GithubButton';
|
||||||
|
@ -59,7 +59,7 @@ function mapStateToProps(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapDispatchToProps(dispatch) {
|
function mapDispatchToProps(dispatch) {
|
||||||
return bindActionCreators({ updateSettings }, dispatch);
|
return bindActionCreators({ updateSettings, initiateVerification }, dispatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asyncValidate(formProps, dispatch, props) {
|
function asyncValidate(formProps, dispatch, props) {
|
||||||
|
@ -81,7 +81,7 @@ function asyncValidate(formProps, dispatch, props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountView.propTypes = {
|
AccountView.propTypes = {
|
||||||
previousPath: PropTypes.string.isRequired
|
previousPath: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default reduxForm({
|
export default reduxForm({
|
||||||
|
|
110
client/modules/User/pages/EmailVerificationView.jsx
Normal file
110
client/modules/User/pages/EmailVerificationView.jsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import React, { PropTypes } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { bindActionCreators } from 'redux';
|
||||||
|
import { browserHistory } from 'react-router';
|
||||||
|
import InlineSVG from 'react-inlinesvg';
|
||||||
|
import get from 'lodash/get';
|
||||||
|
import { verifyEmailConfirmation } from '../actions';
|
||||||
|
|
||||||
|
const exitUrl = require('../../../images/exit.svg');
|
||||||
|
const logoUrl = require('../../../images/p5js-logo.svg');
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationView extends React.Component {
|
||||||
|
static defaultProps = {
|
||||||
|
emailVerificationTokenState: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.closeLoginPage = this.closeLoginPage.bind(this);
|
||||||
|
this.gotoHomePage = this.gotoHomePage.bind(this);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
const verificationToken = this.verificationToken();
|
||||||
|
if (verificationToken != null) {
|
||||||
|
this.props.verifyEmailConfirmation(verificationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verificationToken = () => get(this.props, 'location.query.t', null);
|
||||||
|
|
||||||
|
closeLoginPage() {
|
||||||
|
browserHistory.push(this.props.previousPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoHomePage() {
|
||||||
|
browserHistory.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let status = null;
|
||||||
|
const {
|
||||||
|
emailVerificationTokenState,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
if (this.verificationToken() == null) {
|
||||||
|
status = (
|
||||||
|
<p>That link is invalid</p>
|
||||||
|
);
|
||||||
|
} else if (emailVerificationTokenState === 'checking') {
|
||||||
|
status = (
|
||||||
|
<p>Validating token, please wait...</p>
|
||||||
|
);
|
||||||
|
} else if (emailVerificationTokenState === 'verified') {
|
||||||
|
status = (
|
||||||
|
<p>All done, your email address has been verified.</p>
|
||||||
|
);
|
||||||
|
} else if (emailVerificationTokenState === 'invalid') {
|
||||||
|
status = (
|
||||||
|
<p>Something went wrong.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-container">
|
||||||
|
<div className="form-container__header">
|
||||||
|
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
|
||||||
|
<InlineSVG src={logoUrl} alt="p5js Logo" />
|
||||||
|
</button>
|
||||||
|
<button className="form-container__exit-button" onClick={this.closeLoginPage}>
|
||||||
|
<InlineSVG src={exitUrl} alt="Close Login Page" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-container__content">
|
||||||
|
<h2 className="form-container__title">Verify your email</h2>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStateToProps(state) {
|
||||||
|
return {
|
||||||
|
emailVerificationTokenState: state.user.emailVerificationTokenState,
|
||||||
|
previousPath: state.ide.previousPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch) {
|
||||||
|
return bindActionCreators({
|
||||||
|
verifyEmailConfirmation,
|
||||||
|
}, dispatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
EmailVerificationView.propTypes = {
|
||||||
|
previousPath: PropTypes.string.isRequired,
|
||||||
|
emailVerificationTokenState: PropTypes.oneOf([
|
||||||
|
'checking', 'verified', 'invalid'
|
||||||
|
]),
|
||||||
|
verifyEmailConfirmation: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(EmailVerificationView);
|
|
@ -19,6 +19,14 @@ const user = (state = { authenticated: false }, action) => {
|
||||||
return Object.assign({}, state, { resetPasswordInitiate: false });
|
return Object.assign({}, state, { resetPasswordInitiate: false });
|
||||||
case ActionTypes.INVALID_RESET_PASSWORD_TOKEN:
|
case ActionTypes.INVALID_RESET_PASSWORD_TOKEN:
|
||||||
return Object.assign({}, state, { resetPasswordInvalid: true });
|
return Object.assign({}, state, { resetPasswordInvalid: true });
|
||||||
|
case ActionTypes.EMAIL_VERIFICATION_INITIATE:
|
||||||
|
return Object.assign({}, state, { emailVerificationInitiate: true });
|
||||||
|
case ActionTypes.EMAIL_VERIFICATION_VERIFY:
|
||||||
|
return Object.assign({}, state, { emailVerificationTokenState: 'checking' });
|
||||||
|
case ActionTypes.EMAIL_VERIFICATION_VERIFIED:
|
||||||
|
return Object.assign({}, state, { emailVerificationTokenState: 'verified' });
|
||||||
|
case ActionTypes.EMAIL_VERIFICATION_INVALID:
|
||||||
|
return Object.assign({}, state, { emailVerificationTokenState: 'invalid' });
|
||||||
case ActionTypes.SETTINGS_UPDATED:
|
case ActionTypes.SETTINGS_UPDATED:
|
||||||
return { ...state, ...action.user };
|
return { ...state, ...action.user };
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -7,6 +7,7 @@ import FullView from './modules/IDE/pages/FullView';
|
||||||
import LoginView from './modules/User/pages/LoginView';
|
import LoginView from './modules/User/pages/LoginView';
|
||||||
import SignupView from './modules/User/pages/SignupView';
|
import SignupView from './modules/User/pages/SignupView';
|
||||||
import ResetPasswordView from './modules/User/pages/ResetPasswordView';
|
import ResetPasswordView from './modules/User/pages/ResetPasswordView';
|
||||||
|
import EmailVerificationView from './modules/User/pages/EmailVerificationView';
|
||||||
import NewPasswordView from './modules/User/pages/NewPasswordView';
|
import NewPasswordView from './modules/User/pages/NewPasswordView';
|
||||||
import AccountView from './modules/User/pages/AccountView';
|
import AccountView from './modules/User/pages/AccountView';
|
||||||
// import SketchListView from './modules/Sketch/pages/SketchListView';
|
// import SketchListView from './modules/Sketch/pages/SketchListView';
|
||||||
|
@ -38,6 +39,7 @@ const routes = (store) => {
|
||||||
<Route path="/login" component={forceToHttps(LoginView)} />
|
<Route path="/login" component={forceToHttps(LoginView)} />
|
||||||
<Route path="/signup" component={forceToHttps(SignupView)} />
|
<Route path="/signup" component={forceToHttps(SignupView)} />
|
||||||
<Route path="/reset-password" component={forceToHttps(ResetPasswordView)} />
|
<Route path="/reset-password" component={forceToHttps(ResetPasswordView)} />
|
||||||
|
<Route path="/verify" component={forceToHttps(EmailVerificationView)} />
|
||||||
<Route
|
<Route
|
||||||
path="/reset-password/:reset_password_token"
|
path="/reset-password/:reset_password_token"
|
||||||
component={forceToHttps(NewPasswordView)}
|
component={forceToHttps(NewPasswordView)}
|
||||||
|
|
|
@ -33,6 +33,15 @@
|
||||||
border-color: $secondary-form-title-color;
|
border-color: $secondary-form-title-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form__context {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: #{15 / $base-font-size}rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__status {
|
||||||
|
color: $form-navigation-options-color;
|
||||||
|
}
|
||||||
|
|
||||||
.form input[type="submit"] {
|
.form input[type="submit"] {
|
||||||
@extend %forms-button;
|
@extend %forms-button;
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,12 +79,16 @@
|
||||||
"express": "^4.13.4",
|
"express": "^4.13.4",
|
||||||
"express-session": "^1.13.0",
|
"express-session": "^1.13.0",
|
||||||
"file-type": "^3.8.0",
|
"file-type": "^3.8.0",
|
||||||
|
"fs-promise": "^1.0.0",
|
||||||
"htmlhint": "^0.9.13",
|
"htmlhint": "^0.9.13",
|
||||||
|
"is_js": "^0.9.0",
|
||||||
"js-beautify": "^1.6.4",
|
"js-beautify": "^1.6.4",
|
||||||
"jsdom": "^9.8.3",
|
"jsdom": "^9.8.3",
|
||||||
"jshint": "^2.9.4",
|
"jshint": "^2.9.4",
|
||||||
|
"jsonwebtoken": "^7.2.1",
|
||||||
"lodash": "^4.16.4",
|
"lodash": "^4.16.4",
|
||||||
"loop-protect": "git+https://git@github.com/catarak/loop-protect.git",
|
"loop-protect": "git+https://git@github.com/catarak/loop-protect.git",
|
||||||
|
"mjml": "^3.3.2",
|
||||||
"moment": "^2.14.1",
|
"moment": "^2.14.1",
|
||||||
"mongoose": "^4.4.16",
|
"mongoose": "^4.4.16",
|
||||||
"node-uuid": "^1.4.7",
|
"node-uuid": "^1.4.7",
|
||||||
|
@ -94,6 +98,7 @@
|
||||||
"passport-github": "^1.1.0",
|
"passport-github": "^1.1.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"project-name-generator": "^2.1.3",
|
"project-name-generator": "^2.1.3",
|
||||||
|
"pug": "^2.0.0-beta6",
|
||||||
"q": "^1.4.1",
|
"q": "^1.4.1",
|
||||||
"react": "^15.1.0",
|
"react": "^15.1.0",
|
||||||
"react-dom": "^15.1.0",
|
"react-dom": "^15.1.0",
|
||||||
|
|
|
@ -84,6 +84,7 @@ passport.use(new GitHubStrategy({
|
||||||
existingEmailUser.username = existingEmailUser.username || profile.username;
|
existingEmailUser.username = existingEmailUser.username || profile.username;
|
||||||
existingEmailUser.tokens.push({ kind: 'github', accessToken });
|
existingEmailUser.tokens.push({ kind: 'github', accessToken });
|
||||||
existingEmailUser.name = existingEmailUser.name || profile.displayName;
|
existingEmailUser.name = existingEmailUser.name || profile.displayName;
|
||||||
|
existingEmailUser.verified = User.EmailConfirmation.Verified;
|
||||||
existingEmailUser.save(saveErr => done(null, existingEmailUser));
|
existingEmailUser.save(saveErr => done(null, existingEmailUser));
|
||||||
} else {
|
} else {
|
||||||
const user = new User();
|
const user = new User();
|
||||||
|
@ -92,6 +93,7 @@ passport.use(new GitHubStrategy({
|
||||||
user.username = profile.username;
|
user.username = profile.username;
|
||||||
user.tokens.push({ kind: 'github', accessToken });
|
user.tokens.push({ kind: 'github', accessToken });
|
||||||
user.name = profile.displayName;
|
user.name = profile.displayName;
|
||||||
|
user.verified = User.EmailConfirmation.Verified;
|
||||||
user.save(saveErr => done(null, user));
|
user.save(saveErr => done(null, user));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,6 +13,7 @@ export function createSession(req, res, next) {
|
||||||
email: req.user.email,
|
email: req.user.email,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
preferences: req.user.preferences,
|
preferences: req.user.preferences,
|
||||||
|
verified: req.user.verified,
|
||||||
id: req.user._id
|
id: req.user._id
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -25,6 +26,7 @@ export function getSession(req, res) {
|
||||||
email: req.user.email,
|
email: req.user.email,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
preferences: req.user.preferences,
|
preferences: req.user.preferences,
|
||||||
|
verified: req.user.verified,
|
||||||
id: req.user._id
|
id: req.user._id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,34 @@
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import async from 'async';
|
import async from 'async';
|
||||||
import nodemailer from 'nodemailer';
|
|
||||||
import mg from 'nodemailer-mailgun-transport';
|
|
||||||
import User from '../models/user';
|
import User from '../models/user';
|
||||||
|
import mail from '../utils/mail';
|
||||||
|
import {
|
||||||
|
renderEmailConfirmation,
|
||||||
|
renderResetPassword,
|
||||||
|
} from '../views/mail';
|
||||||
|
|
||||||
|
const random = (done) => {
|
||||||
|
crypto.randomBytes(20, (err, buf) => {
|
||||||
|
const token = buf.toString('hex');
|
||||||
|
done(err, token);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + (3600000 * 24); // 24 hours
|
||||||
|
|
||||||
export function createUser(req, res, next) {
|
export function createUser(req, res, next) {
|
||||||
const user = new User({
|
random((tokenError, token) => {
|
||||||
username: req.body.username,
|
const user = new User({
|
||||||
email: req.body.email,
|
username: req.body.username,
|
||||||
password: req.body.password
|
email: req.body.email,
|
||||||
});
|
password: req.body.password,
|
||||||
|
verified: User.EmailConfirmation.Sent,
|
||||||
|
verifiedToken: token,
|
||||||
|
verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
User.findOne({ email: req.body.email },
|
User.findOne({ email: req.body.email },
|
||||||
(err, existingUser) => {
|
(err, existingUser) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.status(404).send({ error: err });
|
res.status(404).send({ error: err });
|
||||||
|
@ -32,15 +49,28 @@ export function createUser(req, res, next) {
|
||||||
next(loginErr);
|
next(loginErr);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({
|
|
||||||
email: req.user.email,
|
const mailOptions = renderEmailConfirmation({
|
||||||
username: req.user.username,
|
body: {
|
||||||
preferences: req.user.preferences,
|
domain: `http://${req.headers.host}`,
|
||||||
id: req.user._id
|
link: `http://${req.headers.host}/verify?t=${token}`
|
||||||
|
},
|
||||||
|
to: req.user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars
|
||||||
|
res.json({
|
||||||
|
email: req.user.email,
|
||||||
|
username: req.user.username,
|
||||||
|
preferences: req.user.preferences,
|
||||||
|
verified: req.user.verified,
|
||||||
|
id: req.user._id
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function duplicateUserCheck(req, res) {
|
export function duplicateUserCheck(req, res) {
|
||||||
|
@ -90,12 +120,7 @@ export function updatePreferences(req, res) {
|
||||||
|
|
||||||
export function resetPasswordInitiate(req, res) {
|
export function resetPasswordInitiate(req, res) {
|
||||||
async.waterfall([
|
async.waterfall([
|
||||||
(done) => {
|
random,
|
||||||
crypto.randomBytes(20, (err, buf) => {
|
|
||||||
const token = buf.toString('hex');
|
|
||||||
done(err, token);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(token, done) => {
|
(token, done) => {
|
||||||
User.findOne({ email: req.body.email }, (err, user) => {
|
User.findOne({ email: req.body.email }, (err, user) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -111,27 +136,15 @@ export function resetPasswordInitiate(req, res) {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
(token, user, done) => {
|
(token, user, done) => {
|
||||||
const auth = {
|
const mailOptions = renderResetPassword({
|
||||||
auth: {
|
body: {
|
||||||
api_key: process.env.MAILGUN_KEY,
|
domain: `http://${req.headers.host}`,
|
||||||
domain: process.env.MAILGUN_DOMAIN
|
link: `http://${req.headers.host}/reset-password/${token}`,
|
||||||
}
|
},
|
||||||
};
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport(mg(auth));
|
|
||||||
const message = {
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
from: 'p5.js Web Editor <noreply@p5js.org>',
|
|
||||||
subject: 'p5.js Web Editor Password Reset',
|
|
||||||
text: `You are receiving this email because you (or someone else) have requested the reset of the password for your account.
|
|
||||||
\n\nPlease click on the following link, or paste this into your browser to complete the process:
|
|
||||||
\n\nhttp://${req.headers.host}/reset-password/${token}
|
|
||||||
\n\nIf you did not request this, please ignore this email and your password will remain unchanged.
|
|
||||||
\n\nThanks for using the p5.js Web Editor!\n`
|
|
||||||
};
|
|
||||||
transporter.sendMail(message, (error) => {
|
|
||||||
done(error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mail.send(mailOptions, done);
|
||||||
}
|
}
|
||||||
], (err) => {
|
], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
@ -153,6 +166,75 @@ export function validateResetPasswordToken(req, res) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emailVerificationInitiate(req, res) {
|
||||||
|
async.waterfall([
|
||||||
|
random,
|
||||||
|
(token, done) => {
|
||||||
|
User.findById(req.user.id, (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'Document not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.verified === User.EmailConfirmation.Verified) {
|
||||||
|
res.status(409).json({ error: 'Email already verified' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailOptions = renderEmailConfirmation({
|
||||||
|
body: {
|
||||||
|
domain: `http://${req.headers.host}`,
|
||||||
|
link: `http://${req.headers.host}/verify?t=${token}`
|
||||||
|
},
|
||||||
|
to: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars
|
||||||
|
if (mailErr != null) {
|
||||||
|
res.status(500).send({ error: 'Error sending mail' });
|
||||||
|
} else {
|
||||||
|
user.verified = User.EmailConfirmation.Resent;
|
||||||
|
user.verifiedToken = token;
|
||||||
|
user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours
|
||||||
|
user.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
email: req.user.email,
|
||||||
|
username: req.user.username,
|
||||||
|
preferences: req.user.preferences,
|
||||||
|
verified: user.verified,
|
||||||
|
id: req.user._id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyEmail(req, res) {
|
||||||
|
const token = req.query.t;
|
||||||
|
|
||||||
|
User.findOne({ verifiedToken: token, verifiedTokenExpires: { $gt: Date.now() } }, (err, user) => {
|
||||||
|
if (!user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Token is invalid or has expired.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.verified = User.EmailConfirmation.Verified;
|
||||||
|
user.verifiedToken = null;
|
||||||
|
user.verifiedTokenExpires = null;
|
||||||
|
user.save()
|
||||||
|
.then((result) => { // eslint-disable-line
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function updatePassword(req, res) {
|
export function updatePassword(req, res) {
|
||||||
User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => {
|
User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -205,7 +287,6 @@ export function updateSettings(req, res) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
user.email = req.body.email;
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
|
|
||||||
if (req.body.currentPassword) {
|
if (req.body.currentPassword) {
|
||||||
|
@ -218,6 +299,27 @@ export function updateSettings(req, res) {
|
||||||
user.password = req.body.newPassword;
|
user.password = req.body.newPassword;
|
||||||
saveUser(res, user);
|
saveUser(res, user);
|
||||||
});
|
});
|
||||||
|
} else if (user.email !== req.body.email) {
|
||||||
|
user.verified = User.EmailConfirmation.Sent;
|
||||||
|
|
||||||
|
user.email = req.body.email;
|
||||||
|
|
||||||
|
random((error, token) => {
|
||||||
|
user.verifiedToken = token;
|
||||||
|
user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME;
|
||||||
|
|
||||||
|
saveUser(res, user);
|
||||||
|
|
||||||
|
const mailOptions = renderEmailConfirmation({
|
||||||
|
body: {
|
||||||
|
domain: `http://${req.headers.host}`,
|
||||||
|
link: `http://${req.headers.host}/verify?t=${token}`
|
||||||
|
},
|
||||||
|
to: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
mail.send(mailOptions);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
saveUser(res, user);
|
saveUser(res, user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,12 @@ import mongoose from 'mongoose';
|
||||||
|
|
||||||
const bcrypt = require('bcrypt-nodejs');
|
const bcrypt = require('bcrypt-nodejs');
|
||||||
|
|
||||||
|
const EmailConfirmationStates = {
|
||||||
|
Verified: 'verified',
|
||||||
|
Sent: 'sent',
|
||||||
|
Resent: 'resent',
|
||||||
|
};
|
||||||
|
|
||||||
const Schema = mongoose.Schema;
|
const Schema = mongoose.Schema;
|
||||||
|
|
||||||
const userSchema = new Schema({
|
const userSchema = new Schema({
|
||||||
|
@ -10,6 +16,9 @@ const userSchema = new Schema({
|
||||||
password: { type: String },
|
password: { type: String },
|
||||||
resetPasswordToken: String,
|
resetPasswordToken: String,
|
||||||
resetPasswordExpires: Date,
|
resetPasswordExpires: Date,
|
||||||
|
verified: { type: String },
|
||||||
|
verifiedToken: String,
|
||||||
|
verifiedTokenExpires: Date,
|
||||||
github: { type: String },
|
github: { type: String },
|
||||||
email: { type: String, unique: true },
|
email: { type: String, unique: true },
|
||||||
tokens: Array,
|
tokens: Array,
|
||||||
|
@ -73,4 +82,6 @@ userSchema.statics.findByMailOrName = function findByMailOrName(email) {
|
||||||
return this.findOne(query).exec();
|
return this.findOne(query).exec();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userSchema.statics.EmailConfirmation = EmailConfirmationStates;
|
||||||
|
|
||||||
export default mongoose.model('User', userSchema);
|
export default mongoose.model('User', userSchema);
|
||||||
|
|
|
@ -40,6 +40,10 @@ router.route('/reset-password/:reset_password_token').get((req, res) => {
|
||||||
res.send(renderIndex());
|
res.send(renderIndex());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.route('/verify').get((req, res) => {
|
||||||
|
res.send(renderIndex());
|
||||||
|
});
|
||||||
|
|
||||||
router.route('/sketches').get((req, res) => {
|
router.route('/sketches').get((req, res) => {
|
||||||
res.send(renderIndex());
|
res.send(renderIndex());
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,4 +17,8 @@ router.route('/reset-password/:token').post(UserController.updatePassword);
|
||||||
|
|
||||||
router.route('/account').put(UserController.updateSettings);
|
router.route('/account').put(UserController.updateSettings);
|
||||||
|
|
||||||
|
router.route('/verify/send').post(UserController.emailVerificationInitiate);
|
||||||
|
|
||||||
|
router.route('/verify').get(UserController.verifyEmail);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
48
server/utils/mail.js
Normal file
48
server/utils/mail.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Mail service wrapping around mailgun
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import mg from 'nodemailer-mailgun-transport';
|
||||||
|
|
||||||
|
const auth = {
|
||||||
|
api_key: process.env.MAILGUN_KEY,
|
||||||
|
domain: process.env.MAILGUN_DOMAIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
class Mail {
|
||||||
|
constructor() {
|
||||||
|
this.client = nodemailer.createTransport(mg({ auth }));
|
||||||
|
this.sendOptions = {
|
||||||
|
from: process.env.EMAIL_SENDER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMail(mailOptions) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.client.sendMail(mailOptions, (err, info) => {
|
||||||
|
resolve(err, info);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchMail(data, callback) {
|
||||||
|
const mailOptions = {
|
||||||
|
to: data.to,
|
||||||
|
subject: data.subject,
|
||||||
|
from: this.sendOptions.from,
|
||||||
|
html: data.html,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.sendMail(mailOptions)
|
||||||
|
.then((err, res) => {
|
||||||
|
callback(err, res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data, callback) {
|
||||||
|
return this.dispatchMail(data, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Mail();
|
12
server/utils/renderMjml.js
Normal file
12
server/utils/renderMjml.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { mjml2html } from 'mjml';
|
||||||
|
|
||||||
|
export default (template) => {
|
||||||
|
try {
|
||||||
|
const output = mjml2html(template);
|
||||||
|
return output.html;
|
||||||
|
} catch (e) {
|
||||||
|
// fall through to null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
56
server/views/mail.js
Normal file
56
server/views/mail.js
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import renderMjml from '../utils/renderMjml';
|
||||||
|
import mailLayout from './mailLayout';
|
||||||
|
|
||||||
|
export const renderResetPassword = (data) => {
|
||||||
|
const subject = 'p5.js Web Editor Password Reset';
|
||||||
|
const templateOptions = {
|
||||||
|
domain: data.body.domain,
|
||||||
|
headingText: 'Reset your password',
|
||||||
|
greetingText: 'Hello,',
|
||||||
|
messageText: 'We received a request to reset the password for your account. To reset your password, click on the button below:', // eslint-disable-line max-len
|
||||||
|
link: data.body.link,
|
||||||
|
buttonText: 'Reset password',
|
||||||
|
directLinkText: 'Or copy and paste the URL into your browser:',
|
||||||
|
noteText: 'If you did not request this, please ignore this email and your password will remain unchanged. Thanks for using the p5.js Web Editor!', // eslint-disable-line max-len
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return MJML string
|
||||||
|
const template = mailLayout(templateOptions);
|
||||||
|
|
||||||
|
// Render MJML to HTML string
|
||||||
|
const html = renderMjml(template);
|
||||||
|
|
||||||
|
// Return options to send mail
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
{ html, subject },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderEmailConfirmation = (data) => {
|
||||||
|
const subject = 'p5.js Email Verification';
|
||||||
|
const templateOptions = {
|
||||||
|
domain: data.body.domain,
|
||||||
|
headingText: 'Email Verification',
|
||||||
|
greetingText: 'Hello,',
|
||||||
|
messageText: 'To verify your email, click on the button below:',
|
||||||
|
link: data.body.link,
|
||||||
|
buttonText: 'Verify Email',
|
||||||
|
directLinkText: 'Or copy and paste the URL into your browser:',
|
||||||
|
noteText: 'This link is only valid for the next 24 hours. Thanks for using the p5.js Web Editor!',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return MJML string
|
||||||
|
const template = mailLayout(templateOptions);
|
||||||
|
|
||||||
|
// Render MJML to HTML string
|
||||||
|
const html = renderMjml(template);
|
||||||
|
|
||||||
|
// Return options to send mail
|
||||||
|
return Object.assign(
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
{ html, subject },
|
||||||
|
);
|
||||||
|
};
|
59
server/views/mailLayout.js
Normal file
59
server/views/mailLayout.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
export default ({
|
||||||
|
domain,
|
||||||
|
headingText,
|
||||||
|
greetingText,
|
||||||
|
messageText,
|
||||||
|
link,
|
||||||
|
buttonText,
|
||||||
|
directLinkText,
|
||||||
|
noteText,
|
||||||
|
}) => (
|
||||||
|
`
|
||||||
|
<mjml>
|
||||||
|
<mj-body>
|
||||||
|
<mj-container>
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-image width="192" src="${domain}/images/p5js-square-logo.png" alt="p5.js" />
|
||||||
|
<mj-divider border-color="#ed225d"></mj-divider>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-text font-size="20px" color="#333333" font-family="sans-serif">
|
||||||
|
${headingText}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-text color="#333333">
|
||||||
|
${greetingText}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text color="#333333">
|
||||||
|
${messageText}
|
||||||
|
</mj-text>
|
||||||
|
<mj-button background-color="#ed225d" href="${link}">
|
||||||
|
${buttonText}
|
||||||
|
</mj-button>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-text color="#333333">
|
||||||
|
${directLinkText}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text align="center" color="#333333"><a href="${link}">${link}</a></mj-text>
|
||||||
|
<mj-text color="#333333">
|
||||||
|
${noteText}
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-container>
|
||||||
|
</mj-body>
|
||||||
|
</mjml>
|
||||||
|
`
|
||||||
|
);
|
BIN
static/images/p5js-square-logo.png
Normal file
BIN
static/images/p5js-square-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
Loading…
Reference in a new issue