From b77d6cbdfa6b50635b2a2681a6d1cf246e50a6c2 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 20 Apr 2019 12:37:50 -0400 Subject: [PATCH] current user tests --- analysis_options.yaml | 2 +- lib/src/csrf.dart | 19 ++- lib/src/hooks/hash_password.dart | 5 +- lib/src/hooks/query_with_current_user.dart | 12 +- lib/src/sanitize.dart | 5 +- pubspec.yaml | 3 + test/csrf_token_test.dart | 12 +- test/current_user_test.dart | 130 ++++++++++++++++++ test/{hooks_test.dart => hooks_test.dart.old} | 24 ++-- test/sanitize_test.dart | 10 +- 10 files changed, 189 insertions(+), 33 deletions(-) create mode 100644 test/current_user_test.dart rename test/{hooks_test.dart => hooks_test.dart.old} (92%) diff --git a/analysis_options.yaml b/analysis_options.yaml index 0ec8c113..c230cee7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,4 @@ include: package:pedantic/analysis_options.yaml analyzer: strong-mode: - implicit-casts: true \ No newline at end of file + implicit-casts: false \ No newline at end of file diff --git a/lib/src/csrf.dart b/lib/src/csrf.dart index dc99112d..fd33b2f8 100644 --- a/lib/src/csrf.dart +++ b/lib/src/csrf.dart @@ -5,17 +5,30 @@ import 'package:uuid/uuid.dart'; final Uuid _uuid = Uuid(); /// Ensures that the request contains a correct CSRF token. +/// +/// For `POST` methods, or anything requiring, provide [parseBody] as `true`. +/// +/// Note that this is useless without [setCsrfToken]. If no [name] is present in the +/// session, clients will pass through. RequestHandler verifyCsrfToken( {bool allowCookie = false, bool allowQuery = true, + bool parseBody = false, String name = 'csrf_token'}) { return (RequestContext req, res) async { + if (!req.session.containsKey(name)) { + throw AngelHttpException.forbidden( + message: 'You cannot access this resource without a CSRF token.'); + } + String csrfToken; + if (parseBody) { + await req.parseBody(); + } if (allowQuery && req.queryParameters.containsKey(name)) csrfToken = req.queryParameters[name]; - else if ((await req.parseBody().then((_) => req.bodyAsMap)) - .containsKey(name)) + else if (req.hasParsedBody && req.bodyAsMap.containsKey(name)) csrfToken = req.bodyAsMap[name]; else if (allowCookie) { var cookie = @@ -23,7 +36,7 @@ RequestHandler verifyCsrfToken( if (cookie != null) csrfToken = cookie.value; } - if (csrfToken == null || !req.session.containsKey(name)) + if (csrfToken == null) throw AngelHttpException.badRequest(message: 'Missing CSRF token.'); String correctToken = req.session[name]; diff --git a/lib/src/hooks/hash_password.dart b/lib/src/hooks/hash_password.dart index eb718b83..a9621f7c 100644 --- a/lib/src/hooks/hash_password.dart +++ b/lib/src/hooks/hash_password.dart @@ -7,10 +7,11 @@ import 'package:angel_framework/angel_framework.dart'; /// /// 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( +@deprecated +HookedServiceEventListener hashPassword( {Hash hasher, String passwordField, - getPassword(user), + FutureOr Function(User) getPassword, setPassword(password, user)}) { Hash h = hasher ?? sha256; diff --git a/lib/src/hooks/query_with_current_user.dart b/lib/src/hooks/query_with_current_user.dart index 12a5563a..3010f161 100644 --- a/lib/src/hooks/query_with_current_user.dart +++ b/lib/src/hooks/query_with_current_user.dart @@ -6,7 +6,7 @@ import 'is_server_side.dart'; /// Adds the authed user's id to `params['query']`. /// -/// Default [as] is `'userId'`. +/// Default [as] is `'user_id'`. /// Default [userKey] is `'user'`. HookedServiceEventListener queryWithCurrentUser( {String as, @@ -26,15 +26,13 @@ HookedServiceEventListener queryWithCurrentUser( return; } - _getId(user) { + Future _getId(User user) async { if (getId != null) return getId(user); else if (user is Map) - return user[fieldName]; - else if (fieldName == 'id') - return user.id; + return user[fieldName] as Id; else - return reflect(user).getField(Symbol(fieldName)).reflectee; + return reflect(user).getField(Symbol(fieldName)).reflectee as Id; } var id = await _getId(user); @@ -43,7 +41,7 @@ HookedServiceEventListener queryWithCurrentUser( throw AngelHttpException.notProcessable( message: 'Current user is missing a \'$fieldName\' field.'); - var data = {as?.isNotEmpty == true ? as : 'userId': id}; + var data = {as?.isNotEmpty == true ? as : 'user_id': id}; e.params['query'] = e.params.containsKey('query') ? (e.params['query']..addAll(data)) diff --git a/lib/src/sanitize.dart b/lib/src/sanitize.dart index 54fc2fe0..8a9df338 100644 --- a/lib/src/sanitize.dart +++ b/lib/src/sanitize.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; -final Map DEFAULT_SANITIZERS = { +final Map defaultSanitizers = { RegExp(r'<\s*s\s*c\s*r\s*i\s*p\s*t\s*>.*<\s*\/\s*s\s*c\s*r\s*i\s*p\s*t\s*>', caseSensitive: false): '' }; @@ -15,7 +15,8 @@ RequestHandler sanitizeHtmlInput( {bool body = true, bool query = true, Map replace = const {}}) { - var sanitizers = {}..addAll(DEFAULT_SANITIZERS)..addAll(replace ?? {}); + var sanitizers = Map.from(defaultSanitizers) + ..addAll(replace ?? {}); return (req, res) async { if (body) { diff --git a/pubspec.yaml b/pubspec.yaml index dcd8a016..87b973c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,3 +14,6 @@ dev_dependencies: console: ^3.0.0 pedantic: ^1.0.0 test: ^1.0.0 +dependency_overrides: + angel_framework: + path: ../framework diff --git a/test/csrf_token_test.dart b/test/csrf_token_test.dart index f6832572..33bfe4d2 100644 --- a/test/csrf_token_test.dart +++ b/test/csrf_token_test.dart @@ -11,9 +11,11 @@ main() async { TestClient client; setUp(() async { - app = Angel()..responseFinalizers.add(setCsrfToken()); + app = Angel(); - app.chain([verifyCsrfToken()]).get('/valid', (req, res) => 'Valid!'); + app + ..chain([verifyCsrfToken()]).get('/valid', (req, res) => 'Valid!') + ..fallback(setCsrfToken()); client = await connectTo(app); }); @@ -23,15 +25,15 @@ main() async { test('need pre-existing token', () async { var response = await client.get('/valid?csrf_token=evil'); print(response.body); - expect(response, hasStatus(400)); - expect(response.body, contains('Missing')); + expect(response, hasStatus(403)); }); test('fake token', () async { // Get a valid CSRF, but ignore it. var response = await client.get('/'); var sessionId = getCookie(response); - response = await client.get('/valid?csrf_token=evil', + response = await client.get( + Uri(path: '/valid', queryParameters: {'csrf_token': 'evil'}), headers: {'cookie': 'DARTSESSID=$sessionId'}); print(response.body); expect(response, hasStatus(400)); diff --git a/test/current_user_test.dart b/test/current_user_test.dart new file mode 100644 index 00000000..c104d5d5 --- /dev/null +++ b/test/current_user_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +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:logging/logging.dart'; +import 'package:pedantic/pedantic.dart'; +import 'package:test/test.dart'; +import 'pretty_logging.dart'; + +void main() { + Angel app; + TestClient client; + + setUp(() async { + var logger = Logger.detached('hooks_test')..onRecord.listen(prettyLog); + app = Angel(logger: logger); + client = await connectTo(app); + + HookedService> serve(String path, + T Function(Map) encoder, Map Function(T) decoder) { + var inner = MapService(); + var mapped = inner.map(encoder, decoder); + return app.use>(path, mapped); + } + + var userService = + serve('/api/users', User.fromMap, (u) => u.toJson()); + var houseService = + serve('/api/houses', House.fromMap, (h) => h.toJson()); + + // Seed things up. + var pSherman = await userService.create(User('0', 'P Sherman')); + await houseService.create(House('0', pSherman.id, '42 Wallaby Way')); + await houseService.create(House('1', pSherman.id, 'Finding Nemo')); + await houseService + .create(House('1', '4', 'Should Not Appear for P. Sherman')); + + // Inject a user depending on the authorization header. + app.container.registerFactory>((container) async { + var req = container.make(); + var authValue = + req.headers.value('authorization')?.replaceAll('Bearer', '')?.trim(); + if (authValue == null) + throw AngelHttpException.badRequest( + message: 'Missing "authorization".'); + var user = await userService.read(authValue).catchError((_) => null); + if (user == null) + throw AngelHttpException.notAuthenticated( + message: 'Invalid "authorization" ($authValue).'); + return user; + }); + + // ACCESS CONTROL: + + // A user can only see their own houses. + houseService.beforeIndexed.listen(hooks.queryWithCurrentUser( + as: 'owner_id', + getId: (user) => user.id, + )); + + // A house is associated with the current user. + houseService.beforeCreated + .listen(hooks.associateCurrentUser( + getId: (user) => user.id, + assignUserId: (id, house) => house.withOwner(id), + )); + }); + + tearDown(() async { + app.logger.clearListeners(); + await app.close(); + unawaited(client.close()); + }); + + test('query with current user', () async { + client.authToken = '0'; + var houseService = client.service('/api/houses'); + expect(await houseService.index(), [ + {'id': '0', 'owner_id': '0', 'address': '42 Wallaby Way'}, + {'id': '1', 'owner_id': '0', 'address': 'Finding Nemo'} + ]); + }); + + test('associate current user', () async { + client.authToken = '0'; + var houseService = client.service('/api/houses'); + expect( + await houseService.create({'address': 'Hello'}), + allOf( + containsPair('address', 'Hello'), + containsPair('owner_id', '0'), + )); + }); +} + +class User { + final String id; + final String name; + + User(this.id, this.name); + + static User fromMap(Map map) => + User(map['id'] as String, map['name'] as String); + + Map toJson() { + return {'id': id, 'name': name}; + } +} + +class House { + final String id; + final String ownerId; + final String address; + + House(this.id, this.ownerId, this.address); + + static House fromMap(Map map) => House( + map['id'] as String, map['owner_id'] as String, map['address'] as String); + + House withOwner(String newOwnerId) { + return House(id, newOwnerId, address); + } + + Map toJson() { + return {'id': id, 'owner_id': ownerId, 'address': address}; + } +} diff --git a/test/hooks_test.dart b/test/hooks_test.dart.old similarity index 92% rename from test/hooks_test.dart rename to test/hooks_test.dart.old index 2d380a04..02f95b32 100644 --- a/test/hooks_test.dart +++ b/test/hooks_test.dart.old @@ -26,13 +26,14 @@ main() { ..use('/roled', RoledService()); (app.findService('user_data') as HookedService) - ..beforeIndexed.listen(hooks.queryWithCurrentUser()) + ..beforeIndexed.listen(hooks.queryWithCurrentUser()) ..beforeCreated.listen(hooks.hashPassword()); app.findService('artists') as HookedService - ..beforeIndexed.listen(hooks.restrictToAuthenticated()) - ..beforeRead.listen(hooks.restrictToOwner()) - ..beforeCreated.listen(hooks.associateCurrentUser()); + ..beforeIndexed.listen(hooks.restrictToAuthenticated()) + ..beforeRead.listen(hooks.restrictToOwner()) + ..beforeCreated + .listen(hooks.associateCurrentUser()); (app.findService('roled') as HookedService) ..beforeIndexed.listen(Permission('foo:*').toHook()) @@ -245,7 +246,7 @@ class UserDataService extends Service { } } -class ArtistService extends Service { +class ArtistService extends Service { static const List _ARTISTS = const [_MICHAEL_JACKSON, _USHER]; @override @@ -256,10 +257,10 @@ class ArtistService extends Service { @override create(data, [params]) async { - if (data is! Map || !data.containsKey('userId')) - throw AngelHttpException.badRequest(message: 'Required userId'); - - return {'foo': 'bar'}; + return data; + // if (data is! Map || !data.containsKey('userId')) + // throw AngelHttpException.badRequest(message: 'Required userId'); + // return {'foo': 'bar'}; } } @@ -272,10 +273,11 @@ 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 { +class RoledService extends Service { @override index([params]) async { - return ['foo']; + return [Artist(name: 'foo')]; + // return ['foo']; } @override diff --git a/test/sanitize_test.dart b/test/sanitize_test.dart index 9abfd11b..e0fff4e7 100644 --- a/test/sanitize_test.dart +++ b/test/sanitize_test.dart @@ -15,7 +15,8 @@ main() async { TestClient client; setUp(() async { - app = Angel(); + var logger = Logger.detached('angel_security')..onRecord.listen(prettyLog); + app = Angel(logger: logger); app.chain([validate(untrustedSchema), sanitizeHtmlInput()]) ..post('/untrusted', (RequestContext req, ResponseContext res) async { String untrusted = req.bodyAsMap['html']; @@ -46,7 +47,12 @@ main() async { '''); }); - app.logger = Logger.detached('angel_security')..onRecord.listen(prettyLog); + var oldHandler = app.errorHandler; + app.errorHandler = (e, req, res) { + app.logger.severe(e, e.error, e.stackTrace); + return oldHandler(e, req, res); + }; + client = await connectTo(app); });