Proyectos de Subversion Iphone Microlearning

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
//
2
//  AuthenticationInterceptor.swift
3
//
4
//  Copyright (c) 2020 Alamofire Software Foundation (http://alamofire.org/)
5
//
6
//  Permission is hereby granted, free of charge, to any person obtaining a copy
7
//  of this software and associated documentation files (the "Software"), to deal
8
//  in the Software without restriction, including without limitation the rights
9
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
//  copies of the Software, and to permit persons to whom the Software is
11
//  furnished to do so, subject to the following conditions:
12
//
13
//  The above copyright notice and this permission notice shall be included in
14
//  all copies or substantial portions of the Software.
15
//
16
//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
//  THE SOFTWARE.
23
//
24
 
25
import Foundation
26
 
27
/// Types adopting the `AuthenticationCredential` protocol can be used to authenticate `URLRequest`s.
28
///
29
/// One common example of an `AuthenticationCredential` is an OAuth2 credential containing an access token used to
30
/// authenticate all requests on behalf of a user. The access token generally has an expiration window of 60 minutes
31
/// which will then require a refresh of the credential using the refresh token to generate a new access token.
32
public protocol AuthenticationCredential {
33
    /// Whether the credential requires a refresh. This property should always return `true` when the credential is
34
    /// expired. It is also wise to consider returning `true` when the credential will expire in several seconds or
35
    /// minutes depending on the expiration window of the credential.
36
    ///
37
    /// For example, if the credential is valid for 60 minutes, then it would be wise to return `true` when the
38
    /// credential is only valid for 5 minutes or less. That ensures the credential will not expire as it is passed
39
    /// around backend services.
40
    var requiresRefresh: Bool { get }
41
}
42
 
43
// MARK: -
44
 
