Add 'packages/security/' from commit '711f13afd166eabbd5fe1a9b897e07a85b6fc2c4'
git-subtree-dir: packages/security git-subtree-mainline:42a86be549
git-subtree-split:711f13afd1
This commit is contained in:
commit
ac29392d7f
22 changed files with 701 additions and 0 deletions
1
packages/security/.gitattributes
vendored
Normal file
1
packages/security/.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
libinjection/* linguist-vendored
|
54
packages/security/.gitignore
vendored
Normal file
54
packages/security/.gitignore
vendored
Normal file
|
@ -0,0 +1,54 @@
|
|||
# See https://www.dartlang.org/tools/private-files.html
|
||||
|
||||
# Files and directories created by pub
|
||||
.buildlog
|
||||
.packages
|
||||
.project
|
||||
.pub/
|
||||
build/
|
||||
**/packages/
|
||||
|
||||
# Files created by dart2js
|
||||
# (Most Dart developers will use pub build to compile Dart, use/modify these
|
||||
# rules if you intend to use dart2js directly
|
||||
# Convention is to use extension '.dart.js' for Dart compiled to Javascript to
|
||||
# differentiate from explicit Javascript files)
|
||||
*.dart.js
|
||||
*.part.js
|
||||
*.js.deps
|
||||
*.js.map
|
||||
*.info.json
|
||||
|
||||
# Directory created by dartdoc
|
||||
doc/api/
|
||||
|
||||
# Don't commit pubspec lock file
|
||||
# (Library packages only! Remove pattern if developing an application package)
|
||||
pubspec.lock
|
||||
|
||||
log.txt
|
||||
.dart_tool
|
||||
|
||||
# Created by https://www.gitignore.io/api/cmake
|
||||
# Edit at https://www.gitignore.io/?templates=cmake
|
||||
|
||||
### CMake ###
|
||||
CMakeLists.txt.user
|
||||
CMakeCache.txt
|
||||
CMakeFiles
|
||||
CMakeScripts
|
||||
Testing
|
||||
Makefile
|
||||
cmake_install.cmake
|
||||
install_manifest.txt
|
||||
compile_commands.json
|
||||
CTestTestfile.cmake
|
||||
_deps
|
||||
|
||||
### CMake Patch ###
|
||||
# External projects
|
||||
*-prefix/
|
||||
|
||||
# End of https://www.gitignore.io/api/cmake
|
||||
build/
|
||||
cmake-build-*
|
1
packages/security/.travis.yml
Normal file
1
packages/security/.travis.yml
Normal file
|
@ -0,0 +1 @@
|
|||
language: dart
|
6
packages/security/CHANGELOG.md
Normal file
6
packages/security/CHANGELOG.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
# 2.0.0-alpha.1
|
||||
* Make `ServiceRateLimiter` more fail-proof.
|
||||
|
||||
# 2.0.0-alpha
|
||||
* Angel 2 updates. Remove previous functionality.
|
||||
* Add `CookieSigner`, `RateLimiter`/`InMemoryRateLimiter`/`ServiceRateLimiter`.
|
21
packages/security/LICENSE
Normal file
21
packages/security/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017 The Angel Framework
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
8
packages/security/README.md
Normal file
8
packages/security/README.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
# security
|
||||
[![Pub](https://img.shields.io/pub/v/angel_security.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
|
||||
holes.
|
||||
|
||||
**This package is currently going through a major overhaul, for version 2.**
|
4
packages/security/analysis_options.yaml
Normal file
4
packages/security/analysis_options.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
56
packages/security/example/cookie_signer.dart
Normal file
56
packages/security/example/cookie_signer.dart
Normal file
|
@ -0,0 +1,56 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:pretty_logging/pretty_logging.dart';
|
||||
|
||||
main() async {
|
||||
// Logging boilerplate.
|
||||
Logger.root.onRecord.listen(prettyLog);
|
||||
|
||||
// Create an app, and HTTP driver.
|
||||
var app = Angel(logger: Logger('cookie_signer')), http = AngelHttp(app);
|
||||
|
||||
// Create a cookie signer. Uses an SHA256 Hmac by default.
|
||||
var signer = CookieSigner.fromStringKey(
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ab');
|
||||
|
||||
// When a user visits /getid, give them a (signed) uniqid cookie.
|
||||
// When they visit /cookies, print their verified cookies.
|
||||
var rnd = Random.secure();
|
||||
|
||||
// Endpoint to give a signed cookie.
|
||||
app.get('/getid', (req, res) {
|
||||
// Write the uniqid cookie.
|
||||
var uniqid = rnd.nextInt(65536);
|
||||
signer.writeCookie(res, Cookie('uniqid', uniqid.toString()));
|
||||
|
||||
// Send a response.
|
||||
res.write('uniqid=$uniqid');
|
||||
});
|
||||
|
||||
// Endpoint to dump all verified cookies.
|
||||
//
|
||||
// The [onInvalidCookie] callback is optional, but
|
||||
// here we will use it to log invalid cookies.
|
||||
app.get('/cookies', (req, res) {
|
||||
var verifiedCookies = signer.readCookies(req, onInvalidCookie: (cookie) {
|
||||
app.logger.warning('Invalid cookie: $cookie');
|
||||
});
|
||||
res.writeln('${verifiedCookies.length} verified cookie(s)');
|
||||
res.writeln('${req.cookies.length} total unverified cookie(s)');
|
||||
for (var cookie in verifiedCookies) {
|
||||
res.writeln('${cookie.name}=${cookie.value}');
|
||||
}
|
||||
});
|
||||
|
||||
// 404 otherwise.
|
||||
app.fallback((req, res) => throw AngelHttpException.notFound(
|
||||
message: 'The only valid endpoints are /getid and /cookies.'));
|
||||
|
||||
// Start the server.
|
||||
await http.startServer('127.0.0.1', 3000);
|
||||
print('Cookie signer example listening at ${http.uri}');
|
||||
}
|
0
packages/security/example/main.dart
Normal file
0
packages/security/example/main.dart
Normal file
8
packages/security/example/pubspec.yaml
Normal file
8
packages/security/example/pubspec.yaml
Normal file
|
@ -0,0 +1,8 @@
|
|||
name: example
|
||||
publish_to: none
|
||||
dependencies:
|
||||
angel_production: ^1.0.0
|
||||
angel_redis: ^1.0.0
|
||||
angel_security:
|
||||
path: ../
|
||||
pretty_logging: ^1.0.0
|
34
packages/security/example/rate_limit.dart
Normal file
34
packages/security/example/rate_limit.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:pretty_logging/pretty_logging.dart';
|
||||
|
||||
main() async {
|
||||
// Logging boilerplate.
|
||||
Logger.root.onRecord.listen(prettyLog);
|
||||
|
||||
// Create an app, and HTTP driver.
|
||||
var app = Angel(logger: Logger('rate_limit')), http = AngelHttp(app);
|
||||
|
||||
// Create a simple in-memory rate limiter that limits users to 5
|
||||
// queries per 30 seconds.
|
||||
//
|
||||
// In this case, we rate limit users by IP address.
|
||||
var rateLimiter =
|
||||
InMemoryRateLimiter(5, Duration(seconds: 30), (req, res) => req.ip);
|
||||
|
||||
// `RateLimiter.handleRequest` is a middleware, and can be used anywhere
|
||||
// a middleware can be used. In this case, we apply the rate limiter to
|
||||
// *all* incoming requests.
|
||||
app.fallback(rateLimiter.handleRequest);
|
||||
|
||||
// Basic routes.
|
||||
app
|
||||
..get('/', (req, res) => 'Hello!')
|
||||
..fallback((req, res) => throw AngelHttpException.notFound());
|
||||
|
||||
// Start the server.
|
||||
await http.startServer('127.0.0.1', 3000);
|
||||
print('Rate limiting example listening at ${http.uri}');
|
||||
}
|
39
packages/security/example/rate_limit_redis.dart
Normal file
39
packages/security/example/rate_limit_redis.dart
Normal file
|
@ -0,0 +1,39 @@
|
|||
import 'package:angel_redis/angel_redis.dart';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_production/angel_production.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'package:resp_client/resp_client.dart';
|
||||
import 'package:resp_client/resp_commands.dart';
|
||||
|
||||
// We run this through angel_production, so that we can have
|
||||
// multiple instances, all using the same Redis queue.
|
||||
main(List<String> args) =>
|
||||
Runner('rate_limit_redis', configureServer).run(args);
|
||||
|
||||
configureServer(Angel app) async {
|
||||
// Create a simple rate limiter that limits users to 10
|
||||
// queries per 30 seconds.
|
||||
//
|
||||
// In this case, we rate limit users by IP address.
|
||||
//
|
||||
// Our Redis store will be used to manage windows.
|
||||
var connection = await connectSocket('localhost');
|
||||
var client = RespClient(connection);
|
||||
var service =
|
||||
RedisService(RespCommands(client), prefix: 'rate_limit_redis_example');
|
||||
var rateLimiter = ServiceRateLimiter(
|
||||
10, Duration(seconds: 30), service, (req, res) => req.ip);
|
||||
|
||||
// `RateLimiter.handleRequest` is a middleware, and can be used anywhere
|
||||
// a middleware can be used. In this case, we apply the rate limiter to
|
||||
// *all* incoming requests.
|
||||
app.fallback(rateLimiter.handleRequest);
|
||||
|
||||
// Basic routes.
|
||||
app
|
||||
..get('/', (req, res) {
|
||||
var instance = req.container.make<InstanceInfo>();
|
||||
res.writeln('This is instance ${instance.id}.');
|
||||
})
|
||||
..fallback((req, res) => throw AngelHttpException.notFound());
|
||||
}
|
5
packages/security/lib/angel_security.dart
Normal file
5
packages/security/lib/angel_security.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
export 'src/cookie_signer.dart';
|
||||
export 'src/in_memory_rate_limiter.dart';
|
||||
export 'src/rate_limiter.dart';
|
||||
export 'src/rate_limiting_window.dart';
|
||||
export 'src/service_rate_limiter.dart';
|
132
packages/security/lib/src/cookie_signer.dart
Normal file
132
packages/security/lib/src/cookie_signer.dart
Normal file
|
@ -0,0 +1,132 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A utility that signs, and verifies, cookies using an [Hmac].
|
||||
///
|
||||
/// It aims to mitigate so-called "cookie poisoning" attacks by
|
||||
/// ensuring that clients cannot tamper with the cookies they have been
|
||||
/// sent.
|
||||
class CookieSigner {
|
||||
/// The [Hmac] used to sign and verify cookies.
|
||||
final Hmac hmac;
|
||||
|
||||
/// Creates an [hmac] from an array of [keyBytes] and a
|
||||
/// [hash] (defaults to [sha256]).
|
||||
CookieSigner(List<int> keyBytes, {Hash hash})
|
||||
: hmac = Hmac(hash ?? sha256, keyBytes);
|
||||
|
||||
CookieSigner.fromHmac(this.hmac);
|
||||
|
||||
/// Creates an [hmac] from a string [key] and a
|
||||
/// [hash] (defaults to [sha256]).
|
||||
factory CookieSigner.fromStringKey(String key, {Hash hash}) {
|
||||
return CookieSigner(utf8.encode(key), hash: hash);
|
||||
}
|
||||
|
||||
/// Returns a set of all the incoming cookies that had a
|
||||
/// valid signature attached. Any cookies without a
|
||||
/// signature, or with a signature that does not match the
|
||||
/// provided data, are not included in the output.
|
||||
///
|
||||
/// If an [onInvalidCookie] callback is passed, then it will
|
||||
/// be invoked for each unsigned or improperly-signed cookie.
|
||||
List<Cookie> readCookies(RequestContext req,
|
||||
{void Function(Cookie) onInvalidCookie}) {
|
||||
return req.cookies.fold([], (out, cookie) {
|
||||
var data = getCookiePayloadAndSignature(cookie.value);
|
||||
if (data == null || (data[1] != computeCookieSignature(data[0]))) {
|
||||
if (onInvalidCookie != null) {
|
||||
onInvalidCookie(cookie);
|
||||
}
|
||||
return out;
|
||||
} else {
|
||||
return out..add(cookieWithNewValue(cookie, data[0]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Determines whether a cookie is properly signed,
|
||||
/// if it is signed at all.
|
||||
///
|
||||
/// If there is no signature, returns `false`.
|
||||
/// If the provided signature does not match the payload
|
||||
/// provided, returns `false`.
|
||||
/// Otherwise, returns true.
|
||||
bool verify(Cookie cookie) {
|
||||
var data = getCookiePayloadAndSignature(cookie.value);
|
||||
return (data != null && (data[1] == computeCookieSignature(data[0])));
|
||||
}
|
||||
|
||||
/// Gets the payload and signature of a given [cookie], WITHOUT
|
||||
/// verifying its integrity.
|
||||
///
|
||||
/// Returns `null` if no payload can be found.
|
||||
/// Otherwise, returns a list with a length of 2, where
|
||||
/// the item at index `0` is the payload, and the item at
|
||||
/// index `1` is the signature.
|
||||
List<String> getCookiePayloadAndSignature(String cookieValue) {
|
||||
var dot = cookieValue.indexOf('.');
|
||||
if (dot <= 0) {
|
||||
return null;
|
||||
} else if (dot >= cookieValue.length - 1) {
|
||||
return null;
|
||||
} else {
|
||||
var payload = cookieValue.substring(0, dot);
|
||||
var sig = cookieValue.substring(dot + 1);
|
||||
return [payload, sig];
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs a single [cookie], and adds it to an outgoing
|
||||
/// [res]ponse. The input [cookie] is not modified.
|
||||
///
|
||||
/// See [createSignedCookie].
|
||||
void writeCookie(ResponseContext res, Cookie cookie) {
|
||||
res.cookies.add(createSignedCookie(cookie));
|
||||
}
|
||||
|
||||
/// Signs a set of [cookies], and adds them to an outgoing
|
||||
/// [res]ponse. The input [cookies] are not modified.
|
||||
///
|
||||
/// See [createSignedCookie].
|
||||
void writeCookies(ResponseContext res, Iterable<Cookie> cookies) {
|
||||
cookies.forEach((c) => writeCookie(res, c));
|
||||
}
|
||||
|
||||
/// Returns a new cookie, replacing the value of an input
|
||||
/// [cookie] with one that is signed with the [hmac].
|
||||
///
|
||||
/// The new value is:
|
||||
/// `cookie.value + "." + base64Url(sig)`
|
||||
///
|
||||
/// Where `sig` is the cookie's value, signed with the [hmac].
|
||||
Cookie createSignedCookie(Cookie cookie) {
|
||||
return cookieWithNewValue(
|
||||
cookie, cookie.value + '.' + computeCookieSignature(cookie.value));
|
||||
}
|
||||
|
||||
/// Returns a new [Cookie] that is the same as the input
|
||||
/// [cookie], but with a [newValue].
|
||||
Cookie cookieWithNewValue(Cookie cookie, String newValue) {
|
||||
return Cookie(cookie.name, newValue)
|
||||
..domain = cookie.domain
|
||||
..expires = cookie.expires
|
||||
..httpOnly = cookie.httpOnly
|
||||
..maxAge = cookie.maxAge
|
||||
..path = cookie.path
|
||||
..secure = cookie.secure;
|
||||
}
|
||||
|
||||
/// Computes the *signature* of a [cookieValue], either for
|
||||
/// signing an outgoing cookie, or verifying an incoming cookie.
|
||||
String computeCookieSignature(String cookieValue) {
|
||||
// base64Url(cookie) + "." + base64Url(sig)
|
||||
// var encodedCookie = base64Url.encode(cookieValue.codeUnits);
|
||||
var sigBytes = hmac.convert(cookieValue.codeUnits).bytes;
|
||||
return base64Url.encode(sigBytes);
|
||||
// var sig = base64Url.encode(sigBytes);
|
||||
// return encodedCookie + '.' + sig;
|
||||
}
|
||||
}
|
19
packages/security/lib/src/csrf_filter.dart
Normal file
19
packages/security/lib/src/csrf_filter.dart
Normal file
|
@ -0,0 +1,19 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'cookie_signer.dart';
|
||||
|
||||
class CsrfToken {
|
||||
final String value;
|
||||
|
||||
CsrfToken(this.value);
|
||||
}
|
||||
|
||||
class CsrfFilter {
|
||||
final CookieSigner cookieSigner;
|
||||
|
||||
CsrfFilter(this.cookieSigner);
|
||||
|
||||
Future<CsrfToken> readCsrfToken(RequestContext req) async {
|
||||
|
||||
}
|
||||
}
|
5
packages/security/lib/src/helmet.dart
Normal file
5
packages/security/lib/src/helmet.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
|
||||
class Helmet {
|
||||
|
||||
}
|
30
packages/security/lib/src/in_memory_rate_limiter.dart
Normal file
30
packages/security/lib/src/in_memory_rate_limiter.dart
Normal file
|
@ -0,0 +1,30 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'rate_limiter.dart';
|
||||
import 'rate_limiting_window.dart';
|
||||
|
||||
/// A simple [RateLimiter] implementation that uses a simple in-memory map
|
||||
/// to store rate limiting information.
|
||||
class InMemoryRateLimiter<User> extends RateLimiter<User> {
|
||||
/// A callback used to compute the current user.
|
||||
final FutureOr<User> Function(RequestContext, ResponseContext) getUser;
|
||||
final _cache = <User, RateLimitingWindow<User>>{};
|
||||
|
||||
InMemoryRateLimiter(
|
||||
int maxPointsPerWindow, Duration windowDuration, this.getUser,
|
||||
{String errorMessage})
|
||||
: super(maxPointsPerWindow, windowDuration, errorMessage: errorMessage);
|
||||
|
||||
@override
|
||||
FutureOr<RateLimitingWindow<User>> getCurrentWindow(
|
||||
RequestContext req, ResponseContext res, DateTime currentTime) async {
|
||||
var user = await getUser(req, res);
|
||||
return _cache[user] ??= RateLimitingWindow(user, currentTime, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<void> updateCurrentWindow(RequestContext req, ResponseContext res,
|
||||
RateLimitingWindow<User> window, DateTime currentTime) {
|
||||
_cache[window.user] = window;
|
||||
}
|
||||
}
|
156
packages/security/lib/src/rate_limiter.dart
Normal file
156
packages/security/lib/src/rate_limiter.dart
Normal file
|
@ -0,0 +1,156 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_security/angel_security.dart';
|
||||
import 'rate_limiting_window.dart';
|
||||
|
||||
/// A base class that facilitates rate limiting API's or endpoints,
|
||||
/// typically to prevent spam and abuse.
|
||||
///
|
||||
/// The rate limiter operates under the assumption that a [User] object
|
||||
/// can be computed from each request, as well as information about
|
||||
/// the current rate-limiting window.
|
||||
abstract class RateLimiter<User> {
|
||||
/// The maximum number of points that may be consumed
|
||||
/// within the given [windowDuration].
|
||||
final int maxPointsPerWindow;
|
||||
|
||||
/// The amount of time, during which, a user is not allowed to consume
|
||||
/// more than [maxPointsPerWindow].
|
||||
final Duration windowDuration;
|
||||
|
||||
/// The error message to send to a [User] who has exceeded the
|
||||
/// rate limit during the current window.
|
||||
///
|
||||
/// This only applies to the default implementation of
|
||||
/// [rejectRequest].
|
||||
final String errorMessage;
|
||||
|
||||
RateLimiter(this.maxPointsPerWindow, this.windowDuration,
|
||||
{String errorMessage})
|
||||
: this.errorMessage = errorMessage ?? 'Rate limit exceeded.';
|
||||
|
||||
/// Computes the current window in which the user is acting.
|
||||
///
|
||||
/// For example, if your API was limited to 1000 requests/hour,
|
||||
/// then you would return a window containing the current hour,
|
||||
/// and the number of requests the user has sent in the past hour.
|
||||
FutureOr<RateLimitingWindow<User>> getCurrentWindow(
|
||||
RequestContext req, ResponseContext res, DateTime currentTime);
|
||||
|
||||
/// Updates the underlying store with information about the new
|
||||
/// [window] that the user is operating in.
|
||||
FutureOr<void> updateCurrentWindow(RequestContext req, ResponseContext res,
|
||||
RateLimitingWindow<User> window, DateTime currentTime);
|
||||
|
||||
/// Computes the amount of points that a given request will cost. This amount
|
||||
/// is then added to the amount of points that the user has already consumed
|
||||
/// in the current [window].
|
||||
///
|
||||
/// The default behavior is to return `1`, which signifies that all requests
|
||||
/// carry the same weight.
|
||||
FutureOr<int> getEndpointCost(RequestContext req, ResponseContext res,
|
||||
RateLimitingWindow<User> window) {
|
||||
return Future<int>.value(1);
|
||||
}
|
||||
|
||||
/// Alerts the user of information pertinent to the current [window].
|
||||
///
|
||||
/// The default implementation is to send the following headers, akin to
|
||||
/// Github's v4 Graph API:
|
||||
/// * `X-RateLimit-Limit`: The maximum number of points consumed per window.
|
||||
/// * `X-RateLimit-Remaining`: The remaining number of points that may be consumed
|
||||
/// before the rate limit is reached for the current window.
|
||||
/// * `X-RateLimit-Reset`: The Unix timestamp, at which the window will
|
||||
/// reset.
|
||||
FutureOr<void> sendWindowInformation(RequestContext req, ResponseContext res,
|
||||
RateLimitingWindow<User> window) {
|
||||
res.headers.addAll({
|
||||
'x-ratelimit-limit': window.pointLimit.toString(),
|
||||
'x-ratelimit-remaining': window.remainingPoints.toString(),
|
||||
'x-ratelimit-reset':
|
||||
(window.resetTime.millisecondsSinceEpoch ~/ 1000).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Signals to a user that they have exceeded the rate limit for the
|
||||
/// current window, and terminates execution of the current [RequestContext].
|
||||
///
|
||||
/// The default implementation is throw an [AngelHttpException] with
|
||||
/// status code `429` and the given `errorMessage`, as well as sending
|
||||
/// a [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429)
|
||||
/// header, and then returning `false`.
|
||||
///
|
||||
/// Whatever is returned here will be returned in [handleRequest].
|
||||
FutureOr<Object> rejectRequest(RequestContext req, ResponseContext res,
|
||||
RateLimitingWindow<User> window, DateTime currentTime) {
|
||||
var retryAfter = window.resetTime.difference(currentTime);
|
||||
res.headers['retry-after'] = retryAfter.inSeconds.toString();
|
||||
throw AngelHttpException(null, message: errorMessage, statusCode: 429);
|
||||
}
|
||||
|
||||
/// A request middleware that returns `true` if the user has not yet
|
||||
/// exceeded the [maxPointsPerWindow].
|
||||
///
|
||||
/// Because this handler is typically called *before* business logic is
|
||||
/// executed, it technically checks whether the *previous* call raised the
|
||||
/// number of consumed points to greater than, or equal to, the
|
||||
/// [maxPointsPerWindow].
|
||||
Future handleRequest(RequestContext req, ResponseContext res) async {
|
||||
// Obtain information about the current window.
|
||||
var now = DateTime.now().toUtc();
|
||||
var currentWindow = await getCurrentWindow(req, res, now);
|
||||
// Check if the rate limit has been exceeded. If so, reject the request.
|
||||
// To perform this check, we must first determine whether a new window
|
||||
// has begun since the previous request.
|
||||
var currentWindowEnd = currentWindow.startTime.toUtc().add(windowDuration);
|
||||
// We must also compute the missing information about the current window,
|
||||
// so that we can relay that information to the client.
|
||||
var remainingPoints = maxPointsPerWindow - currentWindow.pointsConsumed;
|
||||
currentWindow
|
||||
..pointLimit = maxPointsPerWindow
|
||||
..remainingPoints = remainingPoints < 0 ? 0 : remainingPoints
|
||||
..resetTime = currentWindow.startTime.add(windowDuration);
|
||||
|
||||
// If the previous window ended in the past, begin a new window.
|
||||
if (now.compareTo(currentWindowEnd) >= 0) {
|
||||
// Create a new window.
|
||||
var cost = await getEndpointCost(req, res, currentWindow);
|
||||
var remainingPoints = maxPointsPerWindow - cost;
|
||||
var newWindow = RateLimitingWindow(currentWindow.user, now, cost)
|
||||
..pointLimit = maxPointsPerWindow
|
||||
..remainingPoints = remainingPoints < 0 ? 0 : remainingPoints
|
||||
..resetTime = now.add(windowDuration);
|
||||
await updateCurrentWindow(req, res, newWindow, now);
|
||||
await sendWindowInformation(req, res, newWindow);
|
||||
}
|
||||
|
||||
// If we are still within the previous window, check if the user has
|
||||
// exceeded the rate limit.
|
||||
//
|
||||
// Otherwise, update the current window.
|
||||
//
|
||||
// At this point in the computation,
|
||||
// we are still only considering whether the *previous* request took the
|
||||
// user over the rate limit.
|
||||
else if (currentWindow.pointsConsumed >= maxPointsPerWindow) {
|
||||
await sendWindowInformation(req, res, currentWindow);
|
||||
var result = await rejectRequest(req, res, currentWindow, now);
|
||||
if (result != null) return result;
|
||||
return false;
|
||||
} else {
|
||||
// Add the cost of the current endpoint, and update the window.
|
||||
var cost = await getEndpointCost(req, res, currentWindow);
|
||||
currentWindow
|
||||
..pointsConsumed += cost
|
||||
..remainingPoints -= cost;
|
||||
if (currentWindow.remainingPoints < 0) {
|
||||
currentWindow.remainingPoints = 0;
|
||||
}
|
||||
await updateCurrentWindow(req, res, currentWindow, now);
|
||||
await sendWindowInformation(req, res, currentWindow);
|
||||
}
|
||||
|
||||
// Pass through, so other handlers can be executed.
|
||||
return true;
|
||||
}
|
||||
}
|
50
packages/security/lib/src/rate_limiting_window.dart
Normal file
50
packages/security/lib/src/rate_limiting_window.dart
Normal file
|
@ -0,0 +1,50 @@
|
|||
/// A representation of the abstract "rate-limiting window" in which
|
||||
/// a [user] is accessing some API or endpoint.
|
||||
class RateLimitingWindow<User> {
|
||||
/// The user who is accessing the endpoint.
|
||||
User user;
|
||||
|
||||
/// The time at which the user's current window began.
|
||||
DateTime startTime;
|
||||
|
||||
/// The number of points the user has already consumed within
|
||||
/// the current window.
|
||||
int pointsConsumed;
|
||||
|
||||
/// The maximum amount of points allowed within a single window.
|
||||
///
|
||||
/// This field is typically only set by the [RateLimiter] middleware,
|
||||
/// and is therefore optional in the constructor.
|
||||
int pointLimit;
|
||||
|
||||
/// The amount of points the user can consume before hitting the
|
||||
/// rate limit for the current window.
|
||||
///
|
||||
/// This field is typically only set by the [RateLimiter] middleware,
|
||||
/// and is therefore optional in the constructor.
|
||||
int remainingPoints;
|
||||
|
||||
/// The time at which the window will reset.
|
||||
///
|
||||
/// This field is typically only set by the [RateLimiter] middleware,
|
||||
/// and is therefore optional in the constructor.
|
||||
DateTime resetTime;
|
||||
|
||||
RateLimitingWindow(this.user, this.startTime, this.pointsConsumed,
|
||||
{this.pointLimit, this.remainingPoints, this.resetTime});
|
||||
|
||||
factory RateLimitingWindow.fromJson(Map<String, dynamic> map) {
|
||||
return RateLimitingWindow(
|
||||
map['user'] as User,
|
||||
DateTime.parse(map['start_time'] as String),
|
||||
int.parse(map['points_consumed'] as String));
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user': user,
|
||||
'start_time': startTime.toIso8601String(),
|
||||
'points_consumed': pointsConsumed.toString(),
|
||||
};
|
||||
}
|
||||
}
|
50
packages/security/lib/src/service_rate_limiter.dart
Normal file
50
packages/security/lib/src/service_rate_limiter.dart
Normal file
|
@ -0,0 +1,50 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'rate_limiter.dart';
|
||||
import 'rate_limiting_window.dart';
|
||||
|
||||
/// A RateLimiter] implementation that uses a [Service]
|
||||
/// to store rate limiting information.
|
||||
class ServiceRateLimiter<Id> extends RateLimiter<Id> {
|
||||
/// The underlying [Service] used to store data.
|
||||
final Service<Id, Map<String, dynamic>> service;
|
||||
|
||||
/// A callback used to compute the current user ID.
|
||||
final FutureOr<Id> Function(RequestContext, ResponseContext) getId;
|
||||
|
||||
ServiceRateLimiter(
|
||||
int maxPointsPerWindow, Duration windowDuration, this.service, this.getId,
|
||||
{String errorMessage})
|
||||
: super(maxPointsPerWindow, windowDuration, errorMessage: errorMessage);
|
||||
|
||||
@override
|
||||
FutureOr<RateLimitingWindow<Id>> getCurrentWindow(
|
||||
RequestContext req, ResponseContext res, DateTime currentTime) async {
|
||||
var id = await getId(req, res);
|
||||
try {
|
||||
var data = await service.read(id);
|
||||
if (data != null) {
|
||||
return RateLimitingWindow.fromJson(data);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is AngelHttpException) {
|
||||
if (e.statusCode == 404) {
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
var window = RateLimitingWindow(id, currentTime, 0);
|
||||
await updateCurrentWindow(req, res, window, currentTime);
|
||||
return window;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<void> updateCurrentWindow(RequestContext req, ResponseContext res,
|
||||
RateLimitingWindow<Id> window, DateTime currentTime) async {
|
||||
await service.update(window.user, window.toJson());
|
||||
}
|
||||
}
|
19
packages/security/pubspec.yaml
Normal file
19
packages/security/pubspec.yaml
Normal file
|
@ -0,0 +1,19 @@
|
|||
name: angel_security
|
||||
version: 2.0.0-alpha.1
|
||||
description: Angel infrastructure for improving security, rate limiting, and more.
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/angel-dart/security
|
||||
environment:
|
||||
sdk: ">=2.0.0-dev <3.0.0"
|
||||
dependencies:
|
||||
angel_framework: ^2.0.0
|
||||
crypto: ^2.0.0
|
||||
dev_dependencies:
|
||||
angel_auth: ^2.0.0
|
||||
angel_production: ^1.0.0
|
||||
angel_redis: ^1.0.0
|
||||
angel_test: ^2.0.0
|
||||
angel_validate: ^2.0.0
|
||||
pedantic: ^1.0.0
|
||||
pretty_logging: ^1.0.0
|
||||
test: ^1.0.0
|
3
packages/security/test/all_test.dart
Normal file
3
packages/security/test/all_test.dart
Normal file
|
@ -0,0 +1,3 @@
|
|||
import 'package:test/test.dart';
|
||||
|
||||
void main() {}
|
Loading…
Reference in a new issue