diff --git a/.circleci/config.yml b/.circleci/config.yml index 7c4b39c..d207044 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,23 +2,25 @@ version: 2 jobs: MacOS: macos: - xcode: "9.0" + xcode: "10.0.0" steps: - checkout - restore_cache: keys: - v1-spm-deps-{{ checksum "Package.swift" }} - run: - name: Install CMySQL and CTLS + name: Install dependencies command: | brew tap vapor/homebrew-tap brew install cmysql brew install ctls + brew install libressl + brew install cstack - run: name: Build and Run Tests no_output_timeout: 1800 command: | - swift package generate-xcodeproj --enable-code-coverage + swift package generate-xcodeproj --enable-code-coverage xcodebuild -scheme Gatekeeper-Package -enableCodeCoverage YES test | xcpretty - run: name: Report coverage to Codecov @@ -30,14 +32,14 @@ jobs: - .build Linux: docker: - - image: brettrtoomey/vapor-ci:0.0.1 + - image: nodesvapor/vapor-ci:swift-4.2 steps: - checkout - restore_cache: keys: - - v2-spm-deps-{{ checksum "Package.swift" }} + - v1-spm-deps-{{ checksum "Package.swift" }} - run: - name: Copy Package file + name: Copy Package File command: cp Package.swift res - run: name: Build and Run Tests @@ -45,10 +47,10 @@ jobs: command: | swift test -Xswiftc -DNOJSON - run: - name: Restoring Package file + name: Restoring Package File command: mv res Package.swift - save_cache: - key: v2-spm-deps-{{ checksum "Package.swift" }} + key: v1-spm-deps-{{ checksum "Package.swift" }} paths: - .build workflows: diff --git a/.gitignore b/.gitignore index d5f2889..460f13c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ xcuserdata *.xcodeproj Config/secrets/ .DS_Store -node_modules/ -bower_components/ .swift-version CMakeLists.txt +Package.resolved \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 1e79593..5777ef8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,11 +2,12 @@ included: - Sources function_body_length: warning: 60 -variable_name: +identifier_name: min_length: warning: 2 -line_length: 80 +line_length: 100 disabled_rules: - opening_brace + - nesting colon: - flexible_right_spacing: true + flexible_right_spacing: true \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1ced862..8de12da 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2018 Nodes +Copyright (c) 2017-2019 Nodes Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift index a2f9395..1d0257e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,24 @@ +// swift-tools-version:4.2 import PackageDescription let package = Package( name: "Gatekeeper", + products: [ + .library( + name: "Gatekeeper", + targets: ["Gatekeeper"]), + ], dependencies: [ - .Package(url: "https://github.com/vapor/vapor.git", majorVersion: 2) + .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"), + ], + targets: [ + .target( + name: "Gatekeeper", + dependencies: [ + "Vapor" + ]), + .testTarget( + name: "GatekeeperTests", + dependencies: ["Gatekeeper"]), ] ) diff --git a/README.md b/README.md index 7b60074..f7f876e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Gatekeeper 👮 -[![Swift Version](https://img.shields.io/badge/Swift-3-brightgreen.svg)](http://swift.org) -[![Vapor Version](https://img.shields.io/badge/Vapor-2-F6CBCA.svg)](http://vapor.codes) +[![Swift Version](https://img.shields.io/badge/Swift-4.2-brightgreen.svg)](http://swift.org) +[![Vapor Version](https://img.shields.io/badge/Vapor-3-30B6FC.svg)](http://vapor.codes) [![Circle CI](https://circleci.com/gh/nodes-vapor/gatekeeper/tree/master.svg?style=shield)](https://circleci.com/gh/nodes-vapor/gatekeeper) [![codebeat badge](https://codebeat.co/badges/35c7b0bb-1662-44ae-b953-ab1d4aaf231f)](https://codebeat.co/projects/github-com-nodes-vapor-gatekeeper-master) [![codecov](https://codecov.io/gh/nodes-vapor/gatekeeper/branch/master/graph/badge.svg)](https://codecov.io/gh/nodes-vapor/gatekeeper) @@ -8,85 +8,75 @@ [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/gatekeeper/master/LICENSE) Gatekeeper is a middleware that restricts the number of requests from clients, based on their IP address. -It works by adding the clients IP address to the cache and count how many requests the clients can make during the Gatekeeper's defined lifespan and give back an HTTP 429(Too Many Requests) if the limit has been reached. The number of requests left will be reset when the defined timespan has been reached +It works by adding the clients IP address to the cache and count how many requests the clients can make during the Gatekeeper's defined lifespan and give back an HTTP 429(Too Many Requests) if the limit has been reached. The number of requests left will be reset when the defined timespan has been reached. **Please take into consideration that multiple clients can be using the same IP address. eg. public wifi** ## 📦 Installation -Update your `Package.swift` file. +Update your `Package.swift` dependencies: + ```swift -.Package(url: "https://github.com/nodes-vapor/gatekeeper", majorVersion: 0) +.package(url: "https://github.com/nodes-vapor/gatekeeper.git", from: "3.0.0"), ``` - -## Getting started 🚀 - -`Gatekeeper` has two configurable fields: the maximum rate and the cache to use. If you don't supply your own cache the limiter will create its own, in-memory cache. +as well as to your target (e.g. "App"): ```swift -let gatekeeper = GateKeeper(rate: Rate(10, per: .minute)) +targets: [ + .target(name: "App", dependencies: [..., "Gatekeeper", ...]), +// ... +] ``` -### Adding middleware -You can add the middleware either globally or to a route group. +## Getting started 🚀 -#### Adding Middleware Globally +### Configuration -#### `Sources/App/Config+Setup.swift` +in configure.swift: ```swift import Gatekeeper -``` - -```swift -public func setup() throws { - // ... - - addConfigurable(middleware: Gatekeeper(rate: Rate(10, per: .minute)), name: "gatekeeper") -} -``` - -#### `Config/droplet.json` -Add `gatekeeper` to the middleware array - -```json -"middleware": [ - "error", - "date", - "file", - "gatekeeper" -] +// [...] + +// Register providers first +try services.register( + GatekeeperProvider( + config: GatekeeperConfig(maxRequests: 10, per: .second), + cacheFactory: { container -> KeyedCache in + return try container.make() + } + ) +) ``` +### Add to routes -#### Adding Middleware to a Route Group +You can add the `GatekeeperMiddleware` to specific routes or to all. -```Swift -let gatekeeper = Gatekeeper(rate: Rate(10, per: .minute)) - -drop.group(gatekeeper) { group in - // Routes +**Specific routes** +in routes.swift: +```swift +let protectedRoutes = router.grouped(GatekeeperMiddleware.self) +protectedRoutes.get("protected/hello") { req in + return "Protected Hello, World!" } ``` - -### The `Rate.Interval` enumeration - -The currently implemented intervals are: +**For all requests** +in configure.swift: ```swift -case .second -case .minute -case .hour -case .day +// Register middleware +var middlewares = MiddlewareConfig() // Create _empty_ middleware config +middlewares.use(GatekeeperMiddleware.self) +services.register(middlewares) ``` ## Credits 🏆 -This package is developed and maintained by the Vapor team at [Nodes](https://www.nodes.dk). -The package owner for this project is [Tom](https://github.com/tomserowka). - +This package is developed and maintained by the Vapor team at [Nodes](https://www.nodesagency.com). +The package owner for this project is [Christian](https://github.com/cweinberger). ## License 📄 diff --git a/Sources/Gatekeeper.swift b/Sources/Gatekeeper.swift deleted file mode 100644 index 49f3f5a..0000000 --- a/Sources/Gatekeeper.swift +++ /dev/null @@ -1,90 +0,0 @@ -import HTTP -import Cache -import Vapor -import Foundation - -public struct Rate { - public enum Interval { - case second - case minute - case hour - case day - } - - public let limit: Int - public let interval: Interval - - public init(_ limit: Int, per interval: Interval) { - self.limit = limit - self.interval = interval - } - - internal var refreshInterval: Double { - switch interval { - case .second: - return 1 - case .minute: - return 60 - case .hour: - return 3_600 - case .day: - return 86_400 - } - } -} - -public struct Gatekeeper: Middleware { - internal var cache: CacheProtocol - - internal let limit: Int - internal let refreshInterval: Double - - public init(rate: Rate, cache: CacheProtocol = MemoryCache()) { - self.cache = cache - self.limit = rate.limit - self.refreshInterval = rate.refreshInterval - } - - public func respond(to request: Request, chainingTo next: Responder) throws -> Response { - guard let peer = request.peerHostname else { - throw Abort( - .forbidden, - metadata: nil, - reason: "Unable to verify peer." - ) - } - - var entry = try cache.get(peer) - var createdAt = entry?["createdAt"]?.double ?? Date().timeIntervalSince1970 - var requestsLeft = entry?["requestsLeft"]?.int ?? limit - - let now = Date().timeIntervalSince1970 - if now - createdAt >= refreshInterval { - createdAt = now - requestsLeft = limit - } - - defer { - do { - try cache.set(peer, Node(node: [ - "createdAt": createdAt, - "requestsLeft": requestsLeft - ])) - } catch { - print("WARNING: cache failed: \(error)") - } - } - - requestsLeft -= 1 - guard requestsLeft >= 0 else { - throw Abort( - .tooManyRequests, - metadata: nil, - reason: "Slow down." - ) - } - - let response = try next.respond(to: request) - return response - } -} diff --git a/Sources/Gatekeeper/Gatekeeper.swift b/Sources/Gatekeeper/Gatekeeper.swift new file mode 100644 index 0000000..202d0e5 --- /dev/null +++ b/Sources/Gatekeeper/Gatekeeper.swift @@ -0,0 +1,77 @@ +import Vapor + +public struct Gatekeeper: Service { + + internal let config: GatekeeperConfig + internal let cacheFactory: ((Container) throws -> KeyedCache) + + public init( + config: GatekeeperConfig, + cacheFactory: @escaping ((Container) throws -> KeyedCache) = { container in try container.make() } + ) { + self.config = config + self.cacheFactory = cacheFactory + } + + public func accessEndpoint( + on request: Request + ) throws -> Future { + + guard let peerHostName = request.http.remotePeer.hostname else { + throw Abort( + .forbidden, + reason: "Unable to verify peer" + ) + } + + let peerCacheKey = cacheKey(for: peerHostName) + let cache = try cacheFactory(request) + + return cache.get(peerCacheKey, as: Entry.self) + .map(to: Entry.self) { entry in + if let entry = entry { + return entry + } else { + return Entry( + peerHostname: peerHostName, + createdAt: Date(), + requestsLeft: self.config.limit + ) + } + } + .map(to: Entry.self) { entry in + + let now = Date() + var mutableEntry = entry + if now.timeIntervalSince1970 - entry.createdAt.timeIntervalSince1970 >= self.config.refreshInterval { + mutableEntry.createdAt = now + mutableEntry.requestsLeft = self.config.limit + } + mutableEntry.requestsLeft -= 1 + return mutableEntry + }.then { entry in + return cache.set(peerCacheKey, to: entry).transform(to: entry) + }.map(to: Entry.self) { entry in + + if entry.requestsLeft < 0 { + throw Abort( + .tooManyRequests, + reason: "Slow down. You sent too many requests." + ) + } + return entry + } + } + + private func cacheKey(for hostname: String) -> String { + return "gatekeeper_\(hostname)" + } +} + +extension Gatekeeper { + public struct Entry: Codable { + let peerHostname: String + var createdAt: Date + var requestsLeft: Int + } +} diff --git a/Sources/Gatekeeper/GatekeeperConfig.swift b/Sources/Gatekeeper/GatekeeperConfig.swift new file mode 100644 index 0000000..79597d6 --- /dev/null +++ b/Sources/Gatekeeper/GatekeeperConfig.swift @@ -0,0 +1,32 @@ +import Vapor + +public struct GatekeeperConfig: Service { + + public enum Interval { + case second + case minute + case hour + case day + } + + public let limit: Int + public let interval: Interval + + public init(maxRequests limit: Int, per interval: Interval) { + self.limit = limit + self.interval = interval + } + + internal var refreshInterval: Double { + switch interval { + case .second: + return 1 + case .minute: + return 60 + case .hour: + return 3_600 + case .day: + return 86_400 + } + } +} diff --git a/Sources/Gatekeeper/GatekeeperMiddleware.swift b/Sources/Gatekeeper/GatekeeperMiddleware.swift new file mode 100644 index 0000000..3f91575 --- /dev/null +++ b/Sources/Gatekeeper/GatekeeperMiddleware.swift @@ -0,0 +1,23 @@ +import Vapor + +public struct GatekeeperMiddleware { + let gatekeeper: Gatekeeper +} + +extension GatekeeperMiddleware: Middleware { + public func respond( + to request: Request, + chainingTo next: Responder + ) throws -> Future { + + return try gatekeeper.accessEndpoint(on: request).flatMap { _ in + return try next.respond(to: request) + } + } +} + +extension GatekeeperMiddleware: ServiceType { + public static func makeService(for container: Container) throws -> GatekeeperMiddleware { + return try .init(gatekeeper: container.make()) + } +} diff --git a/Sources/Gatekeeper/GatekeeperProvider.swift b/Sources/Gatekeeper/GatekeeperProvider.swift new file mode 100644 index 0000000..551f7d6 --- /dev/null +++ b/Sources/Gatekeeper/GatekeeperProvider.swift @@ -0,0 +1,33 @@ +import Vapor + +public final class GatekeeperProvider { + + internal let config: GatekeeperConfig + internal let cacheFactory: ((Container) throws -> KeyedCache) + + public init( + config: GatekeeperConfig, + cacheFactory: @escaping ((Container) throws -> KeyedCache) = { container in try container.make() } + ) { + self.config = config + self.cacheFactory = cacheFactory + } +} + +extension GatekeeperProvider: Provider { + public func register(_ services: inout Services) throws { + services.register(config) + services.register( + Gatekeeper( + config: config, + cacheFactory: cacheFactory + ), + as: Gatekeeper.self + ) + services.register(GatekeeperMiddleware.self) + } + + public func didBoot(_ container: Container) throws -> EventLoopFuture { + return .done(on: container) + } +} diff --git a/Tests/GatekeeperTests/GatekeeperTests.swift b/Tests/GatekeeperTests/GatekeeperTests.swift index 0180e87..60b1bf3 100644 --- a/Tests/GatekeeperTests/GatekeeperTests.swift +++ b/Tests/GatekeeperTests/GatekeeperTests.swift @@ -1,27 +1,21 @@ import XCTest - -import URI -import HTTP import Vapor -import Foundation - @testable import Gatekeeper class GatekeeperTests: XCTestCase { - static var allTests = [ - ("testGateKeeper", testGateKeeper), - ("testGateKeeperNoPeer", testGateKeeperNoPeer), - ("testGateKeeperCountRefresh", testGateKeeperCountRefresh), - ("testRefreshIntervalValues", testRefreshIntervalValues), - ] - - func testGateKeeper() { - let middleware = Gatekeeper(rate: Rate(10, per: .second)) - let request = getHTTPSRequest() - + + func testGateKeeper() throws { + + let request = try Request.test( + gatekeeperConfig: GatekeeperConfig(maxRequests: 10, per: .minute), + peerName: "::1" + ) + + let gatekeeperMiddleware = try request.make(GatekeeperMiddleware.self) + for i in 1...11 { do { - _ = try middleware.respond(to: request, chainingTo: MockResponder()) + _ = try gatekeeperMiddleware.respond(to: request, chainingTo: TestResponder()).wait() XCTAssertTrue(i <= 10, "ran \(i) times.") } catch let error as Abort { switch error.status { @@ -38,12 +32,18 @@ class GatekeeperTests: XCTestCase { } } - func testGateKeeperNoPeer() { - let middleware = Gatekeeper(rate: Rate(100, per: .second)) - let request = getHTTPRequest() - + func testGateKeeperNoPeer() throws { + + let request = try Request.test( + gatekeeperConfig: GatekeeperConfig(maxRequests: 10, per: .minute), + peerName: nil + ) + + let gatekeeperMiddleware = try request.make(GatekeeperMiddleware.self) + do { - _ = try middleware.respond(to: request, chainingTo: MockResponder()) + _ = try gatekeeperMiddleware.respond(to: request, chainingTo: TestResponder()).wait() + XCTFail("Gatekeeper should throw") } catch let error as Abort { switch error.status { case .forbidden: @@ -56,81 +56,80 @@ class GatekeeperTests: XCTestCase { XCTFail("Rate limiter failed: \(error)") } } - - func testGateKeeperCountRefresh() { - let middleware = Gatekeeper(rate: Rate(100, per: .second)) - let request = getHTTPSRequest() - + + func testGateKeeperCountRefresh() throws { + + let request = try Request.test( + gatekeeperConfig: GatekeeperConfig(maxRequests: 100, per: .second), + peerName: "192.168.1.2" + ) + + let gatekeeperMiddleware = try request.make(GatekeeperMiddleware.self) + for _ in 0..<50 { do { - _ = try middleware.respond(to: request, chainingTo: MockResponder()) + _ = try gatekeeperMiddleware.respond(to: request, chainingTo: TestResponder()).wait() } catch { XCTFail("Rate limiter failed: \(error)") break } } - - var requestsLeft = try! middleware.cache.get("192.168.1.2")?["requestsLeft"]?.int - XCTAssertEqual(requestsLeft, 50) + + let cache = try request.make(KeyedCache.self) + var entry = try cache.get("gatekeeper_192.168.1.2", as: Gatekeeper.Entry.self).wait() + XCTAssertEqual(entry!.requestsLeft, 50) Thread.sleep(forTimeInterval: 1) do { - _ = try middleware.respond(to: request, chainingTo: MockResponder()) + _ = try gatekeeperMiddleware.respond(to: request, chainingTo: TestResponder()).wait() } catch { XCTFail("Rate limiter failed: \(error)") } - - requestsLeft = try! middleware.cache.get("192.168.1.2")?["requestsLeft"]?.int - XCTAssertEqual(requestsLeft, 99, "Requests left should've reset") + + entry = try! cache.get("gatekeeper_192.168.1.2", as: Gatekeeper.Entry.self).wait() + XCTAssertEqual(entry!.requestsLeft, 99, "Requests left should've reset") + } + + func testGateKeeperWithCacheFactory() throws { + + let request = try Request.test( + gatekeeperConfig: GatekeeperConfig(maxRequests: 10, per: .minute), + peerName: "::1", + cacheFactory: { try $0.make(KeyedCache.self) } + ) + + let gatekeeperMiddleware = try request.make(GatekeeperMiddleware.self) + + for i in 1...11 { + do { + _ = try gatekeeperMiddleware.respond(to: request, chainingTo: TestResponder()).wait() + XCTAssertTrue(i <= 10, "ran \(i) times.") + } catch let error as Abort { + switch error.status { + case .tooManyRequests: + //success + XCTAssertEqual(i, 11, "Should've failed after the 11th attempt.") + break + default: + XCTFail("Expected too many request: \(error)") + } + } catch { + XCTFail("Caught wrong error: \(error)") + } + } } func testRefreshIntervalValues() { - let expected: [(Rate.Interval, Double)] = [ + let expected: [(GatekeeperConfig.Interval, Double)] = [ (.second, 1), (.minute, 60), (.hour, 3_600), (.day, 86_400) ] - + expected.forEach { interval, expected in - let rate = Rate(1, per: interval) + let rate = GatekeeperConfig(maxRequests: 1, per: interval) XCTAssertEqual(rate.refreshInterval, expected) } } } - -extension GatekeeperTests { - var developmentDrop: Droplet { - let config = try! Config() - config.environment = .development - return try! Droplet(config: config) - } - - var productionDrop: Droplet { - let config = try! Config() - config.environment = .production - return try! Droplet(config: config) - } - - func getHTTPRequest() -> Request { - return Request(method: .get, uri: "http://localhost:8080/") - } - - func getHTTPSRequest() -> Request { - - var headers = [HeaderKey: String]() - headers["X-Forwarded-For"] = "192.168.1.2" - - return try! Request( - method: .get, - uri: URI("https://localhost:8080/"), - headers: headers - ) - } -} - -struct MockResponder: Responder { - func respond(to request: Request) throws -> Response { - return "Hello, world".makeResponse() - } -} diff --git a/Tests/GatekeeperTests/Utilities/Request+test.swift b/Tests/GatekeeperTests/Utilities/Request+test.swift new file mode 100644 index 0000000..04d8088 --- /dev/null +++ b/Tests/GatekeeperTests/Utilities/Request+test.swift @@ -0,0 +1,57 @@ +import Gatekeeper +import HTTP +import Vapor + +extension Request { + static func test( + gatekeeperConfig: GatekeeperConfig, + url: URLRepresentable = "http://localhost:8080/test", + peerName: String? = "::1", + cacheFactory: ((Container) throws -> KeyedCache)? = nil + ) throws -> Request { + let config = Config() + + var services = Services() + services.register(KeyedCache.self) { container in + return MemoryKeyedCache() + } + + if let cacheFactory = cacheFactory { + try services.register( + GatekeeperProvider( + config: gatekeeperConfig, + cacheFactory: cacheFactory + ) + ) + } else { + try services.register( + GatekeeperProvider( + config: gatekeeperConfig + ) + ) + } + + services.register(GatekeeperMiddleware.self) + + let sharedThreadPool = BlockingIOThreadPool(numberOfThreads: 2) + sharedThreadPool.start() + services.register(sharedThreadPool) + + let app = try Application(config: config, environment: .testing, services: services) + let request = Request( + http: HTTPRequest( + method: .GET, + url: url + ), + using: app + ) + + var http = request.http + if let peerName = peerName { + http.headers.add(name: .init("X-Forwarded-For"), value: peerName) + } + request.http = http + + return request + } +} diff --git a/Tests/GatekeeperTests/Utilities/TestResponder.swift b/Tests/GatekeeperTests/Utilities/TestResponder.swift new file mode 100644 index 0000000..1c53793 --- /dev/null +++ b/Tests/GatekeeperTests/Utilities/TestResponder.swift @@ -0,0 +1,7 @@ +import Vapor + +public struct TestResponder: Responder { + public func respond(to req: Request) throws -> EventLoopFuture { + return req.future(req.response()) + } +} diff --git a/Tests/GatekeeperTests/XCTestManifests.swift b/Tests/GatekeeperTests/XCTestManifests.swift new file mode 100644 index 0000000..eba17e3 --- /dev/null +++ b/Tests/GatekeeperTests/XCTestManifests.swift @@ -0,0 +1,18 @@ +import XCTest + +extension GatekeeperTests { + static let __allTests = [ + ("testGateKeeper", testGateKeeper), + ("testGateKeeperNoPeer", testGateKeeperNoPeer), + ("testGateKeeperCountRefresh", testGateKeeperCountRefresh), + ("testRefreshIntervalValues", testRefreshIntervalValues), + ] +} + +#if !os(macOS) +public func __allTests() -> [XCTestCaseEntry] { + return [ + testCase(GatekeeperTests.__allTests), + ] +} +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index f20a0ee..4a838d0 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,7 +1,8 @@ import XCTest -@testable import GatekeeperTests +import GatekeeperTests -XCTMain([ - testCase(GatekeeperTests.allTests), -]) +var tests = [XCTestCaseEntry]() +tests += GatekeeperTests.__allTests() + +XCTMain(tests)