As we mentioned in our previous analysis of interceptors, some of the more commonly used interceptors are implemented in Alamofire. AuthenticationInterceptor is definitely one of the full marks (I dozen 🤣) implementation. Let’s read it today.

And AuthenticationInterceptor similarly RetryPolicy, also is penetrating. Specific content in the next chapter, please look forward to.

Problems faced

The problem we often encounter in real projects is that some apis require authorization before they can be accessed. For example, our interface to obtain user information api.xx.com/users/id needs to add Authorization: Bearer of accessToken in the request header to complete Authorization, otherwise the server will return 401 to deny us access. This accessToken has an expiration date, and then we have to retrieve it again, usually through the login interface. Later, in order to reduce the frequency of user login, refreshToken is returned with accessToken, which has a slightly longer validity period than accessToken. It can be used to refresh accessToken and user login can be avoided.

Here is OAuth2.0 and JWT related background knowledge, do not know the students to solve their own.

So what does the client need to do for the above requirements? Details are as follows:

  1. To obtainaccessTokenandrefreshToken
  2. Add the request header to the interface that needs authorization later
  3. accessTokenAfter expiration, userefreshTokenrefresh
  4. The refreshaccessTokenIf the failure occurs, the user needs to log in to the system for reauthorization.

So what does Alamofire do for us? Continue to see 😁

How to solve

First, we can define a certificate of our own (that is, the authentication information to be used later) :

struct OAuthCredential: AuthenticationCredential {
    let accessToken: String
    let refreshToken: String
    let userID: String
    let expiration: Date

    // Here we need to refresh 5 minutes before the expiration date
    var requiresRefresh: Bool { Date(timeIntervalSinceNow: 60 * 5) > expiration }
}
Copy the code

Secondly, we implement another authorization center of our own:

class OAuthAuthenticator: Authenticator {
    / / / add the header
    func apply(_ credential: OAuthCredential.to urlRequest: inout URLRequest) {
        urlRequest.headers.add(.authorization(bearerToken: credential.accessToken))
    }
    /// implement the refresh process
    func refresh(_ credential: OAuthCredential.for session: Session.completion: @escaping (Result<OAuthCredential.Error- > >)Void){}func didRequest(_ urlRequest: URLRequest.with response: HTTPURLResponse.failDueToAuthenticationError error: Error) -> Bool {
        return response.statusCode = = 401
    }

    func isRequest(_ urlRequest: URLRequest.authenticatedWith credential: OAuthCredential) -> Bool {
        let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
        return urlRequest.headers["Authorization"] = = bearerToken
    }
}

Copy the code

After that, we can use AuthenticationInterceptor within the framework of the:

// Generate an authorization certificate. If the user is not logged in, it can not be generated.
let credential = OAuthCredential(accessToken: "a0",
                                 refreshToken: "r0",
                                 userID: "u0",
                                 expiration: Date(timeIntervalSinceNow: 60 * 60))

// Generate an authorization center
let authenticator = OAuthAuthenticator(a)// Configure the interceptor using the authorization center and credentials
let interceptor = AuthenticationInterceptor(authenticator: authenticator,
                                            credential: credential)

// Configure the interceptor to be used on a Session or in a separate Request
let session = Session(a)let urlRequest = URLRequest(url: URL(string: "https://api.example.com/example/user")!)
session.request(urlRequest, interceptor: interceptor)
Copy the code

As you can see, using the above approach, we only need to care about obtaining accessToken and refreshToken, and triggering user re-login authorization if refreshToken also fails. We can say that our own work is extremely little. Less writing means fewer bugs, especially the refreshing of tokens. When to refresh accessToken and how to control excessive refreshing are tedious parts that we do not need to care about.

How to do it

If you know how to do it, you may still be confused. Why do you need to define those two data structures? This section is for you.

AuthenticationCredential

It represents an authorization certificate, and the definition of this protocol is simple:

/// authorization certificate, which can be used to authorize URLRequest.
For example, in the OAuth2 authorization system, credentials contain accessToken, which authorizes all requests of a user.
// Normally this accessToken is valid for 60 minutes; AccessToken can be refreshed before and after expiration (a period of time) using refreshToken.
public protocol AuthenticationCredential {
    /// Whether the authorization credentials need to be refreshed.
    // return true when the credential is about to expire or after it has expired.
    /// For example, accessToken is valid for 60 minutes, and 5 minutes before the credential is about to expire should return true to ensure accessToken is refreshed.
    var requiresRefresh: Bool { get}}Copy the code

