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
|
include: package:pedantic/analysis_options.yaml
|
||||||
analyzer:
|
analyzer:
|
||||||
strong-mode:
|
strong-mode:
|
||||||
implicit-casts: true
|
implicit-casts: false
|
|
@ -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];
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
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());
|
..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
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue