diff --git a/Sources/Imperial/Services/Facebook/Facebook.swift b/Sources/Imperial/Services/Facebook/Facebook.swift new file mode 100644 index 0000000..723d04a --- /dev/null +++ b/Sources/Imperial/Services/Facebook/Facebook.swift @@ -0,0 +1,23 @@ +import Vapor + +public class Facebook: FederatedService { + public var tokens: FederatedServiceTokens + public var router: FederatedServiceRouter + + @discardableResult + public required init( + router: Router, + authenticate: String, + callback: String, + scope: [String] = [], + completion: @escaping (Request, String)throws -> (Future) + ) throws { + self.router = try FacebookRouter(callback: callback, completion: completion) + self.tokens = self.router.tokens + + self.router.scope = scope + try self.router.configureRoutes(withAuthURL: authenticate, on: router) + + OAuthService.register(.facebook) + } +} diff --git a/Sources/Imperial/Services/Facebook/FacebookAuth.swift b/Sources/Imperial/Services/Facebook/FacebookAuth.swift new file mode 100644 index 0000000..6fc5db1 --- /dev/null +++ b/Sources/Imperial/Services/Facebook/FacebookAuth.swift @@ -0,0 +1,16 @@ +import Vapor + +public class FacebookAuth: FederatedServiceTokens { + public var idEnvKey: String = "FACEBOOK_CLIENT_ID" + public var secretEnvKey: String = "FACEBOOK_CLIENT_SECRET" + public var clientID: String + public var clientSecret: String + + public required init() throws { + let idError = ImperialError.missingEnvVar(idEnvKey) + let secretError = ImperialError.missingEnvVar(secretEnvKey) + + self.clientID = try Environment.get(idEnvKey).value(or: idError) + self.clientSecret = try Environment.get(secretEnvKey).value(or: secretError) + } +} diff --git a/Sources/Imperial/Services/Facebook/FacebookCallbackBody.swift b/Sources/Imperial/Services/Facebook/FacebookCallbackBody.swift new file mode 100644 index 0000000..6129b02 --- /dev/null +++ b/Sources/Imperial/Services/Facebook/FacebookCallbackBody.swift @@ -0,0 +1,19 @@ +import Vapor + +struct FacebookCallbackBody: 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/Facebook/FacebookRouter.swift b/Sources/Imperial/Services/Facebook/FacebookRouter.swift new file mode 100644 index 0000000..590f9e3 --- /dev/null +++ b/Sources/Imperial/Services/Facebook/FacebookRouter.swift @@ -0,0 +1,57 @@ +import Vapor +import Foundation + +public class FacebookRouter: FederatedServiceRouter { + public let tokens: FederatedServiceTokens + public let callbackCompletion: (Request, String) throws -> (Future) + public var scope: [String] = [] + public let callbackURL: String + public var accessTokenURL: String = "https://graph.facebook.com/v3.2/oauth/access_token" + public var authURL: String { + return "https://www.facebook.com/v3.2/dialog/oauth?" + + "client_id=\(self.tokens.clientID)" + + "&redirect_uri=\(self.callbackURL)" + } + + public required init(callback: String, completion: @escaping (Request, String) throws -> (Future)) throws { + self.tokens = try FacebookAuth() + self.callbackURL = callback + self.callbackCompletion = completion + } + + 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 = FacebookCallbackBody(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 + 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["access_token"] = accessToken + try session.set("access_token_service", to: OAuthService.facebook) + + return try self.callbackCompletion(request, accessToken) + }.flatMap(to: Response.self) { response in + return try response.encode(for: request) + } + } +} diff --git a/Sources/Imperial/Services/Facebook/Service+Facebook.swift b/Sources/Imperial/Services/Facebook/Service+Facebook.swift new file mode 100644 index 0000000..3748768 --- /dev/null +++ b/Sources/Imperial/Services/Facebook/Service+Facebook.swift @@ -0,0 +1,6 @@ +extension OAuthService { + public static let facebook = OAuthService.init( + name: "facebook", + endpoints: [:] + ) +} diff --git a/docs/Facebook/README.md b/docs/Facebook/README.md new file mode 100644 index 0000000..86eaa9b --- /dev/null +++ b/docs/Facebook/README.md @@ -0,0 +1,205 @@ +# Federated Login with Facebook + +1. [Register with Facebook](#register-with-facebook) +2. [Add Imperial to Vapor App](#add-imperial-to-vapor-app) +3. [Configuring Imperial](#configuring-imperial) +4. [Protecting Routes](#protecting-routes) + +## Register with Facebook +Start by going to the [Facebook Developer page](https://developers.facebook.com/), and sign-in/register. Then, go to the [Apps page](https://developers.facebook.com/apps/). Click 'Add a New App'. Enter an app 'Display Name' and 'Contact Email', then click 'Create App ID': + +![Create the app](https://github.com/vapor-community/Imperial/blob/master/docs/Facebook/create-application.png) + +Select 'Integrate Facebook Login' and click the 'Confirm' button. This will redirect to the 'Settings > Basic' screen where you can find the generated 'App ID' and 'App Secret'. It will also add the 'Facebook Login' Product in the left sidebar. Before the app is live you will need to fill out some of the other fields for privacy and GDPR disclosure. + +![App ID and App Secret](https://github.com/vapor-community/Imperial/blob/master/docs/Facebook/application-id.png) + +In the left sidebar under Products, click 'Facebook Login > Settings'. Enter one or more 'Valid OAuth Redirect URIs'. Ex) https://fancyvapor.app/facebook/callback. + +**Note:** Facebook requires https for redirect URIs so you'll need to use https in development and production environments. Setting up https is outside the scope of this tutorial. + +![Add Redirect URI](https://github.com/vapor-community/Imperial/blob/master/docs/Facebook/add-redirect-uri.png) + +## Add Imperial to Vapor App +Now that the application is registered with Facebook, we can add Imperial to our Vapor project. Creating and setting up a Vapor project is outside the scope of this tutorial. + +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.8.0") +``` + +**Note:** There might be a later version of the package available, in which case you will want to use that version. + +Also, add the package as a dependency for the targets where it will be used: + +```swift +.target(name: "App", dependencies: ["Vapor", "Imperial"], + exclude: ["Config", "Database", "Public", "Resources"]), +``` + +Then run `vapor update` to fetch the Imperial package. Make sure to also regenerate your Xcode project (`vapor xcode`) if you are using Xcode. + +Now that Imperial is installed, we need to add `middlewares.use(SessionsMiddleware.self)` to our middleware configuration in the `configure.swift` file. Remember to import the Imperial package as well. + +```swift +import Imperial +//... + +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) // Imperial session management + services.register(middlewares) + + //... +} +``` + +Now, if you build and run (`vapor build && run`) your app and you are using a database, you may see a KeyedCache ambiguity error similar to below: + +``` +⚠️ [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)` + + +## Configuring Imperial + +Imperial needs the client id and secret to authenticate with Facebook. To allow Imperial access to these tokens, you will need to set the environment variables, `FACEBOOK_CLIENT_ID` and `FACEBOOK_CLIENT_SECRET` using the respective values found on the Facebook settings page described above. + +Now, all you need to do is register the Facebook authentication route in your main router method (for instance, in `configure.swift`): + +```swift +try router.oAuth(from: Facebook.self, authenticate: "facebook", callback: "https://fancyvapor.app/facebook/callback") { (request, token) in + print(token) + return request.future(request.redirect(to: "/")) +} +``` + +The `authenticate` argument is the relative path you will go to when you want to authenticate with Facebook. Therefore, "facebook" would equal a full URL of `https://fancyvapor.app/facebook`. Visiting this URL will trigger Imperial to redirect to Facebook for authentication using the required parameters. Facebook will then validate the request and match the `callback` URI to the 'Valid OAuth Redirect URI' provided during registration. + +Therefore, the `callback` argument needs to match one of the 'Valid OAuth Redirect URI' entered in the Facebook app settings: + +![The callback path for Facebook App](https://github.com/vapor-community/Imperial/blob/master/docs/Facebook/add-redirect-uri.png) + +**Note:** The callback URL should be an Environment variable in your application so it can change between environments. In development using a `.env` file and including this [`Environment+DotEnv.swift`](https://github.com/vapor-community/vapor-ext/blob/master/Sources/ServiceExt/Environment%2BDotEnv.swift) helper in your project makes it simple. + +The completion handler is fired when the callback route is called by the OAuth provider (Facebook). The access token is passed in and a response is returned. + +The `access_token` is available within a route through an Imperial helper method for the `Request` type: + +```swift +import Imperial +//... + +let token = try request.accessToken() +``` + +## Fetching User Data + +With the accessToken your application can now access information about the user. The needs of each application differ so you can test out your implementation using [Facebook's Graph API Explorer](https://developers.facebook.com/tools/explorer/). + +![Facebook's Graph API Explorer](https://github.com/vapor-community/Imperial/blob/master/docs/Facebook/facebook-graph-api-explorer.png) + +When a user signs in with Facebook they will see what data your application is requesting and approve or reject the available data. The controller example below shows setting the route in your application which redirects the user to sign-in with Facebook, and the completion handler calls a `processFacebookLogin` function which will use the `accessToken` to fetch the user's data. It also will create a new user or sign-in existing users. + +```swift +import Vapor +import Imperial +import Authentication + +struct ImperialController: RouteCollection { + func boot(router: Router) throws { + guard let facebookCallbackURL = Environment.get("FACEBOOK_CALLBACK_URI") else { + fatalError("Facebook callback URL not set") + } + try router.oAuth(from: Facebook.self, authenticate: "login-facebook", callback: facebookCallbackURL, + scope: [], completion: processFacebookLogin) + } + + func processFacebookLogin(request: Request, token: String) throws -> Future { + return try Facebook.getUserInfo(on: request).flatMap(to: ResponseEncodable.self) { userInfo in + return User.query(on: request).filter(\.username == userInfo.id).first() + .flatMap(to: ResponseEncodable.self) { foundUser in + guard let existingUser = foundUser else { + return self.buildAndSaveNewUser(request: request, userInfo: userInfo) + } + return self.AuthenticateExistingUser(request: request, user: existingUser) + } + } + } + + private func buildAndSaveNewUser(request: Request, userInfo: FacebookUserInfo) -> Future { + let user = User(name: userInfo.name, username: userInfo.id, password: UUID().uuidString, email: userInfo.email) + return user.save(on: request).map(to: ResponseEncodable.self) { user in + try request.authenticateSession(user) + return request.redirect(to: "users/\(user.id!)") + } + } + + private func AuthenticateExistingUser(request: Request, user: User) -> Future { + return user.save(on: request).map(to: ResponseEncodable.self) { user in + try request.authenticateSession(user) + return request.redirect(to: "users/\(user.id!)") + } + } +} +``` + +We also need to extend the Facebook class to add the `getUserInfo` function. Customizing the last part of the `facebookUserAPIURL` will allow you to access the user data needed by your application. Refer to the Graph Explorer for testing what attributes are available. For convenience we decode the response using a small struct called `FacebookUserInfo`. + +```swift +struct FacebookUserInfo: Content { + let id: String + let email: String + let name: String +} + +extension Facebook { + static func getUserInfo(on request: Request) throws -> Future { + let token = try request.accessToken() + let facebookUserAPIURL = "https://graph.facebook.com/v3.2/me?fields=id,name,email&access_token=\(token)" + return try request.client().get(facebookUserAPIURL).map(to: FacebookUserInfo.self) { response in + guard response.http.status == .ok else { + if response.http.status == .unauthorized { + throw Abort.redirect(to: "/login-facebook") + } else { + throw Abort(.internalServerError) + } + } + return try response.content.syncDecode(FacebookUserInfo.self) + } + } +} +``` + +## Protecting Routes + +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 +import Imperial +//... + +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 when the user is not authenticated: + +```swift +import Imperial +//... + +let protected = router.grouped(ImperialMiddleware(redirect: "/")) +``` diff --git a/docs/Facebook/add-redirect-uri.png b/docs/Facebook/add-redirect-uri.png new file mode 100644 index 0000000..68a70b5 Binary files /dev/null and b/docs/Facebook/add-redirect-uri.png differ diff --git a/docs/Facebook/application-id.png b/docs/Facebook/application-id.png new file mode 100644 index 0000000..cb8d318 Binary files /dev/null and b/docs/Facebook/application-id.png differ diff --git a/docs/Facebook/create-application.png b/docs/Facebook/create-application.png new file mode 100644 index 0000000..812328e Binary files /dev/null and b/docs/Facebook/create-application.png differ diff --git a/docs/Facebook/facebook-graph-api-explorer.png b/docs/Facebook/facebook-graph-api-explorer.png new file mode 100644 index 0000000..bfdb9e3 Binary files /dev/null and b/docs/Facebook/facebook-graph-api-explorer.png differ