iOS中OAuth2的运用之微信第三方登录和获取Github用户信息

OAuth2简介

阅读时长45分钟,Demo涉及iOS的SwiftUI和Swift,用到了原生ASWebAuthenticationSession类来进行授权服务,涉及MVVM架构,并采用了原生的Combine响应式框架。

在传统的开发中,服务器(例如Github)给客户端授权时(授权用户登录以及授权用户获取某些存在服务器上的资源)一般都是保存用户名密码,就是我们登录Github网站输入的用户名密码;但假如有一个第三方App(咱们自己开发的移动端App)也需要访问服务器(Github)的资源时,就会产生以下问题:

  1. 咱们自己开发的App得安全保存用户的Github账号和密码,这是很麻烦的事情。
  2. 假如咱们的App采用密码与账号登录了Github,万一App被破解了或者说自己开发的登录界面不安全,这很可能导致用户Github账户泄漏了。
  3. 服务器无法控制第三方App的权限,因为使用密码账号登录等价于是有获取服务器所有资源的权限,一般资源提供方可不太乐意给这么大的权限。

以上问题如何解决呢,这时候OAuth就闪亮登场了OAuth其实就是一套保证第三方App通过网络服务能够获取资源所有者的资源的机制,看不懂这句话可以忽略,其实第三方App无需进行用户验证(就是所说的登录),既然想获取资源的服务器已经有完整的安全的登录机制(别人登录的早写好了,而且还安全),这些事情交给他们做就好,毕竟咱们是大哥,这些小事不必亲自出马,比如:

  • 想利用微信进行第三方登录,那就直接让微信App完成登录,然后让微信告诉我们的App用户已登录,至于怎么通知,后续会细说。
  • 想在咱们自己开发的App登录Github,那就直接调用Github官方的登录界面就好,用户登录完同样让Github通知咱们的App用户已登录,至于怎么通知,后续会细说。

登录完事后,要获取资源咋办,这时候在Github登录完后会给一个access_token到咱们的App,这就是去拿资源的钥匙当然这把钥匙是有效期的而且还很短,其实一个access_token不够,因为不可能钥匙失效了,你就让用户再次登录,这样体验感巨差,其实Github还给了一把refresh_token,有效期稍微长点,顾名思义就是当access token失效时用这把refresh_token来获取新的access_token,假如refresh_token也失效,那对不起了,赶紧让用户登录来获取新一轮的access_tokenrefresh_token

    怕你还是不明白(其实我想要点赞!),咱再来取个粗暴的例子吧,就比如你现在住在了上海的汤臣一品高端小区内(当然作为新生代农民工的你我是不可能的),小区的大门有一个超高的安保系统的,想进入小区得输入房号密码才能进入,假如要点外卖吃了(毕竟程序员是不做饭的),美团骑手准时到达了你的小区大门,然而你住32楼顶层,穿着大裤衩,不想下去迎接外卖小哥,毕竟是高档小区的住户,毕竟是叫的上门服务,下去到小区大门开门迎接有失身份,这时美团骑手疯狂用过微信语音你,让你告诉大门的房号密码他好进去,你愿意给吗?那我肯定不得给啊,我给了不就他天天可以来?(这对于单身的你多危险呀),就算我不点外卖他也能进来,这时候聪明的你决定给他授权,对没错是授权,你在家远程操控门禁系统,别大惊小怪,现在的小区都能远程操控,你输入了你的房号密码,然后生成了一个授权码,比如5201314,但是这个授权码是有时间的,你设置了只有10分钟有效,美团骑手高兴的输入了你给的授权码门开了,这样你就吃上了美味(但愿)的外卖而不用下楼,且不用担心美团骑手能随便进来,毕竟你只给授权码设置了10分钟的有效期。
    上面的例子中,骑手就好像是第三方APP,小区是某个拥有APP想要资源的服务器,骑手要进入小区不用输入房号密码,只需要得到服务器授权面的例子中,骑手就好像是第三方APP,小区是某个拥有App想要资源的服务器,骑手要进入小区不用输入房号密码,待安保系统验证后就颁发了5201314的授权码。

