From a52d510cce7a184f858a87ca7ab81db7e3455586 Mon Sep 17 00:00:00 2001 From: Berzan Yildiz Date: Thu, 16 Apr 2020 04:17:50 +0200 Subject: [PATCH] Feature/auth intermediate (#23) * Added nested auth modifier * Fixed bugs with nested auth --- .../Modifiers/Auth/NestedAuthModifier.swift | 145 ++++++++++++++++++ Tests/CorvusTests/AuthenticationTests.swift | 131 ++++++++++++++++ Tests/CorvusTests/Models/SecureAccount.swift | 8 +- .../Models/SecureTransaction.swift | 8 +- 4 files changed, 279 insertions(+), 13 deletions(-) create mode 100644 Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift diff --git a/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift b/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift new file mode 100644 index 0000000..1e8c6c0 --- /dev/null +++ b/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift @@ -0,0 +1,145 @@ +import Vapor +import Fluent + +/// A class that wraps a component which utilizes an `.auth()` modifier. Differs +/// from `AuthModifier` by authenticating on the user of an intermediate parent +/// `I` of `A.QuerySubject`. Requires an object `T` that represents the user to +/// authorize. +public final class NestedAuthModifier< + A: AuthEndpoint, + I: CorvusModel, + T: CorvusModelAuthenticatable>: +AuthEndpoint, RestEndpointModifier { + + /// The return type for the `.handler()` modifier. + public typealias Element = A.Element + + /// The return value of the `.query()`, so the type being operated on in + /// the current component. + public typealias QuerySubject = A.QuerySubject + + /// The `KeyPath` to the user property of the intermediate `I` which is to + /// be authenticated. + public typealias UserKeyPath = KeyPath< + I, + I.Parent + > + + /// The `KeyPath` to the intermediate `I` of the endpoint's `QuerySubject`. + public typealias IntermediateKeyPath = KeyPath< + A.QuerySubject, + A.QuerySubject.Parent + > + + /// The `AuthEndpoint` the `.auth()` modifier is attached to. + public let modifiedEndpoint: A + + /// The path to the property to authenticate for. + public let userKeyPath: UserKeyPath + + /// The path to the intermediate. + public let intermediateKeyPath: IntermediateKeyPath + + /// Initializes the modifier with its underlying `QueryEndpoint` and its + /// `auth` path, which is the keypath to the property to run authentication + /// for. + /// + /// - Parameters: + /// - queryEndpoint: The `QueryEndpoint` which the modifer is attached + /// to. + /// - intermediate: A `KeyPath` to the intermediate. + /// - user: A `KeyPath` which leads to the property to authenticate for. + /// - operationType: The HTTP method of the wrapped component. + public init( + _ authEndpoint: A, + intermediate: IntermediateKeyPath, + user: UserKeyPath + ) { + self.modifiedEndpoint = authEndpoint + self.intermediateKeyPath = intermediate + self.userKeyPath = user + } + + /// Returns the `queryEndpoint`'s query. + /// + /// - Parameter req: An incoming `Request`. + /// - Returns: A `QueryBuilder`, which represents a `Fluent` query defined + /// by the `queryEndpoint`. + /// - Throws: An `Abort` error if the item is not found. + public func query(_ req: Request) throws -> QueryBuilder { + try modifiedEndpoint.query(req) + } + + /// A method which checks if the user `T` supplied in the `Request` is + /// equal to the user belonging to the particular `QuerySubject`. + /// + /// - Parameter req: An incoming `Request`. + /// - Returns: An `EventLoopFuture` containing an eagerloaded value as + /// defined by `Element`. If authentication fails or a user is not found, + /// HTTP `.unauthorized` and `.notFound` are thrown respectively. + /// - Throws: An `Abort` error if an item is not found. + public func handler(_ req: Request) throws -> EventLoopFuture { + let users = try query(req) + .with(intermediateKeyPath) + .all() + .mapEach { + $0[keyPath: self.intermediateKeyPath].value + }.map { + $0[0] + }.unwrap(or: Abort(.internalServerError)) + .flatMap { + I.query(on: req.db) + .filter(\I._$id == $0.id!) + .with(self.userKeyPath) + .all() + .mapEach { + $0[keyPath: self.userKeyPath].value + } + } + + let authorized: EventLoopFuture<[Bool]> = users + .mapEachThrowing { optionalUser throws -> Bool in + guard let user = optionalUser else { + throw Abort(.notFound) + } + + guard let authorized = req.auth.get(T.self) else { + throw Abort(.unauthorized) + } + + return authorized.id == user.id + } + + return authorized.flatMap { authorized in + guard authorized.allSatisfy({ $0 }) else { + return req.eventLoop.makeFailedFuture(Abort(.unauthorized)) + } + + do { + return try self.modifiedEndpoint.handler(req) + } catch { + return req.eventLoop.makeFailedFuture(error) + } + } + } +} + +/// An extension that adds a version of the `.auth()` modifier to components +/// conforming to `AuthEndpoint` that allows defining an intermediate type `I`. +extension AuthEndpoint { + + /// A modifier used to make sure components only authorize requests where + /// the supplied user `T` is actually related to the `QuerySubject`. + /// + /// - Parameter intermediate: A `KeyPath` to the intermediate property. + /// - Parameter user: A `KeyPath` to the related user property from the + /// intermediate. + /// - Returns: An instance of a `AuthModifier` with the supplied `KeyPath` + /// to the user. + public func auth ( + _ intermediate: NestedAuthModifier.IntermediateKeyPath, + _ user: NestedAuthModifier.UserKeyPath + ) -> NestedAuthModifier { + NestedAuthModifier(self, intermediate: intermediate, user: user) + } +} diff --git a/Tests/CorvusTests/AuthenticationTests.swift b/Tests/CorvusTests/AuthenticationTests.swift index d15cd9e..e74b13e 100644 --- a/Tests/CorvusTests/AuthenticationTests.swift +++ b/Tests/CorvusTests/AuthenticationTests.swift @@ -760,4 +760,135 @@ final class AuthenticationTests: XCTestCase { XCTAssertEqual(res.status, .ok) } } + + func testNestedAuthModifier() throws { + final class NestedAuthModifierTest: RestApi { + + let testParameter = Parameter() + + var content: Endpoint { + Group("api") { + CRUD("users", softDelete: false) + + Group("accounts") { + Create() + } + + BasicAuthGroup("transactions") { + Create() + + Group(testParameter.id) { + ReadOne(testParameter.id) + .auth(\.$account, \.$user) + } + } + } + } + } + + let app = Application(.testing) + defer { app.shutdown() } + let nestedAuthModifierTest = NestedAuthModifierTest() + + app.databases.use(.sqlite(.memory), as: .test, isDefault: true) + app.middleware.use(CorvusUser.authenticator()) + app.migrations.add(CreateSecureAccount()) + app.migrations.add(CreateSecureTransaction()) + app.migrations.add(CreateCorvusUser()) + + try app.autoMigrate().wait() + + try app.register(collection: nestedAuthModifierTest) + + let user1 = CorvusUser( + username: "berzan", + passwordHash: try Bcrypt.hash("pass") + ) + + let user2 = CorvusUser( + username: "paul", + passwordHash: try Bcrypt.hash("pass") + ) + + var account: SecureAccount! + var transaction: SecureTransaction! + + let basic1 = "berzan:pass" + .data(using: .utf8)! + .base64EncodedString() + + let basic2 = "paul:pass" + .data(using: .utf8)! + .base64EncodedString() + + var transactionRes: SecureTransaction! + + try app.testable() + .test( + .POST, + "/api/users", + headers: ["content-type": "application/json"], + body: user1.encode(), + afterResponse: { res in + let userRes = try res.content.decode(CorvusUser.self) + account = SecureAccount( + name: "berzan", + userID: userRes.id! + ) + } + ) + .test( + .POST, + "/api/users", + headers: ["content-type": "application/json"], + body: user2.encode() + ) + .test( + .POST, + "/api/accounts", + headers: ["content-type": "application/json"], + body: account.encode() + ) { res in + let accountRes = try res.content.decode(SecureAccount.self) + transaction = SecureTransaction( + amount: 42.0, + currency: "€", + accountID: accountRes.id! + ) + } + .test( + .POST, + "/api/transactions", + headers: [ + "content-type": "application/json", + "Authorization": "Basic \(basic1)" + ], + body: transaction.encode() + ) { res in + transactionRes = try res.content.decode( + SecureTransaction.self + ) + XCTAssertTrue(true) + } + .test( + .GET, + "/api/transactions/\(transactionRes.id!)", + headers: [ + "Authorization": "Basic \(basic2)" + ] + ) { res in + XCTAssertEqual(res.status, .unauthorized) + } + .test( + .GET, + "/api/transactions/\(transactionRes.id!)", + headers: [ + "Authorization": "Basic \(basic1)" + ] + ) { res in + XCTAssertEqual(res.status, .ok) + print(res.body.string) + XCTAssertEqualJSON(res.body.string, transaction) + } + } } diff --git a/Tests/CorvusTests/Models/SecureAccount.swift b/Tests/CorvusTests/Models/SecureAccount.swift index bc80e84..4c6cb76 100644 --- a/Tests/CorvusTests/Models/SecureAccount.swift +++ b/Tests/CorvusTests/Models/SecureAccount.swift @@ -7,11 +7,7 @@ final class SecureAccount: CorvusModel { static let schema = "accounts" @ID - var id: UUID? { - didSet { - $id.exists = true - } - } + var id: UUID? @Field(key: "name") var name: String @@ -40,7 +36,7 @@ struct CreateSecureAccount: Migration { .field( "user_id", .uuid, - .references(CorvusUser.schema, "id") + .references(CorvusUser.schema, .id) ) .create() } diff --git a/Tests/CorvusTests/Models/SecureTransaction.swift b/Tests/CorvusTests/Models/SecureTransaction.swift index 5e8d714..6f03af1 100644 --- a/Tests/CorvusTests/Models/SecureTransaction.swift +++ b/Tests/CorvusTests/Models/SecureTransaction.swift @@ -6,7 +6,7 @@ final class SecureTransaction: CorvusModel { static let schema = "transactions" - @ID(key: .id) + @ID var id: UUID? @Field(key: "amount") @@ -15,9 +15,6 @@ final class SecureTransaction: CorvusModel { @Field(key: "currency") var currency: String - @Field(key: "date") - var date: Date - @Parent(key: "account_id") var account: SecureAccount @@ -25,13 +22,11 @@ final class SecureTransaction: CorvusModel { id: UUID? = nil, amount: Double, currency: String, - date: Date, accountID: SecureAccount.IDValue ) { self.id = id self.amount = amount self.currency = currency - self.date = date self.$account.id = accountID } @@ -45,7 +40,6 @@ struct CreateSecureTransaction: Migration { .id() .field("amount", .double, .required) .field("currency", .string, .required) - .field("date", .datetime, .required) .field("account_id", .uuid, .references(SecureAccount.schema, .id)) .create() }