current user tests
This commit is contained in:
parent
275f2129f1
commit
b77d6cbdfa
10 changed files with 189 additions and 33 deletions
|
@ -1,4 +1,4 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: true
|
||||
implicit-casts: false
|
|
@ -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];
|
||||
|
|
|
@ -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<Password, User>(
|
||||
{Hash hasher,
|
||||
String passwordField,
|
||||
getPassword(user),
|
||||
FutureOr<Password> Function(User) getPassword,
|
||||
setPassword(password, user)}) {
|
||||
Hash h = hasher ?? sha256;
|
||||
|
||||
|
|
|
@ -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<Id, User>(
|
||||
{String as,
|
||||
|
@ -26,15 +26,13 @@ HookedServiceEventListener queryWithCurrentUser<Id, User>(
|
|||
return;
|
||||
}
|
||||
|
||||
_getId(user) {
|
||||
Future<Id> _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<Id, User>(
|
|||
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))
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:convert';
|
||||
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*>',
|
||||
caseSensitive: false): ''
|
||||
};
|
||||
|
@ -15,7 +15,8 @@ RequestHandler sanitizeHtmlInput(
|
|||
{bool body = true,
|
||||
bool query = true,
|
||||
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 {
|
||||
if (body) {
|
||||
|
|
|
@ -14,3 +14,6 @@ dev_dependencies:
|
|||
console: ^3.0.0
|
||||
pedantic: ^1.0.0
|
||||
test: ^1.0.0
|
||||
dependency_overrides:
|
||||
angel_framework:
|
||||
path: ../framework
|
||||
|
|
|
@ -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));
|
||||
|
|
130
test/current_user_test.dart
Normal file
130
test/current_user_test.dart
Normal 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};
|
||||
}
|
||||
}
|
|
@ -26,13 +26,14 @@ main() {
|
|||
..use('/roled', RoledService());
|
||||
|
||||
(app.findService('user_data') as HookedService)
|
||||
..beforeIndexed.listen(hooks.queryWithCurrentUser())
|
||||
..beforeIndexed.listen(hooks.queryWithCurrentUser<String, User>())
|
||||
..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<Artist>())
|
||||
..beforeRead.listen(hooks.restrictToOwner<String, Artist, Artist>())
|
||||
..beforeCreated
|
||||
.listen(hooks.associateCurrentUser<String, Artist, Artist>());
|
||||
|
||||
(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<String, Artist> {
|
||||
static const List<Artist> _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<String, Artist> {
|
||||
@override
|
||||
index([params]) async {
|
||||
return ['foo'];
|
||||
return [Artist(name: 'foo')];
|
||||
// return ['foo'];
|
||||
}
|
||||
|
||||
@override
|
|
@ -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 {
|
|||
</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);
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue