1 module vibeauth.router.oauth;
2 
3 import vibe.http.router;
4 import vibe.data.json;
5 import vibe.inet.url;
6 
7 import std.algorithm, std.base64, std..string, std.stdio, std.conv, std.array;
8 import std.datetime;
9 
10 import vibeauth.users;
11 import vibeauth.router.baseAuthRouter;
12 import vibeauth.client;
13 import vibeauth.collection;
14 
15 import vibeauth.router.accesscontrol;
16 
17 /// OAuth2 comfiguration
18 struct OAuth2Configuration {
19   /// Route for generating tokens
20   string tokenPath = "/auth/token";
21 
22   /// Route for authorization
23   string authorizePath = "/auth/authorize";
24 
25   /// Route for authentication
26   string authenticatePath = "/auth/authenticate";
27 
28   /// Route for revoking tokens
29   string revokePath = "/auth/revoke";
30 
31   /// Custom style to be embeded into the html
32   string style;
33 }
34 
35 /// Struct used for user authentication
36 struct AuthData {
37   ///
38   string username;
39   ///
40   string password;
41   ///
42   string refreshToken;
43   /// The authorization scopes
44   string[] scopes;
45 }
46 
47 ///
48 interface IGrantAccess {
49   /// setter for the authentication data
50   void authData(AuthData authData);
51 
52   /// setter for the user collection
53   void userCollection(UserCollection userCollection);
54 
55   /// validate the auth data
56   bool isValid();
57 
58   /// get a Json response
59   Json get();
60 }
61 
62 /// Handle errors during token generation
63 final class UnknownGrantAccess : IGrantAccess {
64   /// Ignores the auth data
65   void authData(AuthData) {}
66 
67   /// Ignore the user collection
68   void userCollection(UserCollection) {};
69 
70   /// All the requests are invalid
71   bool isValid() {
72     return false;
73   }
74 
75   /// Get an error Json response
76   Json get() {
77     auto response = Json.emptyObject;
78     response["error"] = "Invalid `grant_type` value";
79 
80     return response;
81   }
82 }
83 
84 /// Grant user access based on username and password strings
85 final class PasswordGrantAccess : IGrantAccess {
86   private {
87     AuthData data;
88     UserCollection collection;
89   }
90 
91   /// setter for the authentication data
92   void authData(AuthData authData) {
93     this.data = authData;
94   }
95 
96   /// setter for the user collection
97   void userCollection(UserCollection userCollection) {
98     this.collection = userCollection;
99   }
100 
101   /// validate the authentication data
102   bool isValid() {
103     if(!collection.contains(data.username)) {
104       return false;
105     }
106 
107     if(!collection[data.username].isValidPassword(data.password)) {
108       return false;
109     }
110 
111     return true;
112   }
113 
114   /// Get the token Json response object
115   Json get() {
116     auto response = Json.emptyObject;
117 
118     if(!isValid) {
119       response["error"] = "Invalid password or username";
120       return response;
121     }
122 
123     auto accessToken = collection.createToken(data.username, Clock.currTime + 3601.seconds, data.scopes, "Bearer");
124     auto refreshToken = collection.createToken(data.username, Clock.currTime + 30.weeks, data.scopes ~ [ "refresh" ], "Refresh");
125 
126     response["access_token"] = accessToken.name;
127     response["expires_in"] = (accessToken.expire - Clock.currTime).total!"seconds";
128     response["token_type"] = accessToken.type;
129     response["refresh_token"] = refreshToken.name;
130 
131     return response;
132   }
133 }
134 
135 /// Grant user access based on a refresh token
136 final class RefreshTokenGrantAccess : IGrantAccess {
137   private {
138     AuthData data;
139     UserCollection collection;
140     User user;
141   }
142 
143   /// setter for the authentication data
144   void authData(AuthData authData) {
145     this.data = authData;
146     cacheData;
147   }
148 
149   /// setter for the user collection
150   void userCollection(UserCollection userCollection) {
151     this.collection = userCollection;
152     cacheData;
153   }
154 
155   private void cacheData() {
156     if(collection is null || data.refreshToken == "") {
157       return;
158     }
159 
160     user = collection.byToken(data.refreshToken);
161     data.scopes = user.getScopes(data.refreshToken).filter!(a => a != "refresh").array;
162   }
163 
164   /// Validate the refresh token
165   bool isValid() {
166     if(data.refreshToken == "") {
167       return false;
168     }
169 
170     return user.isValidToken(data.refreshToken, "refresh");
171   }
172 
173   /// Get the token Json response object
174   Json get() {
175     auto response = Json.emptyObject;
176 
177     if(!isValid) {
178       response["error"] = "Invalid `refresh_token`";
179       return response;
180     }
181 
182     auto username = user.email();
183 
184     auto accessToken = collection.createToken(username, Clock.currTime + 3601.seconds, data.scopes, "Bearer");
185 
186     response["access_token"] = accessToken.name;
187     response["expires_in"] = (accessToken.expire - Clock.currTime).total!"seconds";
188     response["token_type"] = accessToken.type;
189 
190     return response;
191   }
192 }
193 
194 /// Get the right access generator
195 IGrantAccess getAuthData(HTTPServerRequest req) {
196   AuthData data;
197 
198   if("refresh_token" in req.form) {
199     data.refreshToken = req.form["refresh_token"];
200   }
201 
202   if("username" in req.form) {
203     data.username = req.form["username"];
204   }
205 
206   if("password" in req.form) {
207     data.password = req.form["password"];
208   }
209 
210   if("scope" in req.form) {
211     data.scopes = req.form["scope"].split(" ");
212   }
213 
214   if("grant_type" in req.form) {
215     if(req.form["grant_type"] == "password") {
216       auto grant = new PasswordGrantAccess;
217       grant.authData = data;
218 
219       return grant;
220     }
221 
222     if(req.form["grant_type"] == "refresh_token") {
223       auto grant = new RefreshTokenGrantAccess;
224       grant.authData = data;
225 
226       return grant;
227     }
228   }
229 
230   return new UnknownGrantAccess;
231 }
232 
233 /// OAuth2 autenticator
234 class OAuth2: BaseAuthRouter {
235   protected {
236     const OAuth2Configuration configuration;
237     ClientCollection clientCollection;
238   }
239 
240   ///
241   this(UserCollection userCollection, ClientCollection clientCollection, const OAuth2Configuration configuration = OAuth2Configuration()) {
242     super(userCollection);
243 
244     this.configuration = configuration;
245     this.clientCollection = clientCollection;
246   }
247 
248 
249   /// Handle the OAuth requests. Handles token creation, authorization
250   /// authentication and revocation
251   void tokenHandlers(HTTPServerRequest req, HTTPServerResponse res) {
252 
253     try {
254       setAccessControl(res);
255       if(req.method == HTTPMethod.OPTIONS) {
256         return;
257       }
258 
259       if(req.path == configuration.tokenPath) {
260         createToken(req, res);
261       }
262 
263       if (req.path == configuration.authorizePath) {
264         authorize(req, res);
265       }
266 
267       if(req.path == configuration.authenticatePath) {
268         authenticate(req, res);
269       }
270 
271       if(req.path == configuration.revokePath) {
272         revoke(req, res);
273       }
274     } catch(Exception e) {
275       version(unittest) {} else debug stderr.writeln(e);
276 
277       if(!res.headerWritten) {
278         res.writeJsonBody([ "error": e.msg ], 500);
279       }
280     }
281   }
282 
283   override {
284     /// Auth handler that will fail if a successfull auth was not performed.
285     /// This handler is usefull for routes that want to hide information to the
286     /// public.
287     void mandatoryAuth(HTTPServerRequest req, HTTPServerResponse res) {
288       cleanRequest(req);
289 
290       try {
291         setAccessControl(res);
292         if(req.method == HTTPMethod.OPTIONS) {
293           return;
294         }
295 
296         if(!res.headerWritten && req.path != configuration.style && !isValidBearer(req)) {
297           respondUnauthorized(res);
298         }
299       } catch(Exception e) {
300         version(unittest) {} else debug stderr.writeln(e);
301 
302         if(!res.headerWritten) {
303           res.writeJsonBody([ "error": e.msg ], 400);
304         }
305       }
306     }
307 
308     /// Auth handler that fails only if the auth fields are present and are not valid.
309     /// This handler is usefull when a route should return different data when the user is
310     /// logged in
311     void permisiveAuth(HTTPServerRequest req, HTTPServerResponse res) {
312       cleanRequest(req);
313 
314       if("Authorization" !in req.headers) {
315         return;
316       }
317 
318       mandatoryAuth(req, res);
319     }
320   }
321 
322   private {
323     /// Remove all dangerous fields from the request
324     void cleanRequest(HTTPServerRequest req) {
325       req.username = "";
326       req.password = "";
327       if("email" in req.context) {
328         req.context.remove("email");
329       }
330     }
331 
332     /// Validate the authorization token
333     bool isValidBearer(HTTPServerRequest req) {
334       auto pauth = "Authorization" in req.headers;
335 
336       if(pauth && (*pauth).startsWith("Bearer ")) {
337         auto token = (*pauth)[7 .. $];
338 
339         try {
340           auto const user = collection.byToken(token);
341           req.username = user.id;
342           req.context["email"] = user.email;
343 
344         } catch(UserNotFoundException exception) {
345           return false;
346         }
347 
348         return true;
349       }
350 
351       return false;
352     }
353 
354     /// Handle the authorization step
355     void authorize(HTTPServerRequest req, HTTPServerResponse res) {
356       if("redirect_uri" !in req.query) {
357         showError(res, "Missing `redirect_uri` parameter");
358         return;
359       }
360 
361       if("client_id" !in req.query) {
362         showError(res, "Missing `client_id` parameter");
363         return;
364       }
365 
366       if("state" !in req.query) {
367         showError(res, "Missing `state` parameter");
368         return;
369       }
370 
371       auto const redirectUri = req.query["redirect_uri"];
372       auto const clientId = req.query["client_id"];
373       auto const state = req.query["state"];
374       auto const style = configuration.style;
375 
376       if(clientId !in clientCollection) {
377         showError(res, "Invalid `client_id` parameter");
378         return;
379       }
380 
381       string appTitle = clientCollection[clientId].name;
382 
383       res.render!("loginForm.dt", appTitle, redirectUri, state, style);
384     }
385 
386 
387     /// Show an HTML error
388     void showError(HTTPServerResponse res, const string error) {
389       auto const style = configuration.style;
390       res.statusCode = 400;
391       res.render!("error.dt", error, style);
392     }
393 
394     void authenticate(HTTPServerRequest req, HTTPServerResponse res) {
395       string email;
396       string password;
397 
398       try {
399         email = req.form["email"];
400         password = req.form["password"];
401       } catch (Exception e) {
402         debug showError(res, e.to!string);
403         return;
404       }
405 
406       if(!collection.contains(email) || !collection[email].isValidPassword(password)) {
407         showError(res, "Invalid email or password.");
408         return;
409       }
410 
411       auto token = collection[email].createToken(Clock.currTime + 3601.seconds);
412       auto redirectUri = req.form["redirect_uri"] ~ "#access_token=" ~ token.name ~ "&state=" ~ req.form["state"];
413 
414       res.render!("redirect.dt", redirectUri);
415     }
416 
417     /// Create token for the requested user
418     void createToken(HTTPServerRequest req, HTTPServerResponse res) {
419       auto grant = req.getAuthData;
420 
421       grant.userCollection = collection;
422       res.statusCode = grant.isValid ? 200 : 401;
423       res.writeJsonBody(grant.get);
424     }
425 
426     /// Revoke a previously created token using a POST request
427     void revoke(HTTPServerRequest req, HTTPServerResponse res) {
428       if(req.method != HTTPMethod.POST) {
429         return;
430       }
431 
432       if("token" !in req.form) {
433         res.statusCode = 400;
434         res.writeJsonBody([ "error": "You must provide a `token` parameter." ]);
435 
436         return;
437       }
438 
439       auto const token = req.form["token"];
440       collection.revoke(token);
441 
442       res.statusCode = 200;
443       res.writeBody("");
444     }
445 
446 
447     /// Write the unauthorized message to the server response
448     void respondUnauthorized(HTTPServerResponse res, string message = "Authorization required") {
449       res.statusCode = HTTPStatus.unauthorized;
450       res.writeJsonBody([ "error": message ]);
451     }
452   }
453 }
454 
455 version(unittest) {
456   import fluentasserts.vibe.request;
457   import fluentasserts.vibe.json;
458   import fluent.asserts;
459   import vibeauth.token;
460 
461   UserMemmoryCollection collection;
462   User user;
463   Client client;
464   ClientCollection clientCollection;
465   OAuth2 auth;
466   Token refreshToken;
467   Token bearerToken;
468 
469   auto testRouter(bool requireLogin = true) {
470     auto router = new URLRouter();
471 
472     collection = new UserMemmoryCollection(["doStuff"]);
473     user = new User("user@gmail.com", "password");
474     user.name = "John Doe";
475     user.username = "test";
476     user.id = 1;
477 
478     collection.add(user);
479 
480     refreshToken = collection.createToken("user@gmail.com", Clock.currTime + 3600.seconds, ["doStuff", "refresh"], "Refresh");
481     bearerToken = collection.createToken("user@gmail.com", Clock.currTime + 3600.seconds, ["doStuff"], "Bearer");
482 
483     auto client = new Client();
484     client.id = "CLIENT_ID";
485 
486     clientCollection = new ClientCollection([ client ]);
487 
488     auth = new OAuth2(collection, clientCollection);
489 
490     router.any("*", &auth.tokenHandlers);
491 
492     if(requireLogin) {
493       router.any("*", &auth.mandatoryAuth);
494     } else {
495       router.any("*", &auth.permisiveAuth);
496     }
497 
498 
499     void handleRequest(HTTPServerRequest req, HTTPServerResponse res) {
500       res.statusCode = 200;
501       res.writeBody("Hello, World!");
502     }
503 
504     void showEmail(HTTPServerRequest req, HTTPServerResponse res) {
505       res.statusCode = 200;
506       res.writeBody(req.context["email"].get!string);
507     }
508 
509     router.get("/sites", &handleRequest);
510     router.get("/email", &showEmail);
511 
512     return router;
513   }
514 }
515 
516 /// it should return 401 on missing auth
517 unittest {
518   testRouter.request.get("/sites").expectStatusCode(401).end();
519 }
520 
521 /// it should return 200 on valid credentials
522 unittest {
523   auto router = testRouter;
524 
525   router
526     .request.get("/sites")
527     .header("Authorization", "Bearer " ~ bearerToken.name)
528     .expectStatusCode(200)
529     .end;
530 }
531 
532 /// it should set the email on valid mandatory credentials
533 unittest {
534   auto router = testRouter;
535 
536   router
537     .request.get("/email")
538     .header("Authorization", "Bearer " ~ bearerToken.name)
539     .expectStatusCode(200)
540     .end((Response response) => {
541       response.bodyString.should.equal("user@gmail.com");
542     });
543 }
544 
545 /// it should return 200 on missing auth when it's not mandatory
546 unittest {
547   auto router = testRouter(false);
548 
549   router
550     .request.get("/sites")
551     .expectStatusCode(200)
552     .end;
553 }
554 
555 /// it should clear the username and email when auth it's not mandatory
556 unittest {
557   auto router = testRouter(false);
558 
559   void setUser(HTTPServerRequest req, HTTPServerResponse res) {
560     req.username = "some user";
561     req.password = "some password";
562     req.context["email"] = "some random value";
563   }
564 
565   void showAuth(HTTPServerRequest req, HTTPServerResponse res) {
566     res.statusCode = 200;
567     string hasEmail = "email" in req.context ? "yes" : "no";
568     res.writeBody(req.username ~ ":" ~ req.password ~ ":" ~ hasEmail);
569   }
570 
571   router.any("*", &setUser);
572   router.any("*", &auth.permisiveAuth);
573   router.get("/misc", &showAuth);
574 
575   router
576     .request.get("/misc")
577     .expectStatusCode(200)
578     .end((Response response) => {
579       response.bodyString.should.equal("::no");
580     });
581 }
582 
583 /// it should return 200 on valid auth when it's not mandatory
584 unittest {
585   auto router = testRouter(false);
586 
587   router
588     .request.get("/sites")
589     .header("Authorization", "Bearer " ~ bearerToken.name)
590     .expectStatusCode(200)
591     .end;
592 }
593 
594 
595 /// it should set the email on valid credentials when they are not mandatory
596 unittest {
597   auto router = testRouter(false);
598 
599   router
600     .request.get("/email")
601     .header("Authorization", "Bearer " ~ bearerToken.name)
602     .expectStatusCode(200)
603     .end((Response response) => {
604       response.bodyString.should.equal("user@gmail.com");
605     });
606 }
607 
608 /// it should return 401 on invalid auth when it's not mandatory
609 unittest {
610   auto router = testRouter(false);
611 
612   router
613     .request.get("/sites")
614     .header("Authorization", "Bearer invalid")
615     .expectStatusCode(401)
616     .end;
617 }
618 
619 /// it should return 401 on invalid credentials
620 unittest {
621   testRouter
622     .request.post("/auth/token")
623     .send(["grant_type": "password", "username": "invalid", "password": "invalid"])
624     .expectStatusCode(401)
625     .end((Response response) => {
626       response.bodyJson.should.equal(`{ "error": "Invalid password or username" }`.parseJsonString);
627     });
628 }
629 
630 
631 /// it should return tokens on valid email and password
632 unittest {
633   testRouter
634     .request
635     .post("/auth/token")
636     .send(["grant_type": "password", "username": "user@gmail.com", "password": "password"])
637     .expectStatusCode(200)
638     .end((Response response) => {
639       response.bodyJson.keys.should.contain(["access_token", "expires_in", "refresh_token", "token_type"]);
640 
641       user.isValidToken(response.bodyJson["access_token"].to!string).should.be.equal(true);
642       user.isValidToken(response.bodyJson["refresh_token"].to!string).should.be.equal(true);
643 
644       response.bodyJson["token_type"].to!string.should.equal("Bearer");
645       response.bodyJson["expires_in"].to!int.should.equal(3600);
646     });
647 }
648 
649 /// it should return tokens on valid username and password
650 unittest {
651   testRouter
652     .request
653     .post("/auth/token")
654     .send(["grant_type": "password", "username": "test", "password": "password"])
655     .expectStatusCode(200)
656     .end((Response response) => {
657       response.bodyJson.keys.should.contain(["access_token", "expires_in", "refresh_token", "token_type"]);
658 
659       user.isValidToken(response.bodyJson["access_token"].to!string).should.be.equal(true);
660       user.isValidToken(response.bodyJson["refresh_token"].to!string).should.be.equal(true);
661 
662       response.bodyJson["token_type"].to!string.should.equal("Bearer");
663       response.bodyJson["expires_in"].to!int.should.equal(3600);
664     });
665 }
666 
667 /// it should set the scope tokens on valid credentials
668 unittest {
669   testRouter
670     .request
671     .post("/auth/token")
672     .send(["grant_type": "password", "username": "user@gmail.com", "password": "password", "scope": "access1 access2"])
673     .expectStatusCode(200)
674     .end((Response response) => {
675       user.isValidToken(response.bodyJson["refresh_token"].to!string, "refresh").should.equal(true);
676       user.isValidToken(response.bodyJson["refresh_token"].to!string, "other").should.equal(false);
677 
678       user.isValidToken(response.bodyJson["access_token"].to!string, "access1").should.equal(true);
679       user.isValidToken(response.bodyJson["access_token"].to!string, "access2").should.equal(true);
680       user.isValidToken(response.bodyJson["access_token"].to!string, "other").should.equal(false);
681     });
682 }
683 
684 /// it should return a new access token on refresh token
685 unittest {
686   auto router = testRouter;
687 
688   router
689     .request
690     .post("/auth/token")
691     .send(["grant_type": "refresh_token", "refresh_token": refreshToken.name ])
692     .expectStatusCode(200)
693     .end((Response response) => {
694       response.bodyJson.keys.should.contain(["access_token", "expires_in", "token_type"]);
695 
696       user.isValidToken(response.bodyJson["access_token"].to!string).should.be.equal(true);
697       user.isValidToken(response.bodyJson["access_token"].to!string, "doStuff").should.be.equal(true);
698       user.isValidToken(response.bodyJson["access_token"].to!string, "refresh").should.be.equal(false);
699 
700       response.bodyJson["token_type"].to!string.should.equal("Bearer");
701       response.bodyJson["expires_in"].to!int.should.equal(3600);
702     });
703 }
704 
705 /// it should be able to not block the requests without login
706 unittest {
707   auto router = testRouter(false);
708 
709   router
710     .request
711     .get("/path")
712     .expectStatusCode(404)
713     .end();
714 }
715 
716 /// it should return 404 for GET on revocation path
717 unittest {
718   auto router = testRouter(false);
719 
720   router
721     .request
722     .get("/auth/revoke")
723     .expectStatusCode(404)
724     .end();
725 }
726 
727 /// it should return 400 for POST on revocation path with missing token
728 unittest {
729   auto router = testRouter(false);
730 
731   router
732     .request
733     .post("/auth/revoke")
734     .expectStatusCode(400)
735     .end((Response response) => {
736       response.bodyJson.should.equal("{
737         \"error\": \"You must provide a `token` parameter.\"
738       }".parseJsonString);
739     });
740 }