Skip to content

Commit

Permalink
feat: Multiple Type-Safe Authentication Methods (#68)
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrew-Lees11 authored and djones6 committed Jun 18, 2018
1 parent 4459dbf commit 7fb7dc7
Show file tree
Hide file tree
Showing 7 changed files with 615 additions and 8 deletions.
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ Kitura-Credentials is an authentication middleware for Kitura. Kitura-Credential

Plugins can range from a simple password based authentication or delegated authentication using OAuth (via Facebook OAuth provider, etc.), or federated authentication using OpenID.


There are two main authentication schemes supported by Kitura-Credentials: redirecting and non-redirecting. Redirecting scheme is used for example in OAuth2 Authorization Code flow authentication, where the users, that are not logged in, are redirected to a login page. All other types of authentication are non-redirecting, i.e., unauthorized requests are rejected (usually with 401 Unauthorized HTTP status code). An example of non-redirecting authentication is delegated authentication using OAuth access token (also called bearer token) that was independently acquired (say by a mobile app or other client of the Kitura based backend).

Kitura-Credentials middleware checks if the request belongs to a session. If so and the user is logged in, it updates request's user profile and propagates the request. Otherwise, it loops through the non-redirecting plugins in the order they were registered until a matching plugin is found. The plugin either succeeds to authenticate the request (in that case user profile information is returned) or fails. If a matching plugin is found but it fails to authenticate the request, HTTP status code in the router response is set to Unauthorized (401), or to the code returned from the plugin along with HTTP headers, and the request is not propagated. If no matching plugin is found, in case the request belongs to a session and a redirecting plugin exists, the request is redirected. Otherwise, HTTP status code in the router response is set to Unauthorized (401), or to the first code returned from the plugins along with HTTP headers, and the request is not propagated. In case of successful authentication, request's user profile is set with user profile information received from the authenticating plugin.
Expand All @@ -50,6 +49,43 @@ The latest version of Kitura-Credentials requires **Swift 4.0** or newer. You ca

## Example

### Codable routing

Within Codable routes, you implement a single credentials plugin by defining a Swift type that conforms to the plugins implementation of `TypeSafeCredentials`. This can then be applied to a codable route by defining it in the route signiture:

```swift
router.get("/authenticated") { (userProfile: BasicAuthedUser, respondWith: (BasicAuthedUser?, RequestError?) -> Void) in
print("authenticated \(userProfile.id) using \(userProfile.provider)")
respondWith(userProfile, nil)
}
```

To apply multiple authentication methods to a route, you define a type which conforms to `TypeSafeMultiCredentials` and add it to your codable route signiture. The type must define an array of `TypeSafeCredentials` types, that will be queried in order, to attempt to authenticate a user. It must also define an initialiser that creates an instance of self from an instance of the `TypeSafeCredentials` type.

If a user can authenticate with either HTTP basic or a token, and has defined the types `BasicAuthedUser` and `TokenAuthedUser`, then an implementation could be as follows:

```swift
public struct MultiAuthedUser : TypeSafeMultiCredentials {

public let id: String
public let provider: String

public static var authenticationMethods: [TypeSafeCredentials.Type] = [BasicAuthedUser.self, TokenAuthedUser.self]

public init(successfulAuth: TypeSafeCredentials) {
self.id = successfulAuth.id
self.provider = successfulAuth.provider
}
}

router.get("/multiauth") { (userProfile: MultiAuthedUser, respondWith: (MultiAuthedUser?, RequestError?) -> Void) in
print("authenticated \(userProfile.id) using \(userProfile.provider)")
respondWith(userProfile, nil)
}
```

### Raw routing

For OAuth2 Authorization Code flow authentication example please see [Kitura-Credentials-Sample](https://github.com/IBM-Swift/Kitura-Credentials-Sample).
<br>

Expand Down
101 changes: 101 additions & 0 deletions Sources/Credentials/MultiTypeSafeCredentials.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Copyright IBM Corporation 2018
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

import Kitura
import LoggerAPI

import Foundation

// MARK TypeSafeMultiCredentials

/**
A `TypeSafeMiddleware` for authenticating users using multiple authentication methods. A type conforming to `TypeSafeMultiCredentials` must implement a static array of `TypeSafeCredentials` types and an initializer, which takes a `TypeSafeCredentials` instance. The route will attempt to authenticate by iterating through this array of `TypeSafeCredentials` until an authentication succeeds. This returns an instance of the succesful `TypeSafeCredentials` which is used to initialize the `TypeSafeMultiCredentials` instance. If a plugin fails or you reach then end of your `TypeSafeCredentials array` an unauthorized response is sent. The type conforming to `TypeSafeMultiCredentials` can then be used as a middleware in codable routes.
### Usage Example: ###
```swift
public final class AuthedUser: TypeSafeMultiCredentials {

public let id: String
public let provider: String
public let name: String?

} extension TypeSafeMultiCredentials {
static let authenticationMethods: [TypeSafeCredentials.Type] = [MyBasicAuth.self, GoogleTokenProfile.self]

init(successfulAuth: TypeSafeCredentials) {
self.id = successfulAuth.id
self.provider = successfulAuth.provider
}
}

router.get("/protected") { (authedUser: AuthedUser, respondWith: (AuthedUser?, RequestError?) -> Void) in
print("user: \(authedUser.id) successfully authenticated using: \(authedUser.provider)")
respondWith(authedUser, nil)
}
```
*/
public protocol TypeSafeMultiCredentials: TypeSafeCredentials {

/// An array of authentication types that conform to `TypeSafeCredentials`. The `authenticate` function for each type will be called in order and, on successfully authenticating, will call `init` using the `TypeSafeCredentials` instance.
static var authenticationMethods: [TypeSafeCredentials.Type] { get }

/**
This initalizer creates an instance of the type conforming to `TypeSafeMultiCredentials` from a successfully authenticated `TypeSafeCredentials` instance.
```swift
### Usage Example: ###
init(successfulAuth: TypeSafeCredentials) {
self.id = successfulAuth.id
self.provider = successfulAuth.provider
switch(successAuth.self) {
case let googleProfile as GoogleTokenProfile:
self.name = googleProfile.name
default:
self.name = nil
}
}
```
*/
init(successfulAuth: TypeSafeCredentials)
}

extension TypeSafeMultiCredentials {

/// Static function that attempts to create an instance of Self by iterating through an array `TypeSafeCredentials` types and calling `authenticate`. On a successful authentication, an instance of Self is initialized from the `TypeSafeCredentials` instance and returned so it can be used by a `TypeSafeMiddleware` route. On a failed authentication, an unauthorized response is sent immediately. If the authentication header isn't recognised, authenticate is called on the next `TypeSafeCredentials` type.
/// - Parameter request: The `RouterRequest` object used to get information
/// about the request.
/// - Parameter response: The `RouterResponse` object used to respond to the
/// request.
/// - Parameter onSuccess: The closure to invoke in the case of successful authentication.
/// - Parameter onFailure: The closure to invoke in the case of an authentication failure.
/// - Parameter onSkip: The closure to invoke when the plugin doesn't recognize the
/// authentication data in the request.
public static func authenticate(request: RouterRequest, response: RouterResponse,
onSuccess: @escaping (Self) -> Void,
onFailure: @escaping (HTTPStatusCode?, [String : String]?) -> Void,
onSkip: @escaping (HTTPStatusCode?, [String : String]?) -> Void) {
for authentication in authenticationMethods {
authentication.authenticate(request: request, response: response,
onSuccess: { (successfulAuth) in
return onSuccess(Self(successfulAuth: successfulAuth))
}, onFailure: { (statusCode, _) in
return onFailure(statusCode ?? .unauthorized, nil)
}, onSkip: { (_, _) in
// Do nothing if skipping authentication
})
}
onSkip(.unauthorized, nil)
}

}
13 changes: 7 additions & 6 deletions Sources/Credentials/TypeSafeCredentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ import Foundation

public static func authenticate(request: RouterRequest, response: RouterResponse, onSuccess: @escaping (TypeSafeHTTPBasic) -> Void, onFailure: @escaping (HTTPStatusCode?, [String : String]?) -> Void, onSkip: @escaping (HTTPStatusCode?, [String : String]?) -> Void {

if let user = request.urlURL.user, let password = request.urlURL.password {
if users[user] == password {
return onSuccess(UserHTTPBasic(id: user))
if let user = request.urlURL.user, let password = request.urlURL.password {
if users[user] == password {
return onSuccess(UserHTTPBasic(id: user))
} else {
return onFailure()
}
} else {
return onFailure()
return onSkip()
}
} else {
return onSkip()
}
}
```
Expand Down
111 changes: 111 additions & 0 deletions Tests/CredentialsTests/DummyTypeSafeTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Copyright IBM Corporation 2018
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

import Kitura
import KituraNet
import LoggerAPI
@testable import Credentials
import Foundation

public struct TypeSafeBasic : TypeSafeCredentials {

public let id: String
public let provider: String = "HTTPBasic"
private static let users = ["John" : "123", "Doe" : "456"]

public static func authenticate(request: RouterRequest, response: RouterResponse, onSuccess: @escaping (TypeSafeBasic) -> Void, onFailure: @escaping (HTTPStatusCode?, [String : String]?) -> Void, onSkip: @escaping (HTTPStatusCode?, [String : String]?) -> Void) {

guard let authorizationHeader = request.headers["Authorization"] else {
return onSkip(.unauthorized, nil)
}
let authorizationHeaderComponents = authorizationHeader.components(separatedBy: " ")
guard authorizationHeaderComponents.count == 2,
authorizationHeaderComponents[0] == "Basic",
let decodedData = Data(base64Encoded: authorizationHeaderComponents[1], options: Data.Base64DecodingOptions(rawValue: 0)),
let userAuthorization = String(data: decodedData, encoding: .utf8)
else {
return onSkip(.unauthorized, nil)
}
let credentials = userAuthorization.components(separatedBy: ":")
guard credentials.count >= 2 else {
return onFailure(.badRequest, nil)
}

let userid = credentials[0]
let password = credentials[1]

if users[userid] == password {
return onSuccess(TypeSafeBasic(id: userid))
} else {
return onFailure(.unauthorized, nil)
}
}
}

public struct TypeSafeToken : TypeSafeCredentials {

public let id: String
public let provider: String = "DummyToken"
private static let users = ["John" : "123", "Doe" : "456"]

public static func authenticate(request: RouterRequest, response: RouterResponse, onSuccess: @escaping (TypeSafeToken) -> Void, onFailure: @escaping (HTTPStatusCode?, [String : String]?) -> Void, onSkip: @escaping (HTTPStatusCode?, [String : String]?) -> Void) {

guard let type = request.headers["X-token-type"], type == "DummyToken" else {
return onSkip(nil, nil)
}
guard let token = request.headers["access_token"], token == "dummyToken123" else {
return onFailure(nil, nil)
}

let userProfile = TypeSafeToken(id: token)
onSuccess(userProfile)
}
}

public struct MultiTypeSafeOnlyBasic : TypeSafeMultiCredentials {

public let id: String
public let provider: String

public static var authenticationMethods: [TypeSafeCredentials.Type] = [TypeSafeBasic.self]

public init(successfulAuth: TypeSafeCredentials) {
self.id = successfulAuth.id
self.provider = successfulAuth.provider
}
}

public struct MultiTypeSafeTokenBasic : TypeSafeMultiCredentials {

public let id: String
public let provider: String

public static var authenticationMethods: [TypeSafeCredentials.Type] = [TypeSafeBasic.self, TypeSafeToken.self]

public init(successfulAuth: TypeSafeCredentials) {
self.id = successfulAuth.id
self.provider = successfulAuth.provider
}
}

public struct User: Codable, Equatable {
let name: String
let provider: String

public static func == (lhs: User, rhs: User) -> Bool {
return lhs.name == rhs.name && lhs.provider == rhs.provider
}
}
106 changes: 106 additions & 0 deletions Tests/CredentialsTests/TestTypeSafeBasic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Copyright IBM Corporation 2018
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

import Foundation
import XCTest

import Kitura
import KituraNet

@testable import Credentials

class TestTypeSafeBasic : XCTestCase {

static var allTests : [(String, (TestTypeSafeBasic) -> () throws -> Void)] {
return [
("testTypeSafeNoCredentials", testTypeSafeNoCredentials),
("testTypeSafeBadCredentials", testTypeSafeBadCredentials),
("testTypeSafeBasic", testTypeSafeBasic),
]
}

let host = "127.0.0.1"

let router = TestTypeSafeBasic.setupTypeSafeRouter()

func testTypeSafeNoCredentials() {
performServerTest(router: router) { expectation in
self.performRequest(method: "get", host: self.host, path: "/private/typesafebasic", callback: {response in
XCTAssertNotNil(response, "ERROR!!! ClientRequest response object was nil")
XCTAssertEqual(response?.statusCode, HTTPStatusCode.unauthorized, "HTTP Status code was \(String(describing: response?.statusCode))")
expectation.fulfill()
})
}
}

func testTypeSafeBadCredentials() {

performServerTest(router: router) { expectation in
self.performRequest(method: "get", path:"/private/typesafebasic", callback: {response in
XCTAssertNotNil(response, "ERROR!!! ClientRequest response object was nil")
XCTAssertEqual(response?.statusCode, HTTPStatusCode.unauthorized, "HTTP Status code was \(String(describing: response?.statusCode))")
expectation.fulfill()
}, headers: ["Authorization" : "Basic QWxhZGRpbjpPcGVuU2VzYW1l"])
}

performServerTest(router: router) { expectation in
self.performRequest(method: "get", path:"/private/typesafebasic", callback: {response in
XCTAssertNotNil(response, "ERROR!!! ClientRequest response object was nil")
XCTAssertEqual(response?.statusCode, HTTPStatusCode.unauthorized, "HTTP Status code was \(String(describing: response?.statusCode))")
expectation.fulfill()
}, headers: ["Authorization" : "Basic"])
}
}

func testTypeSafeBasic() {

performServerTest(router: router) { expectation in
self.performRequest(method: "get", path:"/private/typesafebasic", callback: {response in
XCTAssertNotNil(response, "ERROR!!! ClientRequest response object was nil")
XCTAssertEqual(response?.statusCode, HTTPStatusCode.OK, "HTTP Status code was \(String(describing: response?.statusCode))")
do {
guard let stringBody = try response?.readString(),
let jsonData = stringBody.data(using: .utf8)
else {
return XCTFail("Did not receive a JSON body")
}
let decoder = JSONDecoder()
let body = try decoder.decode(User.self, from: jsonData)
XCTAssertEqual(body, User(name: "John", provider: "HTTPBasic"))
} catch {
XCTFail("No response body")
}
expectation.fulfill()
// Basic Sm9objoxMjM= is "John" : "123" base64 encoded.
}, headers: ["Authorization" : "Basic Sm9objoxMjM="])
}
}

static func setupTypeSafeRouter() -> Router {
let router = Router()

router.get("/private/typesafebasic") { (authedUser: TypeSafeBasic, respondWith: (User?, RequestError?) -> Void) in
let user = User(name: authedUser.id, provider: authedUser.provider)
respondWith(user, nil)
}

return router
}




}
Loading

0 comments on commit 7fb7dc7

Please sign in to comment.