例子也举了,在得来一遍官方的定义让你更加深刻,在OAuth一共有四个角色:

  • Resource Owner可以给资源是否能被获取授权的 -- 小区大门门禁
  • Resource Server拥有资源的服务器 -- 小区
  • Client想去获取资源的第三方APP -- 美团骑手
  • Authorization Server在验证通过后颁发token的 -- 手机上生成token的软件,搞不好是个公众号

读到这想必你已了解了OAuth的大致原理,接下来我们进行二个实战

  • 开发一个iOS App用来显示Github用户的远程仓库
  • 微信第三方登录

Github App获取远程仓库代码传送门

首先打开Github官网并进行登录(需要翻墙),然后点击账户找到Setting > Developer Setting > New Github App

image.png

然后在图示的地方填上相应的信息

  • 在1处填上我们所创建App的名字,这个会保存在Github上以便区分不同的App
  • 在2处填上App的描述信息
  • 在3处Homepage URL填上能链接到App主页面的链接,这里没有就随便填百度
  • 在4处填上回调URL,这个有啥用待会解释,可以按照图中的填

我怕你不赖烦看到后面了,提前解释吧,callBackUrl是用来在请求用户登录的服务器返回的重定向URL,简单来说就是我这边已确认登录了,你想去到哪个页面,这个就看咱们App逻辑怎么定,网页端是跳转至重定向的网页,而在移动端一般是回退到App界面,比如微信第三方登录,登陆完你得回到App呀,这里也一样,Github登录完也需要回到App,这个回调URL就是起这个作用。

  • 在5处把勾选上,用于获取refresh_token

image.png

接下来按照如下视图填即可,注意Webhook的Avtive勾不用打上
image.png

接下来的Permissions Setting就是给资源授权的,为了方便起见,将Contents设置为Read-only
image.png

最后勾选Only on this account
image.png

点击创建按钮后,会来到如下的详细页面,点击Generate a new client secret生成客户端秘钥,用来授权能使用Github的API,这个界面请截图保留,Github不会让你看到第二次
image.png

Xcode项目的创建

Demo采用swift和swiftUI进行创建,不熟悉的朋友可以看注释,只需要理解代码的逻辑即可,同时项目用到了ASWebAuthenticationSession这个类,这是Apple’s Authentication Services framework的一个API,用来给用户通过网路服务进行授权。

UI的搭建,主要代码如下:

import SwiftUI

struct SignInView: View {
 // 绑定了SignInViewModel用于跳转至个人仓库列表页
  @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"))
  }
}

代码解读:

  • 通过绑定SignInViewModel来决定显示登陆页还是仓库详情页,绑定的操作需要了解Swift的原生响应式编程Combine框架
  • 通过判断ViewModelisLoading属性来是否展示Progress进度条
  • ViewModelisShowingRepositoriesView属性为true时触发NavigationLink跳转至仓库详情页
  • 在界面出现时请求登录的用户的相关信息,当然在没登录时打印错误信息

