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 }