From a4fa416dfeee155393f3574e1c352eeec0ed73c7 Mon Sep 17 00:00:00 2001 From: thosakwe Date: Sat, 28 Jan 2017 15:29:20 -0500 Subject: [PATCH] 0.0.5 --- README.md | 50 +++- lib/hooks.dart | 11 +- lib/src/hooks/add_user_to_params.dart | 10 + lib/src/hooks/associate_current_user.dart | 47 ++++ lib/src/hooks/errors.dart | 6 + lib/src/hooks/hash_password.dart | 67 +++++ lib/src/hooks/is_server_side.dart | 4 + lib/src/hooks/query_with_current_user.dart | 41 +++ lib/src/hooks/resrict_to_authenticated.dart | 19 ++ lib/src/hooks/restrict_to_owner.dart | 54 ++++ lib/src/hooks/variant_permission.dart | 30 +++ lib/src/permissions.dart | 70 ++++- pubspec.yaml | 2 +- test/hooks_test.dart | 280 ++++++++++++++++++++ test/throttle_test.dart | 30 ++- 15 files changed, 707 insertions(+), 14 deletions(-) create mode 100644 lib/src/hooks/add_user_to_params.dart create mode 100644 lib/src/hooks/associate_current_user.dart create mode 100644 lib/src/hooks/errors.dart create mode 100644 lib/src/hooks/hash_password.dart create mode 100644 lib/src/hooks/is_server_side.dart create mode 100644 lib/src/hooks/query_with_current_user.dart create mode 100644 lib/src/hooks/resrict_to_authenticated.dart create mode 100644 lib/src/hooks/restrict_to_owner.dart create mode 100644 lib/src/hooks/variant_permission.dart create mode 100644 test/hooks_test.dart diff --git a/README.md b/README.md index 7745eab0..75552884 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 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) Angel middleware designed to enhance application security by patching common Web security @@ -80,11 +80,53 @@ import 'package:angel_security/helmet.dart'; ``` # 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 -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 -See the tests. \ No newline at end of file +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}'); +})); +``` \ No newline at end of file diff --git a/lib/hooks.dart b/lib/hooks.dart index 7c72bfa7..9b35ff21 100644 --- a/lib/hooks.dart +++ b/lib/hooks.dart @@ -1,2 +1,11 @@ -/// Coming soon! +/// Service hooks to lock down user data. 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'; diff --git a/lib/src/hooks/add_user_to_params.dart b/lib/src/hooks/add_user_to_params.dart new file mode 100644 index 00000000..ee84b3bc --- /dev/null +++ b/lib/src/hooks/add_user_to_params.dart @@ -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; + }; +} diff --git a/lib/src/hooks/associate_current_user.dart b/lib/src/hooks/associate_current_user.dart new file mode 100644 index 00000000..79cd527b --- /dev/null +++ b/lib/src/hooks/associate_current_user.dart @@ -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); + }; +} diff --git a/lib/src/hooks/errors.dart b/lib/src/hooks/errors.dart new file mode 100644 index 00000000..10e83ecd --- /dev/null +++ b/lib/src/hooks/errors.dart @@ -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.'; +} diff --git a/lib/src/hooks/hash_password.dart b/lib/src/hooks/hash_password.dart new file mode 100644 index 00000000..39feaee6 --- /dev/null +++ b/lib/src/hooks/hash_password.dart @@ -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 + } + } + }; +} diff --git a/lib/src/hooks/is_server_side.dart b/lib/src/hooks/is_server_side.dart new file mode 100644 index 00000000..9d18bf85 --- /dev/null +++ b/lib/src/hooks/is_server_side.dart @@ -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'); diff --git a/lib/src/hooks/query_with_current_user.dart b/lib/src/hooks/query_with_current_user.dart new file mode 100644 index 00000000..5807f4f0 --- /dev/null +++ b/lib/src/hooks/query_with_current_user.dart @@ -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; + }; +} diff --git a/lib/src/hooks/resrict_to_authenticated.dart b/lib/src/hooks/resrict_to_authenticated.dart new file mode 100644 index 00000000..fe3c7968 --- /dev/null +++ b/lib/src/hooks/resrict_to_authenticated.dart @@ -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; + } + }; +} diff --git a/lib/src/hooks/restrict_to_owner.dart b/lib/src/hooks/restrict_to_owner.dart new file mode 100644 index 00000000..4a98ae6b --- /dev/null +++ b/lib/src/hooks/restrict_to_owner.dart @@ -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); + } + } + }; +} diff --git a/lib/src/hooks/variant_permission.dart b/lib/src/hooks/variant_permission.dart new file mode 100644 index 00000000..3a0f7f6c --- /dev/null +++ b/lib/src/hooks/variant_permission.dart @@ -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); + }; +} diff --git a/lib/src/permissions.dart b/lib/src/permissions.dart index d802b557..aa64d594 100644 --- a/lib/src/permissions.dart +++ b/lib/src/permissions.dart @@ -1,7 +1,11 @@ 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. class Permission { + /// A string representation of the minimum required privilege required + /// to access a resource. final String minimum; Permission(this.minimum); @@ -10,28 +14,47 @@ class Permission { 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( - {String message, String userKey, getRoles(user)}) { + {String errorMessage, + String userKey, + bool owner: false, + getRoles(user), + getId(user), + getOwner(obj)}) { return (HookedServiceEvent e) async { if (e.params.containsKey('provider')) { var user = e.request.grab(userKey ?? 'user'); if (user == null) throw new AngelHttpException.forbidden( - message: message ?? - 'You have insufficient permissions to perform this action.'); + message: errorMessage ?? Errors.INSUFFICIENT_PERMISSIONS); var roleFinder = getRoles ?? (user) async => user.roles ?? []; List roles = (await roleFinder(user)).toList(); - if (!roles.any(verify)) - throw new AngelHttpException.forbidden( - message: message ?? - 'You have insufficient permissions to perform this action.'); + if (!roles.any(verify)) { + // Try owner if the roles are not in-place + if (owner == true) { + 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( {String message, String userKey, getRoles(user)}) { 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 verifyOne(String minimum) { if (minimum == '*') return true; @@ -88,24 +113,55 @@ class Permission { String toString() => 'Permission: $minimum'; } +/// Builds [Permission]s. class PermissionBuilder { String _min; + /// A minimum PermissionBuilder(this._min); 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); + /// Adds another level of [constraint]. PermissionBuilder add(String constraint) => new PermissionBuilder('$_min:$constraint'); + /// Adds a wildcard permission. PermissionBuilder allowAll() => add('*'); + /// Duplicates this builder. PermissionBuilder clone() => new PermissionBuilder(_min); + /// Allows an alternative permission. PermissionBuilder or(PermissionBuilder other) => new PermissionBuilder('$_min | ${other._min}'); + /// Builds a [Permission]. Permission toPermission() => new Permission(_min); } diff --git a/pubspec.yaml b/pubspec.yaml index e2e65e8a..80e68e19 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ 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. author: Tobe O environment: diff --git a/test/hooks_test.dart b/test/hooks_test.dart new file mode 100644 index 00000000..6578d91f --- /dev/null +++ b/test/hooks_test.dart @@ -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()); + 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()); + 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()); + 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()); + 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()); + 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 roles; + User({this.id, this.roles: const []}); +} + +class UserDataService extends Service { + static const Map _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 _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); +} diff --git a/test/throttle_test.dart b/test/throttle_test.dart index 7a989232..edb7e2bd 100644 --- a/test/throttle_test.dart +++ b/test/throttle_test.dart @@ -14,12 +14,16 @@ main() { .chain(throttleRequests(1, new Duration(hours: 1))) .get('/once-per-hour', 'OK'); + app + .chain(throttleRequests(3, new Duration(minutes: 1))) + .get('/thrice-per-minute', 'OK'); + client = await connectTo(app); }); tearDown(() => client.close()); - test('enforce limit', () async { + test('once per hour', () async { // First request within the hour is fine var response = await client.get('/once-per-hour'); print(response.body); @@ -30,4 +34,28 @@ main() { print(response.body); 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)); + }); }