网络请求

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 = (response: HTTPURLResponse, object: T)

  // MARK: Private Constants
  static let callbackURLScheme = "填入自己的"
  static let clientID = "填入自己的"
  static let clientSecret = "填入自己的"

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

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

  // MARK: Methods
  func start(responseType: T.Type, completionHandler: @escaping ((Result, 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
      }
      // 1
      if T.self == String.self,
        let responseString = String(data: data, encoding: .utf8) {
        // 2 分割
        let components = responseString.components(separatedBy: "&")
        var dictionary: [String: String] = [:]
        // 3 提取
        for component in components {
          let itemComponents = component.components(separatedBy: "=")
          if let key = itemComponents.first,
             let value = itemComponents.last {
            dictionary[key] = value
          }
        }
        // 4 回到主线程刷新UI
        DispatchQueue.main.async {
          // 5 保存token
          NetworkRequest.accessToken = dictionary["access_token"]
          NetworkRequest.refreshToken = dictionary["refresh_token"]
          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()
  }
}

对网络的请求进行了简单的封装,由于需要在获取资源的过程中有登录Github账户,请求token和请求资源的操作,所以采用了RequestType这个枚举类型来定义网络请求的类型,并根据httpMethod()方法来根据请求的类型来选用是Get还是Post请求,同时利用url()方法根据不同的请求类型生成相应的请求URL

代码解读

  • 请求URL的生成采用了urlComponents这个类,没用过的可自行了解,看一下代码其实也能明白是啥回事
  • 由于请求token需要使用注册所用的clientSecretclientIDcallbackURLScheme等,在 MARK: Private Constants处填入自己上面注册的
  • 将返回的refresh_tokenaccess_token进行了本地持久化存储(实际开发中最好使用保存在钥匙串中的方式),二个token的作用上文已说明,用于在Github上作为令牌请求数据使用
  • 所有请求上送的参数需要去看Github官方的API接口文档要求

请求个人Github远端Repos

import SwiftUI

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

  init(
    viewModel: RepositoriesViewModel = RepositoriesViewModel(),
    displayed: Binding
  ) {
    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() // 界面出现时请求Repos
    }
  }

  private var signOutButton: some View {
    Button("Sign Out") {
      viewModel.signOut()
      displayed = false
    }
  }
}
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()
  }

代码解读:

  • 请求Repos成功后设置RepositoriesViewModelrepositories属性,用于刷新RepositoriesView的数据源
  • 退出登录后触发NetworkRequest.signOut()即清空accessTokenrefreshTokenusername

总结:

  1. 进入登录页面时尝试调用getUser方法请求用户信息,由于第一次肯定没有登录,会打印Failed to get user, or there is no valid/active session错误信息。
  2. 点击SignIn登录按钮后,触发NetworkRequestsignIn请求,这时会调用原生的ASWebAuthenticationSession这个类,其实就是提供了一个Safari页面展示Github的登录界面,并负责拿到请求返回的结果,同时根据callbackURL来回到我们的App,将控制权交回 App
  3. 登录成功后拿到Code并利用此Code去请求access_tokenrefresh_token,并进行本地化保存
  4. 请求token成功后直接触发getUser()请求用户数据,请求成功后设置SignInViewModelisShowingRepositoriesViewtrue,跳转至RepositoriesView
  5. RepositoriesView开始显示时触发getRepos请求,展示个人Repo信息。

微信第三方登录代码传送门

看完了上面的例子,再来看微信第三方登录就非常简单,由于篇幅有限,就只阐述实现的主要步骤,并贴出主要代码,具体实现看Demo,只要知道了OAuth2原理,这些套路都一样

image.png

上面这个配方熟悉吗,是不是一看就懂!Nice,记得点赞

微信开放平台注册App

Github一样,微信也需要知道是谁要访问它的资源,这需要到微信开放平台注册App(需要审核的),然后拿到appIDappSecret,这个简单的小事就在此略过。

集成微信登录SDK

这个没啥好说的,支持手动集成和CocoaPods集成,具体集成看微信官方文档微信SDK接入文档

设置URLscheme

按照下图设置URL Schemes,这是用来微信客户端返回我们App用的,原则就是保住唯一性,可以把申请的appID填写在这里

image.png

开始撸代码

Demo采用了OC,由于这里只是说明第三方微信登录的实现过程,只贴出主要逻辑代码