45
/// Types adopting the `Authenticator` protocol can be used to authenticate `URLRequest`s with an
46
/// `AuthenticationCredential` as well as refresh the `AuthenticationCredential` when required.
47
public protocol Authenticator: AnyObject {
48
    /// The type of credential associated with the `Authenticator` instance.
49
    associatedtype Credential: AuthenticationCredential
50
 
51
    /// Applies the `Credential` to the `URLRequest`.
52
    ///
53
    /// In the case of OAuth2, the access token of the `Credential` would be added to the `URLRequest` as a Bearer
54
    /// token to the `Authorization` header.
55
    ///
56
    /// - Parameters:
57
    ///   - credential: The `Credential`.
58
    ///   - urlRequest: The `URLRequest`.
59
    func apply(_ credential: Credential, to urlRequest: inout URLRequest)
60
 
61
    /// Refreshes the `Credential` and executes the `completion` closure with the `Result` once complete.
62
    ///
63
    /// Refresh can be called in one of two ways. It can be called before the `Request` is actually executed due to
64
    /// a `requiresRefresh` returning `true` during the adapt portion of the `Request` creation process. It can also
65
    /// be triggered by a failed `Request` where the authentication server denied access due to an expired or
66
    /// invalidated access token.
67
    ///
68
    /// In the case of OAuth2, this method would use the refresh token of the `Credential` to generate a new
69
    /// `Credential` using the authentication service. Once complete, the `completion` closure should be called with
70
    /// the new `Credential`, or the error that occurred.
71
    ///
72
    /// In general, if the refresh call fails with certain status codes from the authentication server (commonly a 401),
73
    /// the refresh token in the `Credential` can no longer be used to generate a valid `Credential`. In these cases,
74
    /// you will need to reauthenticate the user with their username / password.
75
    ///
76
    /// Please note, these are just general examples of common use cases. They are not meant to solve your specific
77
    /// authentication server challenges. Please work with your authentication server team to ensure your
78
    /// `Authenticator` logic matches their expectations.
79
    ///
80
    /// - Parameters:
81
    ///   - credential: The `Credential` to refresh.
82
    ///   - session:    The `Session` requiring the refresh.
83
    ///   - completion: The closure to be executed once the refresh is complete.
84
    func refresh(_ credential: Credential, for session: Session, completion: @escaping (Result<Credential, Error>) -> Void)
85
 
86
    /// Determines whether the `URLRequest` failed due to an authentication error based on the `HTTPURLResponse`.
87
    ///
88
    /// If the authentication server **CANNOT** invalidate credentials after they are issued, then simply return `false`
89
    /// for this method. If the authentication server **CAN** invalidate credentials due to security breaches, then you
90
    /// will need to work with your authentication server team to understand how to identify when this occurs.
91
    ///
92
    /// In the case of OAuth2, where an authentication server can invalidate credentials, you will need to inspect the
93
    /// `HTTPURLResponse` or possibly the `Error` for when this occurs. This is commonly handled by the authentication
94
    /// server returning a 401 status code and some additional header to indicate an OAuth2 failure occurred.
95
    ///
96
    /// It is very important to understand how your authentication server works to be able to implement this correctly.
97
    /// For example, if your authentication server returns a 401 when an OAuth2 error occurs, and your downstream
98
    /// service also returns a 401 when you are not authorized to perform that operation, how do you know which layer
99
    /// of the backend returned you a 401? You do not want to trigger a refresh unless you know your authentication
100
    /// server is actually the layer rejecting the request. Again, work with your authentication server team to understand
101
    /// how to identify an OAuth2 401 error vs. a downstream 401 error to avoid endless refresh loops.
102
    ///
103
    /// - Parameters:
104
    ///   - urlRequest: The `URLRequest`.
105
    ///   - response:   The `HTTPURLResponse`.
106
    ///   - error:      The `Error`.
107
    ///
108
    /// - Returns: `true` if the `URLRequest` failed due to an authentication error, `false` otherwise.
109
    func didRequest(_ urlRequest: URLRequest, with response: HTTPURLResponse, failDueToAuthenticationError error: Error) -> Bool
110
 
111
    /// Determines whether the `URLRequest` is authenticated with the `Credential`.
112
    ///
113
    /// If the authentication server **CANNOT** invalidate credentials after they are issued, then simply return `true`
114
    /// for this method. If the authentication server **CAN** invalidate credentials due to security breaches, then
115
    /// read on.
116
    ///
117
    /// When an authentication server can invalidate credentials, it means that you may have a non-expired credential
118
    /// that appears to be valid, but will be rejected by the authentication server when used. Generally when this
119
    /// happens, a number of requests are all sent when the application is foregrounded, and all of them will be
120
    /// rejected by the authentication server in the order they are received. The first failed request will trigger a
121
    /// refresh internally, which will update the credential, and then retry all the queued requests with the new
122
    /// credential. However, it is possible that some of the original requests will not return from the authentication
123
    /// server until the refresh has completed. This is where this method comes in.
124
    ///
125
    /// When the authentication server rejects a credential, we need to check to make sure we haven't refreshed the
126
    /// credential while the request was in flight. If it has already refreshed, then we don't need to trigger an
127
    /// additional refresh. If it hasn't refreshed, then we need to refresh.
128
    ///
129
    /// Now that it is understood how the result of this method is used in the refresh lifecyle, let's walk through how
130
    /// to implement it. You should return `true` in this method if the `URLRequest` is authenticated in a way that
131
    /// matches the values in the `Credential`. In the case of OAuth2, this would mean that the Bearer token in the
132
    /// `Authorization` header of the `URLRequest` matches the access token in the `Credential`. If it matches, then we
133
    /// know the `Credential` was used to authenticate the `URLRequest` and should return `true`. If the Bearer token
134
    /// did not match the access token, then you should return `false`.
135
    ///
136
    /// - Parameters:
137
    ///   - urlRequest: The `URLRequest`.
138
    ///   - credential: The `Credential`.
139
    ///
140
    /// - Returns: `true` if the `URLRequest` is authenticated with the `Credential`, `false` otherwise.
141
    func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: Credential) -> Bool
142
}
143
 
144
// MARK: -
145
 
146
/// Represents various authentication failures that occur when using the `AuthenticationInterceptor`. All errors are
147
/// still vended from Alamofire as `AFError` types. The `AuthenticationError` instances will be embedded within
148
/// `AFError` `.requestAdaptationFailed` or `.requestRetryFailed` cases.
149
public enum AuthenticationError: Error {
150
    /// The credential was missing so the request could not be authenticated.
151
    case missingCredential
152
    /// The credential was refreshed too many times within the `RefreshWindow`.
153
    case excessiveRefresh
154
}
155
 
156
// MARK: -
157
 