The protocol only cares if the credential needs to be refreshed. Different authorization methods require different meta-information that the framework cannot and does not need to know.

Authenticator

Because AuthenticationCredential can be anything, there is a need for a role that knows how to use it. Authenticator is on his way. The implementation of this protocol is more detailed, I have written in the comments.

/// authorization center, you can use the AuthenticationCredential to authorize URLRequest; You can also manage token refresh.
public protocol Authenticator: AnyObject {
    /// The type of credentials used by the authorization center
    associatedtype Credential: AuthenticationCredential
    // use credentials to authorize requests.
    // For example, in OAuth2 systems, request headers should be set ["Authorization": "Bearer accessToken"]
    func apply(_ credential: Credential.to urlRequest: inout URLRequest)
    
    /// Refresh the credentials and call back the result via completion.
    // refresh is performed in two cases:
    /// 1. During adaptation - The adapt(_:for:completion:) method for the interceptor
    Retry - The retry(_:for:dueTo:completion:) method for the interceptor
    ///
    For example, in OAuth2, refreshToken should be used in this method to refresh accessToken, and the new credential should be returned in the callback.
    /// If the refresh request is rejected (status code 401), refreshToken should no longer be used and the user should be asked to reauthorize.
    func refresh(_ credential: Credential.for session: Session.completion: @escaping (Result<Credential.Error- > >)Void)

    // Check whether URLRequest failed because of authorization problem.
    Return false if the authorization server does not support revoking valid credentials (that is, the credentials are permanently valid). Otherwise, it should be judged on a case-by-case basis.
    For example, in OAuth2, you can use status code 401 to indicate authorization failure, in which case you should return true.
    /// Note: this is just a general situation, you should make a decision based on your system.
    func didRequest(_ urlRequest: URLRequest.with response: HTTPURLResponse.failDueToAuthenticationError error: Error) -> Bool
    
    // check whether URLRequest is authorized with credentials.
    Return true if the authorization server does not support revoking valid credentials (that is, the credentials are valid forever). Otherwise, it should be judged on a case-by-case basis.
    /// For example, in OAuth2, you can compare the Authorization value of 'URLRequest header' with the token value of 'Credential';
    /// Return true if they are equal, false otherwise
    func isRequest(_ urlRequest: URLRequest.authenticatedWith credential: Credential) -> Bool
}
Copy the code

AuthenticationInterceptor

To complete the authorization process, the interceptor implements both adaptation and retry of the request.

Adapter

First, an adaptation flow chart:

Here’s the code, which I’ve commented in detail:

public func adapt(_ urlRequest: URLRequest.for session: Session.completion: @escaping (Result<URLRequest.Error- > >)Void) {
    let adaptResult: AdaptResult = $mutableState.write { mutableState in
        // When an URLRequest is being adapted, the credentials are being refreshed. The adaptation is recorded and execution is delayed
        guard !mutableState.isRefreshing else {
            let operation = AdaptOperation(urlRequest: urlRequest, session: session, completion: completion)
            mutableState.adaptOperations.append(operation)
            return .adaptDeferred
        }
        // An error is reported when there is no authorization certificate
        guard let credential = mutableState.credential else {
            let error = AuthenticationError.missingCredential
            return .doNotAdapt(error)
        }
        // If the credential needs to be refreshed, record the adaptation and delay execution. And triggers a refresh operation
        guard !credential.requiresRefresh else {
            let operation = AdaptOperation(urlRequest: urlRequest, session: session, completion: completion)
            mutableState.adaptOperations.append(operation)
            refresh(credential, for: session, insideLock: &mutableState)
            return .adaptDeferred
        }
        // If none of the above conditions is triggered, adaptation needs to be performed
        return .adapt(credential)
    }
    switch adaptResult {
    case let .adapt(credential):
        // Use authorization center for authorization and then callback
        var authenticatedRequest = urlRequest
        authenticator.apply(credential, to: &authenticatedRequest)
        completion(.success(authenticatedRequest))
    case let .doNotAdapt(adaptError):
        // If an error occurs, call back the error directly
        completion(.failure(adaptError))
    case .adaptDeferred:
        // Credentials need to be flushed or are being flushed, adaptation needs to be delayed until the flush is complete
        break}}Copy the code

