1 /++
2   A module that handles the user login. It binds the routes, renders the templates and
3   updates the collections.
4 
5   Copyright: © 2018 Szabo Bogdan
6   License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
7   Authors: Szabo Bogdan
8 +/
9 module vibeauth.router.login.routes;
10 
11 import vibe.http.router;
12 import vibe.data.json;
13 import vibe.inet.url;
14 
15 import std.algorithm, std.base64, std..string, std.stdio, std.conv, std.array;
16 import std.datetime, std.random, std.uri, std.file;
17 
18 import vibe.core.core;
19 
20 import vibeauth.users;
21 import vibeauth.client;
22 import vibeauth.collection;
23 import vibeauth.configuration;
24 import vibeauth.router.accesscontrol;
25 import vibeauth.router.baseAuthRouter;
26 import vibeauth.router.login.responses;
27 import vibeauth.router.request;
28 import vibeauth.mail.base;
29 
30 class LoginRoutes {
31 
32   private {
33     const ServiceConfiguration configuration;
34     UserCollection userCollection;
35     IMailQueue mailQueue;
36     LoginResponses responses;
37   }
38 
39   this(UserCollection userCollection, IMailQueue mailQueue,
40       const ServiceConfiguration configuration = ServiceConfiguration.init) {
41 
42     this.configuration = configuration;
43 
44     this.userCollection = userCollection;
45     this.mailQueue = mailQueue;
46 
47     this.responses = new LoginResponses(configuration);
48   }
49 
50   void handler(HTTPServerRequest req, HTTPServerResponse res) {
51     try {
52       if(req.method == HTTPMethod.GET && req.path == configuration.paths.login.form) {
53         responses.loginForm(req, res);
54       }
55 
56       if(req.method == HTTPMethod.GET && req.path == configuration.paths.login.resetForm) {
57         responses.resetForm(req, res);
58       }
59 
60       if(req.method == HTTPMethod.POST && req.path == configuration.paths.login.login) {
61         loginCheck(req, res);
62       }
63 
64       if(req.method == HTTPMethod.POST && req.path == configuration.paths.login.reset) {
65         reset(req, res);
66       }
67 
68       if(req.method == HTTPMethod.POST && req.path == configuration.paths.login.changePassword) {
69         changePassword(req, res);
70       }
71     } catch(Exception e) {
72       version(unittest) {} else debug stderr.writeln(e);
73 
74       if(!res.headerWritten) {
75         res.writeJsonBody([ "error": ["message": e.msg] ], 500);
76       }
77     }
78   }
79 
80   private auto resetPasswordVariables() {
81     string[string] variables;
82 
83     variables["reset"] = configuration.paths.login.resetForm;
84     variables["location"] = configuration.paths.location;
85     variables["serviceName"] = configuration.name;
86 
87     return variables;
88   }
89 
90   void reset(HTTPServerRequest req, HTTPServerResponse res) {
91     auto requestData = const RequestUserData(req);
92 
93     const string message = `If your email address exists in our database, you will ` ~
94       `receive a password recovery link at your email address in a few minutes.`;
95 
96     auto expire = Clock.currTime + 15.minutes;
97     auto token = userCollection.createToken(requestData.username, expire, [], "passwordReset");
98 
99     auto user = userCollection[requestData.username];
100 
101     auto variables = resetPasswordVariables;
102     variables["user.name"] = user.name;
103 
104     mailQueue.addResetPasswordMessage(user.email, token, variables);
105 
106     res.redirect(configuration.paths.login.form ~ "?username=" ~ requestData.username.encodeComponent ~
107       "&message=" ~ message.encodeComponent);
108   }
109 
110   ///
111   void changePassword(HTTPServerRequest req, HTTPServerResponse res) {
112     auto requestData = const RequestUserData(req);
113     auto user = userCollection[requestData.email];
114 
115     if(requestData.passwordConfirm != requestData.password) {
116       string message = "Your password confirmation doesn't match password";
117 
118       res.redirect(configuration.paths.login.resetForm ~ "?email=" ~ requestData.email.encodeComponent ~
119         "&token=" ~ requestData.token.encodeComponent ~
120         "&message=" ~ message.encodeComponent);
121       return;
122     }
123 
124     if(requestData.password.length < 10) {
125       string message = "Your password should have at least 10 characters";
126 
127       res.redirect(configuration.paths.login.resetForm ~ "?email=" ~ requestData.email.encodeComponent ~
128         "&token=" ~ requestData.token.encodeComponent ~
129         "&message=" ~ message.encodeComponent);
130       return;
131     }
132 
133     if(!user.getTokensByType("passwordReset").map!(a => a.name).canFind(requestData.token)) {
134       sleep(uniform(0, 1000).msecs);
135       string message = "Invalid reset password token";
136 
137       res.redirect(configuration.paths.login.form ~ "?message=" ~ message.encodeComponent);
138 
139       return;
140     }
141 
142     user
143       .getTokensByType("passwordReset")
144       .map!(a => a.name)
145       .each!(a => user.revoke(a));
146 
147     string message = "Your password has been changed successfully.";
148     userCollection[requestData.email].setPassword(requestData.password);
149 
150     auto variables = resetPasswordVariables;
151     variables["user.name"] = user.name;
152 
153     mailQueue.addResetPasswordConfirmationMessage(requestData.email, variables);
154 
155     res.redirect(configuration.paths.login.form ~ "?username=" ~ requestData.email.encodeComponent ~
156       "&message=" ~ message.encodeComponent);
157   }
158 
159   void loginCheck(HTTPServerRequest req, HTTPServerResponse res) {
160     auto requestData = const RequestUserData(req);
161 
162     if(!userCollection.contains(requestData.username)) {
163       sleep(uniform(0, 500).msecs);
164       res.redirect(configuration.paths.login.form ~ queryUserData(requestData, "Invalid username or password"));
165       return;
166     }
167 
168     if(!userCollection[requestData.username].isActive) {
169       sleep(uniform(0, 500).msecs);
170       res.redirect(configuration.paths.login.form ~ queryUserData(requestData, "Please confirm your account before you log in"));
171       return;
172     }
173 
174     if(!userCollection[requestData.username].isValidPassword(requestData.password)) {
175       sleep(uniform(0, 500).msecs);
176       res.redirect(configuration.paths.login.form ~ queryUserData(requestData, "Invalid username or password"));
177       return;
178     }
179 
180     auto scopes = userCollection[requestData.username].getScopes;
181     auto expiration = Clock.currTime + configuration.loginTimeoutSeconds.seconds;
182 
183     auto token = userCollection[requestData.username].createToken(expiration, scopes, "webLogin");
184 
185     res.setCookie("auth-token", token.name);
186     res.cookies["auth-token"].maxAge = configuration.loginTimeoutSeconds;
187 
188     res.redirect(configuration.paths.login.redirect);
189   }
190 
191   private string queryUserData(const RequestUserData data, const string error = "") {
192     return "?username=" ~ data.username.encodeComponent ~ (error != "" ? "&error=" ~ error.encodeComponent : "");
193   }
194 }
195 
196 version(unittest) {
197   import fluentasserts.vibe.request;
198   import fluentasserts.vibe.json;
199   import fluent.asserts;
200   import vibeauth.token;
201 
202   UserMemmoryCollection collection;
203   User user;
204   Client client;
205   ClientCollection clientCollection;
206   Token refreshToken;
207   TestMailQueue mailQueue;
208 
209   alias MailMessage = vibeauth.mail.base.Message;
210 
211   class TestMailQueue : MailQueue
212   {
213     MailMessage[] messages;
214 
215     this() {
216       super(EmailConfiguration());
217     }
218 
219     override
220     void addMessage(MailMessage message) {
221       messages ~= message;
222     }
223   }
224 
225   auto testRouter() {
226     auto router = new URLRouter();
227 
228     collection = new UserMemmoryCollection(["doStuff"]);
229     user = new User("user@gmail.com", "password");
230     user.name = "John Doe";
231     user.username = "test";
232     user.id = 1;
233     user.isActive = true;
234 
235     collection.add(user);
236 
237     refreshToken = collection.createToken("user@gmail.com", Clock.currTime + 3600.seconds, ["doStuff", "refresh"], "Refresh");
238 
239     mailQueue = new TestMailQueue;
240     auto auth = new LoginRoutes(collection, mailQueue);
241 
242     router.any("*", &auth.handler);
243 
244     return router;
245   }
246 }
247 
248 @("Login with valid username and password should redirect to root page")
249 unittest {
250   testRouter
251     .request.post("/login/check")
252     .send(["username": "test", "password": "password"])
253     .expectStatusCode(302)
254     .expectHeader("Location", "/")
255     .expectHeaderContains("Set-Cookie", "auth-token=")
256     .end((Response res) => {
257       res.headers["Set-Cookie"].should.contain(user.getTokensByType("webLogin").front.name);
258     });
259 }
260 
261 @("Login with valid email and password should redirect to root page")
262 unittest {
263   testRouter
264     .request.post("/login/check")
265     .send(["username": "user@gmail.com", "password": "password"])
266     .expectStatusCode(302)
267     .expectHeader("Location", "/")
268     .end();
269 }
270 
271 @("Login with invalid username should redirect to login page")
272 unittest {
273   testRouter
274     .request.post("/login/check")
275     .send(["username": "invalid", "password": "password"])
276     .expectStatusCode(302)
277     .expectHeader("Location", "/login?username=invalid&error=Invalid%20username%20or%20password")
278     .end();
279 }
280 
281 @("Login with inactive user")
282 unittest {
283   auto router = testRouter;
284 
285   user.isActive = false;
286 
287   router
288     .request.post("/login/check")
289     .send(["username": "test", "password": "password"])
290     .expectStatusCode(302)
291     .expectHeader("Location", "/login?username=test&error=Please%20confirm%20your%20account%20before%20you%20log%20in")
292     .end();
293 }
294 
295 @("Login with invalid password should redirect to login page")
296 unittest {
297   testRouter
298     .request.post("/login/check")
299     .send(["username": "test", "password": "invalid"])
300     .expectStatusCode(302)
301     .expectHeader("Location", "/login?username=test&error=Invalid%20username%20or%20password")
302     .end();
303 }
304 
305 @("Reset password form should send an email to existing user")
306 unittest {
307   string expectedMessage = `If your email address exists in our database, you ` ~
308   `will receive a password recovery link at your email address in a few minutes.`;
309 
310   testRouter
311     .request.post("/login/reset/send")
312     .send(["username": "user@gmail.com"])
313     .expectStatusCode(302)
314     .expectHeader("Location", "/login?username=user%40gmail.com&message=" ~ expectedMessage.encodeComponent)
315     .end((Response res) => {
316       string resetLink = "http://localhost/login/reset?email=user@gmail.com&token="
317         ~ collection["user@gmail.com"].getTokensByType("passwordReset").front.name;
318 
319       mailQueue.messages.length.should.equal(1);
320       mailQueue.messages[0].textMessage.should.contain(resetLink);
321       mailQueue.messages[0].htmlMessage.should.contain(`<a href="` ~ resetLink ~ `">`);
322     });
323 }
324 
325 @("Change password route should set a new password")
326 unittest {
327   string expectedMessage = "Your password has been changed successfully.";
328   auto router = testRouter;
329   auto token = collection.createToken(user.email, Clock.currTime + 10.seconds, [], "passwordReset");
330 
331   router
332     .request.post("/login/reset/change?email=user%40gmail.com&token=" ~ token.name)
333     .send(["password": "MyNewPassword", "passwordConfirm": "MyNewPassword"])
334     .expectStatusCode(302)
335     .expectHeader("Location", "/login?username=user%40gmail.com&message=" ~ expectedMessage.encodeComponent)
336     .end((Response res) => {
337       collection["user@gmail.com"].isValidPassword("MyNewPassword").should.equal(true);
338 
339       mailQueue.messages.length.should.equal(1);
340       mailQueue.messages[0].textMessage.should.contain("has successfully been changed");
341       mailQueue.messages[0].htmlMessage.should.contain("has successfully been changed");
342 
343       user.isValidToken(token.name).should.equal(false);
344     });
345 }
346 
347 
348 @("Change password route should set a new password when there is a old reset password token")
349 unittest {
350   string expectedMessage = "Your password has been changed successfully.";
351   auto router = testRouter;
352   collection.createToken(user.email, Clock.currTime - 10.seconds, [], "passwordReset");
353   auto token = collection.createToken(user.email, Clock.currTime + 10.seconds, [], "passwordReset");
354 
355   router
356     .request.post("/login/reset/change?email=user%40gmail.com&token=" ~ token.name)
357     .send(["password": "MyNewPassword", "passwordConfirm": "MyNewPassword"])
358     .expectStatusCode(302)
359     .expectHeader("Location", "/login?username=user%40gmail.com&message=" ~ expectedMessage.encodeComponent)
360     .end((Response res) => {
361       collection["user@gmail.com"].isValidPassword("MyNewPassword").should.equal(true);
362 
363       mailQueue.messages.length.should.equal(1);
364       mailQueue.messages[0].textMessage.should.contain("has successfully been changed");
365       mailQueue.messages[0].htmlMessage.should.contain("has successfully been changed");
366 
367       user.isValidToken(token.name).should.equal(false);
368     });
369 }
370 
371 @("Change password route should return to form on password missmatch")
372 unittest {
373   string expectedMessage = "Your password confirmation doesn't match password";
374   auto router = testRouter;
375   auto token = collection.createToken(user.email, Clock.currTime + 10.seconds, [], "passwordReset");
376 
377   router
378     .request.post("/login/reset/change?email=user%40gmail.com&token=" ~ token.name)
379     .send(["password": "MyNewPassword", "passwordConfirm": "password"])
380     .expectStatusCode(302)
381     .expectHeader("Location", "/login/reset?email=user%40gmail.com&token=" ~ token.name ~ "&message=" ~ expectedMessage.encodeComponent)
382     .end((Response res) => {
383       mailQueue.messages.length.should.equal(0);
384     });
385 }
386 
387 @("Change password route should return to form on short password")
388 unittest {
389   string expectedMessage = "Your password should have at least 10 characters";
390   auto router = testRouter;
391 
392   auto token = collection.createToken(user.email, Clock.currTime + 10.seconds, [], "passwordReset");
393 
394   router
395     .request.post("/login/reset/change?email=user%40gmail.com&token=" ~ token.name)
396     .send(["password": "123", "passwordConfirm": "123"])
397     .expectStatusCode(302)
398     .expectHeader("Location", "/login/reset?email=user%40gmail.com&token=" ~ token.name ~ "&message=" ~ expectedMessage.encodeComponent)
399     .end((Response res) => {
400       mailQueue.messages.length.should.equal(0);
401     });
402 }
403 
404 @("Change password should redirect to login on invalid token")
405 unittest {
406   string expectedMessage = "Invalid reset password token";
407   auto router = testRouter;
408   collection.createToken(user.email, Clock.currTime + 10.seconds, [], "passwordReset");
409 
410   router
411     .request.post("/login/reset/change?email=user%40gmail.com&token=invalid")
412     .send(["password": "MyNewPassword", "passwordConfirm": "MyNewPassword"])
413     .expectStatusCode(302)
414     .expectHeader("Location", "/login?message=" ~ expectedMessage.encodeComponent)
415     .end((Response res) => {
416       mailQueue.messages.length.should.equal(0);
417     });
418 }