用Form认证方式从iOS应用登录和浏览GitHub

   最近由于工作需要,干了1个多月的iOS开发。虽说与安卓开发之间没有到隔行如隔山的地步,但正因为声明式UI、State管理那一套以及Swift和Kotlin本身的似像非像,刚开始接触特别容易搞混。还有用惯了JetBrains家的IDE以后再用Xcode...感觉十分酸爽。

   总之我想把实际动手做的内容总结出来,以便以后打算继续学iOS时回来参考。
   这一篇先简单总结一下如何用Form认证的方式,从应用发送POST请求登录网站,并利用session管理,从WebView显示登录后网站内容的方法。顺便提一下SwiftUI以及iOS中MVVM架构的简单实现方法。Demo应用以登录GitHub为例。

Demo效果

Initialize Input fake credentials Sign in failed
Input correct credentials Sign in succeeded Sign out

实现

项目架构

  项目采用MVVM架构。
  View层包含两个画面,LoginView实现户名密码的输入框和登录按钮,GithubView实现登录成功后,用于浏览GitHub的WebView。
  ViewModel层管理LoginView的状态(State),调用底层方法发送网络请求实施登录,获取响应结果并更新页面显示。
  Model层分为ServiceModel两个文件夹,前者管理通信处理,后者存放枚举类等数据型。

画面

SwiftUI实现画面布局

  从安卓开发者的角度来看,SwiftUI中的ZStack,VStack,HStack分别类似于Jetpack Compose中的Box,Column和Row。当然,各种Stack皆如其名,符合First-In-Last-Out原则,即最先存入的UI组件会被放置在栈底。

  • LoginView.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI

struct LoginView: View {
    //.....Properties
    var body: some View {
        ZStack { // ZStack垂直于画面,由里向外。
          // 背景色放置在ZStack最底层。
            Color("BackgroundBlack").edgesIgnoringSafeArea(.all)
            
            // VStack 平行于画面,由上至下。
            VStack {
                // ...LOGO图片
                // ...标题文字
                VStack { 
                  //嵌套一个VStack方便对这一部分整体进行Padding处理
                    // 用户名输入区域
                    LoginTextField(hintString: "GitHub username", inputString: //TODO:
                    )
                    Spacer().frame(height: 20)
                    
                    // 密码输入区域
                    LoginTextField(hintString: "password"
                    , inputString: //TODO:
                    , isSecure: true)
                    Spacer().frame(height: 50)
                    
                    // 登录按键
                    LoginButton(label: "Sign in" ){
                        // TODO: Trigger login logic
                    }
                }.padding()
                // ......
            }
        }
    }
}

   以上用到的LoginTextFieldLoginButton都是自定义的UI组件。

LoginViewModel管理画面数据

   iOS的开发框架中似乎没有封装好的ViewModel,不过我们可以很方便地利用ObservableObject来创建。
   创建之前,先来考虑一下哪些数据需要作为State由ViewModel来管理。我认为有以下三项:

  1. 用户输入的用户名・密码。
    需要根据输入内容控制按键是否有效,是否显示警告等。
  2. 获取数据的进程。
    需要根据进行中、成功、失败等状态显示进度条,弹窗等元素。这里创建名为ViewModelState的枚举类来管理idle/loading/finish/error四种状态。
  3. 具体的Error信息 方便弹窗显示错误的具体内容。
  • LoginViewModel.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class LoginViewModel: ObservableObject {
    
    @Published var state: ViewModelState = .idle
    // username and password
    @Published var credential = Credential()
    @Published var error: MyError?

    // Computed property. 值随Published property的变化而变化
    var loginDisabled: Bool {
        credential.username.isEmpty || credential.password.isEmpty || state == .loading
    }

    func performLogin() {
        self.state = .loading
        // TODO: Login logic
        // if succeeded: self.state = .finish
        // if failed: self.state = .error
    }
// ......    
}

  ViewModel中附有@Published注解的成员变量能够在其值发生更新时通知订阅者。在View中可以通过ObservedObject或者StateObject对ViewModel进行订阅。二者的区别是当View重新绘制时,由于StateObject为View所有,因此其数据不会丢失,而ObservedObject独立于View之外,其数据会回到初始状态。如果我们希望用户输入的数据始终被保持,那么应该使用StateObject来实现。现在在View中加入对ViewModel的订阅。

  • LoginView.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
