issue #20445: actually reset the password
[xtuple] / node-datasource / routes / auth.js
1 /*jshint node:true, indent:2, curly:false, eqeqeq:true, immed:true, latedef:true, newcap:true, noarg:true,
2 regexp:true, undef:true, strict:true, trailing:true, white:true */
3 /*global X:true, _:true, SYS:true */
4
5 (function () {
6   "use strict";
7
8
9   var recoverEmailText = "Follow this secure link to reset your password: " +
10     "https://%@/%@/recover/reset/%@/%@";
11   var systemErrorMessage = "A system error occurred. I'm very sorry about this, but I can't give " +
12     "you any more details because I'm very cautious about security and this is a sensitive topic.";
13
14   /**
15     @name Auth
16     @class Auth
17     */
18   var passport = require('passport'),
19       url = require('url'),
20       setPassword = require('./change_password').setPassword,
21       utils = require('../oauth2/utils');
22
23   /**
24     Receives user authentication credentials and have passport do the authentication.
25    */
26   exports.login = [
27     //passport.authenticate('local', { successReturnToOrRedirect: '/login/scope', failureRedirect: '/', failureFlash: 'Invalid username or password.' }),
28     passport.authenticate('local', { failureRedirect: '/?login=fail' }),
29     function (req, res, next) {
30
31       if (req && req.session && !req.session.oauth2 && req.session.passport && req.session.passport.user && req.session.passport.user.organization) {
32         res.redirect("/" + req.session.passport.user.organization + '/app');
33         //next();
34       } else {
35         exports.scopeForm(req, res, next);
36       }
37     }
38   ];
39
40   /**
41     Renders the login form
42    */
43   exports.loginForm = function (req, res) {
44     var message = [];
45
46     if (req.query && req.query.login && req.query.login === 'fail') {
47       message = ["Invalid username or password."];
48     }
49
50     res.render('login', { message: message, databases: X.options.datasource.databases });
51   };
52
53   /**
54     Renders the "forgot password?" form
55   */
56   exports.forgotPasswordForm = function (req, res) {
57     res.render('forgot_password', { message: [], databases: X.options.datasource.databases });
58   };
59
60   /**
61     Create a row in the recover table, and send an email to the user with the token
62     whose hash is stored in the row, as well as the id to the row.
63    */
64   exports.recoverPassword = function (req, res) {
65     var userCollection = new SYS.UserCollection(),
66       email = req.body.email,
67       database = req.body.database,
68       errorMessage = "Cannot find email address",
69       successMessage = "An email has been sent with password recovery instructions";
70
71     if (!database || X.options.datasource.databases.indexOf(database) < 0) {
72       // don't show our hand
73       res.render('forgot_password', { message: [errorMessage], databases: X.options.datasource.databases });
74       return;
75     }
76
77     //
78     // Find a user with the inputted email address. Make sure only one result is found.
79     //
80     userCollection.fetch({
81       query: {
82         parameters: [{
83           attribute: "email",
84           value: email
85         }]
86       },
87       database: database,
88       username: X.options.databaseServer.user,
89       success: function (collection, results, options) {
90         var recoverModel = new SYS.Recover(),
91           setRecovery;
92
93         if (results.length === 0) {
94           // XXX Ben recommends we don't show our hand here.
95           res.render('forgot_password', { message: [errorMessage], databases: X.options.datasource.databases });
96           return;
97         } else if (results.length > 1) {
98           // quite a quandary
99           // errorMessage = "Wasn't expecting to see multiple users with this email address";
100           res.render('forgot_password', { message: [systemErrorMessage], databases: X.options.datasource.databases });
101           return;
102         }
103         setRecovery = function () {
104           //
105           // We've initialized our recovery model. Now set and save it.
106           //
107           var uuid = utils.generateUUID(),
108             id = recoverModel.get("id"),
109             uuidHash = X.bcrypt.hashSync(uuid, 12),
110             now = new Date(),
111             tomorrow = new Date(now.getTime() + 1000 * 60 * 60 * 24),
112             attributes = {
113               recoverUsername: results[0].username,
114               hashedToken: uuidHash,
115               accessed: false,
116               reset: false,
117               createdTimestamp: now,
118               expiresTimestamp: tomorrow
119             },
120             saveSuccess = function () {
121               //
122               // We've saved our recovery model. Now send out an email.
123               //
124               var mailContent = {
125                 from: "no-reply@xtuple.com",
126                 to: email,
127                 subject: "xTuple password reset instructions",
128                 text: recoverEmailText.f(req.headers.host, database, id, uuid)
129               };
130               // XXX: don't log this
131               console.log(mailContent);
132               X.smtpTransport.sendMail(mailContent, function (err) {
133                 //
134                 // We've sent out the email. Now return to the user
135                 //
136                 if (err) {
137                   res.render('forgot_password', { message: [systemErrorMessage],
138                     databases: X.options.datasource.databases });
139                   return;
140                 }
141                 res.render('forgot_password', { message: [successMessage],
142                   databases: X.options.datasource.databases });
143               });
144             },
145             saveError = function () {
146               res.render('forgot_password', { message: [errorMessage], databases: X.options.datasource.databases });
147             };
148
149           recoverModel.set(attributes);
150           recoverModel.save(null, {
151             database: database,
152             username: X.options.databaseServer.user,
153             success: saveSuccess,
154             error: saveError
155           });
156         };
157         recoverModel.on('change:id', setRecovery);
158         recoverModel.initialize(null, {isNew: true, database: database});
159       },
160       error: function () {
161         res.render('forgot_password', { message: [errorMessage], databases: X.options.datasource.databases });
162       }
163     });
164   };
165
166   /**
167     Validates the link that the user clicks on when they receive their email.
168     The token in the URL should hash to the hashed value in the table row
169     specified by the ID of the email. If everything checks out, forward them
170     to a screen to reset their password.
171    */
172   exports.verifyRecoverPassword = function (req, res) {
173     var error = function () {
174         res.render('forgot_password', { message: [systemErrorMessage], databases: X.options.datasource.databases });
175       },
176       recoveryModel = new SYS.Recover();
177
178     //
179     // We get the id and the unencrypted token from the URL. Make sure that the token checks out
180     // to that id in the database.
181     //
182     recoveryModel.fetch({
183       id: req.params.id,
184       database: req.params.org,
185       username: X.options.databaseServer.user,
186       success: function (model, result, options) {
187         var now = new Date();
188
189         X.bcrypt.compare(req.params.token, model.get("hashedToken"), function (err, compare) {
190           if (err ||
191               !compare ||
192               model.get("accessed") ||
193               model.get("reset") ||
194               now.getTime() > model.get("expiresTimestamp").getTime()) {
195
196             // TODO: get the paths straight
197             res.render('forgot_password', { message: [systemErrorMessage], databases: X.options.datasource.databases });
198             return;
199           }
200
201           //
202           // This is a valid recovery model. Update it as accessed, and
203           // set recovery variables in the user's session.
204           //
205           req.session.recover = {
206             id: req.params.id,
207             token: req.params.token
208           };
209           recoveryModel.set({
210             accessed: true,
211             accessedTimestamp: now,
212             expiresTimestamp: new Date(now.getTime() + 1000 * 60 * 15), // 15 minutes
213             ip: req.connection.remoteAddress
214           });
215           recoveryModel.save(null, {
216             database: req.params.org,
217             username: X.options.databaseServer.user,
218             error: error,
219             success: function (model, result, options) {
220               res.render('reset_password', {message: []});
221             }
222           });
223         });
224       },
225       error: error
226     });
227   };
228
229   /**
230     Handles the form submission to reset the password. Makes sure that the session is
231     the same one that we recently validate by re-validating. If everything checks out,
232     disable the validation row (by setting reset:true), update the user's password,
233     and redirect to the login page.
234    */
235   exports.resetRecoveredPassword = function (req, res) {
236     var error = function () {
237         res.render('forgot_password', { message: [systemErrorMessage], databases: X.options.datasource.databases });
238       },
239       recoveryModel = new SYS.Recover();
240
241     if (req.body.password !== req.body.password2) {
242       res.render('reset_password', {message: ["Passwords do not match"]});
243       return;
244     }
245     //
246     // We get the id and the unencrypted token from the session.
247     // Make sure that the token checks out to that id in the database.
248     //
249     recoveryModel.fetch({
250       id: req.session.recover.id,
251       database: req.params.org,
252       username: X.options.databaseServer.user,
253       success: function (model, result, options) {
254         var now = new Date();
255
256         X.bcrypt.compare(req.session.recover.token, model.get("hashedToken"), function (err, compare) {
257           if (err ||
258               !compare ||
259               !model.get("accessed") ||
260               model.get("reset") ||
261               model.get("ip") !== req.connection.remoteAddress ||
262               now.getTime() > model.get("expiresTimestamp").getTime()) {
263
264             // TODO: get the paths straight
265             res.render('forgot_password', { message: [systemErrorMessage], databases: X.options.datasource.databases });
266             return;
267           }
268
269           //
270           // This is a valid recovery model. Update it as reset.
271           //
272           recoveryModel.set({
273             reset: true,
274             resetTimestamp: now
275           });
276           recoveryModel.save(null, {
277             database: req.params.org,
278             username: X.options.databaseServer.user,
279             error: error,
280             success: function (model, result, options) {
281               var userModel = new SYS.User();
282               userModel.fetch({
283                 id: model.get("recoverUsername"),
284                 database: req.params.org,
285                 username: X.options.databaseServer.user,
286                 error: error,
287                 success: function (model, result, options) {
288                   //
289                   // NOW we update the user's password
290                   //
291                   setPassword(recoveryModel.get("recoverUsername"),
292                     req.body.password,
293                     req.params.org,
294                     model.get("useEnhancedAuth"),
295                     function (err) {
296                       if (err) {
297                         error();
298                         return;
299                       }
300                       //
301                       // The password has been updated. Redirect the user to the login screen.
302                       //
303                       // TODO: get the path right
304                       res.render('login', {
305                         message: ["Your password has been updated. Please log in."],
306                         databases: X.options.datasource.databases
307                       });
308                     }
309                   );
310                 }
311               });
312             }
313           });
314         });
315       }
316     });
317   };
318
319   /**
320     Logs out user by removing the session and sending the user to the login screen.
321    */
322   exports.logout = function (req, res) {
323     if (req.session.passport) {
324       // Make extra sure passport is empty.
325       req.session.passport = null;
326     }
327
328     if (req.session) {
329       // Kill the whole session, db, cache and all.
330       req.session.destroy(function () {});
331     }
332
333     if (req.path.split("/")[1]) {
334       res.clearCookie(req.path.split("/")[1] + ".sid");
335     }
336
337     req.logout();
338     res.redirect('/');
339   };
340
341   /**
342     Receives a request telling us which organization a user has selected
343     to log into. Note that we don't trust the client; we check
344     to make sure that the user actually belongs to that organization.
345    */
346   exports.scope = function (req, res, next) {
347     var userId = req.session.passport.user.id,
348       selectedOrg = req.body.org,
349       user = new SYS.User(),
350       options = {};
351
352     options.success = function (response) {
353       var privs,
354           userOrg,
355           userName;
356
357       if (response.length === 0) {
358         if (req.session && req.session.oauth2 && req.session.oauth2.redirectURI) {
359           X.log("OAuth 2.0 User %@ has no business trying to log in to organization %@.".f(userId, selectedOrg));
360           res.redirect(req.session.oauth2.redirectURI + '?error=access_denied');
361           return;
362         }
363
364         X.log("User %@ has no business trying to log in to organization %@.".f(userId, selectedOrg));
365         res.redirect('/' + selectedOrg + '/logout');
366         return;
367       } else if (response.length > 1) {
368         X.log("More than one User: %@ exists.".f(userId));
369         res.redirect('/' + selectedOrg + '/logout');
370         return;
371       }
372
373       // We can now trust this user's request to log in to this organization.
374
375       // Update the session store row to add the org choice and username.
376       // Note: Updating this object magically persists the data into the SessionStore table.
377
378       //privs = _.map(response.get("privileges"), function (privAss) {
379       //  return privAss.privilege.name;
380       //});
381
382       //_.each(response.get('organizations'), function (orgValue, orgKey, orgList) {
383       //  if (orgValue.name === selectedOrg) {
384       //    userOrg = orgValue.name;
385       //    userName = orgValue.username;
386       //  }
387       //});
388
389       //if (!userOrg || !userName) {
390       if (!response.get("username")) {
391         // This shouldn't happen.
392         X.log("User %@ has no business trying to log in to organization %@.".f(userId, selectedOrg));
393         res.redirect('/' + selectedOrg + '/logout');
394         return;
395       }
396
397       //req.session.passport.user.globalPrivileges = privs;
398       req.session.passport.user.organization = response.get("organization");
399       req.session.passport.user.username = response.get("username");
400
401 // TODO - req.oauth probably isn't enough here, but it's working 2013-03-15...
402       // If this is an OAuth 2.0 login with only 1 org.
403       if (req.oauth2) {
404         return next();
405       }
406
407       // If this is an OAuth 2.0 login with more than 1 org.
408       if (req.session.returnTo) {
409         res.redirect(req.session.returnTo);
410       } else {
411         // Redirect to start loading the client app.
412         res.redirect('/' + selectedOrg + '/app');
413       }
414     };
415
416     options.error = function (model, error) {
417       X.log("userorg fetch error", error);
418       res.redirect('/' + selectedOrg + '/logout');
419       return;
420     };
421
422
423     // The user id we're searching for.
424     options.id = userId;
425
426     // The user under whose authority the query is run.
427     options.username = X.options.databaseServer.user;
428     options.database = selectedOrg;
429
430     // Verify that the org is valid for the user.
431     user.fetch(options);
432   };
433
434   /**
435     Loads the form to let the user choose their organization. If there's only one
436     organization for the user we choose for them.
437    */
438   exports.scopeForm = function (req, res, next) {
439     var organizations = [],
440         scope,
441         scopes = [];
442
443     try {
444       organizations = _.map(req.user.get("organizations"), function (org) {
445         return org.name;
446       });
447     } catch (error) {
448       // Prevent unauthorized access.
449       res.redirect('/');
450       return;
451     }
452
453     // If this is an OAuth 2.0 login req, try and get the org from the requested scope.
454     if (req.session && req.session.oauth2) {
455
456       if (req.session.oauth2.req && req.session.oauth2.req.scope && req.session.oauth2.req.scope.length > 0) {
457         // Loop through the scope URIs and convert them to org names.
458         _.each(req.session.oauth2.req.scope, function (value, key, list) {
459           var org;
460
461           // Get the org from the scope URI e.g. 'dev' from: 'https://mobile.xtuple.com/auth/dev'
462           scope = url.parse(value, true);
463           org = scope.path.split("/")[1] || null;
464
465           // TODO - Still need more work to support userinfo calls.
466           // See node-datasource/oauth2/oauth2.js authorization.
467
468           // The scope 'https://mobile.xtuple.com/auth/userinfo.xxx' can be used to make userinfo
469           // REST calls and is not a valid org scope, we'll skip it here.
470           if (org && org.indexOf('userinfo') === -1) {
471             scopes[key] = org;
472           }
473         });
474
475         // If we only have one scope/org sent, choose it for this request.
476         if (scopes.length === 1) {
477           req.body.org = scopes[0];
478           exports.scope(req, res, next);
479           return;
480         }
481
482         // TODO - Multiple scopes sent.
483         // Do we want to let them select an org or respond with error and scopeList?
484         // It depends on the scenario. Some support user interaction and can select an org, others
485         // do not and should get an error.
486
487       }
488
489       // TODO - No scope is sent.
490       // Do we want to let them select an org or respond with error and scopeList?
491       // It depends on the scenario. Some support user interaction and can select an org, others
492       // do not and should get an error.
493
494     }
495
496     // Below will handle OAuth "TODO - Multiple scopes sent", "TODO - No scope is sent." above for now.
497
498     // Choose an org automatically if there's only one for this user.
499     if (organizations.length === 1) {
500       req.body.org = organizations[0];
501       exports.scope(req, res, next);
502       return;
503     }
504
505     // Some users may not have any orgs. They should not get this far.
506     if (organizations.length === 0) {
507       X.err("User: %@ shall not pass, they have no orgs to select.".f(req.session.passport.user.id));
508       req.flash('orgerror', 'You have not been assigned to any organizations.');
509     }
510
511     // We've got nothing, let the user choose their scope/org.
512     res.render('scope', { organizations: organizations.sort(), message: req.flash('orgerror') });
513   };
514 }());