This commit is contained in:
thosakwe 2017-06-03 17:39:55 -04:00
parent b00329f632
commit 71a6479b41
6 changed files with 185 additions and 72 deletions

View file

@ -13,7 +13,6 @@
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" /> <orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Dart SDK" level="application" />
<orderEntry type="library" name="Dart Packages" level="project" /> <orderEntry type="library" name="Dart Packages" level="project" />
</component> </component>
</module> </module>

View file

@ -1,15 +1,43 @@
# angel_auth # 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) ![build status](https://travis-ci.org/angel-dart/auth.svg?branch=master)
A complete authentication plugin for Angel. Inspired by Passport. A complete authentication plugin for Angel. Inspired by Passport.
# Documentation # Wiki
[Click here](https://github.com/angel-dart/auth/wiki). [Click here](https://github.com/angel-dart/auth/wiki).
# Supported Strategies # Bundled Strategies
* Local (with and without Basic Auth) * 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 # Default Authentication Callback
A frequent use case within SPA's is opening OAuth login endpoints in a separate window. 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. in this package.
```dart ```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`. 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): `angel_client` [exposes this as a Stream](https://github.com/angel-dart/client#authentication):

View file

@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
/// Serializes a user to the session. /// Serializes a user to the session.
typedef Future UserSerializer(user); typedef FutureOr UserSerializer<T>(T user);
/// Deserializes a user from the session. /// Deserializes a user from the session.
typedef Future UserDeserializer(userId); typedef FutureOr<T> UserDeserializer<T>(userId);

View file

@ -9,21 +9,50 @@ import 'defs.dart';
import 'options.dart'; import 'options.dart';
import 'strategy.dart'; import 'strategy.dart';
class AngelAuth extends AngelPlugin { /// Handles authentication within an Angel application.
class AngelAuth<T> extends AngelPlugin {
Hmac _hs256; Hmac _hs256;
num _jwtLifeSpan; num _jwtLifeSpan;
final StreamController<T> _onLogin = new StreamController<T>(),
_onLogout = new StreamController<T>();
Math.Random _random = new Math.Random.secure(); Math.Random _random = new Math.Random.secure();
final RegExp _rgxBearer = new RegExp(r"^Bearer"); final RegExp _rgxBearer = new RegExp(r"^Bearer");
final bool allowCookie;
final bool allowTokenInQuery;
String middlewareName;
bool debug;
bool enforceIp;
String reviveTokenEndpoint;
List<AuthStrategy> 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<AuthStrategy> strategies = [];
/// Serializes a user into a unique identifier associated only with one identity.
UserSerializer<T> serializer;
/// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance.
UserDeserializer<T> deserializer;
/// Fires the result of [deserializer] whenever a user signs in to the application.
Stream<T> get onLogin => _onLogin.stream;
/// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application.
Stream<T> get onLogout => _onLogout.stream;
/// The [Hmac] being used to encode JWT's.
Hmac get hmac => _hs256; Hmac get hmac => _hs256;
String _randomString( String _randomString(
@ -31,9 +60,7 @@ class AngelAuth extends AngelPlugin {
String validChars: String validChars:
"ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"}) { "ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"}) {
var chars = <int>[]; var chars = <int>[];
while (chars.length < length) chars.add(_random.nextInt(validChars.length)); while (chars.length < length) chars.add(_random.nextInt(validChars.length));
return new String.fromCharCodes(chars); return new String.fromCharCodes(chars);
} }
@ -53,6 +80,13 @@ class AngelAuth extends AngelPlugin {
@override @override
call(Angel app) async { 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); app.container.singleton(this);
if (runtimeType != AngelAuth) app.container.singleton(this, as: AngelAuth); if (runtimeType != AngelAuth) app.container.singleton(this, as: AngelAuth);
@ -62,6 +96,10 @@ class AngelAuth extends AngelPlugin {
if (reviveTokenEndpoint != null) { if (reviveTokenEndpoint != null) {
app.post(reviveTokenEndpoint, reviveJwt); app.post(reviveTokenEndpoint, reviveJwt);
} }
app.justBeforeStop.add((_) {
_onLogin.close();
});
} }
void _apply(RequestContext req, AuthToken token, user) { void _apply(RequestContext req, AuthToken token, user) {
@ -70,6 +108,7 @@ class AngelAuth extends AngelPlugin {
..inject(user.runtimeType, req.properties["user"] = user); ..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 { decodeJwt(RequestContext req, ResponseContext res) async {
if (req.method == "POST" && req.path == reviveTokenEndpoint) { if (req.method == "POST" && req.path == reviveTokenEndpoint) {
// Shouldn't block invalid JWT if we are reviving it // Shouldn't block invalid JWT if we are reviving it
@ -129,6 +168,7 @@ class AngelAuth extends AngelPlugin {
return true; return true;
} }
/// Retrieves a JWT from a request, if any was sent at all.
getJwt(RequestContext req) { getJwt(RequestContext req) {
if (debug) { if (debug) {
print('Attempting to parse JWT'); print('Attempting to parse JWT');
@ -155,6 +195,7 @@ class AngelAuth extends AngelPlugin {
return null; return null;
} }
/// Attempts to revive an expired (or still alive) JWT.
reviveJwt(RequestContext req, ResponseContext res) async { reviveJwt(RequestContext req, ResponseContext res) async {
try { try {
if (debug) print('Attempting to revive JWT...'); 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]) { authenticate(String type, [AngelAuthOptions options]) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
AuthStrategy strategy = var names = type
strategies.firstWhere((AuthStrategy x) => x.name == type); .split(',')
var result = await strategy.authenticate(req, res, options); .map((s) => s.trim())
if (result == true) .where((String s) => s.isNotEmpty)
return result; .toList();
else if (result != false) {
var userId = await serializer(result);
// Create JWT for (int i = 0; i < names.length; i++) {
var token = new AuthToken( var name = names[i];
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
var jwt = token.serialize(_hs256);
if (options?.tokenCallback != null) { AuthStrategy strategy =
var r = await options.tokenCallback( strategies.firstWhere((AuthStrategy x) => x.name == name);
req, res, token, req.properties["user"] = result); var result = await strategy.authenticate(req, res, options);
if (r != null) return r; 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. /// Log a user in on-demand.
Future login(AuthToken token, RequestContext req, ResponseContext res) async { Future login(AuthToken token, RequestContext req, ResponseContext res) async {
var user = await deserializer(token.userId); var user = await deserializer(token.userId);
_apply(req, token, user); _apply(req, token, user);
_onLogin.add(user);
if (allowCookie) if (allowCookie)
res.cookies.add(new Cookie('token', token.serialize(_hs256))); res.cookies.add(new Cookie('token', token.serialize(_hs256)));
@ -301,11 +359,13 @@ class AngelAuth extends AngelPlugin {
var token = new AuthToken( var token = new AuthToken(
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
_apply(req, token, user); _apply(req, token, user);
_onLogin.add(user);
if (allowCookie) if (allowCookie)
res.cookies.add(new Cookie('token', token.serialize(_hs256))); res.cookies.add(new Cookie('token', token.serialize(_hs256)));
} }
/// Log an authenticated user out.
logout([AngelAuthOptions options]) { logout([AngelAuthOptions options]) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
for (AuthStrategy strategy in strategies) { 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 && if (options != null &&
options.successRedirect != null && options.successRedirect != null &&

View file

@ -1,6 +1,6 @@
name: angel_auth name: angel_auth
description: A complete authentication plugin for Angel. description: A complete authentication plugin for Angel.
version: 1.0.4+1 version: 1.0.5
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/angel_auth homepage: https://github.com/angel-dart/angel_auth
environment: environment:

View file

@ -26,11 +26,12 @@ main() {
.service('users') .service('users')
.create({'username': 'jdoe1', 'password': 'password'}); .create({'username': 'jdoe1', 'password': 'password'});
await app.configure(auth = new AngelAuth()); auth = new AngelAuth();
auth.serializer = (User user) async => user.id; auth.serializer = (User user) async => user.id;
auth.deserializer = app.service('users').read; auth.deserializer = app.service('users').read;
await app.configure(auth);
auth.strategies.add(new LocalAuthStrategy((username, password) async { auth.strategies.add(new LocalAuthStrategy((username, password) async {
final List<User> users = await app.service('users').index(); final List<User> users = await app.service('users').index();
final found = users.firstWhere( final found = users.firstWhere(