struct LoginView: View {
    //.....Properties
    @StateObject private var viewModel = LoginViewModel()

    var body: some View {
        ZStack { 
          //......
            VStack {
                // ......
                VStack { 
                    // 用户名输入区域
                    //✨每次输入字符,viewModel中的credential都会被更新
                    LoginTextField(hintString: "GitHub username", inputString: $viewModel.credential.username) 
                    Spacer().frame(height: 20)
                    
                    // 密码输入区域
                    //✨每次输入字符,viewModel中的credential都会被更新
                    LoginTextField(hintString: "password", inputString: $viewModel.credential.password, isSecure: true)
                    Spacer().frame(height: 50)
                    
                    // 登录按键
                    LoginButton(label: "Sign in" ){
                        viewModel.performLogin()
                    }
                    //✨实时更新按键的enabled/disabled状态
                    .disabled(viewModel.loginDisabled)
                }.padding()
                //......
            //✨当viewModel.error更新时显示Alert
            }.alert(item: $viewModel.error) { error in
                return Alert(title: Text("Error"),
                             message: Text(String(describing:error)),
                             dismissButton:.cancel())
            }
            //✨当viewModel.state变为loading时显示loading动画
            if viewModel.state == .loading {
                ProgressView()
            }
        //✨当viewModel.state变为finish时表明登录成功,显示下一个画面
        }.onChange(of: viewModel.state) { state in
            if state == .finish {
                // TODO: move to GithubView
            }
        }
    }
}

EnvironmentObject管理登录状态、实现画面切换

   至此,LoginView和ViewModel中的数据就被绑定起来了。接下去实现登录成功后的画面切换。这里参考了一位YouTuber的教程,不使用NavigationView而实现画面切换的效果。
   首先定义一个ObservableObjectAuthentication,在这个类中定义一个Bool类型的FlagisValidated,用于标记登录成功与否。同时创建一个允许外界更新这个Flag的方法updateValidation

  • Authentication.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Authentication: ObservableObject {
    
    // Track if login succeeded
    @Published var isValidated = false
    
    func updateValidation(success: Bool) {
        withAnimation {
            DispatchQueue.main.async {
                self.isValidated = success
            }
        }
    }
}

   为了使得整个应用的多个画面都可以获取到和更改isValidated的值,我们可以在应用的入口处创建一个Authentication的实例并标记为StateObject。随后根据isValidated的值显示不同的画面,并将Authentication实例以EnvironmentObject的形式注入到各个View中去。

  • GithubBrowserApp.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@main
struct GithubBrowserApp: App {
    @StateObject var authentication = Authentication()
    
    var body: some Scene {
        WindowGroup {
            // Switch first view depending on authentication result
            if authentication.isValidated {
                // 如果isValidated为真,则已登录,显示GithubView
                GithubView().environmentObject(authentication)
            } else {
                // 反之则未登录,显示LoginView
                LoginView().environmentObject(authentication)
            }
        }
    }
}

   在LoginView中加入相应的EnvironmentObject和处理。

  • LoginView.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct LoginView: View {
    // Properties
    @StateObject private var viewModel = LoginViewModel()
    @EnvironmentObject var authentication: Authentication

    var body: some View {
        ZStack { 
          //......
        //✨当viewModel.state变为finish时表明登录成功,显示下一个画面
        }.onChange(of: viewModel.state) { state in
            if state == .finish {
                authentication.updateValidation(success: true)
            }
        }
    }
}

发送表单(Form)数据

   应用的整体架构和画面切换方式确定了,接着就是把网络请求的部分填上。由于实际项目中的服务端不提供OAuth等形式的认证接口,这里避开GitHub提供的接口,而是尝试从登录页面获取Form表单,用发送表单的形式进行认证。
   首先打开“https://github.com/login ”,用浏览器自带的inspector找到Form信息。

   通过读取上述Form包含的信息和实际登录时发送的请求报文,理解整个认证流程并不困难。但已经有人询问过相关做法,可以直接参考:How can I use post from requests module to login to github

