Authentication Services框架詳細解析 (八) —— 使用ASWebAuthenticationSession實現OAuth(二) 版本記錄 前言 源碼 後記

版本記錄

版本號 時間
V1.0 2021.04.03 星期六

前言

Authentication Services框架爲用戶提供了授權身份認證Authentication服務,使用戶更容易登錄App和服務。下面我們就一起來看一下這個框架。感興趣的看下面幾篇文章。
1. Authentication Services框架詳細解析 (一) —— 基本概覽(一)
2. Authentication Services框架詳細解析 (二) —— 使用Sign in with Apple實現用戶身份驗證(一)
3. Authentication Services框架詳細解析 (三) —— 密碼的自動填充(一)
4. Authentication Services框架詳細解析 (四) —— 使用Account Authentication Modification Extension提升賬號安全(一)
5. Authentication Services框架詳細解析 (五) —— 使用web authentication session對App中的用戶進行身份驗證(一)
6. Authentication Services框架詳細解析 (六) —— Web Browser App中支持Single Sign-On(一)
7. Authentication Services框架詳細解析 (七) —— 使用ASWebAuthenticationSession實現OAuth(一)

源碼

1. Swift

首先我們一起看一下工程組織結構:

下面就是源碼了

1. RepositoriesViewModel.swift
import SwiftUI

class RepositoriesViewModel: ObservableObject {
  @Published private(set) var repositories: [Repository]
  let username: String

  init() {
    self.repositories = []
    self.username = NetworkRequest.username ?? ""
  }

  private init(
    repositories: [Repository],
    username: String
  ) {
    self.repositories = repositories
    self.username = username
  }

  func load() {
    NetworkRequest
      .RequestType
      .getRepos
      .networkRequest()?
      .start(responseType: [Repository].self) { [weak self] result in
        switch result {
        case .success(let networkResponse):
          DispatchQueue.main.async {
            self?.repositories = networkResponse.object
          }
        case .failure(let error):
          print("Failed to get the user's repositories: \(error)")
        }
      }
  }

  func signOut() {
    NetworkRequest.signOut()
  }

  static func preview() -> RepositoriesViewModel {
    let repositories: [Repository] = [
      Repository(id: 1, name: "First"),
      Repository(id: 2, name: "Second"),
      Repository(id: 3, name: "Third")
    ]

    return RepositoriesViewModel(
      repositories: repositories,
      username: "GitHub user")
  }
}
2. SignInViewModel.swift
import AuthenticationServices
import SwiftUI

class SignInViewModel: NSObject, ObservableObject {
  @Published var isShowingRepositoriesView = false
  @Published private(set) var isLoading = false

  func signInTapped() {
    guard let signInURL =
      NetworkRequest.RequestType.signIn.networkRequest()?.url
    else {
      print("Could not create the sign in URL .")
      return
    }

    let callbackURLScheme = NetworkRequest.callbackURLScheme
    let authenticationSession = ASWebAuthenticationSession(
      url: signInURL,
      callbackURLScheme: callbackURLScheme) { [weak self] callbackURL, error in
      // 1
      guard
        error == nil,
        let callbackURL = callbackURL,
        // 2
        let queryItems = URLComponents(string: callbackURL.absoluteString)?.queryItems,
        // 3
        let code = queryItems.first(where: { $0.name == "code" })?.value,
        // 4
        let networkRequest =
          NetworkRequest.RequestType.codeExchange(code: code).networkRequest()
      else {
        // 5
        print("An error occurred when attempting to sign in.")
        return
      }

      self?.isLoading = true
      networkRequest.start(responseType: String.self) { result in
        switch result {
        case .success:
          self?.getUser()
        case .failure(let error):
          print("Failed to exchange access code for tokens: \(error)")
          self?.isLoading = false
        }
      }
    }

    authenticationSession.presentationContextProvider = self
    authenticationSession.prefersEphemeralWebBrowserSession = true

    if !authenticationSession.start() {
      print("Failed to start ASWebAuthenticationSession")
    }
  }

  func appeared() {
    // Try to get the user in case the tokens are already stored on this device
    getUser()
  }

  private func getUser() {
    isLoading = true

    NetworkRequest
      .RequestType
      .getUser
      .networkRequest()?
      .start(responseType: User.self) { [weak self] result in
        switch result {
        case .success:
          self?.isShowingRepositoriesView = true
        case .failure(let error):
          print("Failed to get user, or there is no valid/active session: \(error.localizedDescription)")
        }
        self?.isLoading = false
      }
  }
}

