-
Notifications
You must be signed in to change notification settings - Fork 48
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'master' of github.com:vapor-community/Imperial
- Loading branch information
Showing
10 changed files
with
326 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ResponseEncodable>) | ||
) 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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
Sources/Imperial/Services/Facebook/FacebookCallbackBody.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import Vapor | ||
import Foundation | ||
|
||
public class FacebookRouter: FederatedServiceRouter { | ||
public let tokens: FederatedServiceTokens | ||
public let callbackCompletion: (Request, String) throws -> (Future<ResponseEncodable>) | ||
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<ResponseEncodable>)) throws { | ||
self.tokens = try FacebookAuth() | ||
self.callbackURL = callback | ||
self.callbackCompletion = completion | ||
} | ||
|
||
public func fetchToken(from request: Request)throws -> Future<String> { | ||
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<Response> { | ||
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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
extension OAuthService { | ||
public static let facebook = OAuthService.init( | ||
name: "facebook", | ||
endpoints: [:] | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SQLiteDatabase>.] [Suggested fixes: `config.prefer(MemoryKeyedCache.self, for: KeyedCache.self)`. `config.prefer(FluentCache<SQLiteDatabase>.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<SQLiteDatabase>.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<ResponseEncodable> { | ||
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<ResponseEncodable> { | ||
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<ResponseEncodable> { | ||
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<FacebookUserInfo> { | ||
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: "/")) | ||
``` |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.