用Python进行轻量测试

   登录GitHub时,除了用户名密码,在表单的hidden元素中还发送了一个authenticity_token,应该是用来标记session的值。因此在正式发送表单前需要先从页面中提取这个token的值。我们先用python写一个轻量的程序来测试登录流程,这里用到了BeautifulSoup来解析html,获取authenticity_code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import requests
from bs4 import BeautifulSoup

headers = {
 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36'
}

login_data = {
'commit': 'Sign in',
'login': '', # your username
'password': '' # your password
}
url = 'https://github.com/session'
session = requests.Session()
response = session.get(url, headers=headers) # Fetch the login page html

# Retrieve the authenticity token
soup = BeautifulSoup(response.text, 'html.parser')
login_data['authenticity_token'] = soup.find('input', attrs={'name': 'authenticity_token'})['value']
print(login_data['authenticity_token'])

login_request = requests.Request('POST', url, data=login_data)
# Use prepared for logging the request
prepared = session.prepare_request(login_request)
print(prepared.headers)
print(prepared.method)
print(prepared.body)
response = session.send(prepared, verify=False)
print(response.status_code)
print(response)

# Verify being able to access to any private resource 
response = session.get('https://github.com/settings/profile', headers=headers)
with open("resp.txt", "w") as file:
    file.write(response.text)

   只要最终取得的response文本是登录后的页面,即可判断登录成功。

将Python程序的逻辑移植到Swift

   最后只需要把python的逻辑移植到swift中就可以了。我找到了一个可以解析html的swift开源库SwiftSoup,用来完成python中BeautifulSoup的工作。发送请求则是用URLSession.shared.dataTask(with: URLRequest),不使用任何第三方库。

  • LoginManager.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import Foundation
import SwiftSoup

typealias ResultHandler<T> = (Result<T, MyError>) -> Void

struct LoginManager {
    static let urlString = "https://github.com/session"
    static let contentType = "application/x-www-form-urlencoded"
    
    /// 获取登录页
    static func getLoginPage(handler: @escaping ResultHandler<String>) {
        guard let url = URL(string: urlString) else {
            handler(.failure(.invalidRequest))
            return
        }
        
        var requestGetPage = URLRequest(url: url)
        requestGetPage.httpMethod = "GET"
        
        URLSession.shared.dataTask(with: requestGetPage) {
            data, response, error in
            DispatchQueue.main.async {
                //.....
                if let safeData = data {
                  // 将登录页html脚本emit出去,以便经由ViewModel传给`postCredential`
                  // 进行解析,获取authenticity_token并实施发送请求。
                    let content = String(data: safeData, encoding: .utf8)
                    handler(.success(content!))
                }
            }
        }.resume()
    }