extension SignInViewModel: ASWebAuthenticationPresentationContextProviding {
  func presentationAnchor(for session: ASWebAuthenticationSession)
  -> ASPresentationAnchor {
    let window = UIApplication.shared.windows.first { $0.isKeyWindow }
    return window ?? ASPresentationAnchor()
  }
}
3. RepositoriesView.swift
import SwiftUI

struct RepositoriesView: View {
  @ObservedObject private var viewModel = RepositoriesViewModel()
  @Binding private var displayed: Bool

  init(
    viewModel: RepositoriesViewModel = RepositoriesViewModel(),
    displayed: Binding<Bool>
  ) {
    self.viewModel = viewModel
    self._displayed = displayed
  }

  var body: some View {
    VStack {
      Text(viewModel.username)
        .font(.system(size: 20))
        .fontWeight(.semibold)
        .padding()
      List {
        ForEach(viewModel.repositories) { repo in
          Text(repo.name)
        }
      }
    }
    .navigationBarTitle("My Repositories", displayMode: .inline)
    .navigationBarItems(leading: signOutButton)
    .navigationBarBackButtonHidden(true)
    .onAppear {
      viewModel.load()
    }
  }

  private var signOutButton: some View {
    Button("Sign Out") {
      viewModel.signOut()
      displayed = false
    }
  }
}

struct RepositoriesView_Previews: PreviewProvider {
  static var previews: some View {
    RepositoriesView(
      viewModel: RepositoriesViewModel.preview(),
      displayed: .constant(true))
  }
}
4. SignInView.swift
import SwiftUI

struct SignInView: View {
  @ObservedObject private var viewModel = SignInViewModel()

  var body: some View {
    NavigationView {
      VStack(spacing: 30) {
        NavigationLink(
          destination: RepositoriesView(displayed: $viewModel.isShowingRepositoriesView),
          isActive: $viewModel.isShowingRepositoriesView
        ) { EmptyView() }

        Image("rw-logo")
          .resizable()
          .aspectRatio(contentMode: .fit)
          .frame(width: 300, height: 200, alignment: .center)

        if viewModel.isLoading {
          ProgressView()
        } else {
          Button(action: { viewModel.signInTapped() }, label: {
            Text("Sign In")
              .font(Font.system(size: 24).weight(.semibold))
              .foregroundColor(Color("rw-light"))
              .padding(.horizontal, 50)
              .padding(.vertical, 8)
          })
          .background(buttonBackground)
        }
      }
      .navigationBarHidden(true)
      .onAppear {
        viewModel.appeared()
      }
    }
  }

  private var buttonBackground: some View {
    RoundedRectangle(cornerRadius: 8)
      .fill(Color("rw-green"))
  }
}

struct SignInView_Previews: PreviewProvider {
  static var previews: some View {
    SignInView()
  }
}
5. NetworkRequest.swift
import Foundation