158
/// The `AuthenticationInterceptor` class manages the queuing and threading complexity of authenticating requests.
159
/// It relies on an `Authenticator` type to handle the actual `URLRequest` authentication and `Credential` refresh.
160
public class AuthenticationInterceptor<AuthenticatorType>: RequestInterceptor where AuthenticatorType: Authenticator {
161
    // MARK: Typealiases
162
 
163
    /// Type of credential used to authenticate requests.
164
    public typealias Credential = AuthenticatorType.Credential
165
 
166
    // MARK: Helper Types
167
 
168
    /// Type that defines a time window used to identify excessive refresh calls. When enabled, prior to executing a
169
    /// refresh, the `AuthenticationInterceptor` compares the timestamp history of previous refresh calls against the
170
    /// `RefreshWindow`. If more refreshes have occurred within the refresh window than allowed, the refresh is
171
    /// cancelled and an `AuthorizationError.excessiveRefresh` error is thrown.
172
    public struct RefreshWindow {
173
        /// `TimeInterval` defining the duration of the time window before the current time in which the number of
174
        /// refresh attempts is compared against `maximumAttempts`. For example, if `interval` is 30 seconds, then the
175
        /// `RefreshWindow` represents the past 30 seconds. If more attempts occurred in the past 30 seconds than
176
        /// `maximumAttempts`, an `.excessiveRefresh` error will be thrown.
177
        public let interval: TimeInterval
178
 
179
        /// Total refresh attempts allowed within `interval` before throwing an `.excessiveRefresh` error.
180
        public let maximumAttempts: Int
181
 
182
        /// Creates a `RefreshWindow` instance from the specified `interval` and `maximumAttempts`.
183
        ///
184
        /// - Parameters:
185
        ///   - interval:        `TimeInterval` defining the duration of the time window before the current time.
186
        ///   - maximumAttempts: The maximum attempts allowed within the `TimeInterval`.
187
        public init(interval: TimeInterval = 30.0, maximumAttempts: Int = 5) {
188
            self.interval = interval
189
            self.maximumAttempts = maximumAttempts
190
        }
191
    }
192
 
193
    private struct AdaptOperation {
194
        let urlRequest: URLRequest
195
        let session: Session
196
        let completion: (Result<URLRequest, Error>) -> Void
197
    }
198
 
199
    private enum AdaptResult {
200
        case adapt(Credential)
201
        case doNotAdapt(AuthenticationError)
202
        case adaptDeferred
203
    }
204
 
205
    private struct MutableState {
206
        var credential: Credential?
207
 
208
        var isRefreshing = false
209
        var refreshTimestamps: [TimeInterval] = []
210
        var refreshWindow: RefreshWindow?
211
 
212
        var adaptOperations: [AdaptOperation] = []
213
        var requestsToRetry: [(RetryResult) -> Void] = []
214
    }
215
 
216
    // MARK: Properties
217
 
218
    /// The `Credential` used to authenticate requests.
219
    public var credential: Credential? {
220
        get { $mutableState.credential }
221
        set { $mutableState.credential = newValue }
222
    }
223
 
224
    let authenticator: AuthenticatorType
225
    let queue = DispatchQueue(label: "org.alamofire.authentication.inspector")
226
 
227
    @Protected
228
    private var mutableState: MutableState
229
 
230
    // MARK: Initialization
231
 
232
    /// Creates an `AuthenticationInterceptor` instance from the specified parameters.
233
    ///
234
    /// A `nil` `RefreshWindow` will result in the `AuthenticationInterceptor` not checking for excessive refresh calls.
235
    /// It is recommended to always use a `RefreshWindow` to avoid endless refresh cycles.
236
    ///
237
    /// - Parameters:
238
    ///   - authenticator: The `Authenticator` type.
239
    ///   - credential:    The `Credential` if it exists. `nil` by default.
240
    ///   - refreshWindow: The `RefreshWindow` used to identify excessive refresh calls. `RefreshWindow()` by default.
241
    public init(authenticator: AuthenticatorType,
242
                credential: Credential? = nil,
243
                refreshWindow: RefreshWindow? = RefreshWindow()) {
244
        self.authenticator = authenticator
245
        mutableState = MutableState(credential: credential, refreshWindow: refreshWindow)
246
    }
247
 
248
    // MARK: Adapt
249
 
250
    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
251
        let adaptResult: AdaptResult = $mutableState.write { mutableState in
252
            // Queue the adapt operation if a refresh is already in place.
253
            guard !mutableState.isRefreshing else {
254
                let operation = AdaptOperation(urlRequest: urlRequest, session: session, completion: completion)
255
                mutableState.adaptOperations.append(operation)
256
                return .adaptDeferred
257
            }
258
 
259
            // Throw missing credential error is the credential is missing.
260
            guard let credential = mutableState.credential else {
261
                let error = AuthenticationError.missingCredential
262
                return .doNotAdapt(error)
263
            }
264
 
265
            // Queue the adapt operation and trigger refresh operation if credential requires refresh.
266
            guard !credential.requiresRefresh else {
267
                let operation = AdaptOperation(urlRequest: urlRequest, session: session, completion: completion)
268
                mutableState.adaptOperations.append(operation)
269
                refresh(credential, for: session, insideLock: &mutableState)
270
                return .adaptDeferred
271
            }
272
 
273
            return .adapt(credential)
274
        }
275
 
276
        switch adaptResult {
277
        case let .adapt(credential):
278
            var authenticatedRequest = urlRequest
279
            authenticator.apply(credential, to: &authenticatedRequest)
280
            completion(.success(authenticatedRequest))
281
 
282
        case let .doNotAdapt(adaptError):
283
            completion(.failure(adaptError))
284
 
285
        case .adaptDeferred:
286
            // No-op: adapt operation captured during refresh.
287
            break
288
        }
289
    }
