diff --git a/Sources/Imperial/Services/DeviantArt/DeviantArt.swift b/Sources/Imperial/Services/DeviantArt/DeviantArt.swift new file mode 100644 index 00000000..239a3de8 --- /dev/null +++ b/Sources/Imperial/Services/DeviantArt/DeviantArt.swift @@ -0,0 +1,24 @@ +import Vapor + +public class DeviantArt: FederatedService { + 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 DeviantArtRouter(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(.deviantart) + } +} diff --git a/Sources/Imperial/Services/DeviantArt/DeviantArtAuth.swift b/Sources/Imperial/Services/DeviantArt/DeviantArtAuth.swift new file mode 100644 index 00000000..0c1e3ef3 --- /dev/null +++ b/Sources/Imperial/Services/DeviantArt/DeviantArtAuth.swift @@ -0,0 +1,16 @@ +import Vapor + +public class DeviantArtAuth: FederatedServiceTokens { + public static var idEnvKey: String = "DEVIANT_CLIENT_ID" + public static var secretEnvKey: String = "DEVIANT_CLIENT_SECRET" + public var clientID: String + public var clientSecret: String + + public required init() throws { + let idError = ImperialError.missingEnvVar(DeviantArtAuth.idEnvKey) + let secretError = ImperialError.missingEnvVar(DeviantArtAuth.secretEnvKey) + + self.clientID = try Environment.get(DeviantArtAuth.idEnvKey).value(or: idError) + self.clientSecret = try Environment.get(DeviantArtAuth.secretEnvKey).value(or: secretError) + } +} diff --git a/Sources/Imperial/Services/DeviantArt/DeviantArtCallbackBody.swift b/Sources/Imperial/Services/DeviantArt/DeviantArtCallbackBody.swift new file mode 100644 index 00000000..6c30f7a8 --- /dev/null +++ b/Sources/Imperial/Services/DeviantArt/DeviantArtCallbackBody.swift @@ -0,0 +1,19 @@ +import Vapor + +struct DeviantArtCallbackBody: Content { + let code: String + let clientId: String + let clientSecret: String + let redirectURI: String + let grantType: String = "authorization_code" + + static var defaultContentType: MediaType = .urlEncodedForm + + enum CodingKeys: String, CodingKey { + case code = "code" + case clientId = "client_id" + case clientSecret = "client_secret" + case redirectURI = "redirect_uri" + case grantType = "grant_type" + } +} diff --git a/Sources/Imperial/Services/DeviantArt/DeviantArtRouter.swift b/Sources/Imperial/Services/DeviantArt/DeviantArtRouter.swift new file mode 100644 index 00000000..66e8dd93 --- /dev/null +++ b/Sources/Imperial/Services/DeviantArt/DeviantArtRouter.swift @@ -0,0 +1,72 @@ +import Vapor +import Foundation + +public class DeviantArtRouter: 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.deviantart.com/oauth2/token" + + public required init(callback: String, completion: @escaping (Request, String)throws -> (Future)) throws { + self.tokens = try DeviantArtAuth() + self.callbackURL = callback + self.callbackCompletion = completion + } + + public func authURL(_ request: Request) throws -> String { + let scope : String + if self.scope.count > 0 { + scope = "scope="+self.scope.joined(separator: " ")+"&" + } else { + scope = "" + } + return "https://www.deviantart.com/oauth2/authorize?" + + "client_id=\(self.tokens.clientID)&" + + "redirect_uri=\(self.callbackURL)&\(scope)" + + "response_type=code" + } + + 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 = DeviantArtCallbackBody(code: code, clientId: self.tokens.clientID, clientSecret: self.tokens.clientSecret, redirectURI: self.callbackURL) + return try body.encode(using: request).flatMap(to: Response.self) { request in + guard let url = URL(string: self.accessTokenURL) else { + throw Abort(.internalServerError, reason: "Unable to convert String '\(self.accessTokenURL)' to URL") + } + request.http.method = .POST + request.http.url = url + return try request.make(Client.self).send(request) + }.flatMap(to: String.self) { response in + let session = try request.session() + + return response.content.get(String.self, at: ["refresh_token"]) + .flatMap { refresh in + session.setRefreshToken(refresh) + + 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.deviantart) + + return try self.callbackCompletion(request, accessToken) + }.flatMap(to: Response.self) { response in + return try response.encode(for: request) + } + } +} diff --git a/Sources/Imperial/Services/DeviantArt/Service+DeviantArt.swift b/Sources/Imperial/Services/DeviantArt/Service+DeviantArt.swift new file mode 100644 index 00000000..729b3bed --- /dev/null +++ b/Sources/Imperial/Services/DeviantArt/Service+DeviantArt.swift @@ -0,0 +1,6 @@ +extension OAuthService { + public static let deviantart = OAuthService.init( + name: "deviantart", + endpoints: [:] + ) +} diff --git a/docs/DeviantArt/README.md b/docs/DeviantArt/README.md new file mode 100644 index 00000000..dc30bb4e --- /dev/null +++ b/docs/DeviantArt/README.md @@ -0,0 +1,109 @@ +# Federated Login with DeviantArt + +Start by going to the [DeviantArt developers page](https://www.deviantart.com/developers/). Click the 'Register your Application' button. + +![Create the app](create-application.png) + +Fill in the app information, particularly the OAuth2 Redirect URI Whitelist: + +![Redirect URI](callback-url.png) + +Now that we have an OAuth application registered with DeviantArt, 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 DeviantArt. To allow Imperial to access these tokens, you will create these variables, called `DEVIANT_CLIENT_ID` and `DEVIANT_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 DeviantArt. + +Now, all we need to do is register the DeviantArt service in your main router method, like this: + +```swift +try router.oAuth(from: DeviantArt.self, authenticate: "deviantart-login", callback: "https://example.com/deviantart-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: DeviantArt.self, authenticate: "deviantart-login", callback: "https://example.com/deviantart-auth-complete", redirect: "/") +``` + +The `authenticate` argument is the path you will go to when you want to authenticate the user. The `callback` argument has to be the one of the paths that you entered when you registered your application on DeviantArt: + +![The callback paths for DeviantArt OAuth](callback-url.png) + +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: "/")) +``` diff --git a/docs/DeviantArt/callback-url.png b/docs/DeviantArt/callback-url.png new file mode 100644 index 00000000..e65f9ad8 Binary files /dev/null and b/docs/DeviantArt/callback-url.png differ diff --git a/docs/DeviantArt/create-application.png b/docs/DeviantArt/create-application.png new file mode 100644 index 00000000..486990a3 Binary files /dev/null and b/docs/DeviantArt/create-application.png differ