    /// 发送登录请求
    static func postCredential(_ credentials: Credential, content: String
                               , handler: @escaping ResultHandler<Void>) {
        guard let url = URL(string: urlString) else {
            handler(.failure(.invalidRequest))
            return
        }
        
        var requestPostLogin = URLRequest(url: url)
        requestPostLogin.setValue(contentType, forHTTPHeaderField: "Content-Type")
        requestPostLogin.httpMethod = "POST"
        
        var authToken: String = ""
        
        // 从登录页脚本中获取authenticity_token
        do {
            let doc: Document = try SwiftSoup.parseBodyFragment(content)
            let body = doc.body()!
            let elements: Elements = try body.select("input")
            for element in elements {
                
                if try element.attr("name") == "authenticity_token" {
                    authToken = try element.attr("value")
                }
            }
        } catch let error {
            handler(.failure(.other(error)))
        }
        
        // 构建请求body
        let requestBody = "commit=Sign in&login=\(credentials.username)&password=\(credentials.password)&authenticity_token=\(authToken)".data(using: .utf8)
        
        requestPostLogin.httpBody = requestBody
        URLSession.shared.dataTask(with: requestPostLogin) {
            data, response, error in
            DispatchQueue.main.async {
                // ......
                if let safeData = data {
                    let httpResponse = response as! HTTPURLResponse
                    let dataStr = String(data: safeData, encoding: .utf8)
                    // 响应状态为200
                    if httpResponse.statusCode == 200 {
                        // 分析响应数据,判断login是否成功
                        do {
                            let doc: Document = try SwiftSoup.parse(dataStr!)
                            let body = doc.body()
                            // 如果出现名为logged-in的class,则为登录成功
                            if body?.hasClass("logged-in") == true {
                                handler(.success(()))
                            } else {
                                handler(.failure(.error(msg: "Login failed.")))
                                return
                            }
                        } catch let error {
                            handler(.failure(.other(error)))
                            return
                        }
                    } else {
                        handler(.failure(.httpError(code: httpResponse.statusCode)))
                        return
                    }
                }
            }
        }.resume()
    }
}

   在ViewModel中完成对LoginManager的调用

  • LoginViewModel.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class LoginViewModel: ObservableObject {
    
    @Published var state: ViewModelState = .idle
    @Published var credential = Credential()
    @Published var error: MyError?

    // Computed property. 值随Published property的变化而变化
    var loginDisabled: Bool {
        credential.username.isEmpty || credential.password.isEmpty || state == .loading
    }

    func performLogin() {
        self.state = .loading
        URLSession.shared.reset{
            print("Start fetching login page...")
            LoginManager.getLoginPage {result in
                switch result {
                case .failure(let error):
                    self.state = .error
                    self.error = error
                    self.credential = Credential()
                    print(error)
                case .success(let content):
                    self.postLoginData(content: content)
                }
            }
        }
    }

     private func postLoginData(content: String) {
        state = .loading
        print("Start posting credentials...")
        
        LoginManager.postCredential(credential, content: content) {result in
            switch result {
            case .failure(let error):
                self.state = .error
                self.error = error
                self.credential = Credential()
                print(error)
            case .success():
                self.state = .finish
                print("Login successful")
            }
        }
    }  
}

浏览登录后的网站内容

   为了登录成功后能够直接在GitHubView中浏览登录后的内容,我们需要创建一个WebView并共享HTTPCookieStorage.shared中保有的session信息。

创建WebView

  • GithubView.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import SwiftUI
import WebKit

struct GithubView: View {
    @EnvironmentObject var authentication: Authentication
    
    let url = "https://github.com/session"
    var request: URLRequest {
        URLRequest(url: URL(string: url)!)
    }
    
    var body: some View {
        ZStack {
            // ......
            VStack(alignment: .trailing) {
              // ......  
                WebView(request)
            }
        }
    }
}

struct WebView: UIViewRepresentable {  
    let request: URLRequest
    init(_ request: URLRequest) {
        self.request = request
    }
     
    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }
    
    func updateUIView(_ uiView: WKWebView, context: Context) {
        uiView.load(request)
    }
}

将session共享给WebView

  仅仅是在新建的WebView中导入URL只能回到GitHub登录页,要想继续使用LoginView完成的登录认证流程,直接浏览登录后的页面,需要把HTTPCookieStorage.shared中保有的cookies转移到WebView中去。将makeUIView改成以下内容即可:

  • GithubView.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 func makeUIView(context: Context) -> WKWebView {
        let config = WKWebViewConfiguration()
        config.processPool = WKProcessPool()
        let cookies = HTTPCookieStorage.shared.cookies ?? [HTTPCookie]()
        cookies.forEach({
            config.websiteDataStore.httpCookieStore.setCookie($0)
        })
        
        return WKWebView(frame:.zero, configuration: config)
    }

   当然我们必须提供一条能够退回登录页面的“Logout”路线,使应用的操作不会进入死胡同。在WebView的上方加入一个简易的Logout按键,重置session以后把Authentication中的isValidated改回false即可。

  • GithubView.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  VStack(alignment: .trailing) {
      Button {
          // Log out
          URLSession.shared.reset {
              authentication.updateValidation(success: false)
          }
      } label: {
          Text("Logout").padding()
      }.frame(height: 30)
      
      WebView(request)
  }

完整代码

https://github.com/rikucherry1993/GithubBrowser