diff --git a/.idea/angel_auth.iml b/.idea/angel_auth.iml index 085f4ea4..02bd9dfb 100644 --- a/.idea/angel_auth.iml +++ b/.idea/angel_auth.iml @@ -13,7 +13,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 485caabb..f8b1b222 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,43 @@ # angel_auth -[![version 1.0.4+1](https://img.shields.io/badge/version-1.0.4+1-brightgreen.svg)](https://pub.dartlang.org/packages/angel_auth) +[![version 1.0.5](https://img.shields.io/badge/version-1.0.5-brightgreen.svg)](https://pub.dartlang.org/packages/angel_auth) ![build status](https://travis-ci.org/angel-dart/auth.svg?branch=master) A complete authentication plugin for Angel. Inspired by Passport. -# Documentation +# Wiki [Click here](https://github.com/angel-dart/auth/wiki). -# Supported Strategies +# Bundled Strategies * Local (with and without Basic Auth) +* Find other strategies (Twitter, Google, OAuth2, etc.) on Pub!!! + +# Example +Ensure you have read the [wiki](https://github.com/angel-dart/auth/wiki). + +```dart +configureServer(Angel app) async { + var auth = new AngelAuth(); + auth.serializer = ...; + auth.deserializer = ...; + auth.strategies.add(new LocalAuthStrategy(...)); + + // POST route to handle username+password + app.post('/local', auth.authenticate('local')); + + // Use a comma to try multiple strategies!!! + // + // Each strategy is run sequentially. If one succeeds, the loop ends. + // Authentication failures will just cause the loop to continue. + // + // If the last strategy throws an authentication failure, then + // a `401 Not Authenticated` is thrown. + var chainedHandler = auth.authenticate( + 'basic,facebook', + authOptions + ); +} +``` # Default Authentication Callback A frequent use case within SPA's is opening OAuth login endpoints in a separate window. @@ -18,8 +46,26 @@ provides a facility for this, which works perfectly with the default callback pr in this package. ```dart -auth.authenticate('facebook', new AngelAuthOptions(callback: confirmPopupAuthentication())); +configureServer(Angel app) async { + var handler = auth.authenticate( + 'facebook', + new AngelAuthOptions(callback: confirmPopupAuthentication())); + app.get('/auth/facebook', handler); + + // Use a comma to try multiple strategies!!! + // + // Each strategy is run sequentially. If one succeeds, the loop ends. + // Authentication failures will just cause the loop to continue. + // + // If the last strategy throws an authentication failure, then + // a `401 Not Authenticated` is thrown. + var chainedHandler = auth.authenticate( + 'basic,facebook', + authOptions + ); +} ``` + This renders a simple HTML page that fires the user's JWT as a `token` event in `window.opener`. `angel_client` [exposes this as a Stream](https://github.com/angel-dart/client#authentication): diff --git a/lib/src/defs.dart b/lib/src/defs.dart index 9d046397..3b246922 100644 --- a/lib/src/defs.dart +++ b/lib/src/defs.dart @@ -1,7 +1,7 @@ import 'dart:async'; /// Serializes a user to the session. -typedef Future UserSerializer(user); +typedef FutureOr UserSerializer(T user); /// Deserializes a user from the session. -typedef Future UserDeserializer(userId); \ No newline at end of file +typedef FutureOr UserDeserializer(userId); \ No newline at end of file diff --git a/lib/src/plugin.dart b/lib/src/plugin.dart index c0790170..579dede3 100644 --- a/lib/src/plugin.dart +++ b/lib/src/plugin.dart @@ -9,21 +9,50 @@ import 'defs.dart'; import 'options.dart'; import 'strategy.dart'; -class AngelAuth extends AngelPlugin { +/// Handles authentication within an Angel application. +class AngelAuth extends AngelPlugin { Hmac _hs256; num _jwtLifeSpan; + final StreamController _onLogin = new StreamController(), + _onLogout = new StreamController(); Math.Random _random = new Math.Random.secure(); final RegExp _rgxBearer = new RegExp(r"^Bearer"); - final bool allowCookie; - final bool allowTokenInQuery; - String middlewareName; - bool debug; - bool enforceIp; - String reviveTokenEndpoint; - List strategies = []; - UserSerializer serializer; - UserDeserializer deserializer; + /// If `true` (default), then JWT's will be stored and retrieved from a `token` cookie. + final bool allowCookie; + + /// If `true` (default), then users can include a JWT in the query string as `token`. + final bool allowTokenInQuery; + + /// The name to register [requireAuth] as. Default: `auth`. + String middlewareName; + + bool debug; + + /// If `true` (default), then JWT's will be considered invalid if used from a different IP than the first user's it was issued to. + /// + /// This is a security provision. Even if a user's JWT is stolen, a remote attacker will not be able to impersonate anyone. + bool enforceIp; + + /// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`. + String reviveTokenEndpoint; + + /// A set of [AuthStrategy] instances used to authenticate users. + List strategies = []; + + /// Serializes a user into a unique identifier associated only with one identity. + UserSerializer serializer; + + /// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance. + UserDeserializer deserializer; + + /// Fires the result of [deserializer] whenever a user signs in to the application. + Stream get onLogin => _onLogin.stream; + + /// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application. + Stream get onLogout => _onLogout.stream; + + /// The [Hmac] being used to encode JWT's. Hmac get hmac => _hs256; String _randomString( @@ -31,9 +60,7 @@ class AngelAuth extends AngelPlugin { String validChars: "ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"}) { var chars = []; - while (chars.length < length) chars.add(_random.nextInt(validChars.length)); - return new String.fromCharCodes(chars); } @@ -53,6 +80,13 @@ class AngelAuth extends AngelPlugin { @override call(Angel app) async { + if (serializer == null) + throw new StateError( + 'An `AngelAuth` plug-in was called without its `serializer` being set. All authentication will fail.'); + if (deserializer == null) + throw new StateError( + 'An `AngelAuth` plug-in was called without its `deserializer` being set. All authentication will fail.'); + app.container.singleton(this); if (runtimeType != AngelAuth) app.container.singleton(this, as: AngelAuth); @@ -62,6 +96,10 @@ class AngelAuth extends AngelPlugin { if (reviveTokenEndpoint != null) { app.post(reviveTokenEndpoint, reviveJwt); } + + app.justBeforeStop.add((_) { + _onLogin.close(); + }); } void _apply(RequestContext req, AuthToken token, user) { @@ -70,6 +108,7 @@ class AngelAuth extends AngelPlugin { ..inject(user.runtimeType, req.properties["user"] = user); } + /// A middleware that decodes a JWT from a request, and injects a corresponding user. decodeJwt(RequestContext req, ResponseContext res) async { if (req.method == "POST" && req.path == reviveTokenEndpoint) { // Shouldn't block invalid JWT if we are reviving it @@ -129,6 +168,7 @@ class AngelAuth extends AngelPlugin { return true; } + /// Retrieves a JWT from a request, if any was sent at all. getJwt(RequestContext req) { if (debug) { print('Attempting to parse JWT'); @@ -155,6 +195,7 @@ class AngelAuth extends AngelPlugin { return null; } + /// Attempts to revive an expired (or still alive) JWT. reviveJwt(RequestContext req, ResponseContext res) async { try { if (debug) print('Attempting to revive JWT...'); @@ -228,68 +269,85 @@ class AngelAuth extends AngelPlugin { } } + /// Attempts to authenticate a user using one or more strategies. + /// + /// [type] is a comma-separated list of strategy names to try. + /// + /// If a strategy returns `null` or `false`, either the next one is tried, + /// or a `401 Not Authenticated` is thrown, if it is the last one. + /// + /// Any other result is considered an authenticated user, and terminates the loop. authenticate(String type, [AngelAuthOptions options]) { return (RequestContext req, ResponseContext res) async { - AuthStrategy strategy = - strategies.firstWhere((AuthStrategy x) => x.name == type); - var result = await strategy.authenticate(req, res, options); - if (result == true) - return result; - else if (result != false) { - var userId = await serializer(result); + var names = type + .split(',') + .map((s) => s.trim()) + .where((String s) => s.isNotEmpty) + .toList(); - // Create JWT - var token = new AuthToken( - userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); - var jwt = token.serialize(_hs256); + for (int i = 0; i < names.length; i++) { + var name = names[i]; - if (options?.tokenCallback != null) { - var r = await options.tokenCallback( - req, res, token, req.properties["user"] = result); - if (r != null) return r; + AuthStrategy strategy = + strategies.firstWhere((AuthStrategy x) => x.name == name); + var result = await strategy.authenticate(req, res, options); + if (result == true) + return result; + else if (result != false) { + var userId = await serializer(result); + + // Create JWT + var token = new AuthToken( + userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); + var jwt = token.serialize(_hs256); + + if (options?.tokenCallback != null) { + var r = await options.tokenCallback( + req, res, token, req.properties["user"] = result); + if (r != null) return r; + } + + _apply(req, token, result); + + if (allowCookie) res.cookies.add(new Cookie("token", jwt)); + + if (options?.callback != null) { + return await options.callback(req, res, jwt); + } + + if (options?.successRedirect?.isNotEmpty == true) { + res.redirect(options.successRedirect, code: HttpStatus.OK); + return false; + } else if (options?.canRespondWithJson != false && + req.headers.value("accept") != null && + (req.headers.value("accept").contains("application/json") || + req.headers.value("accept").contains("*/*") || + req.headers.value("accept").contains("application/*"))) { + var user = await deserializer(await serializer(result)); + _onLogin.add(user); + return {"data": user, "token": jwt}; + } + + return true; + } else { + if (i < names.length - 1) continue; + // Check if not redirect + if (res.statusCode == 301 || + res.statusCode == 302 || + res.headers.containsKey(HttpHeaders.LOCATION)) + return false; + else + throw new AngelHttpException.notAuthenticated(); } - - _apply(req, token, result); - - if (allowCookie) res.cookies.add(new Cookie("token", jwt)); - - if (options?.callback != null) { - return await options.callback(req, res, jwt); - } - - if (options?.successRedirect?.isNotEmpty == true) { - res.redirect(options.successRedirect, code: HttpStatus.OK); - return false; - } else if (options?.canRespondWithJson != false && - req.headers.value("accept") != null && - (req.headers.value("accept").contains("application/json") || - req.headers.value("accept").contains("*/*") || - req.headers.value("accept").contains("application/*"))) { - var user = await deserializer(await serializer(result)); - return {"data": user, "token": jwt}; - } - - return true; - } else { - // Check if not redirect - if (res.statusCode == 301 || - res.statusCode == 302 || - res.headers.containsKey(HttpHeaders.LOCATION)) - return false; - else - await authenticationFailure(req, res); } }; } - Future authenticationFailure(RequestContext req, ResponseContext res) async { - throw new AngelHttpException.notAuthenticated(); - } - /// Log a user in on-demand. Future login(AuthToken token, RequestContext req, ResponseContext res) async { var user = await deserializer(token.userId); _apply(req, token, user); + _onLogin.add(user); if (allowCookie) res.cookies.add(new Cookie('token', token.serialize(_hs256))); @@ -301,11 +359,13 @@ class AngelAuth extends AngelPlugin { var token = new AuthToken( userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); _apply(req, token, user); + _onLogin.add(user); if (allowCookie) res.cookies.add(new Cookie('token', token.serialize(_hs256))); } + /// Log an authenticated user out. logout([AngelAuthOptions options]) { return (RequestContext req, ResponseContext res) async { for (AuthStrategy strategy in strategies) { @@ -320,7 +380,14 @@ class AngelAuth extends AngelPlugin { } } - res.cookies.removeWhere((cookie) => cookie.name == "token"); + var user = req.grab('user'); + if (user != null) _onLogout.add(user); + + req.injections..remove(AuthToken)..remove('user'); + req.properties.remove('user'); + + if (allowCookie == true) + res.cookies.removeWhere((cookie) => cookie.name == "token"); if (options != null && options.successRedirect != null && diff --git a/pubspec.yaml b/pubspec.yaml index 389fc8a5..42f728cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: angel_auth description: A complete authentication plugin for Angel. -version: 1.0.4+1 +version: 1.0.5 author: Tobe O homepage: https://github.com/angel-dart/angel_auth environment: diff --git a/test/callback_test.dart b/test/callback_test.dart index 0c24cc40..ac405427 100644 --- a/test/callback_test.dart +++ b/test/callback_test.dart @@ -26,11 +26,12 @@ main() { .service('users') .create({'username': 'jdoe1', 'password': 'password'}); - await app.configure(auth = new AngelAuth()); - + auth = new AngelAuth(); auth.serializer = (User user) async => user.id; auth.deserializer = app.service('users').read; + await app.configure(auth); + auth.strategies.add(new LocalAuthStrategy((username, password) async { final List users = await app.service('users').index(); final found = users.firstWhere(