diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 2c22487..468d996 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots fastlane/test_output + +JWTKeychain.xcodeproj/ +Packages/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e806e0f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +os: + - linux + - osx +language: generic +sudo: required +dist: trusty +osx_image: xcode8 +script: + - eval "$(curl -sL https://swift.vapor.sh/ci)" + - eval "$(curl -sL https://swift.vapor.sh/codecov)" diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6575394 --- /dev/null +++ b/Package.swift @@ -0,0 +1,12 @@ +import PackageDescription + +let package = Package( + name: "JWTKeychain", + dependencies: [ + .Package(url: "https://github.com/vapor/vapor.git", majorVersion: 1), + .Package(url: "https://github.com/vapor/mysql-provider.git", majorVersion: 1, minor: 1), + .Package(url: "https://github.com/nodes-vapor/sugar.git", majorVersion: 0), + .Package(url: "https://github.com/siemensikkema/vapor-jwt.git", majorVersion: 0, minor: 4), + .Package(url: "https://github.com/Skyback/vapor-forms.git", majorVersion:0, minor: 3), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b4ac77 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# JWT Keychain +[![Language](https://img.shields.io/badge/Swift-3-brightgreen.svg)](http://swift.org) +[![Build Status](https://travis-ci.org/nodes-vapor/jwt-keychain.svg?branch=master)](https://travis-ci.org/nodes-vapor/jwt-keychain) +[![codecov](https://codecov.io/gh/nodes-vapor/jwt-keychain/branch/master/graph/badge.svg)](https://codecov.io/gh/nodes-vapor/jwt-keychain) +[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/jwt-keychain/master/LICENSE) + + +This package aims to provide developer with an easy way to scaffhold their API +using a JWT Keychain. + +**ATTENTION:** This is a very raw experiment that needs to be tested and validated. + +#Installation + +#### Config +Update your `Package.swift` file. +```swift +.Package(url: "https://github.com/nodes-vapor/jwt-keychain", majorVersion: 0) +``` + +Create config jwt.json + +``` +{ + "secondsToExpire": 3600, + "signatureKey": "our-little-secret" +} +``` + +### main.swift + +``` +import Auth +import JWTKeychain +``` + +Add the AuthMiddleware with the User model + +```swift +drop.middleware.append(AuthMiddleware()) +``` + +Add JWTAuthMiddleware to your API groups + +```swift +drop.group(JWTAuthMiddleware(drop: drop)) { jwtRoutes in + //Routes +} +``` + +This package also provides a User model and some user endpoints that can be used out of the box. + +To register the existing user routes, add this to the main.swift +```swift +// Setup routes +UserRoutes().register(drop: drop) +``` + +The aim is to encode the user identifier on the SubjectClaim of the JWT. This way we don't +need to keep track of the user's tokens on the database. The tokens generated are signed by +the key setup on the config file. + +We just need to verify the token signature and its claims. + +Currently provided endpoints are: + +- Login: `POST api/v1/users/login` +- Register: `POST api/v1/users` +- Logout: `GET api/v1/users/logout` +- Token regenerate: `GET api/v1/users/token/regenerate` +- Me: `GET api/v1/users/me` diff --git a/Sources/JWTKeychain/Controllers/Api/UsersController.swift b/Sources/JWTKeychain/Controllers/Api/UsersController.swift new file mode 100644 index 0000000..55202a4 --- /dev/null +++ b/Sources/JWTKeychain/Controllers/Api/UsersController.swift @@ -0,0 +1,110 @@ +import Vapor +import Auth +import Foundation +import HTTP +import Turnstile +import TurnstileCrypto +import TurnstileWeb +import VaporForms + + + +/// Controller for user api requests +open class UsersController { + + + /// Registers a user on the DB + /// + /// - Parameter request: current request + /// - Returns: JSON response with User data + /// - Throws: on invalid data or if unable to store data on the DB + func register(request: Request) throws -> ResponseRepresentable { + + do{ + + // Validate request + let requestData = try StoreRequest(validating: request.data) + + var user = User( + name: requestData.name, + email: requestData.email, + password: requestData.password + ) + + try user.save() + + return try user.makeJSON(withToken: true) + + }catch FormError.validationFailed(let fieldset) { + throw Abort.custom(status: Status.preconditionFailed, message: "Invalid data: \(fieldset.errors)") + }catch { + throw Abort.custom(status: Status.unprocessableEntity, message: "Could not create user") + } + + } + + + /// Logins the user on the system, giving the token back + /// + /// - Parameter request: current request + /// - Returns: JSON response with User data + /// - Throws: on invalid data or wrong credentials + func login(request: Request) throws -> ResponseRepresentable { + + // Get our credentials + guard let email = request.data["email"]?.string, let password = request.data["password"]?.string else { + throw Abort.custom(status: Status.preconditionFailed, message: "Missing email or password") + } + + let credentials = EmailPassword(email: email, password: password) + + do { + + try request.auth.login(credentials) + + return try request.user().makeJSON() + + } catch _ { + + throw Abort.custom(status: Status.preconditionFailed, message: "Invalid email or password") + + } + } + + + /// Logs the user out of the system + /// + /// - Parameter request: current request + /// - Returns: JSON success response + /// - Throws: if not able to find token + func logout(request: Request) throws -> ResponseRepresentable { + + // Clear the session + request.subject.logout() + + return try JSON(node: ["success": true]) + } + + + /// Generates a new token for the user + /// + /// - Parameter request: current request + /// - Returns: JSON with token + /// - Throws: if not able to generate token + func regenerate(request: Request) throws -> ResponseRepresentable { + let user = try request.user() + + return try JSON(node: ["token": user.generateToken()]) + } + + + /// Returns the authenticated user data + /// + /// - Parameter request: current request + /// - Returns: JSON response with User data + /// - Throws: on no user found + func me(request: Request) throws -> ResponseRepresentable { + return try request.user().makeJSON() + } + +} diff --git a/Sources/JWTKeychain/Extensions/Request+User.swift b/Sources/JWTKeychain/Extensions/Request+User.swift new file mode 100644 index 0000000..850a445 --- /dev/null +++ b/Sources/JWTKeychain/Extensions/Request+User.swift @@ -0,0 +1,53 @@ +import Vapor +import HTTP +import Auth +import Turnstile + + +// MARK: - User and token functionality +extension Request { + + // Base URL returns the hostname, scheme, and port in a URL string form. + var baseURL: String { + return uri.scheme + "://" + uri.host + (uri.port == nil ? "" : ":\(uri.port!)") + } + + // Exposes the Turnstile subject, as Vapor has a facade on it. + var subject: Subject { + return storage["subject"] as! Subject + } + + /// A helper method to retrieve the authenticated user + /// + /// - Returns: Authenticated user + /// - Throws: UnsupportedCredentialsError + func user() throws -> User { + + // Try to retrieve authenticated user + guard let user = try auth.user() as? User else { + throw UnsupportedCredentialsError() + } + return user + } + + + /// Retrieves the access token from the current + /// request authorization header + /// + /// - Returns: AccessToken + /// - Throws: No authorization header or invalid bearer authorization + func getAuthorizationBearerToken() throws -> AccessToken { + + // Try to get the authorization header + guard let authHeader = self.auth.header else { + throw Auth.AuthError.noAuthorizationHeader + } + + // Try to retrieve the bearer token + guard let bearer = authHeader.bearer else { + throw Auth.AuthError.invalidBearerAuthorization + } + + return bearer + } +} diff --git a/Sources/JWTKeychain/Extensions/User+JWT.swift b/Sources/JWTKeychain/Extensions/User+JWT.swift new file mode 100644 index 0000000..072850c --- /dev/null +++ b/Sources/JWTKeychain/Extensions/User+JWT.swift @@ -0,0 +1,52 @@ +import Vapor +import Fluent +import Foundation +import Auth +import VaporJWT +import Core +import HTTP + +extension User { + + /// Generates a token for the user + /// + /// - Returns: string with valid token + /// - Throws: unable to generate token + public func generateToken() throws -> String{ + + // Prepare payload Node + var payload: Node + + // Prepare contents for payload + var contents: [Claim] = [] + + // Create a claim with user ID + guard let userId = self.id else { + throw Abort.custom(status: .internalServerError, message: "Cannot generate tokens for unexisting users") + } + + let subClaim = SubjectClaim(String(describing: userId)) + + contents.append(subClaim) + + // Prepare expiration claim if needed + if Configuration.secondsToExpire! > 0 { + + contents.append(ExpirationTimeClaim(try Configuration.generateExpirationDate())) + + } + + payload = Node(contents) + + // Generate our Token + let jwt = try JWT( + payload: payload, + signer: HS256(key: Configuration.getTokenSignatureKey()) + ) + + // Return the token string + return try jwt.createToken() + + } + +} diff --git a/Sources/JWTKeychain/Middleware/JWTAuthMiddleware.swift b/Sources/JWTKeychain/Middleware/JWTAuthMiddleware.swift new file mode 100644 index 0000000..5fc60cb --- /dev/null +++ b/Sources/JWTKeychain/Middleware/JWTAuthMiddleware.swift @@ -0,0 +1,55 @@ +import Vapor +import HTTP +import Turnstile +import Auth +import VaporJWT + +/// Middleware to extract and authorize a user via +/// Authorization Bearer Token + JWT +class JWTAuthMiddleware: Middleware { + + /// Initiates the middleware logic + /// + /// - Parameters: + /// - request: current request + /// - next: next middleware to execute in the chain + /// - Returns: response from the next middleware in the chain + /// - Throws: Unauthorized if auth fails or bad request if authorization is not set + func respond(to request: Request, chainingTo next: Responder) throws -> Response { + + // Authorization: Bearer Token + do{ + + let bearer = try request.getAuthorizationBearerToken() + + // Verify the token + if try Configuration.validateToken(token: bearer.string) { + + try? request.auth.login(bearer, persist: false) + + } else { + throw Abort.custom( + status: .unauthorized, + message: "Please reauthenticate with the server." + ) + } + + } catch AuthError.noAuthorizationHeader { + + throw Abort.custom( + status: .badRequest, + message: "Authorization header not set." + ) + + } catch AuthError.invalidBearerAuthorization { + + throw Abort.custom( + status: .unauthorized, + message: "Invalid bearer token" + ) + + } + + return try next.respond(to: request) + } +} diff --git a/Sources/JWTKeychain/Models/Users/User.swift b/Sources/JWTKeychain/Models/Users/User.swift new file mode 100644 index 0000000..617a3bf --- /dev/null +++ b/Sources/JWTKeychain/Models/Users/User.swift @@ -0,0 +1,233 @@ +import Vapor +import Fluent +import Foundation +import Turnstile +import TurnstileCrypto +import Auth +import VaporJWT +import Core +import Sugar + + +/// The representation of a User on our system +open class User: Auth.User { + + public var id: Node? + + public var exists: Bool = false + + var name: String! + var email: String! + var password: String! + + var createdAt: Date? + var updatedAt: Date? + var deletedAt: Date? + + /// Initializes the User with name, email and password (plain) + /// + /// - Parameters: + /// - name: name of the user + /// - email: email of the user + /// - password: password of the user (plain) + public init(name: String, email: String, password: String) { + + self.name = name + self.email = email + self.password = BCrypt.hash(password: password) + self.createdAt = Date() + self.updatedAt = Date() + + } + + + /// Initializes a User from a given Node + /// + /// - Parameters: + /// - node: Node with user data + /// - context: context + /// - Throws: if not able to retrieve expected data + required public init(node: Node, in context: Context) throws { + + self.id = try node.extract("id") + self.name = try node.extract("name") + self.email = try node.extract("email") + self.password = try node.extract("password") + + if let createdAt = node["created_at"]?.string { + self.createdAt = Date.parse(.dateTime, createdAt) + } + + if let updatedAt = node["updated_at"]?.string { + self.updatedAt = Date.parse(.dateTime, updatedAt) + } + + if let deletedAt = node["deleted_at"]?.string { + self.deletedAt = Date.parse(.dateTime, deletedAt) + } + + } + + + /// Initializes a User with EmailPassword credentials only + /// + /// - Parameter credentials: the email and password + init(credentials: EmailPassword) { + + self.email = credentials.email + self.password = BCrypt.hash(password: credentials.password) + + } + +} + +// MARK: - Authorization and registration +extension User { + + @discardableResult + /// Authenticates the user with the given credentials + /// + /// - Parameter credentials: user credentials + /// - Returns: authenticated User + /// - Throws: if we can't get the User + public static func authenticate(credentials: Credentials) throws -> Auth.User { + + var user: User? + + switch credentials { + + case let credentials as EmailPassword: + + if let fetchedUser = try User.query().filter("email", credentials.email).first(){ + let passwordMatches = try? BCrypt.verify(password: credentials.password, matchesHash: fetchedUser.password) + + if passwordMatches == true { + user = fetchedUser + } + } + + case let credentials as Identifier: + + user = try User.find(credentials.id) + + case let credentials as Auth.AccessToken: + + let token = try JWT(token: credentials.string) + + if let userId = token.payload["sub"]?.string { + user = try User.query().filter("id", userId).first() + } + + default: + throw UnsupportedCredentialsError() + } + + if let user = user { + + return user + + } else { + throw IncorrectCredentialsError() + } + } + + @discardableResult + public static func register(credentials: Credentials) throws -> Auth.User { + + var newUser: User + + switch credentials { + case let credentials as EmailPassword: + newUser = User(credentials: credentials) + + default: throw UnsupportedCredentialsError() + } + + if try User.query().filter("email", newUser.email).first() == nil { + + try newUser.save() + + return newUser + } else { + throw AccountTakenError() + } + + } + +} + + +// MARK: - Preparation +extension User: Preparation { + + public static func prepare(_ database: Database) throws { + try database.create("users"){ users in + users.id() + users.string("name") + users.string("email") + users.string("password") + users.timestamps() + users.softDelete() + } + + try database.index(table: "users", column: "email", name: "users_email_index") + + } + + public static func revert(_ database: Database) throws { + try database.delete("users") + } +} + +// MARK: - NodeRepresentable +extension User: NodeRepresentable { + + public func makeNode(context: Context) throws -> Node { + return try Node(node: [ + "id": self.id, + "name": self.name, + "email": self.email, + "password": self.password, + "created_at": self.createdAt?.to(Date.Format.dateTime), + "updated_at": self.updatedAt?.to(Date.Format.dateTime), + "deleted_at": self.deletedAt?.to(Date.Format.dateTime) + ]) + } + +} + +// MARK: - JSONRepresentable +extension User: JSONRepresentable { + + func makeJSON(withToken: Bool = false) throws -> JSON { + + if withToken { + + return try JSON(node: [ + "id": self.id, + "name": self.name, + "email": self.email, + "token": self.generateToken(), + "created_at": self.createdAt?.to(Date.Format.ISO8601), + "updated_at": self.updatedAt?.to(Date.Format.ISO8601), + "deleted_at": self.deletedAt?.to(Date.Format.ISO8601), + ]) + + } else { + + return try JSON(node: [ + "id": self.id, + "name": self.name, + "email": self.email, + "created_at": self.createdAt?.to(Date.Format.ISO8601), + "updated_at": self.updatedAt?.to(Date.Format.ISO8601), + "deleted_at": self.deletedAt?.to(Date.Format.ISO8601), + ]) + + } + + } + +} + + diff --git a/Sources/JWTKeychain/Provider.swift b/Sources/JWTKeychain/Provider.swift new file mode 100644 index 0000000..9cc181e --- /dev/null +++ b/Sources/JWTKeychain/Provider.swift @@ -0,0 +1,64 @@ +import Vapor + +public final class JWTProvider: Vapor.Provider { + + /// Seconds the JWT has to expire (in the future) + public var secondsToExpire: Double? = nil + + /// Key used to sign the JWT + public var signatureKey: String? = nil + + public enum Error: Swift.Error { + case noJWTConfig + case missingConfig(String) + } + + public init(config: Config) throws { + + guard let jwtConfig = config["jwt"]?.object else { + throw Error.noJWTConfig + } + + guard let secondsToExpire = jwtConfig["secondsToExpire"]?.double else { + throw Error.missingConfig("secondsToExpire") + } + + guard let signatureKey = jwtConfig["signatureKey"]?.string else { + throw Error.missingConfig("signatureKey") + } + + + self.secondsToExpire = secondsToExpire + self.signatureKey = signatureKey + + } + + public init(signatureKey: String, secondsToExpire: Double) throws { + + self.secondsToExpire = secondsToExpire + self.signatureKey = signatureKey + } + + public func boot(_ drop: Droplet) throws { + + try Configuration.boot(secondsToExpire: self.secondsToExpire!, signatureKey: self.signatureKey!) + + } + + /** + Called after the Droplet has completed + initialization and all provided items + have been accepted. + */ + public func afterInit(_ drop: Droplet) { + + } + + /** + Called before the Droplet begins serving + which is @noreturn. + */ + public func beforeRun(_ drop: Droplet) { + + } +} diff --git a/Sources/JWTKeychain/Requests/Api/Users/StoreRequest.swift b/Sources/JWTKeychain/Requests/Api/Users/StoreRequest.swift new file mode 100644 index 0000000..f0fbe32 --- /dev/null +++ b/Sources/JWTKeychain/Requests/Api/Users/StoreRequest.swift @@ -0,0 +1,26 @@ +import Vapor +import HTTP +import VaporForms + +/// Handles the validation of storing a User +class StoreRequest: Form { + + let name: String + let email: String + let password: String + + static let fieldset = Fieldset([ + "name": StringField(String.MinimumLengthValidator(characters: 2)), + "email": StringField(String.EmailValidator(), UniqueFieldValidator(column: "email", message: "Email already taken")), + "password": StringField(String.MinimumLengthValidator(characters: 6), RegexValidator(regex: "^(?=.*[0-9])(?=.*[A-Z])(?=.*[a-z])")), + ], requiring: ["name", "email", "password"]) + + + required init(validatedData: [String: Node]) throws { + // validatedData is guaranteed to contain correct field names and values. + self.name = validatedData["name"]!.string! + self.email = validatedData["email"]!.string! + self.password = validatedData["password"]!.string! + } + +} diff --git a/Sources/JWTKeychain/Routes/Api/UserRoutes.swift b/Sources/JWTKeychain/Routes/Api/UserRoutes.swift new file mode 100644 index 0000000..0f63811 --- /dev/null +++ b/Sources/JWTKeychain/Routes/Api/UserRoutes.swift @@ -0,0 +1,36 @@ +import Vapor +import Auth + +open class UserRoutes { + + + /// Registers the routes on the given droplet + /// + /// - Parameter drop: droplet instance + func register(drop: Droplet) throws{ + + // Define the controller + let controller = UsersController() + + // Get the base path group + let path = drop.grouped("api").grouped("v1").grouped("users") + + // Set protected middleware + let protect = ProtectMiddleware( + error: Abort.custom(status: .unauthorized, message: "Unauthorized") + ) + + // Public routes + path.post(handler: controller.register) + path.post("login", handler: controller.login) + + // Protected routes + path.group(JWTAuthMiddleware(), protect) { secured in + secured.get("logout", handler: controller.logout) + secured.patch("token", "regenerate", handler: controller.regenerate) + secured.get("me", handler: controller.me) + } + + } + +} diff --git a/Sources/JWTKeychain/Support/Configuration/Configuration.swift b/Sources/JWTKeychain/Support/Configuration/Configuration.swift new file mode 100644 index 0000000..ef4d0b1 --- /dev/null +++ b/Sources/JWTKeychain/Support/Configuration/Configuration.swift @@ -0,0 +1,91 @@ +import Foundation +import Vapor +import HTTP +import VaporJWT +import Auth + +/// Sets the protocol of what is expected on the config file +public protocol ConfigurationType { + + static var secondsToExpire: Double? { get } + + static var signatureKey: String? { get } + + static func boot(secondsToExpire: Double, signatureKey: String) throws +} + +public struct Configuration: ConfigurationType { + + /// Seconds the JWT has to expire (in the future) + public static var secondsToExpire: Double? = nil + + /// Key used to sign the JWT + public static var signatureKey: String? = nil + + // Register configs + public static func boot(secondsToExpire: Double, signatureKey: String) throws { + Configuration.secondsToExpire = secondsToExpire + Configuration.signatureKey = signatureKey + } + + + /// Gets token signature key + /// + /// - Returns: signature key + /// - Throws: if cannot retrieve signature key + public static func getTokenSignatureKey() throws -> Bytes { + + return Array(Configuration.signatureKey!.utf8) + + } + + /// Generates the expiration date based on the + /// configured seconds to expire + /// + /// - Returns: token expiration date + /// - Throws: on unable to create the date + public static func generateExpirationDate() throws -> Date { + + return Date() + Configuration.secondsToExpire! + + } + + + /// Validates a given token + /// + /// - Parameter token: string with the token + /// - Returns: true if token is valid, else false + /// - Throws: if unable to create JWT instance + public static func validateToken(token: String) throws -> Bool { + + do { + + // Validate our current access token + let receivedJWT = try JWT(token: token) + + // Verify signature + if try receivedJWT.verifySignatureWith(HS256(key: Configuration.getTokenSignatureKey())) { + + + // If we have expiration set on config, verify it + if Configuration.secondsToExpire! > 0 { + + return receivedJWT.verifyClaims([ExpirationTimeClaim()]) + + } + + // No claims to verify so return true + return true + + } + + } catch { + + throw AuthError.invalidBearerAuthorization + + } + + return false + } + +} diff --git a/Sources/JWTKeychain/Support/Credentials/EmailPassword.swift b/Sources/JWTKeychain/Support/Credentials/EmailPassword.swift new file mode 100644 index 0000000..c750825 --- /dev/null +++ b/Sources/JWTKeychain/Support/Credentials/EmailPassword.swift @@ -0,0 +1,22 @@ +import Turnstile + +/// Represents User credentials by combination of email and password +public class EmailPassword: Credentials { + + /// Email address + public let email: String + + /// Password (plain) + public let password: String + + + /// Initializes the credentials + /// + /// - Parameters: + /// - email: user email + /// - password: user password + public init(email: String, password: String) { + self.email = email + self.password = password + } +} diff --git a/Sources/JWTKeychain/Validators/RegexValidator.swift b/Sources/JWTKeychain/Validators/RegexValidator.swift new file mode 100644 index 0000000..1c49474 --- /dev/null +++ b/Sources/JWTKeychain/Validators/RegexValidator.swift @@ -0,0 +1,31 @@ +import Vapor +import VaporForms +import Fluent + + +/// Validates a string against the given regex +public class RegexValidator: FieldValidator { + + let regex: String? + let message: String? + + + public init(regex: String?=nil, message: String?=nil) { + self.regex = regex + self.message = message + } + + public override func validate(input value: String) -> FieldValidationResult { + + if let regex = self.regex { + + let range = value.range(of: regex, options: .regularExpression) + guard let _ = range else { + return .failure([.validationFailed(message: message ?? "String did not match regular expression.")]) + } + + } + + return .success(Node(value)) + } +} diff --git a/Sources/JWTKeychain/Validators/UniqueFieldValidator.swift b/Sources/JWTKeychain/Validators/UniqueFieldValidator.swift new file mode 100644 index 0000000..511782f --- /dev/null +++ b/Sources/JWTKeychain/Validators/UniqueFieldValidator.swift @@ -0,0 +1,52 @@ +import Vapor +import VaporForms +import Fluent + +/// Validates if the value exists on the database +public class UniqueFieldValidator: FieldValidator { + + let column: String + let additionalFilters: [(column:String, comparison:Filter.Comparison, value:String)]? + let message: String? + + public init(column: String, additionalFilters: [(column:String, comparison:Filter.Comparison, value:String)]?=nil, message: String?=nil) { + + self.column = column + self.additionalFilters = additionalFilters + self.message = message + + } + + public override func validate(input value: String) -> FieldValidationResult { + + // Let's create the main filter + do{ + let query = try ModelType.query() + + try query.filter(self.column, value) + + // If we have addition filters, add them + if let filters = self.additionalFilters { + + for filter in filters { + + try query.filter(filter.column, filter.comparison, filter.value) + + } + + } + + // Check if any record exists + if(try query.count() > 0){ + return .failure([.validationFailed(message: message ?? "\(self.column) is not unique")]) + } + + // If not we have green light + return .success(Node(value)) + + + } catch { + return .failure([.validationFailed(message: message ?? "\(self.column) is not unique")]) + } + } +} diff --git a/Tests/JWTKeychainTests/JWTKeychainTests.swift b/Tests/JWTKeychainTests/JWTKeychainTests.swift new file mode 100644 index 0000000..017d2fb --- /dev/null +++ b/Tests/JWTKeychainTests/JWTKeychainTests.swift @@ -0,0 +1,11 @@ +import XCTest +@testable import JWTKeychain + +class JWTKeychainTests: XCTestCase { + + static var allTests : [(String, (JWTKeychainTests) -> () throws -> Void)] { + return [ + ] + } + +} diff --git a/Tests/JWTKeychainTests/Middleware/JWTAuthMiddlewareTests.swift b/Tests/JWTKeychainTests/Middleware/JWTAuthMiddlewareTests.swift new file mode 100644 index 0000000..eae76cd --- /dev/null +++ b/Tests/JWTKeychainTests/Middleware/JWTAuthMiddlewareTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import Vapor +@testable import JWTKeychain +import HTTP + +class JWTAuthMiddlewareTests: XCTestCase { + + private var middleware: JWTAuthMiddleware? + + static var allTests : [(String, (JWTAuthMiddlewareTests) -> () throws -> Void)] { + return [ + ("testAbsenseOfAuthorizationHeaderThrows", testAbsenseOfAuthorizationHeaderThrows), + ("testInvalidAuthorizationTokenThrows", testInvalidAuthorizationTokenThrows), + ("testValidAuthorizationHeaderPasses", testValidAuthorizationHeaderPasses) + ] + } + + override func setUp() { + let drop = Droplet() + + do{ + try JWTProvider(signatureKey: "key", secondsToExpire: 0).boot(drop) + + }catch let exception { + + XCTFail(exception.localizedDescription) + } + + self.middleware = JWTAuthMiddleware() + } + + override func tearDown() { + + } + + + // MARK: Authorization header required + func testAbsenseOfAuthorizationHeaderThrows() { + + let next = ResponderMock() + + let req = try? Request(method: .get, uri: "api/v1/users/me") + + do { + _ = try middleware!.respond(to: req!, chainingTo: next) + XCTFail("No auth header should throw Abort.") + } catch let error as Abort { + + XCTAssertEqual(error.status, Status.badRequest) + + } catch { + + XCTFail("Error thrown was not Abort") + + } + } + + // MARK: Authorization header bearer invalid + func testInvalidAuthorizationTokenThrows() { + + let next = ResponderMock() + + let req = try? Request(method: .get, uri: "api/v1/users/me") + req?.headers["Authorization"] = "invalid token" + + do { + _ = try middleware!.respond(to: req!, chainingTo: next) + XCTFail("Inavlid auth header should throw Abort.") + } catch let error as Abort { + + XCTAssertEqual(error.status, Status.unauthorized) + + } catch { + + XCTFail("Error thrown was not Abort") + + } + + } + + // MARK: Authorization header bearer invalid + func testValidAuthorizationHeaderPasses() throws { + + let next = ResponderMock() + + let user = User(name: "test", email: "test", password: "test") + user.id = Node(1) + + let req = try? Request(method: .get, uri: "api/v1/users/me") + + let token = try user.generateToken() + + req?.headers["Authorization"] = "Bearer " + token + + do { + _ = try middleware!.respond(to: req!, chainingTo: next) + + } catch { + + XCTFail("Valid token should not throw") + + } + + } + +} diff --git a/Tests/JWTKeychainTests/Mocks/ResponderMock.swift b/Tests/JWTKeychainTests/Mocks/ResponderMock.swift new file mode 100644 index 0000000..c9b2811 --- /dev/null +++ b/Tests/JWTKeychainTests/Mocks/ResponderMock.swift @@ -0,0 +1,10 @@ +import Vapor +import JWTKeychain +import HTTP + +internal class ResponderMock: Responder { + + func respond(to request: Request) throws -> Response { + return Response() + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..bb3f426 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,6 @@ +import XCTest +@testable import jwt_keychainTests + +XCTMain([ + testCase(jwt_keychainTests.allTests), +])