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 }