This commit is contained in:
thosakwe 2017-01-28 15:29:20 -05:00
parent 7b36c0592f
commit a4fa416dfe
15 changed files with 707 additions and 14 deletions

View file

@ -1,5 +1,5 @@
# security # security
[![version 0.0.0-alpha+4](https://img.shields.io/badge/pub-v0.0.0--alpha+4-red.svg)](https://pub.dartlang.org/packages/angel_security) [![version 0.0.5](https://img.shields.io/badge/pub-v0.0.5-red.svg)](https://pub.dartlang.org/packages/angel_security)
[![build status](https://travis-ci.org/angel-dart/security.svg)](https://travis-ci.org/angel-dart/security) [![build status](https://travis-ci.org/angel-dart/security.svg)](https://travis-ci.org/angel-dart/security)
Angel middleware designed to enhance application security by patching common Web security Angel middleware designed to enhance application security by patching common Web security
@ -80,11 +80,53 @@ import 'package:angel_security/helmet.dart';
``` ```
# Service Hooks # Service Hooks
Also included are a set of service hooks, [ported from FeathersJS](https://github.com/feathersjs/feathers-legacy-authentication-hooks). Also included are a set of service hooks, some [ported from FeathersJS](https://github.com/feathersjs/feathers-legacy-authentication-hooks).
Others are created just for Angel.
```dart ```dart
import 'package:angel_security/hooks.dart'; import 'package:angel_security/hooks.dart' as hooks;
``` ```
Included:
* `addUserToParams`
* `associateCurrentUser`,
* `hashPassword`
* `queryWithCurrentUser`
* `restrictToAuthenticated`
* `restrictToOwner`
* `variantPermission`
Also exported is the helper function `isServerSide`. Use this to determine
whether a service method is being called by the server, or by a client.
# Permissions # Permissions
See the tests. Permissions are a great way to restrict access to resources.
They take the form of:
* `service:foo`
* `service:create:*`
* `some:arbitrary:permission:*:with:*:a:wild:*card`
The specifics are up to you.
```dart
var permission = new Permission('admin | users:find');
// Or:
// PermissionBuilders support + and | operators. Operands can be Strings, Permissions or PermissionBuilders.
var permission = (new PermissionBuilder('admin') | (new PermissionBuilder('users') + 'find')).toPermission();
// Transform into middleware
app.chain(permission.toMiddleware()).get('/protected', ...);
// Or as a service hook
app.service('protected').beforeModify(permission.toHook());
// Dynamically create a permission hook.
// This helps in situations where the resources you need to protect are dynamic.
//
// `variantPermission` is included in the `package:angel_security/hooks.dart` library.
app.service('posts').beforeModify(variantPermission((e) {
return new PermissionBuilder('posts:modify:${e.id}');
}));
```

View file

@ -1,2 +1,11 @@
/// Coming soon! /// Service hooks to lock down user data.
library angel_security.hooks; library angel_security.hooks;
export 'src/hooks/add_user_to_params.dart';
export 'src/hooks/associate_current_user.dart';
export 'src/hooks/hash_password.dart';
export 'src/hooks/is_server_side.dart';
export 'src/hooks/query_with_current_user.dart';
export 'src/hooks/resrict_to_authenticated.dart';
export 'src/hooks/restrict_to_owner.dart';
export 'src/hooks/variant_permission.dart';

View file

@ -0,0 +1,10 @@
import 'package:angel_framework/angel_framework.dart';
/// Adds the authed user to `e.params`, only if present in `req.injections`.
HookedServiceEventListener addUserToParams({String as, String userKey}) {
return (HookedServiceEvent e) {
var user = e.request?.grab(userKey ?? 'user');
if (user != null) e.params[as ?? 'user'] = user;
};
}

View file

@ -0,0 +1,47 @@
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Adds the authed user's id to `data`.
///
/// Default [as] is `'userId'`.
/// Default [userKey] is `'user'`.
HookedServiceEventListener associateCurrentUser(
{String as,
String userKey,
String errorMessage,
bool allowNullUserId: false,
getId(user),
setId(id, user)}) {
return (HookedServiceEvent e) async {
var fieldName = as?.isNotEmpty == true ? as : 'userId';
var user = e.request?.grab(userKey ?? 'user');
if (user == null) {
if (!isServerSide(e))
throw new AngelHttpException.forbidden(
message: errorMessage ?? Errors.NOT_LOGGED_IN);
else
return;
}
_getId(user) => getId == null ? user?.id : getId(user);
var id = await _getId(user);
if (id == null && allowNullUserId != true)
throw new AngelHttpException.notProcessable(
message: 'Current user is missing a $fieldName field.');
_setId(id, user) {
if (setId != null)
return setId(id, user);
else if (user is Map)
user[fieldName] = id;
else
user.userId = id;
}
await _setId(id, e.data);
};
}

View file

@ -0,0 +1,6 @@
abstract class Errors {
static const String NOT_LOGGED_IN =
'You must be logged in to perform this action.';
static const String INSUFFICIENT_PERMISSIONS =
'You have insufficient permissions to access this resource.';
}

View file

@ -0,0 +1,67 @@
import 'dart:async';
import 'package:crypto/crypto.dart';
import 'package:angel_framework/angel_framework.dart';
/// Hashes a user's password using a [Hash] algorithm (Default: [sha256]).
///
/// You may provide your own functions to obtain or set a user's password,
/// or just provide a [passwordField] if you are only ever going to deal with Maps.
HookedServiceEventListener hashPassword(
{Hash hasher,
String passwordField,
getPassword(user),
setPassword(password, user)}) {
Hash h = hasher ?? sha256;
return (HookedServiceEvent e) async {
_getPassword(user) {
if (getPassword != null)
return getPassword(user);
else if (user is Map)
return user[passwordField ?? 'password'];
else
return user?.password;
}
_setPassword(password, user) {
if (setPassword != null)
return setPassword(password, user);
else if (user is Map)
user[passwordField ?? 'password'] = password;
else
user?.password = password;
}
if (e.data != null) {
var password;
if (e.data is Iterable) {
for (var data in e.data) {
var p = await _getPassword(data);
if (p != null) {
password = p;
break;
}
}
} else
password = await _getPassword(e.data);
if (password != null) {
applyHash(user) async {
var password = (await _getPassword(user))?.toString();
var digest = h.convert(password.codeUnits);
return _setPassword(new String.fromCharCodes(digest.bytes), user);
}
if (e.data is Iterable) {
var data = await Future.wait(e.data.map(applyHash));
e.data = e.data is List ? data.toList() : data;
} else
e.data = await applyHash(e.data);
// TODO (thosakwe): Add salting capability
}
}
};
}

View file

@ -0,0 +1,4 @@
import 'package:angel_framework/angel_framework.dart';
/// Returns `true` if the event was triggered server-side.
bool isServerSide(HookedServiceEvent e) => !e.params.containsKey('provider');

View file

@ -0,0 +1,41 @@
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Adds the authed user's id to `params['query']`.
///
/// Default [as] is `'userId'`.
/// Default [userKey] is `'user'`.
HookedServiceEventListener queryWithCurrentUser(
{String as,
String userKey,
String errorMessage,
bool allowNullUserId: false,
getId(user)}) {
return (HookedServiceEvent e) async {
var fieldName = as?.isNotEmpty == true ? as : 'userId';
var user = e.request?.grab(userKey ?? 'user');
if (user == null) {
if (!isServerSide(e))
throw new AngelHttpException.forbidden(
message: errorMessage ?? Errors.NOT_LOGGED_IN);
else
return;
}
_getId(user) => getId == null ? user?.id : getId(user);
var id = await _getId(user);
if (id == null && allowNullUserId != true)
throw new AngelHttpException.notProcessable(
message: 'Current user is missing a $fieldName field.');
var data = {fieldName: id};
e.params['query'] = e.params.containsKey('query')
? (e.params['query']..addAll(data))
: data;
};
}

View file

@ -0,0 +1,19 @@
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Restricts the service method to authed users only.
HookedServiceEventListener restrictToAuthenticated(
{String userKey, String errorMessage}) {
return (HookedServiceEvent e) async {
var user = e.request?.grab(userKey ?? 'user');
if (user == null) {
if (!isServerSide(e))
throw new AngelHttpException.forbidden(
message: errorMessage ?? Errors.NOT_LOGGED_IN);
else
return;
}
};
}

View file

@ -0,0 +1,54 @@
import 'package:angel_framework/angel_framework.dart';
import 'errors.dart';
import 'is_server_side.dart';
/// Restricts users to accessing only their own resources.
HookedServiceEventListener restrictToOwner(
{String userKey, String errorMessage, getId(user), getOwner(obj)}) {
return (HookedServiceEvent e) async {
if (!isServerSide(e)) {
var user = e.request?.grab(userKey ?? 'user');
if (user == null)
throw new AngelHttpException.notAuthenticated(
message:
'The current user is missing. You must not be authenticated.');
_getId(user) {
if (getId != null)
return getId(user);
else if (user is Map)
return user['id'];
else
return user.id;
}
var id = await _getId(user);
if (id == null) throw new Exception('The current user has no ID.');
var resource = await e.service.read(
e.id,
{}
..addAll(e.params ?? {})
..remove('provider'));
if (resource != null) {
_getOwner(obj) {
if (getOwner != null)
return getOwner(obj);
else if (obj is Map)
return obj['userId'];
else
return obj.userId;
}
var ownerId = await _getOwner(resource);
if ((ownerId is Iterable && !ownerId.contains(id)) || ownerId != id)
throw new AngelHttpException.forbidden(
message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS);
}
}
};
}

View file

@ -0,0 +1,30 @@
import 'package:angel_framework/angel_framework.dart';
import '../permissions.dart';
/// Generates a [Permission] based on the situation, and runs it as a hook.
///
/// This is ideal for cases when you want to limit permissions to a dynamic
/// resource.
HookedServiceEventListener variantPermission(
createPermission(HookedServiceEvent e),
{String errorMessage,
String userKey,
bool owner: false,
getRoles(user),
getId(user),
getOwner(obj)}) {
return (HookedServiceEvent e) async {
var permission = await createPermission(e);
if (permission is! Permission)
throw new ArgumentError(
'createPermission must generate a Permission, whether synchronously or asynchronously.');
await permission.toHook(
errorMessage: errorMessage,
userKey: userKey,
owner: owner,
getRoles: getRoles,
getId: getId,
getOwner: getOwner)(e);
};
}

View file

@ -1,7 +1,11 @@
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'hooks/errors.dart';
import 'hooks/restrict_to_owner.dart';
/// Easy mechanism to restrict access to services or routes. /// Easy mechanism to restrict access to services or routes.
class Permission { class Permission {
/// A string representation of the minimum required privilege required
/// to access a resource.
final String minimum; final String minimum;
Permission(this.minimum); Permission(this.minimum);
@ -10,28 +14,47 @@ class Permission {
return toMiddleware()(req, res); return toMiddleware()(req, res);
} }
/// Creates a hook that restricts a service method to users with this
/// permission, or if they are the resource [owner].
///
/// [getId] and [getOwner] are passed to [restrictToOwner], along with
/// [userKey] and [errorMessage].
HookedServiceEventListener toHook( HookedServiceEventListener toHook(
{String message, String userKey, getRoles(user)}) { {String errorMessage,
String userKey,
bool owner: false,
getRoles(user),
getId(user),
getOwner(obj)}) {
return (HookedServiceEvent e) async { return (HookedServiceEvent e) async {
if (e.params.containsKey('provider')) { if (e.params.containsKey('provider')) {
var user = e.request.grab(userKey ?? 'user'); var user = e.request.grab(userKey ?? 'user');
if (user == null) if (user == null)
throw new AngelHttpException.forbidden( throw new AngelHttpException.forbidden(
message: message ?? message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS);
'You have insufficient permissions to perform this action.');
var roleFinder = getRoles ?? (user) async => user.roles ?? []; var roleFinder = getRoles ?? (user) async => user.roles ?? [];
List<String> roles = (await roleFinder(user)).toList(); List<String> roles = (await roleFinder(user)).toList();
if (!roles.any(verify)) if (!roles.any(verify)) {
throw new AngelHttpException.forbidden( // Try owner if the roles are not in-place
message: message ?? if (owner == true) {
'You have insufficient permissions to perform this action.'); var listener = restrictToOwner(
userKey: userKey,
errorMessage: errorMessage,
getId: getId,
getOwner: getOwner);
await listener(e);
} else
throw new AngelHttpException.forbidden(
message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS);
}
} }
}; };
} }
/// Restricts a route to users who have sufficient permissions.
RequestMiddleware toMiddleware( RequestMiddleware toMiddleware(
{String message, String userKey, getRoles(user)}) { {String message, String userKey, getRoles(user)}) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
@ -54,6 +77,8 @@ class Permission {
}; };
} }
/// Returns `true` if the [given] permission string
/// represents a sufficient permission, matching the [minimum].
bool verify(String given) { bool verify(String given) {
bool verifyOne(String minimum) { bool verifyOne(String minimum) {
if (minimum == '*') return true; if (minimum == '*') return true;
@ -88,24 +113,55 @@ class Permission {
String toString() => 'Permission: $minimum'; String toString() => 'Permission: $minimum';
} }
/// Builds [Permission]s.
class PermissionBuilder { class PermissionBuilder {
String _min; String _min;
/// A minimum
PermissionBuilder(this._min); PermissionBuilder(this._min);
factory PermissionBuilder.wildcard() => new PermissionBuilder('*'); factory PermissionBuilder.wildcard() => new PermissionBuilder('*');
PermissionBuilder operator +(other) {
if (other is String)
return add(other);
else if (other is PermissionBuilder)
return add(other._min);
else if (other is Permission)
return add(other.minimum);
else
throw new ArgumentError(
'Cannot add a ${other.runtimeType} to a PermissionBuilder.');
}
PermissionBuilder operator |(other) {
if (other is String)
return or(new PermissionBuilder(other));
else if (other is PermissionBuilder)
return or(other);
else if (other is Permission)
return or(new PermissionBuilder(other.minimum));
else
throw new ArgumentError(
'Cannot or a ${other.runtimeType} and a PermissionBuilder.');
}
call(RequestContext req, ResponseContext res) => toPermission()(req, res); call(RequestContext req, ResponseContext res) => toPermission()(req, res);
/// Adds another level of [constraint].
PermissionBuilder add(String constraint) => PermissionBuilder add(String constraint) =>
new PermissionBuilder('$_min:$constraint'); new PermissionBuilder('$_min:$constraint');
/// Adds a wildcard permission.
PermissionBuilder allowAll() => add('*'); PermissionBuilder allowAll() => add('*');
/// Duplicates this builder.
PermissionBuilder clone() => new PermissionBuilder(_min); PermissionBuilder clone() => new PermissionBuilder(_min);
/// Allows an alternative permission.
PermissionBuilder or(PermissionBuilder other) => PermissionBuilder or(PermissionBuilder other) =>
new PermissionBuilder('$_min | ${other._min}'); new PermissionBuilder('$_min | ${other._min}');
/// Builds a [Permission].
Permission toPermission() => new Permission(_min); Permission toPermission() => new Permission(_min);
} }

View file

@ -1,5 +1,5 @@
name: angel_security name: angel_security
version: 0.0.0-alpha+4 version: 0.0.5
description: Angel middleware designed to enhance application security by patching common Web security holes. description: Angel middleware designed to enhance application security by patching common Web security holes.
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
environment: environment:

280
test/hooks_test.dart Normal file
View file

@ -0,0 +1,280 @@
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_security/angel_security.dart';
import 'package:angel_security/hooks.dart' as hooks;
import 'package:angel_test/angel_test.dart';
import 'package:crypto/crypto.dart';
import 'package:test/test.dart';
main() {
Angel app;
TestClient client;
setUp(() async {
app = new Angel()
..before.add((RequestContext req, res) async {
var xUser = req.headers.value('X-User');
if (xUser != null)
req.inject('user',
new User(id: xUser, roles: xUser == 'John' ? ['foo:bar'] : []));
return true;
});
app
..use('/user_data', new UserDataService())
..use('/artists', new ArtistService())
..use('/roled', new RoledService());
app.service('user_data')
..beforeIndexed.listen(hooks.queryWithCurrentUser())
..beforeCreated.listen(hooks.hashPassword());
app.service('artists')
..beforeIndexed.listen(hooks.restrictToAuthenticated())
..beforeRead.listen(hooks.restrictToOwner())
..beforeCreated.listen(hooks.associateCurrentUser());
app.service('roled')
..beforeIndexed.listen(new Permission('foo:*').toHook())
..beforeRead.listen(new Permission('foo:*').toHook(owner: true));
app.fatalErrorStream.listen((e) {
print('Fatal: ${e.error}');
print(e.stack);
});
client = await connectTo(app);
});
tearDown(() => client.close());
group('associateCurrentUser', () {
test('fail', () async {
try {
var response = await client.service('artists').create({'foo': 'bar'});
print(response);
throw new StateError('Creating without userId bad request');
} catch (e) {
print(e);
expect(e, new isInstanceOf<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response = await client
.post('/artists', headers: {'X-User': 'John'}, body: {'foo': 'bar'});
print('Response: ${response.body}');
expect(response, allOf(hasStatus(200), isJson({'foo': 'bar'})));
});
});
group('queryWithCurrentUser', () {
test('fail', () async {
try {
var response = await client.service('user_data').index();
print(response);
throw new StateError('Indexing without user forbidden');
} catch (e) {
print(e);
expect(e, new isInstanceOf<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response = await client.get('user_data', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(response, allOf(hasStatus(200), isJson(['foo', 'bar'])));
});
});
test('hashPassword', () async {
var response = await client
.service('user_data')
.create({'username': 'foo', 'password': 'jdoe1'});
print('Response: ${response}');
expect(response, equals({'foo': 'bar'}));
});
group('restrictToAuthenticated', () {
test('fail', () async {
try {
var response = await client.service('artists').index();
print(response);
throw new StateError('Indexing without user forbidden');
} catch (e) {
print(e);
expect(e, new isInstanceOf<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response = await client.get('/artists', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(
response,
allOf(
hasStatus(200),
isJson([
{
"id": "king_of_pop",
"userId": "John",
"name": "Michael Jackson"
},
{"id": "raymond", "userId": "Bob", "name": "Usher"}
])));
});
});
group('restrictToOwner', () {
test('fail', () async {
try {
var response = await client.service('artists').read('king_of_pop');
print(response);
throw new StateError('Reading without owner forbidden');
} catch (e) {
print(e);
expect(e, new isInstanceOf<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(401));
}
});
test('succeed', () async {
var response =
await client.get('/artists/king_of_pop', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(
response,
allOf(
hasStatus(200),
isJson({
"id": "king_of_pop",
"userId": "John",
"name": "Michael Jackson"
})));
});
});
group('permission restrict', () {
test('fail', () async {
try {
var response = await client.service('roled').index();
print(response);
throw new StateError('Reading without roles forbidden');
} catch (e) {
print(e);
expect(e, new isInstanceOf<AngelHttpException>());
var err = e as AngelHttpException;
expect(err.statusCode, equals(403));
}
});
test('succeed', () async {
var response =
await client.get('/roled/king_of_pop', headers: {'X-User': 'John'});
print('Response: ${response.body}');
expect(
response,
allOf(
hasStatus(200),
isJson({
"id": "king_of_pop",
"userId": "John",
"name": "Michael Jackson"
})));
});
test('owner', () async {
var response =
await client.get('/roled/raymond', headers: {'X-User': 'Bob'});
print('Response: ${response.body}');
expect(
response,
allOf(hasStatus(200),
isJson({"id": "raymond", "userId": "Bob", "name": "Usher"})));
});
});
}
class User {
String id;
List<String> roles;
User({this.id, this.roles: const []});
}
class UserDataService extends Service {
static const Map<String, List> _data = const {
'John': const ['foo', 'bar']
};
@override
index([Map params]) async {
print('Params: $params');
if (params?.containsKey('query') != true)
throw new AngelHttpException.badRequest(message: 'query required');
String name = params['query']['userId']?.toString();
if (!_data.containsKey(name))
throw new AngelHttpException.notFound(
message: "No data found for user '$name'.");
return _data[name];
}
@override
create(data, [Map params]) async {
if (data is! Map || !data.containsKey('password'))
throw new AngelHttpException.badRequest(message: 'Required password!');
var expected =
new String.fromCharCodes(sha256.convert('jdoe1'.codeUnits).bytes);
if (data['password'] != (expected))
throw new AngelHttpException.conflict(message: 'Passwords do not match.');
return {'foo': 'bar'};
}
}
class ArtistService extends Service {
static const List<Artist> _ARTISTS = const [_MICHAEL_JACKSON, _USHER];
@override
index([params]) async => _ARTISTS;
@override
read(id, [params]) async => _ARTISTS.firstWhere((a) => a.id == id);
@override
create(data, [params]) async {
if (data is! Map || !data.containsKey('userId'))
throw new AngelHttpException.badRequest(message: 'Required userId');
return {'foo': 'bar'};
}
}
class Artist {
final String id, userId, name;
const Artist({this.id, this.userId, this.name});
}
const Artist _USHER = const Artist(id: 'raymond', userId: 'Bob', name: 'Usher');
const Artist _MICHAEL_JACKSON =
const Artist(id: 'king_of_pop', userId: 'John', name: 'Michael Jackson');
class RoledService extends Service {
@override
index([params]) {
return ['foo'];
}
@override
read(id, [params]) async =>
ArtistService._ARTISTS.firstWhere((a) => a.id == id);
}

View file

@ -14,12 +14,16 @@ main() {
.chain(throttleRequests(1, new Duration(hours: 1))) .chain(throttleRequests(1, new Duration(hours: 1)))
.get('/once-per-hour', 'OK'); .get('/once-per-hour', 'OK');
app
.chain(throttleRequests(3, new Duration(minutes: 1)))
.get('/thrice-per-minute', 'OK');
client = await connectTo(app); client = await connectTo(app);
}); });
tearDown(() => client.close()); tearDown(() => client.close());
test('enforce limit', () async { test('once per hour', () async {
// First request within the hour is fine // First request within the hour is fine
var response = await client.get('/once-per-hour'); var response = await client.get('/once-per-hour');
print(response.body); print(response.body);
@ -30,4 +34,28 @@ main() {
print(response.body); print(response.body);
expect(response, hasStatus(429)); expect(response, hasStatus(429));
}); });
test('thrice per minute', () async {
// First request within the minute is fine
var response = await client.get('/thrice-per-minute');
print(response.body);
expect(response.body, contains('OK'));
// Second request within the minute is fine
response = await client.get('/thrice-per-minute');
print(response.body);
expect(response.body, contains('OK'));
// Third request within the minute is fine
response = await client.get('/thrice-per-minute');
print(response.body);
expect(response.body, contains('OK'));
// Fourth request within a minute? No no no!
response = await client.get('/thrice-per-minute');
print(response.body);
expect(response, hasStatus(429));
});
} }