Files
firebase-ios-sdk/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/AccountLinkingViewController.swift
T

531 lines
18 KiB
Swift
Raw Normal View History

// Copyright 2020 Google LLC
//
// 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 FirebaseAuth
2023-11-21 21:25:23 -08:00
import FirebaseCore
2024-05-06 13:44:47 -07:00
import GameKit
2023-11-21 21:25:23 -08:00
import UIKit
// For Account Linking with Sign in with Google.
import GoogleSignIn
// For Account Linking with Sign in with Facebook.
import FBSDKLoginKit
// For Account Linking with Sign in with Apple.
import AuthenticationServices
import CryptoKit
class AccountLinkingViewController: UIViewController, DataSourceProviderDelegate {
2024-05-06 15:53:49 -07:00
var dataSourceProvider: DataSourceProvider<AuthMenuData>!
var tableView: UITableView { view as! UITableView }
override func loadView() {
view = UITableView(frame: .zero, style: .insetGrouped)
}
let user: User
/// Designated initializer requires a valid, non-nil Firebase user.
/// - Parameter user: An instance of a Firebase `User`.
init(for user: User) {
self.user = user
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
configureNavigationBar()
configureDataSourceProvider()
registerForLoginNotifications()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setTitleColor(.systemOrange)
}
// MARK: - DataSourceProviderDelegate
func didSelectRowAt(_ indexPath: IndexPath, on tableView: UITableView) {
let item = dataSourceProvider.item(at: indexPath)
let providerName = item.title!
2023-08-24 15:42:51 -07:00
guard let provider = AuthMenu(rawValue: providerName) else {
// The row tapped has no affiliated action.
return
}
// If the item's affiliated provider is currently linked with the user,
// unlink the provider from the user's account.
if item.isChecked {
unlinkFromProvider(provider.id)
return
}
switch provider {
case .google:
performGoogleAccountLink()
case .apple:
performAppleAccountLink()
case .facebook:
performFacebookAccountLink()
2024-05-09 14:30:23 -07:00
case .twitter, .microsoft, .gitHub, .yahoo, .linkedIn:
performOAuthAccountLink(for: provider)
2024-05-06 13:44:47 -07:00
case .gameCenter:
performGameCenterAccountLink()
case .emailPassword:
performEmailPasswordAccountLink()
case .passwordless:
performPasswordlessAccountLink()
case .phoneNumber:
performPhoneNumberAccountLink()
default:
break
}
}
// MARK: Firebase 🔥
/// Wrapper method that uses Firebase's `link(with:)` API to link a user to another auth provider.
/// Used when linking a user to each of the following auth providers.
/// This method will update the UI upon the linking's completion.
/// - Parameter authCredential: The credential used to link the user with the auth provider.
private func linkAccount(authCredential: AuthCredential) {
user.link(with: authCredential) { result, error in
guard error == nil else { return self.displayError(error) }
self.updateUI()
}
}
/// Wrapper method that uses Firebase's `unlink(fromProvider:)` API to unlink a user from an auth
/// provider.
/// This method will update the UI upon the unlinking's completion.
/// - Parameter providerID: The string id of the auth provider.
private func unlinkFromProvider(_ providerID: String) {
user.unlink(fromProvider: providerID) { user, error in
guard error == nil else { return self.displayError(error) }
print("Unlinked user from auth provider: \(providerID)")
self.updateUI()
}
}
// MARK: - Sign in with Google Account Linking 🔥
/// This method will initate the Google Sign In flow.
/// See this class's conformance to `GIDSignInDelegate` below for
/// context on how the linking is made.
private func performGoogleAccountLink() {
guard let clientID = FirebaseApp.app()?.options.clientID else { return }
// Create Google Sign In configuration object.
// TODO: Move configuration to Info.plist
let config = GIDConfiguration(clientID: clientID)
GIDSignIn.sharedInstance.configuration = config
// Start the sign in flow!
GIDSignIn.sharedInstance.signIn(withPresenting: self) { [unowned self] result, error in
guard error == nil else { return displayError(error) }
guard
let user = result?.user,
let idToken = user.idToken?.tokenString
else {
let error = NSError(
domain: "GIDSignInError",
code: -1,
userInfo: [
NSLocalizedDescriptionKey: "Unexpected sign in result: required authentication data is missing.",
]
)
return displayError(error)
}
let credential = GoogleAuthProvider.credential(withIDToken: idToken,
accessToken: user.accessToken.tokenString)
// Rather than use the credential to sign in the user, we will use it to link to the currently
// signed in user's account.
linkAccount(authCredential: credential)
}
}
// MARK: - Sign in with Apple Account Linking 🔥
// For Sign in with Apple
var currentNonce: String?
/// This method will initate the Sign In with Apple flow.
/// See this class's conformance to `ASAuthorizationControllerDelegate` below for
/// context on how the linking is made.
private func performAppleAccountLink() {
do {
let nonce = try CryptoUtils.randomNonceString()
currentNonce = nonce
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = CryptoUtils.sha256(nonce)
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
} catch {
// In the unlikely case that nonce generation fails, show error view.
displayError(error)
}
}
2024-05-09 14:30:23 -07:00
// MARK: - Twitter, Microsoft, GitHub, Yahoo, LinkedIn Account Linking 🔥
// Maintain a strong reference to an OAuthProvider for login
private var oauthProvider: OAuthProvider!
2023-08-24 15:42:51 -07:00
private func performOAuthAccountLink(for provider: AuthMenu) {
oauthProvider = OAuthProvider(providerID: provider.id)
oauthProvider.getCredentialWith(nil) { [weak self] credential, error in
guard let strongSelf = self else { return }
guard error == nil else { return strongSelf.displayError(error) }
guard let credential = credential else { return }
strongSelf.linkAccount(authCredential: credential)
}
}
// MARK: - Sign in with Facebook Account Linking 🔥
private func performFacebookAccountLink() {
// The following config can also be stored in the project's .plist
Settings.shared.appID = "ENTER APP ID HERE"
Settings.shared.displayName = "AuthenticationExample"
// Create a Facebook `LoginManager` instance
let loginManager = LoginManager()
loginManager.logIn(permissions: ["email"], from: self) { [weak self] result, error in
guard let strongSelf = self else { return }
guard error == nil else { return strongSelf.displayError(error) }
guard let accessToken = AccessToken.current else { return }
let credential = FacebookAuthProvider.credential(withAccessToken: accessToken.tokenString)
strongSelf.linkAccount(authCredential: credential)
}
}
2024-05-06 13:44:47 -07:00
private func performGameCenterAccountLink() {
// Step 1: Ensure Game Center Authentication
guard GKLocalPlayer.local.isAuthenticated else {
print("Error: Player not authenticated with Game Center.")
return
}
// Step 2: Get Game Center Credential for Linking
GameCenterAuthProvider.getCredential { credential, error in
if let error = error {
print("Error getting Game Center credential: \(error.localizedDescription)")
return
}
guard let credential = credential else {
print("Error: Missing Game Center credential")
return
}
// Step 3: Link Credential with Current Firebase User
Auth.auth().currentUser?.link(with: credential) { authResult, error in
if let error = error {
print("Error linking Game Center to Firebase: \(error.localizedDescription)")
return
}
}
}
}
// MARK: - Email & Password Login Account Linking 🔥
private func performEmailPasswordAccountLink() {
presentEmailPasswordLinkAlertController { [weak self] email, password in
guard let strongSelf = self else { return }
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
strongSelf.linkAccount(authCredential: credential)
}
}
// MARK: - Phone Number Account Linking 🔥
public func performPhoneNumberAccountLink() {
presentPhoneNumberAlertController { [weak self] phoneNumber in
let phoneNumber = String(format: "+%@", phoneNumber)
PhoneAuthProvider.provider()
.verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationID, error in
guard let strongSelf = self else { return }
guard error == nil else { return strongSelf.displayError(error) }
guard let verificationID = verificationID else { return }
strongSelf.presentPhoneLinkAlertController { verificationCode in
let credential = PhoneAuthProvider.provider()
.credential(withVerificationID: verificationID, verificationCode: verificationCode)
strongSelf.linkAccount(authCredential: credential)
}
}
}
}
private func presentPhoneNumberAlertController(linkHandler: @escaping (String) -> Void) {
presentTextFieldAlertController(
title: "Link with Phone Auth",
message: "Example input for +1 (123)456-7890 would be 11234567890",
textfieldPlaceholder: "Enter a phone number.",
saveHandler: linkHandler
)
}
private func presentPhoneLinkAlertController(saveHandler: @escaping (String) -> Void) {
presentTextFieldAlertController(
title: "Link with Phone Auth",
textfieldPlaceholder: "Enter verification code.",
saveHandler: saveHandler
)
}
// MARK: - Email Link/Passwordless Account Linking 🔥
/// Similar to in `PasswordlessViewController`, enter the authorized domain.
/// Please refer to this Quickstart's README for more information.
private let authorizedDomain: String = "ENTER AUTHORIZED DOMAIN"
/// Maintain a reference to the email entered for linking user to Passwordless.
private var email: String?
private func performPasswordlessAccountLink() {
presentPasswordlessAlertController { [weak self] email in
guard let strongSelf = self else { return }
strongSelf.sendSignInLink(to: email)
}
}
private func presentPasswordlessAlertController(saveHandler: @escaping (String) -> Void) {
presentTextFieldAlertController(
title: "Link with Passwordless Login",
message: "Leave this view up while you check your email for the verification link.",
textfieldPlaceholder: "Enter a valid email address.",
saveHandler: saveHandler
)
}
private func sendSignInLink(to email: String) {
let actionCodeSettings = ActionCodeSettings()
let stringURL = "https://\(authorizedDomain).firebaseapp.com/login?email=\(email)"
actionCodeSettings.url = URL(string: stringURL)
// The sign-in operation must be completed in the app.
actionCodeSettings.handleCodeInApp = true
actionCodeSettings.setIOSBundleID(Bundle.main.bundleIdentifier!)
2023-08-24 15:42:51 -07:00
AppManager.shared.auth()
.sendSignInLink(toEmail: email, actionCodeSettings: actionCodeSettings) { error in
guard error == nil else { return self.displayError(error) }
2023-08-24 15:42:51 -07:00
// Set `email` property as it will be used to complete sign in after opening email link
self.email = email
}
}
@objc
private func passwordlessSignIn() {
// Retrieve link that we stored in user defaults in `SceneDelegate`.
guard let email = email,
let link = UserDefaults.standard.value(forKey: "Link") as? String else { return }
let credential = EmailAuthProvider.credential(withEmail: email, link: link)
linkAccount(authCredential: credential)
self.email = nil
}
private func registerForLoginNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(passwordlessSignIn),
name: Notification.Name("PasswordlessEmailNotificationSuccess"),
object: nil
)
}
// MARK: - UI Configuration
private func configureNavigationBar() {
navigationItem.title = "Account Linking"
navigationItem.backBarButtonItem?.tintColor = .systemYellow
navigationController?.navigationBar.prefersLargeTitles = true
}
private func presentTextFieldAlertController(title: String? = nil,
message: String? = nil,
textfieldPlaceholder: String? = nil,
saveHandler: @escaping (String) -> Void) {
let textFieldAlertController = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)
textFieldAlertController.addTextField { textfield in
textfield.placeholder = textfieldPlaceholder
textfield.textContentType = .oneTimeCode
}
let onContinue: (UIAlertAction) -> Void = { _ in
let text = textFieldAlertController.textFields!.first!.text!
saveHandler(text)
}
textFieldAlertController.addAction(
UIAlertAction(title: "Continue", style: .default, handler: onContinue)
)
textFieldAlertController.addAction(
UIAlertAction(title: "Cancel", style: .cancel)
)
present(textFieldAlertController, animated: true, completion: nil)
}
private func presentEmailPasswordLinkAlertController(linkHandler: @escaping (String, String)
-> Void) {
let loginAlertController = UIAlertController(
title: "Link Password Auth",
message: "Enter a valid email and password to link",
preferredStyle: .alert
)
2024-04-17 11:57:25 -07:00
for placeholder in ["Email", "Password"] {
loginAlertController.addTextField { textfield in
textfield.placeholder = placeholder
}
}
let onContinue: (UIAlertAction) -> Void = { _ in
let email = loginAlertController.textFields![0].text!
let password = loginAlertController.textFields![1].text!
linkHandler(email, password)
}
loginAlertController
.addAction(UIAlertAction(title: "Continue", style: .default, handler: onContinue))
loginAlertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(loginAlertController, animated: true, completion: nil)
}
// MARK: - TableView Configuration & Refresh
private func configureDataSourceProvider() {
dataSourceProvider = DataSourceProvider(
dataSource: sections,
tableView: tableView
)
dataSourceProvider.delegate = self
}
private func updateUI() {
configureDataSourceProvider()
animateUpdates(for: tableView)
}
private func animateUpdates(for tableView: UITableView) {
UIView.transition(with: tableView, duration: 0.05,
options: .transitionCrossDissolve,
animations: { tableView.reloadData() })
}
}
// MARK: DataSourceProvidable
extension AccountLinkingViewController: DataSourceProvidable {
var sections: [Section] { buildSections() }
private func buildSections() -> [Section] {
2024-05-06 15:53:49 -07:00
var section = AuthMenuData.authLinkSections.first!
section.items = section.items.compactMap { item -> Item? in
var item = item
item.hasNestedContent = false
item.isChecked = userProviderDataContains(item: item)
return ["Anonymous Authentication", "Custom Auth System"].contains(item.title) ? nil : item
}
return [section]
}
private func userProviderDataContains(item: Item) -> Bool {
2023-08-24 15:42:51 -07:00
guard let authProvider = AuthMenu(rawValue: item.title ?? "") else { return false }
return user.providerData.map { $0.providerID }.contains(authProvider.id)
}
}
// MARK: - Implementing Sign in with Apple with Firebase
extension AccountLinkingViewController: ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
// MARK: ASAuthorizationControllerDelegate
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential
else {
print("Unable to retrieve AppleIDCredential")
return
}
guard let nonce = currentNonce else {
fatalError("Invalid state: A login callback was received, but no login request was sent.")
}
guard let appleIDToken = appleIDCredential.identityToken else {
print("Unable to fetch identity token")
return
}
guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
print("Unable to serialize token string from data: \(appleIDToken.debugDescription)")
return
}
let credential = OAuthProvider.credential(withProviderID: "apple.com",
idToken: idTokenString,
rawNonce: nonce)
// Once we have created the above `credential`, we can link accounts to it.
linkAccount(authCredential: credential)
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
// Ensure that you have:
// - enabled `Sign in with Apple` on the Firebase console
// - added the `Sign in with Apple` capability for this project
print("Sign in with Apple errored: \(error)")
}
// MARK: ASAuthorizationControllerPresentationContextProviding
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
return view.window!
}
}