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(