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:
- To obtain
accessToken
andrefreshToken
- Add the request header to the interface that needs authorization later
accessToken
After expiration, userefreshToken
refresh- The refresh
accessToken
If 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 😂.