diff --git a/packages/security/.gitattributes b/packages/security/.gitattributes new file mode 100644 index 00000000..9447b90c --- /dev/null +++ b/packages/security/.gitattributes @@ -0,0 +1 @@ +libinjection/* linguist-vendored \ No newline at end of file diff --git a/packages/security/.gitignore b/packages/security/.gitignore new file mode 100644 index 00000000..87ea0c20 --- /dev/null +++ b/packages/security/.gitignore @@ -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-* \ No newline at end of file diff --git a/packages/security/.travis.yml b/packages/security/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/packages/security/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/packages/security/CHANGELOG.md b/packages/security/CHANGELOG.md new file mode 100644 index 00000000..3aaa1f19 --- /dev/null +++ b/packages/security/CHANGELOG.md @@ -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`. \ No newline at end of file diff --git a/packages/security/LICENSE b/packages/security/LICENSE new file mode 100644 index 00000000..89074fd3 --- /dev/null +++ b/packages/security/LICENSE @@ -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. diff --git a/packages/security/README.md b/packages/security/README.md new file mode 100644 index 00000000..53339b5f --- /dev/null +++ b/packages/security/README.md @@ -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.** \ No newline at end of file diff --git a/packages/security/analysis_options.yaml b/packages/security/analysis_options.yaml new file mode 100644 index 00000000..c230cee7 --- /dev/null +++ b/packages/security/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/packages/security/example/cookie_signer.dart b/packages/security/example/cookie_signer.dart new file mode 100644 index 00000000..c2db9ff3 --- /dev/null +++ b/packages/security/example/cookie_signer.dart @@ -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}'); +} diff --git a/packages/security/example/main.dart b/packages/security/example/main.dart new file mode 100644 index 00000000..e69de29b diff --git a/packages/security/example/pubspec.yaml b/packages/security/example/pubspec.yaml new file mode 100644 index 00000000..25444875 --- /dev/null +++ b/packages/security/example/pubspec.yaml @@ -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 \ No newline at end of file diff --git a/packages/security/example/rate_limit.dart b/packages/security/example/rate_limit.dart new file mode 100644 index 00000000..baccb01a --- /dev/null +++ b/packages/security/example/rate_limit.dart @@ -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}'); +} diff --git a/packages/security/example/rate_limit_redis.dart b/packages/security/example/rate_limit_redis.dart new file mode 100644 index 00000000..40d9c33b --- /dev/null +++ b/packages/security/example/rate_limit_redis.dart @@ -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 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(); + res.writeln('This is instance ${instance.id}.'); + }) + ..fallback((req, res) => throw AngelHttpException.notFound()); +} diff --git a/packages/security/lib/angel_security.dart b/packages/security/lib/angel_security.dart new file mode 100644 index 00000000..98a01dd1 --- /dev/null +++ b/packages/security/lib/angel_security.dart @@ -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'; \ No newline at end of file diff --git a/packages/security/lib/src/cookie_signer.dart b/packages/security/lib/src/cookie_signer.dart new file mode 100644 index 00000000..4d9c81de --- /dev/null +++ b/packages/security/lib/src/cookie_signer.dart @@ -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 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 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 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 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; + } +} diff --git a/packages/security/lib/src/csrf_filter.dart b/packages/security/lib/src/csrf_filter.dart new file mode 100644 index 00000000..df0e8938 --- /dev/null +++ b/packages/security/lib/src/csrf_filter.dart @@ -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 readCsrfToken(RequestContext req) async { + + } +} diff --git a/packages/security/lib/src/helmet.dart b/packages/security/lib/src/helmet.dart new file mode 100644 index 00000000..7cababd9 --- /dev/null +++ b/packages/security/lib/src/helmet.dart @@ -0,0 +1,5 @@ +import 'package:angel_framework/angel_framework.dart'; + +class Helmet { + +} \ No newline at end of file diff --git a/packages/security/lib/src/in_memory_rate_limiter.dart b/packages/security/lib/src/in_memory_rate_limiter.dart new file mode 100644 index 00000000..d545b8b6 --- /dev/null +++ b/packages/security/lib/src/in_memory_rate_limiter.dart @@ -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 extends RateLimiter { + /// A callback used to compute the current user. + final FutureOr Function(RequestContext, ResponseContext) getUser; + final _cache = >{}; + + InMemoryRateLimiter( + int maxPointsPerWindow, Duration windowDuration, this.getUser, + {String errorMessage}) + : super(maxPointsPerWindow, windowDuration, errorMessage: errorMessage); + + @override + FutureOr> getCurrentWindow( + RequestContext req, ResponseContext res, DateTime currentTime) async { + var user = await getUser(req, res); + return _cache[user] ??= RateLimitingWindow(user, currentTime, 0); + } + + @override + FutureOr updateCurrentWindow(RequestContext req, ResponseContext res, + RateLimitingWindow window, DateTime currentTime) { + _cache[window.user] = window; + } +} diff --git a/packages/security/lib/src/rate_limiter.dart b/packages/security/lib/src/rate_limiter.dart new file mode 100644 index 00000000..fb8b99cd --- /dev/null +++ b/packages/security/lib/src/rate_limiter.dart @@ -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 { + /// 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> getCurrentWindow( + RequestContext req, ResponseContext res, DateTime currentTime); + + /// Updates the underlying store with information about the new + /// [window] that the user is operating in. + FutureOr updateCurrentWindow(RequestContext req, ResponseContext res, + RateLimitingWindow 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 getEndpointCost(RequestContext req, ResponseContext res, + RateLimitingWindow window) { + return Future.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 sendWindowInformation(RequestContext req, ResponseContext res, + RateLimitingWindow 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 rejectRequest(RequestContext req, ResponseContext res, + RateLimitingWindow 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; + } +} diff --git a/packages/security/lib/src/rate_limiting_window.dart b/packages/security/lib/src/rate_limiting_window.dart new file mode 100644 index 00000000..07fff1b1 --- /dev/null +++ b/packages/security/lib/src/rate_limiting_window.dart @@ -0,0 +1,50 @@ +/// A representation of the abstract "rate-limiting window" in which +/// a [user] is accessing some API or endpoint. +class RateLimitingWindow { + /// 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 map) { + return RateLimitingWindow( + map['user'] as User, + DateTime.parse(map['start_time'] as String), + int.parse(map['points_consumed'] as String)); + } + + Map toJson() { + return { + 'user': user, + 'start_time': startTime.toIso8601String(), + 'points_consumed': pointsConsumed.toString(), + }; + } +} diff --git a/packages/security/lib/src/service_rate_limiter.dart b/packages/security/lib/src/service_rate_limiter.dart new file mode 100644 index 00000000..f3bb1e4f --- /dev/null +++ b/packages/security/lib/src/service_rate_limiter.dart @@ -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 extends RateLimiter { + /// The underlying [Service] used to store data. + final Service> service; + + /// A callback used to compute the current user ID. + final FutureOr Function(RequestContext, ResponseContext) getId; + + ServiceRateLimiter( + int maxPointsPerWindow, Duration windowDuration, this.service, this.getId, + {String errorMessage}) + : super(maxPointsPerWindow, windowDuration, errorMessage: errorMessage); + + @override + FutureOr> 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 updateCurrentWindow(RequestContext req, ResponseContext res, + RateLimitingWindow window, DateTime currentTime) async { + await service.update(window.user, window.toJson()); + } +} diff --git a/packages/security/pubspec.yaml b/packages/security/pubspec.yaml new file mode 100644 index 00000000..5211f6aa --- /dev/null +++ b/packages/security/pubspec.yaml @@ -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 +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 diff --git a/packages/security/test/all_test.dart b/packages/security/test/all_test.dart new file mode 100644 index 00000000..54b7b267 --- /dev/null +++ b/packages/security/test/all_test.dart @@ -0,0 +1,3 @@ +import 'package:test/test.dart'; + +void main() {}