diff --git a/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift b/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift index 12aa0c9..d4400a3 100644 --- a/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift +++ b/Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift @@ -80,31 +80,27 @@ AuthEndpoint, RestEndpointModifier { /// - 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.first - } - .unwrap(or: Abort(.internalServerError)) - .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 { + .with(intermediateKeyPath) { + $0.with(userKeyPath) + }.all() + .mapEachThrowing { item -> T in + guard let intermediate = item[ + keyPath: self.intermediateKeyPath + ].value else { throw Abort(.notFound) } + + guard let user = intermediate[ + keyPath: self.userKeyPath + ].value else { + throw Abort(.notFound) + } + + return user + } + let authorized: EventLoopFuture<[Bool]> = users + .mapEachThrowing { user throws -> Bool in guard let authorized = req.auth.get(T.self) else { throw Abort(.unauthorized) } diff --git a/Sources/Corvus/Endpoints/Modifiers/Auth/ReadAllAuthModifier.swift b/Sources/Corvus/Endpoints/Modifiers/Auth/ReadAllAuthModifier.swift new file mode 100644 index 0000000..d04b90d --- /dev/null +++ b/Sources/Corvus/Endpoints/Modifiers/Auth/ReadAllAuthModifier.swift @@ -0,0 +1,129 @@ +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 ReadAllAuthModifier< + A: AuthEndpoint, + I: CorvusModel, + T: CorvusModelAuthenticatable>: +AuthEndpoint, RestEndpointModifier { + + /// 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<[QuerySubject]> + { + try query(req) + .with(intermediateKeyPath) { + $0.with(userKeyPath) + }.all() + .flatMapEachCompactThrowing { item -> QuerySubject? in + guard let intermediate = item[ + keyPath: self.intermediateKeyPath + ].value else { + throw Abort(.notFound) + } + + guard let user = intermediate[ + keyPath: self.userKeyPath + ].value else { + throw Abort(.notFound) + } + + guard let authorized = req.auth.get(T.self) else { + throw Abort(.unauthorized) + } + + if authorized.id == user.id { + return item + } else { + return nil + } + } + } +} + +/// An extension that adds a version of the `.auth()` modifier to components +/// conforming to `AuthEndpoint` that allows defining an intermediate type `I`. +extension ReadAll { + + /// 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: ReadAllAuthModifier.IntermediateKeyPath, + _ user: ReadAllAuthModifier.UserKeyPath + ) -> ReadAllAuthModifier { + ReadAllAuthModifier(self, intermediate: intermediate, user: user) + } +} diff --git a/Tests/CorvusTests/AuthenticationTests.swift b/Tests/CorvusTests/AuthenticationTests.swift index e74b13e..cf74cef 100644 --- a/Tests/CorvusTests/AuthenticationTests.swift +++ b/Tests/CorvusTests/AuthenticationTests.swift @@ -891,4 +891,138 @@ final class AuthenticationTests: XCTestCase { XCTAssertEqualJSON(res.body.string, transaction) } } + + func testReadAllAuthModifier() throws { + final class ReadAllAuthModifierTest: RestApi { + + var content: Endpoint { + Group("api") { + CRUD("users", softDelete: false) + + Group("accounts") { + Create() + } + + BasicAuthGroup("transactions") { + Create() + ReadAll() + .auth(\.$account, \.$user) + } + } + } + } + + let app = Application(.testing) + defer { app.shutdown() } + let readAllAuthModifierTest = ReadAllAuthModifierTest() + + 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: readAllAuthModifierTest) + + 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() + + 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() + ) + .test( + .POST, + "/api/transactions", + headers: [ + "content-type": "application/json", + "Authorization": "Basic \(basic1)" + ], + body: transaction.encode() + ) + .test( + .GET, + "/api/transactions", + headers: [ + "Authorization": "Basic \(basic2)" + ] + ) { res in + let transactions = try res.content.decode( + [SecureTransaction].self + ) + + XCTAssertEqual(transactions.count, 0) + } + .test( + .GET, + "/api/transactions", + headers: [ + "Authorization": "Basic \(basic1)" + ] + ) { res in + let transactions = try res.content.decode( + [SecureTransaction].self + ) + + XCTAssertEqual(transactions.count, 2) + } + } }