#import "AppDelegate.h"
#import "WXApi.h"
#import "ViewController.h"
@interface AppDelegate ()
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    //appID,向微信注册App
    [WXApi registerApp:appId];
    return YES;
}
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options{
    return [WXApi handleOpenURL:url delegate:self];
}
// 微信的回调
- (void)onResp:(BaseResp *)resp{
    if([resp isKindOfClass:[SendAuthResp class]]){
        SendAuthResp *resp2 = (SendAuthResp *)resp;
        [[NSNotificationCenter defaultCenter] postNotificationName:@"wxLogin" object:resp2];
    }else{
        NSLog(@"授权失败");
    }
}

代码解读:

  • 先向微信客户端注册App
  • 实现openURL方法,并设置代理
  • 在微信回调里拿到授权消息体
- (void)login{
    //判断微信是否安装
    if([WXApi isWXAppInstalled]){
        SendAuthReq *req = [[SendAuthReq alloc] init];
        req.scope = @"snsapi_userinfo";
        req.state = @"App";
        [WXApi sendAuthReq:req viewController:self delegate:self];
    }else{
        [self setupAlertController];
    }
}

代码解读:

  • 判断是否安装了微信客户端
  • 安装了则向微信客户端发送登录请求,这会拉起微信客户端,跳转至微信客户端
- (void)wxLogin:(NSNotification*)noti{
    //获取到code
    SendAuthResp *resp = noti.object;
    NSLog(@"%@",resp.code);
    _code = resp.code;
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    NSString *url = [NSString stringWithFormat:@"https://api.weixin.qq.com/sns/oauth2/access_token?appid=%@&secret=%@&code=%@&grant_type=%@",appId,appSecret,_code,@"authorization_code"];
    manager.requestSerializer = [AFJSONRequestSerializer serializer];
    [manager.requestSerializer setValue:@"text/html; charset=utf-8" forHTTPHeaderField:@"Content-Type"];
    NSMutableSet *mgrSet = [NSMutableSet set];
    mgrSet.set = manager.responseSerializer.acceptableContentTypes;
    [mgrSet addObject:@"text/html"];
    //因为微信返回的参数是text/plain 必须加上 会进入fail方法
    [mgrSet addObject:@"text/plain"];
    [mgrSet addObject:@"application/json"];
    manager.responseSerializer.acceptableContentTypes = mgrSet;
    [manager GET:url parameters:nil progress:^(NSProgress * _Nonnull downloadProgress) {   
    } success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        NSLog(@"success");
        NSDictionary *resp = (NSDictionary*)responseObject;
        NSString *openid = resp[@"openid"];
        NSString *accessToken = resp[@"access_token"];
        NSString *refreshToken = resp[@"refresh_token"];
        if(accessToken && ![accessToken isEqualToString:@""] && openid && ![openid isEqualToString:@""]){
            [[NSUserDefaults standardUserDefaults] setObject:openid forKey:WX_OPEN_ID];
            [[NSUserDefaults standardUserDefaults] setObject:accessToken forKey:WX_ACCESS_TOKEN];
            [[NSUserDefaults standardUserDefaults] setObject:refreshToken forKey:WX_REFRESH_TOKEN];
            [[NSUserDefaults standardUserDefaults] synchronize];
        }
        [self getUserInfo];
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
    }];
}

代码解读:

  • 登录成功后拿到code,然后利用codeappIdsecret去请求access_tokenrefresh_token并进行保存。
  • 请求token成功后,利用token去请求用户数据,包括用户微信名,图像等等,在实际开发中会将这些数据保存在后台。
  • access_token失效后利用refresh_token去重新请求的代码这里没贴出,具体看Demo

总结:

本文通过二个实例Demo来说明了OAuth2的工作原理,只要理解透了OAuth2的工作原理,类似于新浪,淘宝等第三方登录实现都差不多,当然现在友盟可以一键集成这些登录,但是内部的实现原理还是逃不出OAuth2的,看到这里,希望大家能理解透OAuth2的作用,最后希望大家能点赞一个,这是对我最大的鼓励,谢谢啦!

你可能感兴趣的:(iOS中OAuth2的运用之微信第三方登录和获取Github用户信息)