struct NetworkRequest {
  enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
  }

  enum RequestError: Error {
    case invalidResponse
    case networkCreationError
    case otherError
    case sessionExpired
  }

  enum RequestType: Equatable {
    case codeExchange(code: String)
    case getRepos
    case getUser
    case signIn

    func networkRequest() -> NetworkRequest? {
      guard let url = url() else {
        return nil
      }
      return NetworkRequest(method: httpMethod(), url: url)
    }

    private func httpMethod() -> NetworkRequest.HTTPMethod {
      switch self {
      case .codeExchange:
        return .post
      case .getRepos:
        return .get
      case .getUser:
        return .get
      case .signIn:
        return .get
      }
    }

    private func url() -> URL? {
      switch self {
      case .codeExchange(let code):
        let queryItems = [
          URLQueryItem(name: "client_id", value: NetworkRequest.clientID),
          URLQueryItem(name: "client_secret", value: NetworkRequest.clientSecret),
          URLQueryItem(name: "code", value: code)
        ]
        return urlComponents(host: "github.com", path: "/login/oauth/access_token", queryItems: queryItems).url
      case .getRepos:
        guard
          let username = NetworkRequest.username,
          !username.isEmpty
        else {
          return nil
        }
        return urlComponents(path: "/users/\(username)/repos", queryItems: nil).url
      case .getUser:
        return urlComponents(path: "/user", queryItems: nil).url
      case .signIn:
        let queryItems = [
          URLQueryItem(name: "client_id", value: NetworkRequest.clientID)
        ]

        return urlComponents(host: "github.com", path: "/login/oauth/authorize", queryItems: queryItems).url
      }
    }

    private func urlComponents(host: String = "api.github.com", path: String, queryItems: [URLQueryItem]?) -> URLComponents {
      switch self {
      default:
        var urlComponents = URLComponents()
        urlComponents.scheme = "https"
        urlComponents.host = host
        urlComponents.path = path
        urlComponents.queryItems = queryItems
        return urlComponents
      }
    }
  }

  typealias NetworkResult<T: Decodable> = (response: HTTPURLResponse, object: T)

  // MARK: Private Constants
  static let callbackURLScheme = "YOUR_CALLBACK_SCHEME_HERE"
  static let clientID = "YOUR_CLIENT_ID_HERE"
  static let clientSecret = "YOUR_CLIENT_SECRET_HERE"

  // MARK: Properties
  var method: HTTPMethod
  var url: URL

  // MARK: Static Methods
  static func signOut() {
    Self.accessToken = ""
    Self.refreshToken = ""
    Self.username = ""
  }

  // MARK: Methods
  func start<T: Decodable>(responseType: T.Type, completionHandler: @escaping ((Result<NetworkResult<T>, Error>) -> Void)) {
    var request = URLRequest(url: url)
    request.httpMethod = method.rawValue
    if let accessToken = NetworkRequest.accessToken {
      request.setValue("token \(accessToken)", forHTTPHeaderField: "Authorization")
    }
    let session = URLSession.shared.dataTask(with: request) { data, response, error in
      guard let response = response as? HTTPURLResponse else {
        DispatchQueue.main.async {
          completionHandler(.failure(RequestError.invalidResponse))
        }
        return
      }
      guard
        error == nil,
        let data = data
      else {
        DispatchQueue.main.async {
          let error = error ?? NetworkRequest.RequestError.otherError
          completionHandler(.failure(error))
        }
        return
      }

      if T.self == String.self, let responseString = String(data: data, encoding: .utf8) {
        let components = responseString.components(separatedBy: "&")
        var dictionary: [String: String] = [:]
        for component in components {
          let itemComponents = component.components(separatedBy: "=")
          if let key = itemComponents.first, let value = itemComponents.last {
            dictionary[key] = value
          }
        }
        DispatchQueue.main.async {
          NetworkRequest.accessToken = dictionary["access_token"]
          NetworkRequest.refreshToken = dictionary["refresh_token"]
          // swiftlint:disable:next force_cast
          completionHandler(.success((response, "Success" as! T)))
        }
        return
      } else if let object = try? JSONDecoder().decode(T.self, from: data) {
        DispatchQueue.main.async {
          if let user = object as? User {
            NetworkRequest.username = user.login
          }
          completionHandler(.success((response, object)))
        }
        return
      } else {
        DispatchQueue.main.async {
          completionHandler(.failure(NetworkRequest.RequestError.otherError))
        }
      }
    }
    session.resume()
  }
}
6. NetworkRequest+User.swift
import Foundation

extension NetworkRequest {
  // MARK: Private Constants
  private static let accessTokenKey = "accessToken"
  private static let refreshTokenKey = "refreshToken"
  private static let usernameKey = "username"

  // MARK: Properties
  static var accessToken: String? {
    get {
      UserDefaults.standard.string(forKey: accessTokenKey)
    }
    set {
      UserDefaults.standard.setValue(newValue, forKey: accessTokenKey)
    }
  }

  static var refreshToken: String? {
    get {
      UserDefaults.standard.string(forKey: refreshTokenKey)
    }
    set {
      UserDefaults.standard.setValue(newValue, forKey: refreshTokenKey)
    }
  }

  static var username: String? {
    get {
      UserDefaults.standard.string(forKey: usernameKey)
    }
    set {
      UserDefaults.standard.setValue(newValue, forKey: usernameKey)
    }
  }
}
7. Repository.swift
import Foundation

struct Repository: Decodable, Identifiable {
  var id: Int
  var name: String
}
8. User.swift
import Foundation

struct User: Decodable {
  var login: String
  var name: String
}
9. AppMain.swift
import SwiftUI

@main
struct AppMain: App {
  var body: some Scene {
    WindowGroup {
      SignInView()
    }
  }
}

後記

本篇主要講述了使用ASWebAuthenticationSession實現OAuth,感興趣的給個贊或者關注~~~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章