0.0.5
This commit is contained in:
parent
7b36c0592f
commit
a4fa416dfe
15 changed files with 707 additions and 14 deletions
50
README.md
50
README.md
|
@ -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}');
|
||||||
|
}));
|
||||||
|
```
|
|
@ -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';
|
||||||
|
|
10
lib/src/hooks/add_user_to_params.dart
Normal file
10
lib/src/hooks/add_user_to_params.dart
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
47
lib/src/hooks/associate_current_user.dart
Normal file
47
lib/src/hooks/associate_current_user.dart
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
6
lib/src/hooks/errors.dart
Normal file
6
lib/src/hooks/errors.dart
Normal 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.';
|
||||||
|
}
|
67
lib/src/hooks/hash_password.dart
Normal file
67
lib/src/hooks/hash_password.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
4
lib/src/hooks/is_server_side.dart
Normal file
4
lib/src/hooks/is_server_side.dart
Normal 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');
|
41
lib/src/hooks/query_with_current_user.dart
Normal file
41
lib/src/hooks/query_with_current_user.dart
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
19
lib/src/hooks/resrict_to_authenticated.dart
Normal file
19
lib/src/hooks/resrict_to_authenticated.dart
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
54
lib/src/hooks/restrict_to_owner.dart
Normal file
54
lib/src/hooks/restrict_to_owner.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
30
lib/src/hooks/variant_permission.dart
Normal file
30
lib/src/hooks/variant_permission.dart
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
280
test/hooks_test.dart
Normal 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);
|
||||||
|
}
|
|
@ -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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue