Build a login approval system - Part 3: Building the approval app
You will need Xcode 10.x+ and Laravel 5.x+ installed on your machine.
In the previous part, we implemented the login logic and triggered events to the Pusher Channels and Pusher Beams API when login was completed and authorized. We will, however, need a client to consume the events. This is where the iOS application comes in.
In this final part, we will be creating an iOS application that will we will use to approve login requests to our dashboard.
Here’s a screen recording to show how it will work:
Requirements
To build this system, we need the following requirements:
- Xcode 10.x installed on your local machine.
- Knowledge of the Xcode IDE.
- Laravel v5.x installed on your local machine.
- Knowledge of the Laravel PHP framework.
- Knowledge of the Swift programming language.
- A Pusher Beams application. Create one here.
- A Pusher Channels application. Create one here.
- CocoaPods installed on your local machine.
If you have these requirements, let’s get started.
Creating the iOS application
Create a new single project in Xcode and name it whatever you wish, for example, dashboard. Open the terminal and cd
to the location of your iOS project and then run the following command:
$ pod init
Open the created Podfile
and add a new pod to the list of pods:
pod 'PushNotifications', '~> 1.2.0'
pod 'PusherSwift', '~> 6.1'
pod 'Alamofire', '~> 4.8.2'
Next, in the terminal, run the following command to install the dependencies we just added to the Podfile
:
$ pod install --repo-update
When the installation is complete, close Xcode and open the newly generated .xcworkspace
file in the project root. This will relaunch Xcode.
Designing the application
The application will be a simple one with two scenes. The first scene will be the default state when there are no approvals pending and the second state will be the approval pending state.
This is what the scenes will look like:
The scenes are designed with images from undraw.co. Few things to note about the storyboard are:
- There is a manual segue with identifier
approval_window
that is presented modally. - The first scene is connected to a
ViewController
class. - The second scene is connected to a
ApproveViewController
class. You will need to create one. - The APPROVE and DENY buttons have an
@IBAction
each for touch inside events.
When you are done designing the storyboard, we can move into the logic of the code.
If you want to copy the exact design of this storyboard, you can copy the XML source for the storyboard from the GitHub repository and paste it in your own file.
Adding a realtime approval request
The first thing we want to add to the application is a realtime request while the application is open. This will mean that while the application is open, when an approval request comes in, the approval window will pop up and the user can then click on the approve button.
Open the ViewController
class and replace the contents of the file with the following code:
// File: ./dashboard/ViewController.swift
import UIKit
import Alamofire
import PusherSwift
class ViewController: UIViewController {
var pusher: Pusher!
var payload: [String: String] = [:]
override func viewDidLoad() {
super.viewDidLoad()
pusher = Pusher(
key: AppConstants.PUSHER_KEY,
options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_CLUSTER))
)
pusher.connect()
let channel = pusher.subscribe("auth-request")
let _ = channel.bind(eventName: "key-dispatched", callback: { [unowned self] (data: Any?) -> Void in
guard let payload = data as? [String: String] else { return }
guard payload["hash"] != nil, payload["email"] != nil else { return }
self.showApprovalWindow(with: payload)
})
}
private func showApprovalWindow(with payload: [String: String]) {
self.payload = payload
performSegue(withIdentifier: "approval_window", sender: self)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let vc = segue.destination as? ApprovalViewController {
vc.payload = self.payload
}
}
}
In the viewDidLoad
method, we register the Pusher Channels instance and subscribe to the auth-request
channel. In this channel, we listen for the key-dispatched
event. Once this event is triggered, we then call the showApprovalWindow(with:)
method.
In the showApprovalWindow
method, we set the payload
property and then perform the approval_window
segue we registered in the storyboard earlier. This will display that scene. However, before that scene is shown, the prepare
method will be triggered automatically.
In the prepare
method we pass on the payload to the ApprovalViewController
. The payload
contains the hash of the login. This hash needs to be sent back to the server so it can be validated and authorized.
Next, open the ApprovalViewController
you created and replace the code with the following code:
// File: ./dashboard/ApprovalViewController.swift
import UIKit
import PusherSwift
import Alamofire
class ApprovalViewController: UIViewController {
var payload: [String: String]?
private var channel: PusherChannel!
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if payload?["hash"] == nil || payload?["email"] == nil {
return denyButtonWasPressed(nil)
}
// push notification
NotificationCenter.default.addObserver(
self,
selector: #selector(changeStatusFromPushNotification),
name: Notification.Name("status"),
object: nil
)
}
@objc private func changeStatusFromPushNotification(notification: Notification) {
guard let data = notification.userInfo as? [String: Any] else { return }
guard let status = data["status"] as? String else { return }
guard let payload = data["payload"] as? [String: String] else { return }
if status == "approved" {
self.payload = payload
self.approveButtonWasPressed(nil)
} else {
self.denyButtonWasPressed(self)
}
}
@IBAction func approveButtonWasPressed(_ sender: Any?) {
let url = AppConstants.API_URL + "/login/client-authorized"
Alamofire.request(url, method: .post, parameters: payload)
.validate()
.responseJSON { response in
self.dismiss(animated: true)
}
}
@IBAction func denyButtonWasPressed(_ sender: Any?) {
dismiss(animated: true)
}
}
In the controller above, we have just a few methods. In the viewDidAppear
method, we check that there is a hash in the payload. If there is no hash, then we will just dismiss the modal.
The approveButtonWasPressed
method is an @IBAction
that is fired when the APPROVE button is pressed on the app. This method will fire an HTTP POST request with the hash to the /login/client-authorized
endpoint on our backend server.
The denyButtonWasPressed
method is connected to the DENY button on the app. When this button is pressed, the approval window is closed and thus that approval session will be forgotten and will eventually expire.
In both classes above, we tried to access properties of a nonexistent AppConstant
class. Create a new Swift file named AppConstant
and replace the contents with the following:
// File: ./dashboard/AppConstants.swift
import Foundation
class AppConstants {
static let API_URL = "http://127.0.0.1:8000"
static let PUSHER_CLUSTER = "PUSHER_CLUSTER"
static let PUSHER_KEY = "PUSHER_KEY"
static let PUSHER_BEAMS_INSTANCE_ID = "PUSHER_BEAMS_INSTANCE_ID"
}
Replace the
PUSHER_*
placeholders with values from your Pusher dashboard.
If you notice, the API_URL
points to a localhost address. In iOS this is not allowed by default. To bypass this (and it is strongly recommended not to bypass this on production), update your info.plist
file as seen below:
Adding push notifications support
The next thing to do is add push notifications support. When a new login approval request comes in and the application is in the background, we will get a push notification with notification actions to Approve or Deny the request straight from the notification.
To get started, you need to provision your application for push notifications. You can do this by turning it on in the Capabilities tab of your projects settings.
First, turn on the Remote notifications option in the Background Modes section as seen above. Then, turn on the Push Notifications toggle as seen below.
Next, open your AppDelegate
file and replace the contents with the following:
// File: ./dashboard/AppDelegate.swift
import UIKit
import PushNotifications
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
let pushNotifications = PushNotifications.shared
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
pushNotifications.start(instanceId: AppConstants.PUSHER_BEAMS_INSTANCE_ID)
pushNotifications.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current()
center.delegate = self
let deny = UNNotificationAction(identifier: "deny", title: "Deny", options: [.destructive])
let approve = UNNotificationAction(identifier: "approve", title: "Approve", options: [.foreground, .authenticationRequired])
center.setNotificationCategories([
UNNotificationCategory(identifier: "LoginActions", actions: [approve, deny], intentIdentifiers: [])
])
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
pushNotifications.registerDeviceToken(deviceToken) {
let interest = "auth-janedoe-at-pushercom"
try? self.pushNotifications.addDeviceInterest(interest: interest)
}
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
pushNotifications.handleNotification(userInfo: userInfo)
completionHandler(.newData)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let name = Notification.Name("status")
let status = (response.actionIdentifier == "approve") ? "approved" : "denied"
let userInfo = response.notification.request.content.userInfo
if let aps = userInfo["aps"] as? [String: AnyObject], let payload = aps["payload"] as? [String: String] {
if status == "approved" {
NotificationCenter.default.post(
name: name,
object: nil,
userInfo: ["status": status, "payload": payload]
)
}
}
completionHandler()
}
}
Above, we first added the UNUserNotificationCenterDelegate
to the class. This is so we can benefit from the UserNotifications
framework. Next we register the device for push notifications using the Pusher Beams Swift library. We register the deny and approve UNNotificationAction
s and then register them both as notification categories.
We also register the interest for the device. When sending push notifications, interests are used to signify if the device should receive a notification or not.
In the last method, we try to parse the notification and then when we have parsed the remote push notification, we trigger a NotificationCenter
message app wide. We can then use this notification inside our view controller to approve the request. Let’s do that.
Open the ViewController
class and in the viewDidLoad
method, add the following code:
NotificationCenter.default.addObserver(
self,
selector: #selector(changeStatusFromPushNotification),
name: Notification.Name("status"),
object: nil
)
Also in the same file, add the following method to the class:
@objc private func changeStatusFromPushNotification(notification: Notification) {
guard let data = notification.userInfo as? [String: Any] else { return }
guard let status = data["status"] as? String else { return }
guard let payload = data["payload"] as? [String: String] else { return }
if status == "approved" {
let url = AppConstants.API_URL + "/login/client-authorized"
Alamofire.request(url, method: .post, parameters: payload)
.validate()
.responseJSON { response in self.dismiss(animated: true) }
}
}
In this method, we check the notification we triggered from the AppDelegate
and we extract the hash. If the status is approved, then we send an HTTP POST request similar to the one in the ApprovalViewController
thus approving the login.
Sending and accepting requests from the app
Laravel by default requires a CSRF token for web requests. Disable this by opening the VerifyCsrfMiddleware
class in the app/Http/Middleware
directory. In this class, add the following to the except
array:
protected $except = [
'/login/authorized',
'/login/client-authorized'
];
Now, Laravel will allow requests without tokens to this route.
Tunnelling your Laravel application
Before building the iOS application, we need to update the AppConstant.API_URL
. This is because we need to use a real server and not localhost if we want push notifications to work.
To do this, you need to download ngrok. This will be used to tunnel your localhost to a functioning and publicly accessible web address.
First, cd
to the root of your Laravel application and run the following command:
$ php artisan serve
Next, while keeping that terminal window active, open another terminal window and run the command below:
$ ./ngrok http 8000
If your ngrok file is in a different path, you need to specify the path to it. For example:
/path/to/ngrok http 8000
The tunnel will give you a web accessible URL. Assign the URL to the AppConstants.API_URL
property.
Now you can build the application. Make sure the PHP server and ngrok are still running and then log in using the credentials mentioned earlier in the series.
Conclusion
In this series we have seen how one can use the power of Pusher to create amazing features. Realtime technology can be used for so much more.
The source code to the application is available on GitHub.
21 May 2019
by Neo Ighodaro