diff --git a/.rubocop.yml b/.rubocop.yml index 18399d5..df6ceb0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -249,6 +249,8 @@ Naming/AccessorMethodName: # ( ) for method calls Style/MethodCallWithArgsParentheses: Enabled: true + Exclude: + - "**/*.podspec" IgnoredMethods: - 'require' - 'require_relative' diff --git a/Example/App/AppDelegate.swift b/Example/App/AppDelegate.swift index dc19b12..6f2fdbd 100644 --- a/Example/App/AppDelegate.swift +++ b/Example/App/AppDelegate.swift @@ -33,18 +33,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { let factory = TinkoffIDFactory( clientId: clientId, - callbackUrl: callbackUrl + callbackUrl: callbackUrl, + webViewSourceProvider: self ) return factory.build() }() + lazy var authController: AuthViewController = { + AuthViewController(signInInitializer: tinkoffId, + credentialsRefresher: tinkoffId, + signOutInitializer: tinkoffId) + }() + func applicationDidFinishLaunching(_ application: UIApplication) { - let authController = AuthViewController( - signInInitializer: tinkoffId, - credentialsRefresher: tinkoffId, - signOutInitializer: tinkoffId - ) authController.tabBarItem = UITabBarItem(title: "Auth", image: nil, tag: 0) let tinkoffButtonsController = TinkoffButtonsViewController() @@ -65,6 +67,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - tinkoffId.handleCallbackUrl(url) + return tinkoffId.handleCallbackUrl(url) + } +} + +// MARK: - AuthWebView Usage + +extension AppDelegate: IAuthWebViewSourceProvider { + func getSourceViewController() -> UIViewController { + authController } } diff --git a/Example/App/Resources/Info.plist b/Example/App/Resources/Info.plist index 091780c..efbc803 100644 --- a/Example/App/Resources/Info.plist +++ b/Example/App/Resources/Info.plist @@ -58,5 +58,34 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + certs.tinkoff.ru + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + tcsbank.ru + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + tinkoff.ru + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + diff --git a/Example/Podfile b/Example/Podfile index 68edf55..9edbbb7 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -1,3 +1,5 @@ +source 'https://github.com/CocoaPods/Specs.git' + use_frameworks! target 'TinkoffIDExample' do diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 79bf1fc..4a5e3e4 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -1,7 +1,12 @@ PODS: - - SnapKit (5.0.1) - - TinkoffID (1.1.0) - - TinkoffID/Tests (1.1.0) + - SnapKit (5.6.0) + - TCSSSLPinningPublic (4.0.0): + - TrustKit (= 1.6.3) + - TinkoffID (1.2.0): + - TCSSSLPinningPublic (~> 4.0) + - TinkoffID/Tests (1.2.0): + - TCSSSLPinningPublic (~> 4.0) + - TrustKit (1.6.3) DEPENDENCIES: - SnapKit @@ -9,17 +14,21 @@ DEPENDENCIES: - TinkoffID/Tests (from `../`) SPEC REPOS: - trunk: + https://github.com/CocoaPods/Specs.git: - SnapKit + - TCSSSLPinningPublic + - TrustKit EXTERNAL SOURCES: TinkoffID: :path: "../" SPEC CHECKSUMS: - SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb - TinkoffID: 5f8c16ed1ec6ab3c055d76b162d5770a324ad3db + SnapKit: e01d52ebb8ddbc333eefe2132acf85c8227d9c25 + TCSSSLPinningPublic: 7d8aed728c4a1ff66feb2323f686a389b868f1e5 + TinkoffID: c78e0033baeeefcf1c3782496fe4e1b65159f6d1 + TrustKit: a2f0c3a926f0a3ce3c082db9a39f1f540dbb04cb -PODFILE CHECKSUM: ebbf3aa204a2c47033aee78dc783acf8bc5423bd +PODFILE CHECKSUM: afca0ae77c379b2ea123dd308359330458e3b198 COCOAPODS: 1.10.1 diff --git a/Example/TinkoffIDExample.xcodeproj/project.pbxproj b/Example/TinkoffIDExample.xcodeproj/project.pbxproj index 44dc21d..3d21761 100644 --- a/Example/TinkoffIDExample.xcodeproj/project.pbxproj +++ b/Example/TinkoffIDExample.xcodeproj/project.pbxproj @@ -353,7 +353,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/App/Resources/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -378,7 +378,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/App/Resources/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/README.md b/README.md index d45d75c..60be2db 100755 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ * [Отзыв авторизационных данных](#Отзыв-авторизационных-данных) * [Структура TinkoffTokenPayload](#Структура-TinkoffTokenPayload) * [Хранение Refresh Token](#Хранение-Refresh-Token) +* [Авторизация через WebView](#Авторизация-через-WebView) * [UI](#UI) * [Отладка без приложения Тинькофф](#Отладка-без-приложения-Тинькофф) * [Настройка приложения](#Настройка-приложения) @@ -65,6 +66,40 @@ pod 'TinkoffID' tinkoffbank ``` ++ Добавленная запись в `plist`, позволяющая Вашему приложению получать запасные сертификаты SSL Тинькофф. + +``` +NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + certs.tinkoff.ru + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + tcsbank.ru + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + tinkoff.ru + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + +``` ## Структура публичной части SDK @@ -75,6 +110,7 @@ pod 'TinkoffID' + `ITinkoffAuthCallbackHandler` - обработчик возврата в приложение из приложения Тинькофф + `ITinkoffCredentialsRefresher` - объект, умеющий обновлять `Credentials` по их `Refresh token` + `ITinkoffSignOutInitiator` - инициатор отзыва авторизационных данных ++ `ITinkoffWebViewPresentationProvider` - провайдет источника для показа [WebView](#Авторизация-через-WebView) В зависимости от архитектуры приложения можно использовать непосредственно`ITinkoffID` или каждый подпротокол отдельно в требуемой части системы. @@ -197,6 +233,23 @@ tinkoffId.signOut(with: credentials.accessToken, tokenTypeHint: .access, complet При получении `TinkoffTokenPayload` и наличии у него поля `refreshToken` имеет смысл сохранить значение этого поля чтобы иметь возможность запросить новый `accessToken`, когда прежний станет неактивным. Рекомендуемый способ хранения токена - [Keychain Services](https://developer.apple.com/documentation/security/keychain_services) +## Авторизация через WebView + +В некоторых случаях система не открывает приложения по universal link, а ведет в браузер. Для того чтобы избежать этого, добавлен fallback на открытие WebView. + +В данном сценарии при отсутствии установленного на телефоне приложения Тиннькофф или невозможности открыть приложение по universal link, SDK попробует открыть WebView с веб-авторизацией. Пользователю будет предложена авторизация по номеру телефону. После успешной авторизации WebView автоматически передаст управление SDK для продлолжения процесса. + +Для использования необходимо установить значение `usesUniversalLinks` в `true` у `TargetAppConfiguration` (для `TinkoffApp` настроено по умолчанию), и передать провайдер `IAuthWebViewSourceProvider`. + +Если же использовать дефолтную конфигурацию фабрики: +``` +let factory = TinkoffIDFactory( + clientId: clientId, + callbackUrl: callbackUrl) +``` +открытие WebView будет происходить от `keyWindow`. + + ## UI SDK поставляет два варианта фирменных кнопок входа через Тинькофф. Первый вариант - стандартная прямоугольная кнопка с текстом, с возможностью задать текст, радиус скругления и шрифт. Так же можно выбрать один из трех вариантов цветового стиля и размера. Есть возможность добавить дополнительный текст для привлечения клиентов. @@ -299,13 +352,7 @@ SDK поставляется с примером приложения. Для з ### AuthViewController -`AuthViewController` инициируется ссылками на объекты, реализующими `ITinkoffAuthInitiator`, `ITinkoffCredentialsRefresher` и `ITinkoffSignOutInitiator` соответственно. - -В текущей реализации все эти ссылки указывают на один и тот же экземпляр объекта `TinkoffID`, реализующий интерфейс `ITinkoffID`. Такой подход был выбран для демонстрации возможности использования подинтерфейсов `ITinkoffID` в той или иной части системы. Пользователь SDK вправе сам решать использовать ли ему единый интерфейс `ITinkoffID` или необходимый подинтерфейс в зависимости от архитектуры приложения. - -Подробнее с подинтерфесами `ITinkoffID` можно ознакомиться в разделе `Структура публичной части SDK`. - -После загрузки `view` контроллер добавляет на него кнопку входа через Тинькофф, по нажатию на которую будет инициирована авторизация. +`AuthViewController` инициируется ссылками на объекты, реализующими `ITinkoffAuthInitiator`, `ITinkoffCredentialsRefresher`, `ITinkoffSignOutInitiator` и `ITinkoffWebViewPresentationProvider` соответственно. ## Поддержка Сообщать об ошибках и запрашивать новый функционал можно в разделе [Issues](https://github.com/tinkoff-mobile-tech/TinkoffID-iOS/issues) diff --git a/Sources/API/PinningDelegate/PinningDelegate.swift b/Sources/API/PinningDelegate/PinningDelegate.swift new file mode 100644 index 0000000..30afdc7 --- /dev/null +++ b/Sources/API/PinningDelegate/PinningDelegate.swift @@ -0,0 +1,67 @@ +// +// PinningDelegate.swift +// Pods-TinkoffIDExample +// +// Created by Aleksandr Moskalyuk on 18.05.2023. +// + +import Foundation +import TCSSSLPinning +import WebKit + +protocol IPinningDelegate {} + +final class PinningDelegate: NSObject, IPinningDelegate { + + // MARK: - Dependencies + + private var httpPublicKeyPinningService: IHTTPPublicKeyPinningService + + // MARK: - Lifestyle + + init(hostAndPinsURL: String?) { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let bundleID = Bundle.main.bundleIdentifier?.description ?? "ru.tinkoff.id" + let configuration = HPKPServiceConfiguration(hostAndPinsURL: URL(string: hostAndPinsURL ?? "") ?? HPKPServiceConstants.Configuration.productionHostAndPinsURL, + untrustedConnectionPolicy: .continue, + cachedHostsAndPinsDefaultsKey: "\(bundleID).hostsandpins", + appParameters: AppParameters(version: version ?? "1.0", origin: "origin")) + self.httpPublicKeyPinningService = HPKPServiceAssembly.createHPKPPinningService(with: configuration) + + self.httpPublicKeyPinningService.configure() + self.httpPublicKeyPinningService.updateHostsAndPins() + + super.init() + } +} + +// MARK: - URLSessionDelegate + +extension PinningDelegate: URLSessionDelegate { + func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { + httpPublicKeyPinningService.urlSession?(session, didBecomeInvalidWithError: error) + } + + func urlSession(_ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + httpPublicKeyPinningService.urlSession?(session, + didReceive: challenge, + completionHandler: completionHandler) + + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + httpPublicKeyPinningService.urlSessionDidFinishEvents?(forBackgroundURLSession: session) + } +} + +// MARK: - WKNavigationDelegate + +extension PinningDelegate: WKNavigationDelegate { + public func webView(_ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + httpPublicKeyPinningService.webView?(webView, didReceive: challenge, completionHandler: completionHandler) + } +} \ No newline at end of file diff --git a/Sources/AppLaunching/IAppLauncher/IAppLauncher.swift b/Sources/AppLaunching/IAppLauncher/IAppLauncher.swift index e9841bd..a607200 100644 --- a/Sources/AppLaunching/IAppLauncher/IAppLauncher.swift +++ b/Sources/AppLaunching/IAppLauncher/IAppLauncher.swift @@ -25,5 +25,5 @@ protocol IAppLauncher { var canLaunchApp: Bool { get } /// Запускает приложение с заданными опциями - func launchApp(with options: AppLaunchOptions) throws + func launchApp(with options: AppLaunchOptions, universalLinksOnly: Bool, completion: @escaping ((Bool) -> Void)) throws } diff --git a/Sources/AppLaunching/IAppLauncher/URLSchemeAppLauncher.swift b/Sources/AppLaunching/IAppLauncher/URLSchemeAppLauncher.swift index 79a0705..bea0a94 100644 --- a/Sources/AppLaunching/IAppLauncher/URLSchemeAppLauncher.swift +++ b/Sources/AppLaunching/IAppLauncher/URLSchemeAppLauncher.swift @@ -39,11 +39,10 @@ final class URLSchemeAppLauncher: IAppLauncher { .map(router.canOpenURL) ?? false } - func launchApp(with options: AppLaunchOptions) throws { + func launchApp(with options: AppLaunchOptions, universalLinksOnly: Bool, completion: @escaping ((Bool) -> Void)) throws { let appUrl = try builder.buildUrlScheme(with: options) - if !router.open(appUrl) { - // Не удалось запустить приложение + if !router.openWithFallback(appUrl, universalLinksOnly: universalLinksOnly, completion: completion) { throw Error.launchFailure } } diff --git a/Sources/AppLaunching/IAppLauncher/Utils/Router/IURLRouter.swift b/Sources/AppLaunching/IAppLauncher/Utils/Router/IURLRouter.swift index 9f9a034..d5a3495 100644 --- a/Sources/AppLaunching/IAppLauncher/Utils/Router/IURLRouter.swift +++ b/Sources/AppLaunching/IAppLauncher/Utils/Router/IURLRouter.swift @@ -26,6 +26,9 @@ protocol IURLRouter { /// Открывает заданный URL и возвращает `true` если открытие удалось func open(_ url: URL) -> Bool + + /// Открывает заданный URL c фоллбэком на открытие вебвью в случае если приложение не установлено + func openWithFallback(_ url: URL, universalLinksOnly: Bool, completion: @escaping ((Bool) -> Void)) -> Bool } extension UIApplication: IURLRouter { @@ -36,4 +39,12 @@ extension UIApplication: IURLRouter { return true } + + func openWithFallback(_ url: URL, universalLinksOnly: Bool, completion: @escaping ((Bool) -> Void)) -> Bool { + guard canOpenURL(url) else { return false } + + open(url, options: [.universalLinksOnly : universalLinksOnly], completionHandler: completion) + + return true + } } diff --git a/Sources/Extensions/Environment+EnvironmentConfiguration.swift b/Sources/Extensions/Environment+EnvironmentConfiguration.swift index c11bb61..31c89d5 100644 --- a/Sources/Extensions/Environment+EnvironmentConfiguration.swift +++ b/Sources/Extensions/Environment+EnvironmentConfiguration.swift @@ -17,8 +17,13 @@ // limitations under the License. import Foundation +import TCSSSLPinning extension TinkoffEnvironment: EnvironmentConfiguration { + public var hostAndPinsUrl: String? { + return HPKPServiceConstants.Configuration.productionHostAndPinsURL.absoluteString + } + public var apiBaseUrl: String { switch self { case .production: diff --git a/Sources/Extensions/TinkoffApp+TargetAppConfiguration.swift b/Sources/Extensions/TinkoffApp+TargetAppConfiguration.swift index 0a35d2c..6dd7c06 100644 --- a/Sources/Extensions/TinkoffApp+TargetAppConfiguration.swift +++ b/Sources/Extensions/TinkoffApp+TargetAppConfiguration.swift @@ -19,6 +19,7 @@ import Foundation extension TinkoffApp: TargetAppConfiguration { + public var urlScheme: String { switch self { case .bank: @@ -26,6 +27,13 @@ extension TinkoffApp: TargetAppConfiguration { } } + public var usesUniversalLinks: Bool { + switch self { + case .bank: + return true + } + } + public var authUrl: String { switch self { case .bank: diff --git a/Sources/Resources/TinkoffID.xcassets/reload.imageset/Contents.json b/Sources/Resources/TinkoffID.xcassets/reload.imageset/Contents.json new file mode 100644 index 0000000..8f259c4 --- /dev/null +++ b/Sources/Resources/TinkoffID.xcassets/reload.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "reload.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Resources/TinkoffID.xcassets/reload.imageset/reload.pdf b/Sources/Resources/TinkoffID.xcassets/reload.imageset/reload.pdf new file mode 100644 index 0000000..45088d6 Binary files /dev/null and b/Sources/Resources/TinkoffID.xcassets/reload.imageset/reload.pdf differ diff --git a/Sources/SDK/Concrete/DebugTinkoffID/DebugTinkoffID.swift b/Sources/SDK/Concrete/DebugTinkoffID/DebugTinkoffID.swift index 58d9457..2323baa 100644 --- a/Sources/SDK/Concrete/DebugTinkoffID/DebugTinkoffID.swift +++ b/Sources/SDK/Concrete/DebugTinkoffID/DebugTinkoffID.swift @@ -17,6 +17,7 @@ // limitations under the License. import Foundation +import UIKit final class DebugTinkoffID: ITinkoffID { @@ -92,6 +93,10 @@ final class DebugTinkoffID: ITinkoffID { } } + func getSourceViewController() -> UIViewController { + UIViewController() + } + // MARK: - Private private func resolveDebugAppResult(_ url: URL) -> DebugAppResult? { diff --git a/Sources/SDK/Concrete/DebugTinkoffID/IDebugAppLauncher/DebugAppLauncher.swift b/Sources/SDK/Concrete/DebugTinkoffID/IDebugAppLauncher/DebugAppLauncher.swift index ebad6a3..a152040 100644 --- a/Sources/SDK/Concrete/DebugTinkoffID/IDebugAppLauncher/DebugAppLauncher.swift +++ b/Sources/SDK/Concrete/DebugTinkoffID/IDebugAppLauncher/DebugAppLauncher.swift @@ -25,6 +25,6 @@ final class DebugAppLauncher: IDebugAppLauncher { } func launchDebugApp() { - _ = debugAppUrl.map(router.open(_:)) +// _ = debugAppUrl.map(router.open(_:)) } } diff --git a/Sources/SDK/Concrete/TinkoffID/Configuration/EnvironmentConfiguration.swift b/Sources/SDK/Concrete/TinkoffID/Configuration/EnvironmentConfiguration.swift index 11cf853..6cd4241 100644 --- a/Sources/SDK/Concrete/TinkoffID/Configuration/EnvironmentConfiguration.swift +++ b/Sources/SDK/Concrete/TinkoffID/Configuration/EnvironmentConfiguration.swift @@ -22,4 +22,6 @@ import Foundation public protocol EnvironmentConfiguration { /// Базовый URL API var apiBaseUrl: String { get } + /// URL Host and Pins для работы с кастомными сертификатами TLS/SSL + var hostAndPinsUrl: String? { get } } diff --git a/Sources/SDK/Concrete/TinkoffID/Configuration/TargetAppConfiguration.swift b/Sources/SDK/Concrete/TinkoffID/Configuration/TargetAppConfiguration.swift index 40ee41a..82bd602 100644 --- a/Sources/SDK/Concrete/TinkoffID/Configuration/TargetAppConfiguration.swift +++ b/Sources/SDK/Concrete/TinkoffID/Configuration/TargetAppConfiguration.swift @@ -25,4 +25,7 @@ public protocol TargetAppConfiguration { /// Ссылка для проведения авторизации var authUrl: String { get } + + /// Признак того является ли `authUrl` универсальной ссылкой (https://...) + var usesUniversalLinks: Bool { get } } diff --git a/Sources/SDK/Concrete/TinkoffID/TinkoffID.swift b/Sources/SDK/Concrete/TinkoffID/TinkoffID.swift index 6ff4553..0c95247 100644 --- a/Sources/SDK/Concrete/TinkoffID/TinkoffID.swift +++ b/Sources/SDK/Concrete/TinkoffID/TinkoffID.swift @@ -26,9 +26,13 @@ final class TinkoffID: ITinkoffID { let appLauncher: IAppLauncher let callbackUrlParser: ICallbackURLParser let api: IAPI + let authWebViewBuilder: IAuthWebViewBuilder + let webViewSourceProvider: IAuthWebViewSourceProvider? + let universalLinksOnly: Bool // MARK: - State private var currentProcess: AuthProcess? + private var authWebViewSourceController: UIViewController? // MARK: - Properties let clientId: String @@ -38,14 +42,20 @@ final class TinkoffID: ITinkoffID { appLauncher: IAppLauncher, callbackUrlParser: ICallbackURLParser, api: IAPI, + authWebViewBuilder: IAuthWebViewBuilder, + webViewSourceProvider: IAuthWebViewSourceProvider?, clientId: String, - callbackUrl: String) { + callbackUrl: String, + universalLinksOnly: Bool) { self.payloadGenerator = payloadGenerator self.appLauncher = appLauncher self.callbackUrlParser = callbackUrlParser self.api = api + self.authWebViewBuilder = authWebViewBuilder + self.webViewSourceProvider = webViewSourceProvider self.clientId = clientId self.callbackUrl = callbackUrl + self.universalLinksOnly = universalLinksOnly } // MARK: - ITinkoffAuthInitiator @@ -63,7 +73,19 @@ final class TinkoffID: ITinkoffID { let process = AuthProcess(appLaunchOptions: options, completion: completion) - try appLauncher.launchApp(with: options) + try appLauncher.launchApp(with: options, + universalLinksOnly: self.universalLinksOnly, + completion: { [weak self] didLaunchMobileApp in + guard let self = self else { return } + + guard !didLaunchMobileApp else { return } + + if self.universalLinksOnly, let _ = self.webViewSourceProvider { + self.openWebView(options: options) + } else { + completion(.failure(.failedToLaunchApp)) + } + }) currentProcess = process } catch { @@ -71,6 +93,13 @@ final class TinkoffID: ITinkoffID { } } + + func openWebView(options: AppLaunchOptions) { + let authWebView = authWebViewBuilder.build(with: options) + authWebView.delegate = self + authWebView.open(from: webViewSourceProvider?.getSourceViewController()) + } + // MARK: - ITinkoffAuthCallbackHandler public func handleCallbackUrl(_ url: URL) -> Bool { @@ -132,3 +161,12 @@ final class TinkoffID: ITinkoffID { } } } + +extension TinkoffID: IAuthWebViewDelegate { + func authWebView(_ webView: IAuthWebView, didOpen url: URL) { + let handled = handleCallbackUrl(url) + if handled { + webView.dismiss() + } + } +} diff --git a/Sources/SDK/Factory/Concrete/TinkoffIDFactory.swift b/Sources/SDK/Factory/Concrete/TinkoffIDFactory.swift index f881d7a..3bc2007 100644 --- a/Sources/SDK/Factory/Concrete/TinkoffIDFactory.swift +++ b/Sources/SDK/Factory/Concrete/TinkoffIDFactory.swift @@ -27,6 +27,7 @@ public final class TinkoffIDFactory: ITinkoffIDFactory { private let callbackUrl: String private let appConfiguration: TargetAppConfiguration private let environmentConfiguration: EnvironmentConfiguration + private let webViewSourceProvider: IAuthWebViewSourceProvider? // MARK: - Initialization @@ -41,13 +42,15 @@ public final class TinkoffIDFactory: ITinkoffIDFactory { clientId: String, callbackUrl: String, app: TinkoffApp = .bank, - environment: TinkoffEnvironment = .production + environment: TinkoffEnvironment = .production, + webViewSourceProvider: IAuthWebViewSourceProvider? = DefaultAuthWebViewSourceProvider.instance ) { self.init( clientId: clientId, callbackUrl: callbackUrl, appConfiguration: app, - environmentConfiguration: environment + environmentConfiguration: environment, + webViewSourceProvider: webViewSourceProvider ) } @@ -62,12 +65,14 @@ public final class TinkoffIDFactory: ITinkoffIDFactory { clientId: String, callbackUrl: String, appConfiguration: TargetAppConfiguration, - environmentConfiguration: EnvironmentConfiguration + environmentConfiguration: EnvironmentConfiguration, + webViewSourceProvider: IAuthWebViewSourceProvider? = DefaultAuthWebViewSourceProvider.instance ) { self.clientId = clientId self.callbackUrl = callbackUrl self.environmentConfiguration = environmentConfiguration self.appConfiguration = appConfiguration + self.webViewSourceProvider = webViewSourceProvider } // MARK: - ITinkoffIDFactory @@ -80,10 +85,14 @@ public final class TinkoffIDFactory: ITinkoffIDFactory { router: UIApplication.shared ) + let pinningDelegate = PinningDelegate(hostAndPinsURL: environmentConfiguration.hostAndPinsUrl) + let urlSession = URLSession(configuration: URLSessionConfiguration.default, + delegate: pinningDelegate, + delegateQueue: nil) let requestBuilder = RequestBuilder(baseUrl: environmentConfiguration.apiBaseUrl) let api = API( requestBuilder: requestBuilder, - requestProcessor: URLSession.shared, + requestProcessor: urlSession, responseDispatcher: DispatchQueue.main ) @@ -96,14 +105,19 @@ public final class TinkoffIDFactory: ITinkoffIDFactory { ) let callbackUrlParser = CallbackURLParser() + let authWebViewBuilder = AuthWebViewBuilder(baseUrl: environmentConfiguration.apiBaseUrl, + pinningDelegate: pinningDelegate) return TinkoffID( payloadGenerator: payloadGenerator, appLauncher: appLauncher, callbackUrlParser: callbackUrlParser, api: api, + authWebViewBuilder: authWebViewBuilder, + webViewSourceProvider: webViewSourceProvider, clientId: clientId, - callbackUrl: callbackUrl + callbackUrl: callbackUrl, + universalLinksOnly: appConfiguration.usesUniversalLinks ) } } diff --git a/Sources/WebView/AuthWebView.swift b/Sources/WebView/AuthWebView.swift new file mode 100644 index 0000000..c074c2e --- /dev/null +++ b/Sources/WebView/AuthWebView.swift @@ -0,0 +1,173 @@ +// +// AuthWebView.swift +// TinkoffID +// +// Copyright (c) 2023 Tinkoff +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import WebKit + +protocol IAuthWebViewDelegate: AnyObject { + func authWebView(_ webView: IAuthWebView, didOpen url: URL) +} + +protocol IAuthWebView: AnyObject { + var delegate: IAuthWebViewDelegate? { get set } + + func open(from: UIViewController?) + func dismiss() +} + +final class AuthWebView: UIViewController { + + weak var delegate: IAuthWebViewDelegate? + + private let webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + return WKWebView(frame: .zero, configuration: configuration) + }() + private let options: AppLaunchOptions + private var baseUrl: String + private let pinningDelegate: WKNavigationDelegate + + init(pinningDelegate: PinningDelegate, + options: AppLaunchOptions, + baseUrl: String) { + self.pinningDelegate = pinningDelegate + self.options = options + self.baseUrl = baseUrl + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Закрыть", + style: .plain, + target: self, + action: #selector(closeButtonClicked)) + navigationItem.rightBarButtonItem = UIBarButtonItem(image: Bundle.resourcesBundle?.imageNamed("reload"), + style: .plain, + target: self, + action: #selector(reloadButtonClicked)) + + view.addSubview(webView) + webView.navigationDelegate = self + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.topAnchor.constraint(equalTo: view.topAnchor, constant: navigationController?.navigationBar.frame.size.height ?? 44).isActive = true + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0).isActive = true + + if #available(iOS 13.0, *) { + view.backgroundColor = UIColor.systemBackground + } else { + view.backgroundColor = .white + } + + loadWebView() + } + + // MARK: - Private + + private func loadWebView() { + do { + let url = try buildWebViewURL(with: options) + let request = URLRequest(url: url) + DispatchQueue.main.async { + self.webView.load(request) + } + } catch { + fatalError("Invalid URL provided to WebView") + } + } + + private func buildWebViewURL(with options: AppLaunchOptions) throws -> URL { + let params = [ + "client_id": options.clientId, + "code_verifier": options.payload.verifier, + "code_challenge_method": options.payload.challengeMethod, + "code_challenge": options.payload.challenge, + "redirect_uri": options.callbackUrl, + "response_type": "code", + "response_mode": "query" + ] + + var components = URLComponents(string: "\(baseUrl)/auth/authorize") + components?.queryItems = params.map { + URLQueryItem(name: $0.key, value: $0.value) + } + + enum Error: Swift.Error { + case unableToInitializeUrl + } + + guard let url = components?.url else { + throw Error.unableToInitializeUrl + } + + return url + } + + @objc private func closeButtonClicked() { + dismiss(animated: true) + } + + @objc private func reloadButtonClicked() { + loadWebView() + } +} + +// MARK: - IAuthWebView + +extension AuthWebView: IAuthWebView { + + func open(from: UIViewController?) { + let navigationController = UINavigationController(rootViewController: self) + from?.present(navigationController, animated: true) + } + + func dismiss() { + dismiss(animated: true) + } +} + +// MARK: - WKNavigationDelegate + +extension AuthWebView: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { return } + + if url.absoluteString == "https://www.tinkoff.ru/" { + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + delegate?.authWebView(self, didOpen: url) + } + + public func webView(_ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + + pinningDelegate.webView?(webView, didReceive: challenge, completionHandler: completionHandler) + } +} diff --git a/Sources/WebView/AuthWebViewBuilder.swift b/Sources/WebView/AuthWebViewBuilder.swift new file mode 100644 index 0000000..2c143a2 --- /dev/null +++ b/Sources/WebView/AuthWebViewBuilder.swift @@ -0,0 +1,39 @@ +// +// AuthWebViewBuilder.swift +// TinkoffID +// +// Copyright (c) 2023 Tinkoff +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +protocol IAuthWebViewBuilder { + func build(with options: AppLaunchOptions) -> IAuthWebView +} + +final class AuthWebViewBuilder: IAuthWebViewBuilder { + + private var baseUrl: String + private var pinningDelegate: PinningDelegate + + init(baseUrl: String, + pinningDelegate: PinningDelegate) { + self.baseUrl = baseUrl + self.pinningDelegate = pinningDelegate + } + + func build(with options: AppLaunchOptions) -> IAuthWebView { + return AuthWebView(pinningDelegate: pinningDelegate, options: options, baseUrl: baseUrl) + } +} diff --git a/Sources/WebView/DefaultAuthWebViewSourceProvider.swift b/Sources/WebView/DefaultAuthWebViewSourceProvider.swift new file mode 100644 index 0000000..8fb62da --- /dev/null +++ b/Sources/WebView/DefaultAuthWebViewSourceProvider.swift @@ -0,0 +1,50 @@ +// +// DefaultAuthWebViewSourceProvider.swift +// TinkoffID +// +// Copyright (c) 2023 Tinkoff +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public final class DefaultAuthWebViewSourceProvider: IAuthWebViewSourceProvider { + + public static var instance: IAuthWebViewSourceProvider { + DefaultAuthWebViewSourceProvider() + } + + public func getSourceViewController() -> UIViewController { + guard let topViewController = UIApplication.topViewController() else { + fatalError("Ошибка в иерархии вью") + } + return topViewController + } +} + +extension UIApplication { + class func topViewController(controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? { + if let navigationController = controller as? UINavigationController { + return topViewController(controller: navigationController.visibleViewController) + } + if let tabController = controller as? UITabBarController { + if let selected = tabController.selectedViewController { + return topViewController(controller: selected) + } + } + if let presented = controller?.presentedViewController { + return topViewController(controller: presented) + } + return controller + } +} diff --git a/Sources/WebView/Public/IAuthWebViewSourceProvider.swift b/Sources/WebView/Public/IAuthWebViewSourceProvider.swift new file mode 100644 index 0000000..2275401 --- /dev/null +++ b/Sources/WebView/Public/IAuthWebViewSourceProvider.swift @@ -0,0 +1,23 @@ +// +// IAuthWebViewProvider.swift +// TinkoffID +// +// Copyright (c) 2023 Tinkoff +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +public protocol IAuthWebViewSourceProvider { + func getSourceViewController() -> UIViewController +} diff --git a/Tests/Mocks/MockedAppLauncher.swift b/Tests/Mocks/MockedAppLauncher.swift index 38761f0..b8ed808 100644 --- a/Tests/Mocks/MockedAppLauncher.swift +++ b/Tests/Mocks/MockedAppLauncher.swift @@ -22,16 +22,19 @@ import Foundation final class MockedAppLauncher: IAppLauncher { var stubbedCanLaunchApp: Bool! var stubbedLaunchAppError: Error? + var stubbedLaunchAppCompletionResult: Bool! var lastLaunchAppOptions: AppLaunchOptions? var canLaunchApp: Bool { stubbedCanLaunchApp } - - func launchApp(with options: AppLaunchOptions) throws { + + func launchApp(with options: AppLaunchOptions, universalLinksOnly: Bool = false, completion: @escaping ((Bool) -> Void)) throws { lastLaunchAppOptions = options - + + completion(stubbedLaunchAppCompletionResult) + if let error = stubbedLaunchAppError { throw error } diff --git a/Tests/Mocks/MockedURLRouter.swift b/Tests/Mocks/MockedURLRouter.swift index 9773e40..965788e 100644 --- a/Tests/Mocks/MockedURLRouter.swift +++ b/Tests/Mocks/MockedURLRouter.swift @@ -45,4 +45,16 @@ final class MockedURLRouter: IURLRouter { return nextOpenResult } + + func openWithFallback(_ url: URL, universalLinksOnly: Bool, completion: @escaping ((Bool) -> Void)) -> Bool { + lastOpenedURL = url + + defer { + nextOpenResult = nil + } + + completion(nextOpenResult) + + return nextOpenResult + } } diff --git a/Tests/Mocks/MockedWebView.swift b/Tests/Mocks/MockedWebView.swift new file mode 100644 index 0000000..b3e28ed --- /dev/null +++ b/Tests/Mocks/MockedWebView.swift @@ -0,0 +1,33 @@ +// +// MockedWebView.swift +// TinkoffID +// +// Copyright (c) 2021 Tinkoff +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +@testable import TinkoffID + +final class MockedWebView: IAuthWebView { + + weak var delegate: IAuthWebViewDelegate? + + var subbedDidOpenURLResult: URL! + + func open(from: UIViewController?) { + delegate?.authWebView(self, didOpen: subbedDidOpenURLResult) + } + + func dismiss() {} +} diff --git a/Tests/Mocks/MockedWebViewBuilder.swift b/Tests/Mocks/MockedWebViewBuilder.swift new file mode 100644 index 0000000..fedc0d6 --- /dev/null +++ b/Tests/Mocks/MockedWebViewBuilder.swift @@ -0,0 +1,28 @@ +// +// MockedWebViewBuilder.swift +// TinkoffID +// +// Copyright (c) 2021 Tinkoff +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import TinkoffID + +final class MockedWebViewBuilder: IAuthWebViewBuilder { + + var stubbedAuthWebViewResult: IAuthWebView! + + func build(with options: AppLaunchOptions) -> IAuthWebView { + stubbedAuthWebViewResult + } +} diff --git a/Tests/TinkoffIDTests.swift b/Tests/TinkoffIDTests.swift index 72397d2..b4e66c8 100644 --- a/Tests/TinkoffIDTests.swift +++ b/Tests/TinkoffIDTests.swift @@ -25,6 +25,9 @@ class TinkoffIDTests: XCTestCase { private var appLauncher: MockedAppLauncher! private var callbackParser: MockedCallbackURLParser! private var api: MockedAPI! + private var authWebView: MockedWebView! + private var authWebViewBuilder: MockedWebViewBuilder! + private let clientId = "some_client" private let callbackUrl = "nowhere://" @@ -33,13 +36,18 @@ class TinkoffIDTests: XCTestCase { appLauncher = MockedAppLauncher() callbackParser = MockedCallbackURLParser() api = MockedAPI() + authWebView = MockedWebView() + authWebViewBuilder = MockedWebViewBuilder() sdk = TinkoffID(payloadGenerator: payloadGenerator, appLauncher: appLauncher, callbackUrlParser: callbackParser, api: api, + authWebViewBuilder: authWebViewBuilder, + webViewSourceProvider: nil, clientId: clientId, - callbackUrl: callbackUrl) + callbackUrl: callbackUrl, + universalLinksOnly: false) } func testThatIsTinkoffAuthAvailableValueDependsOnAppLauncher() { @@ -56,6 +64,7 @@ class TinkoffIDTests: XCTestCase { func testThatAppLauncherWillLaunchAppWithCorrectOptionsWhenStartingTinkoffAuth() { // Given payloadGenerator.stubbedPayload = .stub + appLauncher.stubbedLaunchAppCompletionResult = true let expectedOptions = AppLaunchOptions(clientId: clientId, callbackUrl: callbackUrl, @@ -95,6 +104,7 @@ class TinkoffIDTests: XCTestCase { func testThatHandleCallbackUrlWillReturnFalseIfCallbackUrlDoesNotMatchExpectedOne() { // Given payloadGenerator.stubbedPayload = .stub + appLauncher.stubbedLaunchAppCompletionResult = true let callbackUrl = incorrectCallbackUrl @@ -113,6 +123,7 @@ class TinkoffIDTests: XCTestCase { // Given payloadGenerator.stubbedPayload = .stub callbackParser.stubbedParseResult = .cancelled + appLauncher.stubbedLaunchAppCompletionResult = true // When var callbackUrlHandlingResult: Bool! @@ -129,6 +140,7 @@ class TinkoffIDTests: XCTestCase { // Given payloadGenerator.stubbedPayload = .stub callbackParser.stubbedParseResult = CallbackURLParseResult?.none + appLauncher.stubbedLaunchAppCompletionResult = true // When @@ -144,6 +156,7 @@ class TinkoffIDTests: XCTestCase { // Given callbackParser.stubbedParseResult = .cancelled payloadGenerator.stubbedPayload = .stub + appLauncher.stubbedLaunchAppCompletionResult = true // When let result = startTinkoffAuth { _ in @@ -160,6 +173,7 @@ class TinkoffIDTests: XCTestCase { // Given callbackParser.stubbedParseResult = .unavailable payloadGenerator.stubbedPayload = .stub + appLauncher.stubbedLaunchAppCompletionResult = true // When let result = startTinkoffAuth { _ in @@ -180,6 +194,7 @@ class TinkoffIDTests: XCTestCase { api.obtainCredentialsResult = .failure(ErrorStub.foo) callbackParser.stubbedParseResult = .codeObtained(code) payloadGenerator.stubbedPayload = payload + appLauncher.stubbedLaunchAppCompletionResult = true // When _ = startTinkoffAuth { _ in @@ -201,6 +216,7 @@ class TinkoffIDTests: XCTestCase { api.obtainCredentialsResult = .failure(ErrorStub.foo) callbackParser.stubbedParseResult = .codeObtained(code) payloadGenerator.stubbedPayload = payload + appLauncher.stubbedLaunchAppCompletionResult = true // When let result = startTinkoffAuth { _ in @@ -221,6 +237,7 @@ class TinkoffIDTests: XCTestCase { api.obtainCredentialsResult = .success(.stub) callbackParser.stubbedParseResult = .codeObtained(code) payloadGenerator.stubbedPayload = payload + appLauncher.stubbedLaunchAppCompletionResult = true // When let result = startTinkoffAuth { _ in diff --git a/Tests/URLSchemeAppLauncherTests.swift b/Tests/URLSchemeAppLauncherTests.swift index 9dfd445..e01002f 100644 --- a/Tests/URLSchemeAppLauncherTests.swift +++ b/Tests/URLSchemeAppLauncherTests.swift @@ -55,7 +55,7 @@ class URLSchemeAppLauncherTests: XCTestCase { router.nextOpenResult = true // When - try! launcher.launchApp(with: expectedOptions) + try! launcher.launchApp(with: expectedOptions, universalLinksOnly: false, completion: { _ in }) // Then XCTAssertEqual(expectedOptions, schemeBuilder.lastOptions) @@ -70,7 +70,7 @@ class URLSchemeAppLauncherTests: XCTestCase { // When assertNoError(message: "App has to be launched") { - try launcher.launchApp(with: .stub) + try launcher.launchApp(with: .stub, universalLinksOnly: false) { _ in } } // Then @@ -83,7 +83,7 @@ class URLSchemeAppLauncherTests: XCTestCase { router.nextOpenResult = false // When - let when = { try self.launcher.launchApp(with: .stub) } + let when = { try self.launcher.launchApp(with: .stub, universalLinksOnly: false) { _ in } } // Then assertErrorEqual(URLSchemeAppLauncher.Error.launchFailure, when) diff --git a/TinkoffID.podspec b/TinkoffID.podspec index 6de0cca..6102afb 100644 --- a/TinkoffID.podspec +++ b/TinkoffID.podspec @@ -6,10 +6,11 @@ Pod::Spec.new do |s| s.homepage = 'https://github.com/tinkoff-mobile-tech/TinkoffID' s.license = { type: 'MIT', file: 'LICENSE' } s.source = { git: 'https://github.com/tinkoff-mobile-tech/TinkoffID.git', tag: s.version.to_s } - s.ios.deployment_target = '10.0' + s.ios.deployment_target = '12.0' s.swift_version = '5.0' s.source_files = 'Sources/**/*.swift' s.resources = 'Sources/**/*.{xcassets,lproj}' + s.dependency 'TCSSSLPinningPublic', '~> 4.0' s.test_spec('Tests') do |test_spec| test_spec.source_files = 'Tests/**/*.{swift}'