diff --git a/Brand/Intro/NCIntroViewController.swift b/Brand/Intro/NCIntroViewController.swift index c626a4c557..d340729b8b 100644 --- a/Brand/Intro/NCIntroViewController.swift +++ b/Brand/Intro/NCIntroViewController.swift @@ -22,6 +22,7 @@ class NCIntroViewController: UIViewController, UICollectionViewDataSource, UICol private var timer: Timer? private var textColor: UIColor = .white private var textColorOpponent: UIColor = .black + private var activeLoginProvider: NCLoginProvider? // MARK: - View Life Cycle @@ -164,11 +165,12 @@ class NCIntroViewController: UIViewController, UICollectionViewDataSource, UICol } @IBAction func signupWithProvider(_ sender: Any) { - if let viewController = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginProvider") as? NCLoginProvider { - viewController.controller = self.controller - viewController.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders - self.navigationController?.pushViewController(viewController, animated: true) - } + let loginProvider = NCLoginProvider() + loginProvider.controller = self.controller + loginProvider.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders + loginProvider.presentingViewController = self + loginProvider.startAuthentication() + self.activeLoginProvider = loginProvider } @IBAction func host(_ sender: Any) { diff --git a/iOSClient/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index 6740efb7c4..892d88885d 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -44,6 +44,7 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { private var p12Data: Data? private var p12Password: String? private var QRCodeCheck: Bool = false + private var activeLoginProvider: NCLoginProvider? // MARK: - View Life Cycle @@ -338,12 +339,14 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { // Login Flow V2 if error == .success, let token, let endpoint, let login { nkLog(debug: "Successfully received login flow information.") - let safariVC = NCLoginProvider() - safariVC.initialURLString = login - safariVC.uiColor = textColor - safariVC.delegate = self - safariVC.startPolling(loginFlowV2Token: token, loginFlowV2Endpoint: endpoint, loginFlowV2Login: login) - navigationController?.pushViewController(safariVC, animated: true) + let loginProvider = NCLoginProvider() + loginProvider.initialURLString = login + loginProvider.delegate = self + loginProvider.controller = self.controller + loginProvider.presentingViewController = self + loginProvider.startPolling(loginFlowV2Token: token, loginFlowV2Endpoint: endpoint, loginFlowV2Login: login) + loginProvider.startAuthentication() + self.activeLoginProvider = loginProvider } } case .failure(let error): @@ -506,5 +509,7 @@ extension NCLogin: NCLoginProviderDelegate { func onBack() { loginButton.isEnabled = true loginButton.hideSpinnerAndShowButton() + activeLoginProvider?.cancel() + activeLoginProvider = nil } } diff --git a/iOSClient/Login/NCLoginProvider.swift b/iOSClient/Login/NCLoginProvider.swift index c427fd3205..8b6e495da3 100644 --- a/iOSClient/Login/NCLoginProvider.swift +++ b/iOSClient/Login/NCLoginProvider.swift @@ -3,64 +3,53 @@ // SPDX-FileCopyrightText: 2025 Milen Pivchev // SPDX-License-Identifier: GPL-3.0-or-later +import AuthenticationServices import UIKit @preconcurrency import WebKit import NextcloudKit protocol NCLoginProviderDelegate: AnyObject { /// - /// Called when the back button is tapped in the login provider view. + /// Called when the authentication is cancelled or fails. /// func onBack() } /// -/// View which presents the web view to login at a Nextcloud instance. +/// Handles login authentication using ASWebAuthenticationSession with WKWebView fallback for mTLS. /// -class NCLoginProvider: UIViewController { - var webView: WKWebView! - var titleView: String = "" +class NCLoginProvider: NSObject, ASWebAuthenticationPresentationContextProviding { var initialURLString = "" - var uiColor: UIColor = .white weak var delegate: NCLoginProviderDelegate? var controller: NCMainTabBarController? + /// The presenting view controller for the authentication session. + weak var presentingViewController: UIViewController? + + /// The active authentication session. + private var authSession: ASWebAuthenticationSession? + + /// Fallback web view controller for mTLS/certificate handling. + private var webViewFallbackVC: NCLoginProviderWebViewFallback? + /// /// A polling loop active in the background to check for the current status of the login flow. /// var pollingTask: Task? - // MARK: - View Life Cycle + // MARK: - ASWebAuthenticationPresentationContextProviding - override func viewDidLoad() { - super.viewDidLoad() - nkLog(debug: "Login provider view did load.") - let configuration = WKWebViewConfiguration() - configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() - - let webView = WKWebView(frame: CGRect.zero, configuration: configuration) - webView.customUserAgent = userAgent - webView.isInspectable = true - webView.navigationDelegate = self - view.addSubview(webView) - - webView.translatesAutoresizingMaskIntoConstraints = false - webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true - webView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true - webView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true - - self.webView = webView - - let navigationItemBack = UIBarButtonItem(image: UIImage(systemName: "arrow.left"), style: .plain, target: self, action: #selector(goBack(_:))) - navigationItemBack.tintColor = uiColor - navigationItem.leftBarButtonItem = navigationItemBack + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return presentingViewController?.view.window ?? UIApplication.shared.mainAppWindow ?? ASPresentationAnchor() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - nkLog(debug: "Login provider appeared.") + // MARK: - Authentication + /// + /// Start the authentication flow using ASWebAuthenticationSession. + /// Falls back to WKWebView if authentication fails (e.g., asked for mTLS cert etc). + /// + func startAuthentication() { guard let url = URL(string: initialURLString) else { Task { await showErrorBanner(controller: self.controller, text: "_login_url_error_", errorCode: 0) @@ -68,62 +57,132 @@ class NCLoginProvider: UIViewController { return } - if let host = url.host { - titleView = host + // Use custom URL scheme to handle login callbacks (e.g., nc://login/...) + let callbackScheme = NCBrandOptions.shared.webLoginAutenticationProtocol.replacingOccurrences(of: "://", with: "") + + authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in + guard let self else { return } + + if let error = error { + if let asError = error as? ASWebAuthenticationSessionError, asError.code == .canceledLogin { + // Only treat as user cancellation if polling hasn't succeeded yet + if self.pollingTask != nil { + Task { @MainActor in + self.delegate?.onBack() + } + } + } else { + // Fall back to WKWebView for other errors (e.g., certificate issues) + Task { @MainActor in + self.fallbackToWebView(url: url) + } + } + return + } - if let activeTableAccount = NCManageDatabase.shared.getActiveTableAccount(), NCPreferences().getPassword(account: activeTableAccount.account).isEmpty { - titleView = NSLocalizedString("_user_", comment: "") + " " + activeTableAccount.userId + " " + NSLocalizedString("_in_", comment: "") + " " + host + // Handle login callback URL (e.g., nc://login/server:...&user:...&password:...) + if let callbackURL { + self.handleLoginCallback(url: callbackURL) } } - loadWebPage(url: url) - self.title = titleView - } + authSession?.presentationContextProvider = self + authSession?.prefersEphemeralWebBrowserSession = true - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - nkLog(debug: "Login provider view did disappear.") + if authSession?.start() != true { + // Fall back to WKWebView if ASWebAuthenticationSession fails to start + fallbackToWebView(url: url) + } + } - NCActivityIndicator.shared.stop() + /// + /// Cancel the authentication session and clean up. + /// + func cancel() { + authSession?.cancel() + authSession = nil - guard pollingTask != nil else { - return - } + webViewFallbackVC?.dismiss(animated: true) + webViewFallbackVC = nil - nkLog(debug: "Cancelling existing polling task because view did disappear...") pollingTask?.cancel() pollingTask = nil } - // MARK: - Navigation + // MARK: - WKWebView Fallback - private func loadWebPage(url: URL) { - let language = NSLocale.preferredLanguages[0] as String - var request = URLRequest(url: url) + /// + /// Present WKWebView as a fallback for mTLS/certificate handling. + /// + private func fallbackToWebView(url: URL) { + authSession?.cancel() + authSession = nil - request.addValue("true", forHTTPHeaderField: "OCS-APIRequest") - request.addValue(language, forHTTPHeaderField: "Accept-Language") + guard let presentingVC = presentingViewController else { return } - webView.load(request) + let fallbackVC = NCLoginProviderWebViewFallback() + fallbackVC.initialURL = url + fallbackVC.initialURLString = initialURLString + fallbackVC.controller = controller + fallbackVC.loginProvider = self + + let navController = UINavigationController(rootViewController: fallbackVC) + navController.modalPresentationStyle = .fullScreen + + presentingVC.present(navController, animated: true) + self.webViewFallbackVC = fallbackVC } /// - /// Dismiss the login web view from the hierarchy. + /// Handle the login callback URL from the authentication session. /// - @objc func goBack(_ sender: Any?) { - delegate?.onBack() + func handleLoginCallback(url: URL) { + let urlString = url.absoluteString.lowercased() + + // Check if this is a login callback + guard urlString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && urlString.contains("login") else { + return + } + + var server: String = "" + var user: String = "" + var password: String = "" + let keyValue = url.path.components(separatedBy: "&") + + for value in keyValue { + if value.contains("server:") { server = value } + if value.contains("user:") { user = value } + if value.contains("password:") { password = value } + } + + if !server.isEmpty, !user.isEmpty, !password.isEmpty { + let server = server.replacingOccurrences(of: "/server:", with: "") + let username = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ") + let password = password.replacingOccurrences(of: "password:", with: "") + + // Stop polling since we got credentials from callback + pollingTask?.cancel() + pollingTask = nil + + // Dismiss fallback web view if present + webViewFallbackVC?.dismiss(animated: true) + webViewFallbackVC = nil + + if self.controller == nil { + self.controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController + } - if isModal { - dismiss(animated: true) - } else { - navigationController?.popViewController(animated: true) + Task { @MainActor in + guard let viewController = self.presentingViewController else { return } + await NCAccount().createAccount(viewController: viewController, urlBase: server, user: username, password: password, controller: self.controller) + } } } // MARK: - Polling /// - /// Start checking the status of the login flow in the background periodally. + /// Start checking the status of the login flow in the background periodically. /// func startPolling(loginFlowV2Token: String, loginFlowV2Endpoint: String, loginFlowV2Login: String) { nkLog(start: "Starting polling at \(loginFlowV2Endpoint) with token \(loginFlowV2Token)") @@ -136,10 +195,9 @@ class NCLoginProvider: UIViewController { /// private func poll(token: String, endpoint: String, options: NKRequestOptions) async -> (urlBase: String, loginName: String, appPassword: String)? { await withCheckedContinuation { continuation in - NextcloudKit.shared.getLoginFlowV2Poll(token: token, endpoint: endpoint, options: options) {server, loginName, appPassword, _, error in + NextcloudKit.shared.getLoginFlowV2Poll(token: token, endpoint: endpoint, options: options) { server, loginName, appPassword, _, error in guard error == .success else { - nkLog(error: "Login poll result for token \"\(token)\" is not successful!") continuation.resume(returning: nil) return } @@ -169,22 +227,35 @@ class NCLoginProvider: UIViewController { } /// - /// Handle the values acquired by polling successfully. + /// Handle login when polling is successful and access is granted. /// private func handleGrant(urlBase: String, loginName: String, appPassword: String) async { nkLog(debug: "Handling login grant values for \(loginName) on \(urlBase)") + // Cancel the auth session since login was successful + authSession?.cancel() + authSession = nil + + // Dismiss fallback web view if present + webViewFallbackVC?.dismiss(animated: true) + webViewFallbackVC = nil + if controller == nil { nkLog(debug: "View controller is still undefined, will resolve root view controller of first window.") controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController } - await NCAccount().createAccount(viewController: self, urlBase: urlBase, user: loginName, password: appPassword, controller: controller) + guard let viewController = presentingViewController else { + nkLog(error: "No presenting view controller available for account creation.") + return + } + + await NCAccount().createAccount(viewController: viewController, urlBase: urlBase, user: loginName, password: appPassword, controller: controller) nkLog(debug: "Account creation for \(loginName) on \(urlBase) completed based on login grant values.") } /// - /// Set up the `Task` which frequently checks the server. + /// Sets up polling. /// private func createPollingTask(token: String, endpoint: String) -> Task { let options = NKRequestOptions(customUserAgent: userAgent) @@ -202,79 +273,112 @@ class NCLoginProvider: UIViewController { return } + // Clear the polling task before handling grant to prevent cancellation callback + self.pollingTask = nil + await handleGrant(urlBase: grantValues.urlBase, loginName: grantValues.loginName, appPassword: grantValues.appPassword) nkLog(debug: "Polling task completed.") } } } -// MARK: - WKNavigationDelegate +// MARK: - WKWebView Fallback View Controller -extension NCLoginProvider: WKNavigationDelegate { - func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { - nkLog(debug: "Web view did receive server redirect for provisional navigation.") +/// +/// Fallback view controller using WKWebView for mTLS/certificate handling. +/// +class NCLoginProviderWebViewFallback: UIViewController, WKNavigationDelegate { + var initialURL: URL? + var initialURLString = "" + var controller: NCMainTabBarController? + weak var loginProvider: NCLoginProvider? - guard let currentWebViewURL = webView.url else { - nkLog(error: "Web view does not have a URL after receiving a server redirect for provisional navigation!") - return - } + private var webView: WKWebView! + + override func viewDidLoad() { + super.viewDidLoad() - let currentWebViewURLString: String = currentWebViewURL.absoluteString.lowercased() + view.backgroundColor = NCBrandColor.shared.customer - // Prevent HTTP redirects. - if initialURLString.lowercased().hasPrefix("https://") && currentWebViewURLString.hasPrefix("http://") { - nkLog(error: "Web view redirect degrades session from HTTPS to HTTP and must be cancelled!") + // Navigation bar + let closeButton = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(closeTapped)) + closeButton.tintColor = .white + navigationItem.leftBarButtonItem = closeButton - let alertController = UIAlertController(title: NSLocalizedString("_error_", comment: ""), message: NSLocalizedString("_prevent_http_redirection_", comment: ""), preferredStyle: .alert) + if let host = initialURL?.host { + title = host + } - alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - _ = self.navigationController?.popViewController(animated: true) - })) + // Web view setup + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() - self.present(alertController, animated: true) + webView = WKWebView(frame: .zero, configuration: configuration) + webView.customUserAgent = userAgent + webView.navigationDelegate = self + view.addSubview(webView) - return + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + if let url = initialURL { + loadWebPage(url: url) } + } - // Login via provider. - if currentWebViewURLString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && currentWebViewURLString.contains("login") { - nkLog(debug: "Web view redirect to provider login URL detected.") + @objc private func closeTapped() { + loginProvider?.delegate?.onBack() + dismiss(animated: true) + } + + private func loadWebPage(url: URL) { + let language = NSLocale.preferredLanguages[0] as String + var request = URLRequest(url: url) - var server: String = "" - var user: String = "" - var password: String = "" - let keyValue = currentWebViewURL.path.components(separatedBy: "&") + request.addValue("true", forHTTPHeaderField: "OCS-APIRequest") + request.addValue(language, forHTTPHeaderField: "Accept-Language") - for value in keyValue { - if value.contains("server:") { server = value } - if value.contains("user:") { user = value } - if value.contains("password:") { password = value } - } + webView.load(request) + } - if !server.isEmpty, !user.isEmpty, !password.isEmpty { - let server: String = server.replacingOccurrences(of: "/server:", with: "") - let username: String = user.replacingOccurrences(of: "user:", with: "").replacingOccurrences(of: "+", with: " ") - let password: String = password.replacingOccurrences(of: "password:", with: "") + // MARK: - WKNavigationDelegate - if self.controller == nil { - self.controller = UIApplication.shared.mainAppWindow?.rootViewController as? NCMainTabBarController - } + func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { + guard let currentWebViewURL = webView.url else { return } - Task { @MainActor in - await NCAccount().createAccount(viewController: self, urlBase: server, user: username, password: password, controller: controller) - } - } + let currentWebViewURLString = currentWebViewURL.absoluteString.lowercased() + + // Prevent HTTP redirects + if initialURLString.lowercased().hasPrefix("https://") && currentWebViewURLString.hasPrefix("http://") { + let alertController = UIAlertController( + title: NSLocalizedString("_error_", comment: ""), + message: NSLocalizedString("_prevent_http_redirection_", comment: ""), + preferredStyle: .alert + ) + alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default) { _ in + self.dismiss(animated: true) + }) + present(alertController, animated: true) + return + } + + // Login via provider + if currentWebViewURLString.hasPrefix(NCBrandOptions.shared.webLoginAutenticationProtocol) && currentWebViewURLString.contains("login") { + loginProvider?.handleLoginCallback(url: currentWebViewURL) } } func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - nkLog(debug: "Web view did receive authentication challenge.") - DispatchQueue.global().async { if let serverTrust = challenge.protectionSpace.serverTrust { - completionHandler(Foundation.URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverTrust)) + completionHandler(.useCredential, URLCredential(trust: serverTrust)) } else { - completionHandler(URLSession.AuthChallengeDisposition.useCredential, nil) + completionHandler(.useCredential, nil) } } } @@ -286,7 +390,7 @@ extension NCLoginProvider: WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { nkLog(debug: "Web view did start provisional navigation.") - NCActivityIndicator.shared.startActivity(backgroundView: self.view, style: .medium, blurEffect: false) + NCActivityIndicator.shared.startActivity(backgroundView: view, style: .medium, blurEffect: false) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index d83b987a48..6813746ec4 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -291,6 +291,7 @@ "_connection_error_" = "Connection error"; "_login_url_" = "Server address https:// …"; "_login_url_error_" = "URL error, please verify your server URL"; +"_login_error_" = "Could not log in. Please try again later."; "_favorites_" = "Favorites"; "_favorite_short_" = "Favorite"; "_tutorial_favorite_view_" = "Files and folders you mark as favorites will show up here";