Managing multiple login/registration flows in an iOS Swift app

0

Getting users registered in your app is key to building engagement. Supporting social login gives a huge boost to conversion rates! Using Swift and some nifty software design patterns, you can structure multiple login flows in a clean, testable way that is easy to change later on.

Introduction

Your registration screen is really important. You want to get as many users to register as possible, so the whole experience has to be simple and easy. This is why social login is cool: It’s a one- or two-click way to get users registered inside your app.

Supporting social login is a huge boost to conversion rates. Supporting both Facebook and Google login, alongside a regular registration flow, is even better. Managing multiple registration flows in code, however, can be pretty painful.

No matter which way your users try to login or register, there are often actions you want to perform after a successful login. You want to cache the user, save some credentials, perform a request to your backend etc.

This can very quickly lead to spaghetti code, where different login flows are coupled together and completely untestable. Especially if you didn’t plan to add social login from the start, it can quickly become a huge mess.

With the power of Swift and its protocol-oriented programming, we can think of a nice way to solve this problem. Let’s think about what our login flow actually is.

Providers

We want to grab the users info, send it to our backend for storage or validation, and receive some sort of authorization token. This is all that a login is.

The user’s info is different in each case: Facebook and Google return different authorization tokens, and regular registration grabs the user’s info in the form of an email and password.

When you think about it, the job of Facebook, Google and a regular registration form is to obtain an authorization token from your server. They all have the same output. This means they are nice candidates for an abstraction via a protocol.

protocol LoginProvider: AutoMockable {
func login(completion: (LoginResult)-> Void)
}

The login result is an enum that can either be successful or an error.

enum LoginResult {
case success((User, LoginToken))
case failure(LoginError)
}

enum LoginError {
case emailOrPasswordInvalid
case noInternet
case serverError
case userCancelled
case emailNotVerified
}

The primary job of a LoginProvider is to request a token from the specific service it uses, and to return it. In the case of an error, it needs to present the error in a way the rest of the application will understand.

The rest of the application doesn’t care how a user had registered or logged in, all it cares about is that there is an authorization token it can use.

Now that we have this abstraction, we can define our post-login or post-registration actions in a single place. We cache the user the same way no matter how he had logged in.

What’s good about this approach is that the rest of the app doesn’t know about the way a user logged in. This means we can easily change login providers and add new ones, with only additive changes to the code-base. In other words, we don’t need to make changes to the existing code to add new functionality.

This is often called the strategy pattern.

We’ll have three different providers: one for each login platform we are supporting. Google, Facebook and a native login flow.

App Provider

Let’s start with our regular provider first.

A regular provider needs the user’s email and password to work. So we’ll instantiate it with those properties.

class AppLoginProvider: LoginProvider {

private let email: String
private let password: String
private let dataService: DataServiceProtocol

init(email: String, password: String, dataService: DataServiceProtocol) {
self.email = email
self.password = password
self.dataService = dataService
}

We’re also giving it a DataService so it can make calls to our backend.

This depends heavily on your API, but in general a register/login method looks something like this.

class AppLoginProvier: LoginProvider {

//...

func login(completion: (LoginResult) -> Void) {
guard password.characters.count >= 6 && email.isValidEmail else {
completion(.failure(.emailOrPasswordInvalid))
return
}

dataService.performRequest(.login(email: email, password: password)) {
result in
case .success(let user, let token):
completion(.success(user, token))
case .failure(let error):
completion(.failure(.serverError))
}
}
}

We take the user’s input, and if it’s valid, we perform a network request. Once we get the user entity and the token, we’ll call completion and let our caller take care of the rest.

Validation

Speaking of the strategy pattern, one of the ways it’s often used is for validation. What we can do is to separate our string validation logic, like the email and password logic, into separate Validator structs.

A Validator is simply something that takes a type (it can be any type) and returns a Bool, depending on whether the type is valid or not.

struct Validator<T> {
let validate: (T)-> Bool
}

Since functions are first class citizens in Swift, we can have the validation be a function property.

We can then declare some validators we will use throughout our app. For instance, a validator that will make sure a string isn’t empty.

struct Validators {
static var nonEmpty: Validator<String> {
return Validator<String> { text in
return !text.isEmpty
}
}
}

Or, in the case of login validation, a validator that will make sure the email is in the correct format.

struct Validators {
static var email: Validator<String> {
return Validator<String> { text in
let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
return emailTest.evaluate(with: text)
}
}
}

This lets us reuse the validation logic in our app, thus preventing code duplication. It’s also a very simple struct with a single pure function, which means we can test it really easily. We just input a valid string and check that it returns true, and vice-versa.

With our validators in place, we can use them on the password and email in our login method.

class AppLoginProvier: LoginProvider {

//...

func login(completion: (LoginResult) -> Void) {
guard Validators.password.validate(password) && Validators.email.validate(email) else {
completion(.failure(.usernameOrPasswordInvalid))
return
}

//...

Facebook and Google login are a little bit more complex. Let’s start with Facebook.

Facebook

In the case of Facebook, we first need to obtain a user token from Facebook.

For this, we’ll need a couple of things. First, a LoginManager instance from the Facebook login kit. We’ll also need a UIViewController on which to present the login screen.

class FacebookLoginProvider: LoginProvider {

private let loginManager: LoginManager = LoginManager()
private let viewController: UIViewController
private let dataService: DataServiceProtocol

init(_ viewController: UIViewController, dataService: DataServiceProtocol) {
self.viewController = viewController
self.dataService = dataService
}

Once we have that, we can start the login flow, and ask for a token upon completion.

private func fetchToken(
from viewController: UIViewController,
onSuccess: (String)-> Void,
onError: (LoginError)-> Void) {

guard token == nil else {
onSuccess(token!.authenticationToken)
return
}

loginManager.logIn([.publicProfile, .email], viewController: viewController) {
result in
switch result {
case .cancelled:
onError(.userCancelled))
case .failed(let error):
onError(.other(error)))
case .success(_, _, let token):
onSuccess(token.authenticationToken))
}
}
}

We’ll first check if a login token already exists. (i.e. the user is already logged in) In that case, we don’t really need to fetch a new one, we can just return the one we already have.

We’ll call the logIn method on the LoginManager, and handle the result.

We’ll then send that token to our backend, which will hopefully return us a user.

func login(completion: (LoginResult)-> Void) {

guard dataService.isConnectedToInternet else {
completion(.failure(.noInternet))
return
}

fetchToken(from: viewController) { result in
switch result {

case .success(let token):
dataService.fetch(.login(facebookToken: token)) {
[weak self] result in
switch result {
case .success(let user, let token):
completion(.success(user, token))
case .failure(let error):
completion(.failure(.serverError))
}
}
}

case .failure(let error):
completion(.failure(error))
}
}
}

