diff --git a/Package.swift b/Package.swift index c114556..519df27 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/IBM-Swift/Kitura-Credentials.git", from: "2.0.0"), + .package(url: "https://github.com/IBM-Swift/Kitura-Credentials.git", from: "2.2.0"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/README.md b/README.md index 3f01163..410c286 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,40 @@ CredentialsHTTPDigest initialization is similar to CredentialsHTTPBasic. In addi ## Example +### Codable routing + +First create a struct or final class that conforms to `TypeSafeHTTPBasic`, +adding any instance variables which you will initialise in verifyPassword: + +```swift +import CredentialsHTTP + +public struct MyBasicAuth: TypeSafeHTTPBasic { + + public let id: String + + static let users = ["John" : "12345", "Mary" : "qwerasdf"] + + public static func verifyPassword(username: String, password: String, callback: @escaping (MyBasicAuth?) -> Void) { + if let storedPassword = users[username], storedPassword == password { + callback(MyBasicAuth(id: username)) + } else { + callback(nil) + } + } +} +``` + +Add authentication to routes by adding your `TypeSafeHTTPBasic` object, as a `TypeSafeMiddleware`, to your codable routes: + +```swift +router.get("/authedFruits") { (userProfile: MyBasicAuth, respondWith: (MyBasicAuth?, RequestError?) -> Void) in + print("authenticated \(userProfile.id) using \(userProfile.provider)") + respondWith(userProfile, nil) +} +``` + +### Raw routing This example shows how to use this plugin to authenticate requests with HTTP Basic authentication. HTTP Digest authentication is similar.
diff --git a/Sources/CredentialsHTTP/TypeSafeHTTPBasic.swift b/Sources/CredentialsHTTP/TypeSafeHTTPBasic.swift new file mode 100644 index 0000000..1f93add --- /dev/null +++ b/Sources/CredentialsHTTP/TypeSafeHTTPBasic.swift @@ -0,0 +1,126 @@ +/** + * 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 Credentials + +import Foundation + +/** + A `TypeSafeCredentials` plugin for HTTP basic authentication. + This protocol will be implemented by a Swift object defined by the user. + The plugin must implement a `verifyPassword` function which takes a username and password as input + and returns an instance of `Self` on success or `nil` on failure. + This instance must contain the authentication `provider` (defaults to "HTTPBasic") and an `id`, uniquely identifying the user. + The users object can then be used in TypeSafeMiddlware routes to authenticate with HTTP basic. + ### Usage Example: ### + ```swift + public struct MyHTTPBasic: TypeSafeHTTPBasic { + + public var id: String + + static let users = ["John" : "12345", "Mary" : "qwerasdf"] + + public static let realm = "Login message" + + public static func verifyPassword(username: String, password: String, callback: @escaping (TestHTTPBasic?) -> Void) { + if let storedPassword = users[username], storedPassword == password { + callback(TestHTTPBasic(id: username)) + } else { + callback(nil) + } + } + } + + struct User: Codable { + let name: String + } + + router.get("/authedFruits") { (authedUser: MyHTTPBasic, respondWith: (User?, RequestError?) -> Void) in + let user = User(name: authedUser.id) + respondWith(user, nil) + } + ``` + */ +public protocol TypeSafeHTTPBasic : TypeSafeCredentials { + + /// The realm for which these credentials are valid (defaults to "User") + static var realm: String { get } + + /// The function that takes a username, a password and a callback which accepts a TypeSafeHTTPBasic instance on success or nil on failure. + static func verifyPassword(username: String, password: String, callback: @escaping (Self?) -> Void) -> Void + +} + +extension TypeSafeHTTPBasic { + + /// The name of the authentication provider (defaults to "HTTPBasic") + public var provider: String { + return "HTTPBasic" + } + + /// The realm for which these credentials are valid (defaults to "User") + public static var realm: String { + return "User" + } + + /// Authenticate incoming request using HTTP Basic authentication. + /// + /// - 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) { + + let userid: String + let password: String + if let requestUser = request.urlURL.user, let requestPassword = request.urlURL.password { + userid = requestUser + password = requestPassword + } else { + guard let authorizationHeader = request.headers["Authorization"] else { + return onSkip(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"" + realm + "\""]) + } + + 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, ["WWW-Authenticate" : "Basic realm=\"" + realm + "\""]) + } + let credentials = userAuthorization.components(separatedBy: ":") + guard credentials.count >= 2 else { + return onFailure(.badRequest, nil) + } + userid = credentials[0] + password = credentials[1] + } + + verifyPassword(username: userid, password: password) { selfInstance in + if let selfInstance = selfInstance { + onSuccess(selfInstance) + } else { + onFailure(.unauthorized, ["WWW-Authenticate" : "Basic realm=\"" + self.realm + "\""]) + } + } + } +} diff --git a/Tests/CredentialsHTTPTests/TestTypeSafeBasic.swift b/Tests/CredentialsHTTPTests/TestTypeSafeBasic.swift new file mode 100644 index 0000000..f6e7b9a --- /dev/null +++ b/Tests/CredentialsHTTPTests/TestTypeSafeBasic.swift @@ -0,0 +1,141 @@ +/** + * Copyright IBM Corporation 2016 + * + * 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 +import Credentials + +@testable import CredentialsHTTP + +class TestTypeSafeBasic : XCTestCase { + + static var allTests : [(String, (TestTypeSafeBasic) -> () throws -> Void)] { + return [ + ("testTypeSafeNoCredentials", testTypeSafeNoCredentials), + ("testTypeSafeBadCredentials", testTypeSafeBadCredentials), + ("testTypeSafeBasic", testTypeSafeBasic), + ] + } + + override func setUp() { + doSetUp() + } + + override func tearDown() { + doTearDown() + } + + 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))") + XCTAssertEqual(response?.headers["WWW-Authenticate"]?.first, "Basic realm=\"test\"") + 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))") + XCTAssertEqual(response?.headers["WWW-Authenticate"]?.first, "Basic realm=\"test\"") + 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))") + XCTAssertEqual(response?.headers["WWW-Authenticate"]?.first, "Basic realm=\"test\"") + 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: "Mary", provider: "HTTPBasic")) + } catch { + XCTFail("No response body") + } + expectation.fulfill() + }, headers: ["Authorization" : "Basic TWFyeTpxd2VyYXNkZg=="]) + } + } + + static func setupTypeSafeRouter() -> Router { + let router = Router() + + router.get("/private/typesafebasic") { (authedUser: TestHTTPBasic, respondWith: (User?, RequestError?) -> Void) in + let user = User(name: authedUser.id, provider: authedUser.provider) + respondWith(user, nil) + } + + return router + } + + public struct TestHTTPBasic: TypeSafeHTTPBasic { + + public let id: String + + static let users = ["John" : "12345", "Mary" : "qwerasdf"] + + public static let realm = "test" + + public static func verifyPassword(username: String, password: String, callback: @escaping (TestHTTPBasic?) -> Void) { + if let storedPassword = users[username], storedPassword == password { + callback(TestHTTPBasic(id: username)) + } else { + callback(nil) + } + } + } + + struct User: Codable, Equatable { + let name: String + let provider: String + + static func == (lhs: User, rhs: User) -> Bool { + return lhs.name == rhs.name && lhs.provider == rhs.provider + } + } + + +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index d949af1..5451d30 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -21,5 +21,6 @@ import XCTest XCTMain([ testCase(TestBasic.allTests), - testCase(TestDigest.allTests) + testCase(TestDigest.allTests), + testCase(TestTypeSafeBasic.allTests) ])