current user tests

This commit is contained in:
Tobe O 2019-04-20 12:37:50 -04:00
parent 275f2129f1
commit b77d6cbdfa
10 changed files with 189 additions and 33 deletions

View file

@ -1,4 +1,4 @@
include: package:pedantic/analysis_options.yaml include: package:pedantic/analysis_options.yaml
analyzer: analyzer:
strong-mode: strong-mode:
implicit-casts: true implicit-casts: false

View file

@ -5,17 +5,30 @@ import 'package:uuid/uuid.dart';
final Uuid _uuid = Uuid(); final Uuid _uuid = Uuid();
/// Ensures that the request contains a correct CSRF token. /// 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( RequestHandler verifyCsrfToken(
{bool allowCookie = false, {bool allowCookie = false,
bool allowQuery = true, bool allowQuery = true,
bool parseBody = false,
String name = 'csrf_token'}) { String name = 'csrf_token'}) {
return (RequestContext req, res) async { 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; String csrfToken;
if (parseBody) {
await req.parseBody();
}
if (allowQuery && req.queryParameters.containsKey(name)) if (allowQuery && req.queryParameters.containsKey(name))
csrfToken = req.queryParameters[name]; csrfToken = req.queryParameters[name];
else if ((await req.parseBody().then((_) => req.bodyAsMap)) else if (req.hasParsedBody && req.bodyAsMap.containsKey(name))
.containsKey(name))
csrfToken = req.bodyAsMap[name]; csrfToken = req.bodyAsMap[name];
else if (allowCookie) { else if (allowCookie) {
var cookie = var cookie =
@ -23,7 +36,7 @@ RequestHandler verifyCsrfToken(
if (cookie != null) csrfToken = cookie.value; if (cookie != null) csrfToken = cookie.value;
} }
if (csrfToken == null || !req.session.containsKey(name)) if (csrfToken == null)
throw AngelHttpException.badRequest(message: 'Missing CSRF token.'); throw AngelHttpException.badRequest(message: 'Missing CSRF token.');
String correctToken = req.session[name]; String correctToken = req.session[name];

View file

@ -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, /// 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. /// or just provide a [passwordField] if you are only ever going to deal with Maps.
HookedServiceEventListener hashPassword( @deprecated
HookedServiceEventListener hashPassword<Password, User>(
{Hash hasher, {Hash hasher,
String passwordField, String passwordField,
getPassword(user), FutureOr<Password> Function(User) getPassword,
setPassword(password, user)}) { setPassword(password, user)}) {
Hash h = hasher ?? sha256; Hash h = hasher ?? sha256;

View file

@ -6,7 +6,7 @@ import 'is_server_side.dart';
/// Adds the authed user's id to `params['query']`. /// Adds the authed user's id to `params['query']`.
/// ///
/// Default [as] is `'userId'`. /// Default [as] is `'user_id'`.
/// Default [userKey] is `'user'`. /// Default [userKey] is `'user'`.
HookedServiceEventListener queryWithCurrentUser<Id, User>( HookedServiceEventListener queryWithCurrentUser<Id, User>(
{String as, {String as,
@ -26,15 +26,13 @@ HookedServiceEventListener queryWithCurrentUser<Id, User>(
return; return;
} }
_getId(user) { Future<Id> _getId(User user) async {
if (getId != null) if (getId != null)
return getId(user); return getId(user);
else if (user is Map) else if (user is Map)
return user[fieldName]; return user[fieldName] as Id;
else if (fieldName == 'id')
return user.id;
else else
return reflect(user).getField(Symbol(fieldName)).reflectee; return reflect(user).getField(Symbol(fieldName)).reflectee as Id;
} }
var id = await _getId(user); var id = await _getId(user);
@ -43,7 +41,7 @@ HookedServiceEventListener queryWithCurrentUser<Id, User>(
throw AngelHttpException.notProcessable( throw AngelHttpException.notProcessable(
message: 'Current user is missing a \'$fieldName\' field.'); 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'] = e.params.containsKey('query')
? (e.params['query']..addAll(data)) ? (e.params['query']..addAll(data))

View file

@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
final Map<Pattern, String> DEFAULT_SANITIZERS = { final Map<Pattern, String> 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*>', 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): '' caseSensitive: false): ''
}; };
@ -15,7 +15,8 @@ RequestHandler sanitizeHtmlInput(
{bool body = true, {bool body = true,
bool query = true, bool query = true,
Map<Pattern, String> replace = const {}}) { Map<Pattern, String> replace = const {}}) {
var sanitizers = {}..addAll(DEFAULT_SANITIZERS)..addAll(replace ?? {}); var sanitizers = Map<Pattern, String>.from(defaultSanitizers)
..addAll(replace ?? {});
return (req, res) async { return (req, res) async {
if (body) { if (body) {

View file

@ -14,3 +14,6 @@ dev_dependencies:
console: ^3.0.0 console: ^3.0.0
pedantic: ^1.0.0 pedantic: ^1.0.0
test: ^1.0.0 test: ^1.0.0
dependency_overrides:
angel_framework:
path: ../framework

View file

@ -11,9 +11,11 @@ main() async {
TestClient client; TestClient client;
setUp(() async { 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); client = await connectTo(app);
}); });
@ -23,15 +25,15 @@ main() async {
test('need pre-existing token', () async { test('need pre-existing token', () async {
var response = await client.get('/valid?csrf_token=evil'); var response = await client.get('/valid?csrf_token=evil');
print(response.body); print(response.body);
expect(response, hasStatus(400)); expect(response, hasStatus(403));
expect(response.body, contains('Missing'));
}); });
test('fake token', () async { test('fake token', () async {
// Get a valid CSRF, but ignore it. // Get a valid CSRF, but ignore it.
var response = await client.get('/'); var response = await client.get('/');
var sessionId = getCookie(response); 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'}); headers: {'cookie': 'DARTSESSID=$sessionId'});
print(response.body); print(response.body);
expect(response, hasStatus(400)); expect(response, hasStatus(400));

130
test/current_user_test.dart Normal file
View file

@ -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<String, T, Service<String, T>> serve<T>(String path,
T Function(Map) encoder, Map<String, dynamic> Function(T) decoder) {
var inner = MapService();
var mapped = inner.map(encoder, decoder);
return app.use<String, T, Service<String, T>>(path, mapped);
}
var userService =
serve<User>('/api/users', User.fromMap, (u) => u.toJson());
var houseService =
serve<House>('/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<Future<User>>((container) async {
var req = container.make<RequestContext>();
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<String, User>(
as: 'owner_id',
getId: (user) => user.id,
));
// A house is associated with the current user.
houseService.beforeCreated
.listen(hooks.associateCurrentUser<String, House, User>(
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<String, dynamic> 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<String, dynamic> toJson() {
return {'id': id, 'owner_id': ownerId, 'address': address};
}
}

View file

@ -26,13 +26,14 @@ main() {
..use('/roled', RoledService()); ..use('/roled', RoledService());
(app.findService('user_data') as HookedService) (app.findService('user_data') as HookedService)
..beforeIndexed.listen(hooks.queryWithCurrentUser()) ..beforeIndexed.listen(hooks.queryWithCurrentUser<String, User>())
..beforeCreated.listen(hooks.hashPassword()); ..beforeCreated.listen(hooks.hashPassword());
app.findService('artists') as HookedService app.findService('artists') as HookedService
..beforeIndexed.listen(hooks.restrictToAuthenticated()) ..beforeIndexed.listen(hooks.restrictToAuthenticated<Artist>())
..beforeRead.listen(hooks.restrictToOwner()) ..beforeRead.listen(hooks.restrictToOwner<String, Artist, Artist>())
..beforeCreated.listen(hooks.associateCurrentUser()); ..beforeCreated
.listen(hooks.associateCurrentUser<String, Artist, Artist>());
(app.findService('roled') as HookedService) (app.findService('roled') as HookedService)
..beforeIndexed.listen(Permission('foo:*').toHook()) ..beforeIndexed.listen(Permission('foo:*').toHook())
@ -245,7 +246,7 @@ class UserDataService extends Service {
} }
} }
class ArtistService extends Service { class ArtistService extends Service<String, Artist> {
static const List<Artist> _ARTISTS = const [_MICHAEL_JACKSON, _USHER]; static const List<Artist> _ARTISTS = const [_MICHAEL_JACKSON, _USHER];
@override @override
@ -256,10 +257,10 @@ class ArtistService extends Service {
@override @override
create(data, [params]) async { create(data, [params]) async {
if (data is! Map || !data.containsKey('userId')) return data;
throw AngelHttpException.badRequest(message: 'Required userId'); // if (data is! Map || !data.containsKey('userId'))
// throw AngelHttpException.badRequest(message: 'Required userId');
return {'foo': 'bar'}; // return {'foo': 'bar'};
} }
} }
@ -272,10 +273,11 @@ const Artist _USHER = const Artist(id: 'raymond', userId: 'Bob', name: 'Usher');
const Artist _MICHAEL_JACKSON = const Artist _MICHAEL_JACKSON =
const Artist(id: 'king_of_pop', userId: 'John', name: 'Michael Jackson'); const Artist(id: 'king_of_pop', userId: 'John', name: 'Michael Jackson');
class RoledService extends Service { class RoledService extends Service<String, Artist> {
@override @override
index([params]) async { index([params]) async {
return ['foo']; return [Artist(name: 'foo')];
// return ['foo'];
} }
@override @override

View file

@ -15,7 +15,8 @@ main() async {
TestClient client; TestClient client;
setUp(() async { setUp(() async {
app = Angel(); var logger = Logger.detached('angel_security')..onRecord.listen(prettyLog);
app = Angel(logger: logger);
app.chain([validate(untrustedSchema), sanitizeHtmlInput()]) app.chain([validate(untrustedSchema), sanitizeHtmlInput()])
..post('/untrusted', (RequestContext req, ResponseContext res) async { ..post('/untrusted', (RequestContext req, ResponseContext res) async {
String untrusted = req.bodyAsMap['html']; String untrusted = req.bodyAsMap['html'];
@ -46,7 +47,12 @@ main() async {
</html>'''); </html>''');
}); });
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); client = await connectTo(app);
}); });