The refresh process is more interesting than this. Relates to the concept of refreshing Windows. Simply put, it is a certain time range. Within this range, you can also set a maximum number of flushes. Before the official refresh, it will determine whether the refresh condition meets the window setting. Details are as follows:

/// Determine whether the refresh is excessive
private func isRefreshExcessive(insideLock mutableState: inout MutableState) -> Bool {
    // refreshWindow is a reference for judging excessive refreshWindow. If there is no refreshWindow, the refresh is not restricted
    guard let refreshWindow = mutableState.refreshWindow else { return false }
    // Calculate the point in time that can be refreshed
    let refreshWindowMin = ProcessInfo.processInfo.systemUptime - refreshWindow.interval
    // Count the number of flushes before the flushable point in time
    let refreshAttemptsWithinWindow = mutableState.refreshTimestamps.reduce(into: 0) { attempts, refreshTimestamp in
        guard refreshWindowMin < = refreshTimestamp else { return }
        attempts + = 1
    }
    // If the number of flushes is greater than or equal to the maximum number of flushes allowed, the system considers that the flushes are excessive
    let isRefreshExcessive = refreshAttemptsWithinWindow > = refreshWindow.maximumAttempts

    return isRefreshExcessive
}
Copy the code

If the above conditions pass, the refresh will be performed:

private func refresh(_ credential: Credential.for session: Session.insideLock mutableState: inout MutableState) {
    // If the refresh is excessive, an error is reported directly
    guard !isRefreshExcessive(insideLock: &mutableState) else {
        let error = AuthenticationError.excessiveRefresh
        handleRefreshFailure(error, insideLock: &mutableState)
        return
    }
    // Record the refresh time and set the refresh flag
    mutableState.refreshTimestamps.append(ProcessInfo.processInfo.systemUptime)
    mutableState.isRefreshing = true

    queue.async {
        // Refresh with authorization center. This is the authorization center that we implemented ourselves.
        self.authenticator.refresh(credential, for: session) { result in
            self.$mutableState.write { mutableState in
                switch result {
                case let .success(credential):
                    self.handleRefreshSuccess(credential, insideLock: &mutableState)
                case let .failure(error):
                    self.handleRefreshFailure(error, insideLock: &mutableState)
                }
            }
        }
    }
}
Copy the code

Retrier

Again, look at the flow chart:

It will determine if it is related to authorization and retry if it is not. In addition, if the current latest credentials are not used, the retry process will be entered. The last refresh is because: since the need for authorization, there is also a credential, also authorized, but also into the retry that means that the credential expired. Here is the code:

public func retry(_ request: Request.for session: Session.dueTo error: Error.completion: @escaping (RetryResult) - >Void) {
    // No original request or response received from the server, no retry required
    guard let urlRequest = request.request, let response = request.response else {
        completion(.doNotRetry)
        return
    }
    // Failed not because of authorization, no need to retry
    guard authenticator.didRequest(urlRequest, with: response, failDueToAuthenticationError: error) else {
        completion(.doNotRetry)
        return
    }
    // Callback error if authorization is required but no credential is available
    guard let credential = credential else {
        let error = AuthenticationError.missingCredential
        completion(.doNotRetryWithError(error))
        return
    }
    // Authorization is required, but the current credentials are not used. Retry is required
    guard authenticator.isRequest(urlRequest, authenticatedWith: credential) else {
        completion(.retry)
        return
    }
    // Select * from * where * / * where * /
    $mutableState.write { mutableState in
        mutableState.requestsToRetry.append(completion)
        guard !mutableState.isRefreshing else { return }
        refresh(credential, for: session, insideLock: &mutableState)
    }
}
Copy the code

At this point, the process becomes clear. For more details, see GitHub

conclusion

Today we from specific issues, to understand how to use Alamofire to solve the problem, and then analyzes the concrete implementation AuthenticationInterceptor, it is how to solve the problem. In the end, Alamofire is really thin 😂.