290
 
291
    // MARK: Retry
292
 
293
    public func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
294
        // Do not attempt retry if there was not an original request and response from the server.
295
        guard let urlRequest = request.request, let response = request.response else {
296
            completion(.doNotRetry)
297
            return
298
        }
299
 
300
        // Do not attempt retry unless the `Authenticator` verifies failure was due to authentication error (i.e. 401 status code).
301
        guard authenticator.didRequest(urlRequest, with: response, failDueToAuthenticationError: error) else {
302
            completion(.doNotRetry)
303
            return
304
        }
305
 
306
        // Do not attempt retry if there is no credential.
307
        guard let credential = credential else {
308
            let error = AuthenticationError.missingCredential
309
            completion(.doNotRetryWithError(error))
310
            return
311
        }
312
 
313
        // Retry the request if the `Authenticator` verifies it was authenticated with a previous credential.
314
        guard authenticator.isRequest(urlRequest, authenticatedWith: credential) else {
315
            completion(.retry)
316
            return
317
        }
318
 
319
        $mutableState.write { mutableState in
320
            mutableState.requestsToRetry.append(completion)
321
 
322
            guard !mutableState.isRefreshing else { return }
323
 
324
            refresh(credential, for: session, insideLock: &mutableState)
325
        }
326
    }
327
 
328
    // MARK: Refresh
329
 
330
    private func refresh(_ credential: Credential, for session: Session, insideLock mutableState: inout MutableState) {
331
        guard !isRefreshExcessive(insideLock: &mutableState) else {
332
            let error = AuthenticationError.excessiveRefresh
333
            handleRefreshFailure(error, insideLock: &mutableState)
334
            return
335
        }
336
 
337
        mutableState.refreshTimestamps.append(ProcessInfo.processInfo.systemUptime)
338
        mutableState.isRefreshing = true
339
 
340
        // Dispatch to queue to hop out of the lock in case authenticator.refresh is implemented synchronously.
341
        queue.async {
342
            self.authenticator.refresh(credential, for: session) { result in
343
                self.$mutableState.write { mutableState in
344
                    switch result {
345
                    case let .success(credential):
346
                        self.handleRefreshSuccess(credential, insideLock: &mutableState)
347
                    case let .failure(error):
348
                        self.handleRefreshFailure(error, insideLock: &mutableState)
349
                    }
350
                }
351
            }
352
        }
353
    }
354
 
355
    private func isRefreshExcessive(insideLock mutableState: inout MutableState) -> Bool {
356
        guard let refreshWindow = mutableState.refreshWindow else { return false }
357
 
358
        let refreshWindowMin = ProcessInfo.processInfo.systemUptime - refreshWindow.interval
359
 
360
        let refreshAttemptsWithinWindow = mutableState.refreshTimestamps.reduce(into: 0) { attempts, refreshTimestamp in
361
            guard refreshWindowMin <= refreshTimestamp else { return }
362
            attempts += 1
363
        }
364
 
365
        let isRefreshExcessive = refreshAttemptsWithinWindow >= refreshWindow.maximumAttempts
366
 
367
        return isRefreshExcessive
368
    }
369
 
370
    private func handleRefreshSuccess(_ credential: Credential, insideLock mutableState: inout MutableState) {
371
        mutableState.credential = credential
372
 
373
        let adaptOperations = mutableState.adaptOperations
374
        let requestsToRetry = mutableState.requestsToRetry
375
 
376
        mutableState.adaptOperations.removeAll()
377
        mutableState.requestsToRetry.removeAll()
378
 
379
        mutableState.isRefreshing = false
380
 
381
        // Dispatch to queue to hop out of the mutable state lock
382
        queue.async {
383
            adaptOperations.forEach { self.adapt($0.urlRequest, for: $0.session, completion: $0.completion) }
384
            requestsToRetry.forEach { $0(.retry) }
385
        }
386
    }
387
 
388
    private func handleRefreshFailure(_ error: Error, insideLock mutableState: inout MutableState) {
389
        let adaptOperations = mutableState.adaptOperations
390
        let requestsToRetry = mutableState.requestsToRetry
391
 
392
        mutableState.adaptOperations.removeAll()
393
        mutableState.requestsToRetry.removeAll()
394
 
395
        mutableState.isRefreshing = false
396
 
397
        // Dispatch to queue to hop out of the mutable state lock
398
        queue.async {
399
            adaptOperations.forEach { $0.completion(.failure(error)) }
400
            requestsToRetry.forEach { $0(.doNotRetryWithError(error)) }
401
        }
402
    }
403
}