Google

Google follows a similar pattern. It also needs a UIViewController to work.

class GoogleLoginProvider: NSObject, LoginProvider {

fileprivate let presentingViewController: UIViewController
private let dataService: DataServiceProtocol

init(_ viewController: UIViewController, _ dataService: DataServiceProtocol) {
self.presentingViewController = viewController
self.dataService = dataService
super.init()
GIDSignIn.sharedInstance().delegate = self
}
}

However, Google’s API is a bit more unwieldy. It doesn’t offer a nice swifty API with blocks. It uses the delegate pattern instead. This means we can’t call the completion block inside our login method, so we’ll need to store it and call it later.

fileprivate var loginCompletionCallback: ((LoginResult)-> Void)?

func login(completion: @escaping (LoginResult)-> Void) {
self.loginCompletionCallback = completion
GIDSignIn.sharedInstance().signIn()
}

Here we are storing the login completion block, and starting the login process. In order to get notified about the result of the sign in process, we need to conform to GIDSignInDelegate.

extension GoogleLoginProvider: GIDSignInDelegate {

func sign(_ signIn: GIDSignIn!, didSignInFor user: GIDGoogleUser!, withError error: Error!) {

guard error == nil else {
let errorCode = (error as NSError).code
if let signInError = GIDSignInErrorCode(rawValue: errorCode) {
if case .canceled = signInError {
loginCompletionCallback?(.failure(.userCancelled))
return
}
}

loginCompletionCallback?(.failure(.serverError))
return
}

initiateLogin(withToken: user.authentication.idToken)
}
}

We are doing a bit of casting and dancing around with the error to check if the user has cancelled the login process, or there was an actual error. This is important because we don’t want to display an error to the user if they have decided to cancel the login process.

On the other hand, if we do manage to get the token, we will initiate a request to our backend, and call the stored completion block if it returns successfully.

fileprivate func initiateLogin(withToken token: String) {

dataService.fetch(.login(googleToken: token)) { [weak self] result in
switch result {
case .success(let user, let token):
loginCompletionCallback?(.success(user, token))
case .failure(let error):
loginCompletionCallback?(.failure(.serverError))
}
}
}

Hooking it all up

Okay, we have our providers, but we still have no way of hooking them up.

This is where the magic happens: We can create a single LoginService that will work with every provider. All we need is a login method that will take a provider as a parameter. That method will call on the provider, and perform post-login actions.

struct LoginService {

func login(
with provider: LoginProvider,
onError: (LoginError)-> Void,
onSuccess: ()-> Void) {

provider.login { [weak self] result in
switch result {
case .success(let user, let token):
//post-login actions, like caching the token and user
onSuccess()
case .failure(let error):
onError(error)
}
}
}
}

If we need to add another provider we can very easily do that.

Why do all of this separation?

1. Separating domains
Fetching Facebook tokens and sending them to the web server is the login feature’s domain. Saving a User and their token is in the application domain. The whole app relies on having the token. However, the app can work fine without a Facebook login. This should be reflected in code.

If the rest of the app doesn’t depend on a component, we should be able to delete that component without breaking the rest of the app. In this case, we can delete a LoginProvider with minimal impact to the codebase.

2. Testability
Each provider can be tested (or not) separately. The logic of what happens after the provider is done is tested separately and only once. It’s also very easy to mock providers and see if our LoginService behaves correctly.

3. De-coupling
Facebook releases an update that breaks everyone’s code? No worries, it’s a 5 minute fix because we only need to edit a single file in our application. It’s always a good idea to keep dependencies in a sort of quarantine, away from your actual code. Whether that’s trough making wrappers, dependency-inversion or something else.

4. Easier on the eyes
As developers, we have to keep a lot of stuff in our head. Holding a large project with intertwined dependencies can be very difficult. This is why it’s good to separate the project into smaller components. Not only does it make thinking about the project easier, debugging is easier since we can take a look at each component separately and isolate the problem.

A note on post-login actions

Caching the user is a very good idea. It means you don’t need to make calls to the backend to get some information about the current user. That reduces your server load and makes the application feel more responsive.

A note on caching data though, when it comes to authentications tokens, always store them in the Keychain. UserDefaults can be easily accessed if somebody has a jailbroken device! Keychain, on the other hand, is fully encrypted and can’t be read. It’s not too much more work, and it will make your users much more secure.

We hope this article will help you in your future projects. Happy coding!

Marin Bencevic

Marin Bencevic

A Swift developer who likes to work on cool iOS apps, nerd out about programming, learn new things and then blog about it.

Linkedin profile
Marin Bencevic

Latest posts by Marin Bencevic (see all)

Related posts

No blog post found.