From 20b1d975193ec46e7a0c3798ec9324ababd891e8 Mon Sep 17 00:00:00 2001 From: Matthias Neeracher Date: Wed, 11 Mar 2020 02:32:31 +0100 Subject: [PATCH] Add Mixcloud support --- .../Imperial/Services/Mixcloud/Mixcloud.swift | 27 +++++ .../Services/Mixcloud/MixcloudAuth.swift | 16 +++ .../Mixcloud/MixcloudCallbackBody.swift | 17 ++++ .../Services/Mixcloud/MixcloudRouter.swift | 55 +++++++++++ .../Services/Mixcloud/Service+Mixcloud.swift | 6 ++ docs/Mixcloud/README.md | 99 +++++++++++++++++++ 6 files changed, 220 insertions(+) create mode 100644 Sources/Imperial/Services/Mixcloud/Mixcloud.swift create mode 100644 Sources/Imperial/Services/Mixcloud/MixcloudAuth.swift create mode 100644 Sources/Imperial/Services/Mixcloud/MixcloudCallbackBody.swift create mode 100644 Sources/Imperial/Services/Mixcloud/MixcloudRouter.swift create mode 100644 Sources/Imperial/Services/Mixcloud/Service+Mixcloud.swift create mode 100644 docs/Mixcloud/README.md diff --git a/Sources/Imperial/Services/Mixcloud/Mixcloud.swift b/Sources/Imperial/Services/Mixcloud/Mixcloud.swift new file mode 100644 index 00000000..5396a325 --- /dev/null +++ b/Sources/Imperial/Services/Mixcloud/Mixcloud.swift @@ -0,0 +1,27 @@ +import Vapor + +public class Mixcloud: FederatedService { + public static var instance: Mixcloud! + + public var tokens: FederatedServiceTokens + public var router: FederatedServiceRouter + + @discardableResult + public required init( + router: Router, + authenticate: String, + authenticateCallback: ((Request)throws -> (Future))?, + callback: String, + scope: [String] = [], + completion: @escaping (Request, String)throws -> (Future) + ) throws { + self.router = try MixcloudRouter(callback: callback, completion: completion) + self.tokens = self.router.tokens + + self.router.scope = scope + try self.router.configureRoutes(withAuthURL: authenticate, authenticateCallback: authenticateCallback, on: router) + + OAuthService.register(.mixcloud) + Mixcloud.instance = self + } +} diff --git a/Sources/Imperial/Services/Mixcloud/MixcloudAuth.swift b/Sources/Imperial/Services/Mixcloud/MixcloudAuth.swift new file mode 100644 index 00000000..1ae80602 --- /dev/null +++ b/Sources/Imperial/Services/Mixcloud/MixcloudAuth.swift @@ -0,0 +1,16 @@ +import Vapor + +public class MixcloudAuth: FederatedServiceTokens { + public static var idEnvKey: String = "MIXCLOUD_CLIENT_ID" + public static var secretEnvKey: String = "MIXCLOUD_CLIENT_SECRET" + public var clientID: String + public var clientSecret: String + + public required init() throws { + let idError = ImperialError.missingEnvVar(MixcloudAuth.idEnvKey) + let secretError = ImperialError.missingEnvVar(MixcloudAuth.secretEnvKey) + + self.clientID = try Environment.get(MixcloudAuth.idEnvKey).value(or: idError) + self.clientSecret = try Environment.get(MixcloudAuth.secretEnvKey).value(or: secretError) + } +} diff --git a/Sources/Imperial/Services/Mixcloud/MixcloudCallbackBody.swift b/Sources/Imperial/Services/Mixcloud/MixcloudCallbackBody.swift new file mode 100644 index 00000000..6a7ae09f --- /dev/null +++ b/Sources/Imperial/Services/Mixcloud/MixcloudCallbackBody.swift @@ -0,0 +1,17 @@ +import Vapor + +struct MixcloudCallbackBody: Content { + let code: String + let clientId: String + let clientSecret: String + let redirectURI: String + + static var defaultContentType: MediaType = .urlEncodedForm + + enum CodingKeys: String, CodingKey { + case code = "code" + case clientId = "client_id" + case clientSecret = "client_secret" + case redirectURI = "redirect_uri" + } +} diff --git a/Sources/Imperial/Services/Mixcloud/MixcloudRouter.swift b/Sources/Imperial/Services/Mixcloud/MixcloudRouter.swift new file mode 100644 index 00000000..5f832a75 --- /dev/null +++ b/Sources/Imperial/Services/Mixcloud/MixcloudRouter.swift @@ -0,0 +1,55 @@ +import Vapor +import Foundation + +public class MixcloudRouter: FederatedServiceRouter { + public let tokens: FederatedServiceTokens + public let callbackCompletion: (Request, String)throws -> (Future) + public var scope: [String] = [] + public var callbackURL: String + public let accessTokenURL: String = "https://www.mixcloud.com/oauth/access_token" + + public required init(callback: String, completion: @escaping (Request, String)throws -> (Future)) throws { + self.tokens = try MixcloudAuth() + self.callbackURL = callback + self.callbackCompletion = completion + } + + public func authURL(_ request: Request) throws -> String { + return "https://www.mixcloud.com/oauth/authorize?" + + "client_id=\(self.tokens.clientID)&" + + "redirect_uri=\(self.callbackURL)" + } + + public func fetchToken(from request: Request)throws -> Future { + let code: String + if let queryCode: String = try request.query.get(at: "code") { + code = queryCode + } else if let error: String = try request.query.get(at: "error") { + throw Abort(.badRequest, reason: error) + } else { + throw Abort(.badRequest, reason: "Missing 'code' key in URL query") + } + + let body = MixcloudCallbackBody(code: code, clientId: self.tokens.clientID, clientSecret: self.tokens.clientSecret, redirectURI: self.callbackURL) + return try request + .client() + .get(self.accessTokenURL) { request in + try request.query.encode(body) + }.flatMap(to: String.self) { response in + return response.content.get(String.self, at: ["access_token"]) + } + } + + public func callback(_ request: Request)throws -> Future { + return try self.fetchToken(from: request).flatMap(to: ResponseEncodable.self) { accessToken in + let session = try request.session() + + session.setAccessToken(accessToken) + try session.set("access_token_service", to: OAuthService.mixcloud) + + return try self.callbackCompletion(request, accessToken) + }.flatMap(to: Response.self) { response in + return try response.encode(for: request) + } + } +} diff --git a/Sources/Imperial/Services/Mixcloud/Service+Mixcloud.swift b/Sources/Imperial/Services/Mixcloud/Service+Mixcloud.swift new file mode 100644 index 00000000..492c2e82 --- /dev/null +++ b/Sources/Imperial/Services/Mixcloud/Service+Mixcloud.swift @@ -0,0 +1,6 @@ +extension OAuthService { + public static let mixcloud = OAuthService.init( + name: "mixcloud", + endpoints: [:] + ) +} diff --git a/docs/Mixcloud/README.md b/docs/Mixcloud/README.md new file mode 100644 index 00000000..669403dd --- /dev/null +++ b/docs/Mixcloud/README.md @@ -0,0 +1,99 @@ +# Federated Login with Mixcloud + +Start by going to the [Mixcloud Create new app page](https://www.mixcloud.com/developers/create/). Fill in the your app information (as opposed to most other services, you do *not* have to register a callback URI). + +Now that we have an OAuth application registered with Mixcloud, we can add Imperial to our project (We will not be going over how to create the project, as I will assume that you have already done that). + +Add the following line of code to your `dependencies` array in your package manifest file: + +```swift +.package(url: "https://github.com/vapor-community/Imperial.git", from: "0.5.3") +``` + +**Note:** There might be a later version of the package available, in which case you will want to use that version. + +You will also need to add the package as a dependency for the targets you will be using it in: + +```swift +.target(name: "App", dependencies: ["Vapor", "Imperial"], + exclude: [ + "Config", + "Database", + "Public", + "Resources" + ]), +``` + +Then run `vapor update` or `swift package update`. Make sure you regenerate your Xcode project afterwards if you are using Xcode. + +Now that Imperial is installed, we need to add `SessionMiddleware` to our middleware configuration: + +```swift +public func configure( + _ config: inout Config, + _ env: inout Environment, + _ services: inout Services +) throws { + //... + + // Register middleware + var middlewares = MiddlewareConfig() // Create _empty_ middleware config + // Other Middleware... + middlewares.use(SessionsMiddleware.self) + services.register(middlewares) + + //... +} + +``` + +Now, when you run your app and you are using `FluentSQLite`, you will probably get the following error: + +``` +⚠️ [ServiceError.ambiguity: Please choose which KeyedCache you prefer, multiple are available: MemoryKeyedCache, FluentCache.] [Suggested fixes: `config.prefer(MemoryKeyedCache.self, for: KeyedCache.self)`. `config.prefer(FluentCache.self, for: KeyedCache.self)`.] +``` + +Just pick one of the listed suggestions and place it at the top of your `configure` function. If you want your data to persist across server reboots, use `config.prefer(FluentCache.self, for: KeyedCache.self)` + +Imperial uses environment variables to access the client ID and secret to authenticate with Mixcloud. To allow Imperial to access these tokens, you will create these variables, called `MIXCLOUD_CLIENT_ID` and `MIXCLOUD_CLIENT_SECRET`, with the App key and App secret assigned to them. Imperial can then access these vars and use their values to authenticate with Mixcloud. + +Now, all we need to do is register the Mixcloud service in your main router method, like this: + +```swift +try router.oAuth(from: Mixcloud.self, authenticate: "mixcloud-login", callback: "http://localhost:8080/mixcloud-auth-complete") { (request, token) in + print(token) + return Future(request.redirect(to: "/")) +} +``` + +If you just want to redirect, without doing anything else in the callback, you can use the helper `Route.oAuth` method that takes in a redirect string: + +```swift +try router.oAuth(from: Mixcloud.self, authenticate: "mixcloud-login", callback: "http://localhost:8080/mixcloud-auth-complete", redirect: "/") +``` + +The `authenticate` argument is the path you will go to when you want to authenticate the user. The `callback` is an arbitrary route you want to register. The completion handler is fired when the callback route is called by the OAuth provider. The access token is passed in and a response is returned. + +If you ever want to get the `access_token` in a route, you can use a helper method for the `Request` type that comes with Imperial: + +```swift +let token = try request.accessToken() +``` + +Now that you are authenticating the user, you will want to protect certain routes to make sure the user is authenticated. You can do this by adding the `ImperialMiddleware` to a router group (or maybe your middleware config): + +```swift +let protected = router.grouped(ImperialMiddleware()) +``` + +Then, add your protected routes to the `protected` group: + +```swift +protected.get("me", handler: me) +``` + +The `ImperialMiddleware` by default passes the errors it finds onto `ErrorMiddleware` where they are caught, but you can initialize it with a redirect path to go to if the user is not authenticated: + +```swift +let protected = router.grouped(ImperialMiddleware(redirect: "/")) +```