Forgot password flow (modulo sending email).

Also, url helpers prepared for account validation emails.
This commit is contained in:
Avital Oliver
2012-08-06 16:12:06 -07:00
parent 0019e5f716
commit 9186c63b73
11 changed files with 432 additions and 64 deletions

View File

@@ -124,4 +124,45 @@
});
}
};
// Sends an email to a user with a link that can be used to reset
// their password
//
// @param options {Object}
// - email: (email)
// @param callback (optional) {Function(error|undefined)}
Meteor.forgotPassword = function(options) {
if (!options.email)
throw new Error("Must pass options.email");
options.baseUrl = window.location.protocol + "//"
+ window.location.host + "/";
Meteor.call("forgotPassword", options, callback);
};
// Resets a password based on a token originally created by
// Meteor.forgotPassword, and then logs in the matching user.
//
// @param token {String}
// @param newPassword {String}
// @param callback (optional) {Function(error|undefined)}
Meteor.resetPassword = function(token, newPassword, callback) {
if (!token)
throw new Error("Need to pass options.token");
if (!newPassword)
throw new Error("Need to pass options.newPassword");
var verifier = Meteor._srp.generateVerifier(newPassword);
Meteor.apply(
"resetPassword", [token, verifier], {wait: true},
function (error, result) {
if (error || !result) {
error = error || new Error("No result from call to resetPassword");
callback && callback(error);
}
Meteor.accounts.makeClientLoggedIn(result.id, result.token);
callback && callback();
});
};
})();

View File

@@ -137,6 +137,51 @@
var loginToken = Meteor.accounts._loginTokens.insert({userId: userId});
this.setUserId(userId);
return {token: loginToken, id: userId};
},
forgotPassword: function (options) {
var email = options.email;
var baseUrl = options.baseUrl;
if (!email)
throw new Meteor.Error(400, "Need to set options.email");
if (!baseUrl)
throw new Meteor.Error(400, "Need to set options.baseUrl");
var user = Meteor.users.findOne({emails: email});
if (!user)
throw new Meteor.Error(403, "User not found");
var token = Meteor.uuid();
var creationTime = +(new Date);
Meteor.users.update(user._id, {$set: {
"services.password.reset": {
token: token,
creationTime: creationTime
}
}});
// XXX definitely *not* the final form!
Meteor.mail.send(email, Meteor.accounts.urls.resetPassword(baseUrl, token));
},
resetPassword: function (token, newVerifier) {
if (!token)
throw new Meteor.Error(400, "Need to pass token");
if (!newVerifier)
throw new Meteor.Error(400, "Need to pass newVerifier");
var user = Meteor.users.findOne({"services.password.reset.token": token});
if (!user)
throw new Meteor.Error(403, "Reset password link expired");
Meteor.users.update({_id: user._id}, {
$set: {'services.password.srp': newVerifier},
$unset: {'services.password.reset': 1}
});
var loginToken = Meteor.accounts._loginTokens.insert({userId: user._id});
this.setUserId(user._id);
return {token: loginToken, id: user._id};
}
});
@@ -199,3 +244,10 @@
});
})();
Meteor.mail = {};
Meteor.mail.send = function() {
console.log("Send mail:");
console.log(arguments);
};

View File

