Skip to content
This repository has been archived by the owner on Sep 7, 2021. It is now read-only.

Commit

Permalink
Feature/auth intermediate (#23)
Browse files Browse the repository at this point in the history
* Added nested auth modifier

* Fixed bugs with nested auth
  • Loading branch information
Berzan Yildiz authored Apr 16, 2020
1 parent 2775825 commit a52d510
Show file tree
Hide file tree
Showing 4 changed files with 279 additions and 13 deletions.
145 changes: 145 additions & 0 deletions Sources/Corvus/Endpoints/Modifiers/Auth/NestedAuthModifier.swift
Original file line number Diff line number Diff line change
@@ -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<T>
>

/// The `KeyPath` to the intermediate `I` of the endpoint's `QuerySubject`.
public typealias IntermediateKeyPath = KeyPath<
A.QuerySubject,
A.QuerySubject.Parent<I>
>

/// 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<QuerySubject> {
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<Element> {
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<I: CorvusModel, T: CorvusModelAuthenticatable> (
_ intermediate: NestedAuthModifier<Self, I, T>.IntermediateKeyPath,
_ user: NestedAuthModifier<Self, I, T>.UserKeyPath
) -> NestedAuthModifier<Self, I, T> {
NestedAuthModifier(self, intermediate: intermediate, user: user)
}
}
131 changes: 131 additions & 0 deletions Tests/CorvusTests/AuthenticationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -760,4 +760,135 @@ final class AuthenticationTests: XCTestCase {
XCTAssertEqual(res.status, .ok)
}
}

func testNestedAuthModifier() throws {
final class NestedAuthModifierTest: RestApi {

let testParameter = Parameter<SecureTransaction>()

var content: Endpoint {
Group("api") {
CRUD<CorvusUser>("users", softDelete: false)

Group("accounts") {
Create<SecureAccount>()
}

BasicAuthGroup<CorvusUser>("transactions") {
Create<SecureTransaction>()

Group(testParameter.id) {
ReadOne<SecureTransaction>(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)
}
}
}
8 changes: 2 additions & 6 deletions Tests/CorvusTests/Models/SecureAccount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +36,7 @@ struct CreateSecureAccount: Migration {
.field(
"user_id",
.uuid,
.references(CorvusUser.schema, "id")
.references(CorvusUser.schema, .id)
)
.create()
}
Expand Down
8 changes: 1 addition & 7 deletions Tests/CorvusTests/Models/SecureTransaction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ final class SecureTransaction: CorvusModel {

static let schema = "transactions"

@ID(key: .id)
@ID
var id: UUID?

@Field(key: "amount")
Expand All @@ -15,23 +15,18 @@ final class SecureTransaction: CorvusModel {
@Field(key: "currency")
var currency: String

@Field(key: "date")
var date: Date

@Parent(key: "account_id")
var account: SecureAccount

init(
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
}

Expand All @@ -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()
}
Expand Down

0 comments on commit a52d510

Please sign in to comment.