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