1 /++ 2 A module containing the user handling logic 3 4 Copyright: © 2018 Szabo Bogdan 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Szabo Bogdan 7 +/ 8 module vibeauth.users; 9 10 import vibe.data.json; 11 12 import std.stdio; 13 import std.algorithm.searching; 14 import std.algorithm.iteration; 15 import std.exception; 16 import std.uuid; 17 import std.conv; 18 import std.datetime; 19 import std.array; 20 21 import vibeauth.collection; 22 import vibeauth.token; 23 24 version(unittest) import fluent.asserts; 25 26 /// Exception thrown when an user does not exist 27 alias UserNotFoundException = ItemNotFoundException; 28 29 /// Exception thrown when an access level does not exist 30 class UserAccesNotFoundException : Exception { 31 32 /// Create the exception 33 this(string msg = null, Throwable next = null) { super(msg, next); } 34 35 /// dutto 36 this(string msg, string file, size_t line, Throwable next = null) { 37 super(msg, file, line, next); 38 } 39 } 40 41 /// User data used to manage an user 42 struct UserData { 43 /// The user id 44 string _id; 45 46 /// 47 string name; 48 49 /// 50 string username; 51 52 /// 53 string email; 54 55 /// 56 string password; 57 58 /// 59 string salt; 60 61 /// Flag used to determine if the user can perform any actions 62 bool isActive; 63 64 /// Scopes that the user has access to 65 string[] scopes; 66 67 /// A list of active tokens 68 Token[] tokens; 69 } 70 71 /// Class used to manage one user 72 class User { 73 74 /// Event type raised when the user data has changed 75 alias ChangedEvent = void delegate(User); 76 77 /// Event raised when the user changed 78 ChangedEvent onChange; 79 80 private { 81 UserData userData; 82 } 83 84 /// 85 this() { } 86 87 /// 88 this(UserData userData) { 89 this.userData = userData; 90 } 91 92 /// 93 this(string email, string password) { 94 this.userData.email = email; 95 setPassword(password); 96 } 97 98 /// Convert the user object ot a Json pretty string 99 override string toString() { 100 return toJson.toPrettyString; 101 } 102 103 @property { 104 /// Get the user id 105 auto id() const { 106 return userData._id; 107 } 108 109 /// Set the user id 110 void id(ulong value) { 111 userData._id = value.to!string; 112 113 if(onChange) { 114 onChange(this); 115 } 116 } 117 118 /// Check if the user is active 119 bool isActive() const { 120 return userData.isActive; 121 } 122 123 /// Check the user active status 124 void isActive(bool value) { 125 userData.isActive = value; 126 127 if(onChange) { 128 onChange(this); 129 } 130 } 131 132 /// Get the user email 133 string email() const { 134 return userData.email; 135 } 136 137 /// Set the user email 138 void email(string value) { 139 userData.email = value; 140 141 if(onChange) { 142 onChange(this); 143 } 144 } 145 146 /// Get the user real name 147 auto name() const { 148 return userData.name; 149 } 150 151 /// Set the user real name 152 void name(string value) { 153 userData.name = value; 154 155 if(onChange) { 156 onChange(this); 157 } 158 } 159 160 /// Get the user alias name 161 auto username() const { 162 return userData.username; 163 } 164 165 /// Set the user alias name 166 void username(string value) { 167 userData.username = value; 168 169 if(onChange) { 170 onChange(this); 171 } 172 } 173 } 174 175 /// Revoke a token 176 void revoke(string token) { 177 userData.tokens = userData.tokens.filter!(a => a.name != token).array; 178 } 179 180 const { 181 /// Get the user scopes assigned to a particullar token 182 string[] getScopes(string token) { 183 return userData.tokens.filter!(a => a.name == token).front.scopes.to!(string[]); 184 } 185 186 /// Get all user scopes 187 string[] getScopes() { 188 return userData.scopes.dup; 189 } 190 191 /// Check if an user can access a scope 192 bool can(string access)() { 193 return userData.scopes.canFind(access); 194 } 195 196 /// Get a range of tokens of a certain type 197 auto getTokensByType(string type) { 198 return userData.tokens.filter!(a => a.type == type); 199 } 200 201 /// Validate a password 202 bool isValidPassword(string password) { 203 return sha1UUID(userData.salt ~ "." ~ password).to!string == userData.password; 204 } 205 206 /// Validate a token 207 bool isValidToken(string token) { 208 return userData.tokens.map!(a => a.name).canFind(token); 209 } 210 211 /// Validate a token against a scope 212 bool isValidToken(string token, string requiredScope) { 213 return userData.tokens.filter!(a => a.scopes.canFind(requiredScope)).map!(a => a.name).canFind(token); 214 } 215 } 216 217 /// Change the user password 218 void setPassword(string password) { 219 userData.salt = randomUUID.to!string; 220 userData.password = sha1UUID(userData.salt ~ "." ~ password).to!string; 221 222 if(onChange) { 223 onChange(this); 224 } 225 } 226 227 /// Change the user password by providing a salting string 228 void setPassword(string password, string salt) { 229 userData.salt = salt; 230 userData.password = password; 231 232 if(onChange) { 233 onChange(this); 234 } 235 } 236 237 /// Add a scope to the user 238 void addScope(string access) { 239 userData.scopes ~= access; 240 241 if(onChange) { 242 onChange(this); 243 } 244 } 245 246 /// Remove a scope from user 247 void removeScope(string access) { 248 userData.scopes = userData.scopes 249 .filter!(a => a != access).array; 250 251 if(onChange) { 252 onChange(this); 253 } 254 } 255 256 /// Create an user token 257 Token createToken(SysTime expire, string[] scopes = [], string type = "Bearer") { 258 auto token = Token(randomUUID.to!string, expire, scopes, type); 259 userData.tokens ~= token; 260 261 if(onChange) { 262 onChange(this); 263 } 264 265 return token; 266 } 267 268 /// Convert the object to a json. It's not safe to share this value 269 /// with the outside world. Use it to store the user to db. 270 Json toJson() const { 271 return userData.serializeToJson; 272 } 273 274 /// Convert the object to a json that can be shared with the outside world 275 Json toPublicJson() const { 276 Json data = Json.emptyObject; 277 278 data["id"] = id; 279 data["name"] = name; 280 data["username"] = username; 281 data["email"] = email; 282 data["scopes"] = Json.emptyArray; 283 284 foreach(s; userData.scopes) { 285 data["scopes"] ~= s; 286 } 287 288 return data; 289 } 290 291 /// Restore the user from a json value 292 static User fromJson(Json data) { 293 return new User(data.deserializeJson!UserData); 294 } 295 } 296 297 /// Password validation 298 unittest { 299 auto user = new User("user", "password"); 300 auto password = user.toJson["password"].to!string; 301 auto salt = user.toJson["salt"].to!string; 302 303 assert(password == sha1UUID(salt ~ ".password").to!string, "It should salt the password"); 304 assert(user.isValidPassword("password"), "It should return true for a valid password"); 305 assert(!user.isValidPassword("other passowrd"), "It should return false for an invalid password"); 306 } 307 308 309 /// Converting a user to a public json 310 unittest { 311 auto user = new User("user", "password"); 312 auto json = user.toPublicJson; 313 314 assert("id" in json, "It should contain the id"); 315 assert("name" in json, "It should contain the name"); 316 assert("username" in json, "It should contain the username"); 317 assert("email" in json, "It should contain the email"); 318 assert("password" !in json, "It should not contain the password"); 319 assert("salt" !in json, "It should not contain the salt"); 320 assert("scopes" in json, "It should contain the scope"); 321 assert("tokens" !in json, "It should not contain the tokens"); 322 } 323 324 325 /// User serialization 326 unittest { 327 auto user = new User("user", "password"); 328 auto json = user.toJson; 329 330 assert("_id" in json, "It should contain the id"); 331 assert("email" in json, "It should contain the email"); 332 assert("password" in json, "It should contain the password"); 333 assert("salt" in json, "It should contain the salt"); 334 assert("scopes" in json, "It should contain the scope"); 335 assert("tokens" in json, "It should contain the tokens"); 336 } 337 338 /// User data deserialization 339 unittest { 340 auto json = `{ 341 "_id": "1", 342 "name": "name", 343 "username": "username", 344 "email": "test@asd.asd", 345 "password": "password", 346 "salt": "salt", 347 "isActive": true, 348 "scopes": ["scopes"], 349 "tokens": [ { "name": "token", "expire": "2100-01-01T00:00:00", "scopes": [], "type": "Bearer" }], 350 }`.parseJsonString; 351 352 353 auto user = User.fromJson(json); 354 auto juser = user.toJson; 355 356 assert(user.id == "1", "It should deserialize the id"); 357 assert(user.name == "name", "It should deserialize the name"); 358 assert(user.username == "username", "It should deserialize the username"); 359 assert(user.email == "test@asd.asd", "It should deserialize the email"); 360 assert(juser["password"] == "password", "It should deserialize the password"); 361 assert(juser["salt"] == "salt", "It should deserialize the salt"); 362 assert(juser["isActive"] == true, "It should deserialize the isActive field"); 363 assert(juser["scopes"][0] == "scopes", "It should deserialize the scope"); 364 assert(juser["tokens"][0]["name"] == "token", "It should deserialize the tokens"); 365 } 366 367 /// Change event 368 unittest { 369 auto user = new User(); 370 auto changed = false; 371 372 void userChanged(User u) { 373 changed = true; 374 } 375 376 user.onChange = &userChanged; 377 378 user.id = 1; 379 assert(changed, "onChange should be called when the id is changed"); 380 381 changed = false; 382 user.email = "email"; 383 assert(changed, "onChange should be called when the email is changed"); 384 385 changed = false; 386 user.setPassword("password"); 387 assert(changed, "onChange should be called when the password is changed"); 388 389 changed = false; 390 user.setPassword("password", "salt"); 391 assert(changed, "onChange should be called when the password is changed"); 392 393 changed = false; 394 user.createToken(Clock.currTime + 3600.seconds); 395 assert(changed, "onChange should be called when a token is created"); 396 } 397 398 /// Collection used to manage user objects 399 abstract class UserCollection : Collection!User { 400 /// 401 alias opBinaryRight = Collection!User.opBinaryRight; 402 403 /// 404 alias opIndex = Collection!User.opIndex; 405 406 /// Initialize the collection 407 this(User[] list = []) { 408 super(list); 409 } 410 411 abstract { 412 /// Create a new user data from some user data 413 bool createUser(UserData data, string password); 414 415 /// Create a token for an user 416 Token createToken(string email, SysTime expire, string[] scopes = [], string type = "Bearer"); 417 418 /// Revoke a token 419 void revoke(string token); 420 421 /// Empower an user with some scope access 422 void empower(string email, string access); 423 424 /// Get an user by an existing token 425 User byToken(string token); 426 427 /// Get an user by id 428 User byId(string id); 429 430 /// Check if the collection has an user by email 431 bool contains(string email); 432 } 433 } 434 435 /// Create an user collection stored in memmory 436 class UserMemmoryCollection : UserCollection { 437 private { 438 long index = 0; 439 immutable(string[]) accessList; 440 } 441 442 /// 443 this(immutable(string[]) accessList, User[] list = []) { 444 this.accessList = accessList; 445 super(list); 446 } 447 448 override { 449 /// Create a new user data from some user data 450 bool createUser(UserData data, string password) { 451 auto user = new User(data); 452 user.setPassword(password); 453 454 list ~= user; 455 456 return true; 457 } 458 459 /// Get an user by email or username 460 User opIndex(string identification) { 461 auto result = list.find!(a => a.email == identification || a.username == identification); 462 463 enforce!UserNotFoundException(result.count > 0, "User not found"); 464 465 return result[0]; 466 } 467 468 /// Create a token for an user 469 Token createToken(string email, SysTime expire, string[] scopes = [], string type = "Bearer") { 470 return opIndex(email).createToken(expire, scopes, type); 471 } 472 473 /// Revoke a token 474 void revoke(string token) { 475 byToken(token).revoke(token); 476 } 477 478 /// Empower an user with some scope access 479 void empower(string email, string access) { 480 auto user = this[email]; 481 482 enforce!UserAccesNotFoundException(accessList.canFind(access), "`" ~ access ~ "` it's not in the list"); 483 484 user.addScope(access); 485 } 486 487 /// Get an user by an existing token 488 User byToken(string token) { 489 auto result = list.find!(a => a.isValidToken(token)); 490 491 enforce!UserNotFoundException(!result.empty, "User not found"); 492 493 return result.front; 494 } 495 496 /// Get an user by id 497 User byId(string id) { 498 auto result = list.find!(a => a.id == id); 499 500 enforce!UserNotFoundException(!result.empty, "User not found"); 501 502 return result.front; 503 } 504 505 /// Check if the collection has an user by email or username 506 bool contains(string identification) { 507 return !list.filter!(a => a.email == identification || a.username == identification).empty; 508 } 509 } 510 } 511 512 /// Throw exceptions on selecting invalid users 513 unittest { 514 auto collection = new UserMemmoryCollection([]); 515 auto user = new User("user", "password"); 516 517 collection.add(user); 518 assert(collection["user"] == user, "It should return user by name"); 519 assert(collection.contains("user"), "It should find user by name"); 520 assert(!collection.contains("other user"), "It should not find user by name"); 521 522 ({ 523 collection["other user"]; 524 }).should.throwAnyException; 525 } 526 527 /// User access 528 unittest { 529 auto collection = new UserMemmoryCollection(["doStuff"]); 530 auto user = new User("user", "password"); 531 user.id = 1; 532 533 auto otherUser = new User("otherUser", "password"); 534 otherUser.id = 2; 535 536 collection.add(user); 537 collection.add(otherUser); 538 collection.empower("user", "doStuff"); 539 540 assert(user.can!"doStuff", "It should return true if the user can `doStuff`"); 541 assert(!otherUser.can!"doStuff", "It should return false if the user can not `doStuff`"); 542 } 543 544 /// Searching for a missing token 545 unittest { 546 auto collection = new UserMemmoryCollection([]); 547 auto user = new User("user", "password"); 548 549 collection.add(user); 550 auto token = user.createToken(Clock.currTime + 3600.seconds); 551 552 collection.byToken(token.name).name.should.equal(user.name).because("It should find user by token"); 553 554 ({ 555 collection.byToken("token"); 556 }).should.throwAnyException; 557 } 558 559 /// Token revoke 560 unittest { 561 auto collection = new UserMemmoryCollection([]); 562 auto user = new User("user", "password"); 563 564 collection.add(user); 565 auto token = user.createToken(Clock.currTime + 3600.seconds); 566 567 assert(collection.byToken(token.name) == user, "It should find user by token"); 568 569 collection.revoke(token.name); 570 571 ({ 572 collection.byToken(token.name); 573 }).should.throwAnyException; 574 } 575 576 /// Get tokens by type 577 unittest { 578 auto collection = new UserMemmoryCollection([]); 579 auto user = new User("user", "password"); 580 581 collection.add(user); 582 auto token = user.createToken(Clock.currTime + 3600.seconds, [], "activation").name; 583 auto tokens = collection["user"].getTokensByType("activation").map!(a => a.name).array; 584 585 tokens.length.should.equal(1); 586 tokens.should.contain(token); 587 } 588 589 /// Get user by id 590 unittest { 591 auto collection = new UserMemmoryCollection([]); 592 auto user = new User("user", "password"); 593 user.id = 1; 594 595 collection.add(user); 596 auto result = collection.byId("1"); 597 598 result.id.should.equal("1"); 599 } 600 601 /// Remove user by id 602 unittest { 603 bool wasRemoved; 604 605 void onRemove(User user) { 606 wasRemoved = user.id == "1"; 607 } 608 609 auto collection = new UserMemmoryCollection([]); 610 collection.onRemove = &onRemove; 611 612 auto user = new User("user", "password"); 613 user.id = 1; 614 615 collection.add(user); 616 collection.remove("1"); 617 618 collection.length.should.equal(0); 619 wasRemoved.should.equal(true); 620 }