@@ -37,7 +37,10 @@
<span class="hline">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
</div>
{{/if}}
<div class="login-password-form">
{{#if isForgotPasswordFlow}}
{{> forgotPasswordForm}}
{{else}}
<div class="login-form login-password-form">
<label id="login-username-label" for="login-username">Username</label>
<input id="login-username"/>
@@ -46,6 +49,7 @@
{{> loginButtonsServicesRowDynamicPart}}
</div>
{{/if}}
{{else}}
<div class="login-button" id="login-buttons-{{name}}">
<div class="login-image" id="login-buttons-image-{{name}}"></div>
@@ -62,11 +66,9 @@
<input id="login-password-again" type="password"/>
{{/if}}
{{#if errorMessage}}
<div class="error-message">{{errorMessage}}</div>
{{/if}}
{{> loginButtonsMessages}}
<div class="login-button" id="login-buttons-password">
<div class="login-button login-button-form-submit" id="login-buttons-password">
{{#if inSignupFlow}}
Create account
{{else}}
@@ -74,16 +76,31 @@
{{/if}}
</div>
{{#unless inSignupFlow}}
<a id="signup-link">Create account</a>
{{/unless}}
{{#if inLoginFlow}}
<div class="additional-link-container">
<a id="signup-link" class="additional-link">Create account</a>
</div>
<div class="additional-link-container">
<a id="forgot-password-link" class="additional-link">Forgot password</a>
</div>
{{/if}}
</template>
<template name="loginButtonsMessages">
{{#if errorMessage}}
<div class="message error-message">{{errorMessage}}</div>
{{/if}}
{{#if infoMessage}}
<div class="message info-message">{{infoMessage}}</div>
{{/if}}
</template>
<template name="loginButtonsServicesDropdown">
<div class="login-link-and-dropdown-list">
<a class="login-link-text">Sign in</a>
{{#if dropdownVisible}}
<div id="login-dropdown-list">
<div id="login-dropdown-list" class="accounts-dialog">
<a class="login-close-text">Close</a>
<div class="login-close-text-clear"></div>
{{> loginButtonsServicesRow}}
@@ -91,3 +108,39 @@
{{/if}}
</div>
</template>
<template name="forgotPasswordForm">
<div class="login-form">
<label id="forgot-password-email-label" for="forgot-password-email">Email</label>
<input id="forgot-password-email"/>
{{> loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="login-buttons-forgot-password">
Reset password
</div>
</div>
</template>
<template name="resetPasswordForm">
{{#if inResetPasswordFlow}}
<div class="hide-background"></div>
<div id="reset-password-form" class="accounts-dialog">
<label id="reset-password-new-password-label" for="reset-password-new-password">
New password
</label>
<input id="reset-password-new-password"/>
{{> loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="reset-password-button">
Reset password
</div>
</div>
{{/if}}
</template>
<body>
{{> resetPasswordForm}}
</body>

View File

@@ -5,12 +5,21 @@
var DROPDOWN_VISIBLE_KEY = 'Meteor.loginButtons.dropdownVisible';
var IN_SIGNUP_FLOW_KEY = 'Meteor.loginButtons.inSignupFlow';
var IN_FORGOT_PASSWORD_FLOW_KEY = 'Meteor.loginButtons.inForgotPasswordFlow';
var ERROR_MESSAGE_KEY = 'Meteor.loginButtons.errorMessage';
var INFO_MESSAGE_KEY = 'Meteor.loginButtons.infoMessage';
var RESET_PASSWORD_TOKEN_KEY = 'Meteor.loginButtons.resetPasswordToken';
var resetSession = function () {
Session.set(IN_SIGNUP_FLOW_KEY, false);
Session.set(IN_FORGOT_PASSWORD_FLOW_KEY, false);
Session.set(DROPDOWN_VISIBLE_KEY, false);
resetMessages();
};
var resetMessages = function () {
Session.set(ERROR_MESSAGE_KEY, null);
Session.set(INFO_MESSAGE_KEY, null);
};
@@ -89,16 +98,18 @@
loginOrSignup();
},
'click #signup-link': function () {
Session.set(ERROR_MESSAGE_KEY, null);
resetMessages();
Session.set(IN_SIGNUP_FLOW_KEY, true);
Session.set(IN_FORGOT_PASSWORD_FLOW_KEY, false);
},
'click #forgot-password-link': function () {
resetMessages();
Session.set(IN_SIGNUP_FLOW_KEY, false);
Session.set(IN_FORGOT_PASSWORD_FLOW_KEY, true);
},
'keypress #login-username,#login-password,#login-password-again': function (event) {
if (event.keyCode === 13)
loginOrSignup();
},
'keypress #login-password-again': function (event) {
if (event.keyCode === 13)
loginOrSignup();
}
};
@@ -114,13 +125,29 @@
return getLoginServices().length > 1;
};
Template.loginButtonsServicesRow.isForgotPasswordFlow = function () {
return Session.get(IN_FORGOT_PASSWORD_FLOW_KEY);
};
//
// loginButtonsMessage template
//
Template.loginButtonsMessages.errorMessage = function () {
return Session.get(ERROR_MESSAGE_KEY);
};
Template.loginButtonsMessages.infoMessage = function () {
return Session.get(INFO_MESSAGE_KEY);
};
//
// loginButtonsServicesRowDynamicPart template
//
Template.loginButtonsServicesRowDynamicPart.errorMessage = function () {
return Session.get(ERROR_MESSAGE_KEY);
Template.loginButtonsServicesRowDynamicPart.inLoginFlow = function () {
return !Session.get(IN_SIGNUP_FLOW_KEY) && !Session.get(IN_FORGOT_PASSWORD_FLOW_KEY);
};
Template.loginButtonsServicesRowDynamicPart.inSignupFlow = function () {
@@ -128,6 +155,36 @@
};
//
// forgotPasswordForm template
//
Template.forgotPasswordForm.events = {
'keypress #forgot-password-email': function (event) {
if (event.keyCode === 13)
forgotPassword();
},
'click #login-buttons-forgot-password': function () {
forgotPassword();
}
};
var forgotPassword = function () {
resetMessages();
var email = document.getElementById("forgot-password-email").value;
if (email.indexOf('@') !== -1) {
Meteor.forgotPassword({email: email}, function (error) {
if (error)
Session.set(ERROR_MESSAGE_KEY, error.reason);
else
Session.set(INFO_MESSAGE_KEY, "Email sent");
});
} else {
Session.set(ERROR_MESSAGE_KEY, "Invalid email");
}
};
//
// loginButtonsServicesDropdown template
//
@@ -161,11 +218,53 @@
};
//
// resetPasswordForm template
//
Template.resetPasswordForm.events = {
'click #reset-password-button': function () {
resetPassword();
},
'keypress #reset-password-new-password': function (event) {
if (event.keyCode === 13)
resetPassword();
}
};
var resetPassword = function () {
resetMessages();
var newPassword = document.getElementById('reset-password-new-password').value;
if (!validatePassword(newPassword))
return;
Meteor.resetPassword(
Session.get(RESET_PASSWORD_TOKEN_KEY), newPassword,
function (error) {
if (error) {
Session.set(ERROR_MESSAGE_KEY, error.reason);
} else {
Session.set(RESET_PASSWORD_TOKEN_KEY, null);
Meteor.accounts._preventAutoLogin = false;
}
});
};
Template.resetPasswordForm.inResetPasswordFlow = function () {
return Session.get(RESET_PASSWORD_TOKEN_KEY);
};
if (Meteor.accounts._resetPasswordToken) {
Session.set(RESET_PASSWORD_TOKEN_KEY, Meteor.accounts._resetPasswordToken);
}
//
// helpers
//
var login = function () {
resetMessages();
var username = document.getElementById('login-username').value;
var password = document.getElementById('login-password').value;
@@ -177,25 +276,26 @@
};
var signup = function () {
resetMessages();
var username = document.getElementById('login-username').value;
var password = document.getElementById('login-password').value;
var passwordAgain = document.getElementById('login-password-again').value;
// XXX these will become configurable, and will be validated on
// the server as well.
if (username.length < 3) {
Session.set(ERROR_MESSAGE_KEY, "Username must be at least 3 characters long");
} else if (password.length < 6) {
Session.set(ERROR_MESSAGE_KEY, "Password must be at least 6 characters long");
} else if (password !== passwordAgain) {
if (!validateUsername(username) || !validatePassword(password))
return;
if (password !== passwordAgain) {
Session.set(ERROR_MESSAGE_KEY, "Passwords don't match");
} else {
Meteor.createUser({username: username, password: password}, function (error) {
if (error) {
Session.set(ERROR_MESSAGE_KEY, error.reason);
}
});
return;
}
Meteor.createUser({username: username, password: password}, function (error) {
if (error) {
Session.set(ERROR_MESSAGE_KEY, error.reason);
}
});
};
var loginOrSignup = function () {
@@ -223,4 +323,24 @@
return ret;
};
// XXX improve these? should this be in accounts-passwords instead?
var validateUsername = function (username) {
if (username.length >= 3) {
return true;
} else {
Session.set(ERROR_MESSAGE_KEY, "Username must be at least 3 characters long");
return false;
}
};
var validatePassword = function (password) {
if (password.length >= 6) {
return true;
} else {
Session.set(ERROR_MESSAGE_KEY, "Password must be at least 6 characters long");
return false;
}
};
})();

View File

@@ -15,7 +15,9 @@
margin-left: 4px;
}
#login-buttons .login-button {
@login-buttons-accounts-dialog-width: 158px;
#login-buttons .login-button, .accounts-dialog .login-button {
float: left;
cursor: pointer;
padding: 1px 4px;
@@ -43,7 +45,7 @@
margin-left: 5px; /* so that other elements aren't too close */
}
#login-dropdown-list .login-button {
.accounts-dialog .login-button {
width: 158px;
margin-bottom: 4px;
}
@@ -80,24 +82,13 @@
clear: right;
}
#login-dropdown-list {
position: absolute;
@login-buttons-accounts-dialog-padding-left: 8px;
.accounts-dialog {
border: 1px solid black;
z-index: 1000;
width: 167px;
right: 0px;
top: 0px;
background: white;
margin-top: -5px;
padding-top: 4px; /* = border-width - margin-top */
margin-right: -8px;
padding-right: 8px; /* = -margin-right */
padding-left: 8px;
padding-bottom: 8px;
-moz-box-shadow: -2px 3px 3px 1px rgba(0, 0, 0, 0.3);
-webkit-box-shadow: -2px 3px 3px 1px rgba(0, 0, 0, 0.3);
box-shadow: -2px 3px 3px 1px rgba(0, 0, 0, 0.3);
@@ -105,6 +96,23 @@
-moz-border-radius: 3px;
-webkit-border-radius: 3px;
border-radius: 3px;
margin-top: -5px;
padding-top: 4px; /* = border-width - margin-top */
margin-right: -8px;
padding-right: 8px; /* = -margin-right */
padding-left: @login-buttons-accounts-dialog-padding-left;
padding-bottom: 8px;
width: 167px;
}
#login-dropdown-list {
position: absolute;
right: 0px;
top: 0px;
}
#login-buttons .hline {
@@ -123,26 +131,66 @@
font-weight: bold;
}
#login-buttons label {
.accounts-dialog label {
font-weight: bold;
font-size: 80%;
}
#login-buttons .login-password-form input {
.accounts-dialog input {
width: 162px;
}
#login-buttons #login-buttons-password {
.accounts-dialog .login-button-form-submit {
margin-top: 8px;
}
#login-buttons .error-message {
color: red;
.accounts-dialog .message {
font-size: 80%;
margin-top: 2px;
}
.accounts-dialog .error-message {
color: red;
}
.accounts-dialog .info-message {
color: green;
}
#login-buttons .additional-link {
font-size: 60%;
}
#login-buttons #signup-link {
float: right;
font-size: 70%;
}
}
#login-buttons #forgot-password-link {
float: left;
}
#reset-password-form {
z-index: 1000;
position: fixed;
left: 50%;
margin-left: -(@login-buttons-accounts-dialog-width
+ @login-buttons-accounts-dialog-padding-left) / 2;
top: 50%;
margin-top: -40px; /* = approximately -height/2, though height can change */
}
.hide-background {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 999;
/* XXX consider replacing with DXImageTransform */
background-color: rgb(0.2, 0.2, 0.2); /* fallback for IE7-8 */
background-color: rgba(0, 0, 0, 0.7);
}

View File

@@ -3,10 +3,11 @@ Package.describe({
});
Package.on_use(function (api) {
api.use(['accounts', 'underscore', 'liveui', 'templating'], 'client');
api.use(['accounts-urls', 'accounts', 'underscore', 'liveui', 'templating'], 'client');
api.use('less', 'server');
api.add_files([
'login_buttons.css',
'login_buttons.less',
'login_buttons_images.css',
'login_buttons.html',
'login_buttons.js'], 'client');

View File

@@ -0,0 +1,9 @@
Package.describe({
summary: "Generate and consume reset password and validate account URLs",
internal: true
});
Package.on_use(function (api) {
api.add_files('url_client.js', 'client');
api.add_files('url_server.js', 'server');
});

View File

@@ -0,0 +1,26 @@
// reads a reset password token from the url's hash fragment, if it's there. if so
// prevent automatically logging in since it could be confusing to be logged in as user
// A while resetting password for user B
//
// reset password urls use hash fragments instead of url paths/query
// strings so that the reset password token is not sent over the wire
// on the http request
(function () {
if (!Meteor.accounts)
Meteor.accounts = {};
var match;
match = window.location.hash.match(/^\#\?reset-password\/(.*)$/);
if (match) {
Meteor.accounts._preventAutoLogin = true;
Meteor.accounts._resetPasswordToken = match[1];
window.location.hash = '';
}
match = window.location.hash.match(/^\#\?validate-user\/(.*)$/);
if (match) {
Meteor.accounts._preventAutoLogin = true;
Meteor.accounts._validateUserToken = match[1];
window.location.hash = '';
}
})();

View File

@@ -0,0 +1,13 @@
if (!Meteor.accounts)
Meteor.accounts = {};
if (!Meteor.accounts.urls)
Meteor.accounts.urls = {};
Meteor.accounts.urls.resetPassword = function (baseUrl, token) {
return baseUrl + '#?reset-password/' + token;
};
Meteor.accounts.urls.validateUser = function (baseUrl, token) {
return baseUrl + '#?validate-user/' + token;
};

View File

@@ -66,23 +66,28 @@ Meteor.loginWithToken = function (token, errorCallback) {
});
};
// Immediately try to log in via local storage, so that any DDP
// messages are sent after we have established our user account
var token = Meteor.accounts.storedLoginToken();
if (token) {
// On startup, optimistically present us as logged in while the
// request is in flight. This reduces page flicker on startup.
var userId = Meteor.accounts.storedUserId();
userId && Meteor.default_connection.setUserId(userId);
Meteor.loginWithToken(token, function () {
Meteor.accounts.makeClientLoggedOut();
});
if (!Meteor.accounts._preventAutoLogin) {
// Immediately try to log in via local storage, so that any DDP
// messages are sent after we have established our user account
var token = Meteor.accounts.storedLoginToken();
if (token) {
// On startup, optimistically present us as logged in while the
// request is in flight. This reduces page flicker on startup.
var userId = Meteor.accounts.storedUserId();
userId && Meteor.default_connection.setUserId(userId);
Meteor.loginWithToken(token, function () {
Meteor.accounts.makeClientLoggedOut();
});
}
}
// Poll local storage every 3 seconds to login if someone logged in in
// another tab
Meteor.accounts._lastLoginTokenWhenPolled = token;
setInterval(function() {
if (Meteor.accounts._preventAutoLogin)
return;
var currentLoginToken = Meteor.accounts.storedLoginToken();
// != instead of !== just to make sure undefined and null are treated the same
@@ -94,4 +99,3 @@ setInterval(function() {
}
Meteor.accounts._lastLoginTokenWhenPolled = currentLoginToken;
}, 3000);

View File

@@ -5,6 +5,7 @@ Package.describe({
Package.on_use(function (api) {
api.use('underscore', 'server');
api.use('localstorage-polyfill', 'client');
api.use('accounts-urls', 'client');
// need this because of the Meteor.users collection but in the future
// we'd probably want to abstract this away