OAuth2简介
阅读时长45分钟,Demo涉及iOS的SwiftUI和Swift,用到了原生ASWebAuthenticationSession类来进行授权服务,涉及MVVM架构,并采用了原生的Combine响应式框架。
在传统的开发中,服务器(例如Github
)给客户端授权时(授权用户登录以及授权用户获取某些存在服务器上的资源)一般都是保存用户名
和密码
,就是我们登录Github
网站输入的用户名
和密码
;但假如有一个第三方App(咱们自己开发的移动端App)也需要访问服务器(Github
)的资源时,就会产生以下问题:
- 咱们自己开发的App得安全保存用户的
Github
账号和密码,这是很麻烦的事情。 - 假如咱们的App采用密码与账号登录了
Github
,万一App被破解了或者说自己开发的登录界面不安全,这很可能导致用户Github
账户泄漏了。 - 服务器无法控制第三方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_token
和refresh_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
然后在图示的地方填上相应的信息
- 在1处填上我们所创建App的名字,这个会保存在
Github
上以便区分不同的App - 在2处填上App的描述信息
- 在3处
Homepage URL
填上能链接到App主页面的链接,这里没有就随便填百度 - 在4处填上
回调URL
,这个有啥用待会解释,可以按照图中的填
我怕你不赖烦看到后面了,提前解释吧,
callBackUrl
是用来在请求用户登录的服务器返回的重定向URL
,简单来说就是我这边已确认登录了,你想去到哪个页面,这个就看咱们App逻辑怎么定,网页端是跳转至重定向的网页,而在移动端一般是回退到App界面,比如微信第三方登录,登陆完你得回到App呀,这里也一样,Github
登录完也需要回到App,这个回调URL
就是起这个作用。
- 在5处把勾选上,用于获取
refresh_token
接下来按照如下视图填即可,
注意Webhook的Avtive勾不用打上
接下来的
Permissions Setting
就是给资源授权的,为了方便起见,将Contents
设置为Read-only
最后勾选
Only on this account
点击创建按钮后,会来到如下的详细页面,点击
Generate a new client secret
生成客户端秘钥,用来授权能使用Github
的API,这个界面请截图保留,Github不会让你看到第二次
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
框架 - 通过判断
ViewModel
的isLoading
属性来是否展示Progress
进度条 - 当
ViewModel
的isShowingRepositoriesView
属性为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
需要使用注册所用的clientSecret
,clientID
和callbackURLScheme
等,在MARK: Private Constants
处填入自己上面注册的 - 将返回的
refresh_token
和access_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
成功后设置RepositoriesViewModel
的repositories
属性,用于刷新RepositoriesView
的数据源 - 退出登录后触发
NetworkRequest.signOut()
即清空accessToken
,refreshToken
和username
总结:
- 进入登录页面时尝试调用
getUser
方法请求用户信息,由于第一次肯定没有登录,会打印Failed to get user, or there is no valid/active session错误信息。- 点击
SignIn
登录按钮后,触发NetworkRequest
的signIn
请求,这时会调用原生的ASWebAuthenticationSession
这个类,其实就是提供了一个Safari
页面展示Github
的登录界面,并负责拿到请求返回的结果,同时根据callbackURL
来回到我们的App
,将控制权交回App
。- 登录成功后拿到
Code
并利用此Code
去请求access_token
和refresh_token
,并进行本地化保存- 请求
token
成功后直接触发getUser()
请求用户数据,请求成功后设置SignInViewModel
的isShowingRepositoriesView
为true
,跳转至RepositoriesView
。RepositoriesView
开始显示时触发getRepos
请求,展示个人Repo
信息。
微信第三方登录代码传送门
看完了上面的例子,再来看微信第三方登录就非常简单,由于篇幅有限,就只阐述实现的主要步骤,并贴出主要代码,具体实现看Demo,只要知道了
OAuth2
原理,这些套路都一样
上面这个配方熟悉吗,是不是一看就懂!Nice,记得点赞
微信开放平台注册App
和Github
一样,微信也需要知道是谁要访问它的资源,这需要到微信开放平台注册App
(需要审核的),然后拿到appID
和appSecret
,这个简单的小事就在此略过。
集成微信登录SDK
这个没啥好说的,支持手动集成和CocoaPods
集成,具体集成看微信官方文档微信SDK接入文档
设置URLscheme
按照下图设置URL Schemes
,这是用来微信客户端返回我们App
用的,原则就是保住唯一性,可以把申请的appID
填写在这里
开始撸代码
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
,然后利用code
和appId
,secret
去请求access_token
和refresh_token
并进行保存。 - 请求
token
成功后,利用token
去请求用户数据,包括用户微信名,图像等等,在实际开发中会将这些数据保存在后台。 -
access_token
失效后利用refresh_token
去重新请求的代码这里没贴出,具体看Demo
。
总结:
本文通过二个实例
Demo
来说明了OAuth2
的工作原理,只要理解透了OAuth2
的工作原理,类似于新浪,淘宝等第三方登录实现都差不多,当然现在友盟可以一键集成这些登录,但是内部的实现原理还是逃不出OAuth2
的,看到这里,希望大家能理解透OAuth2
的作用,最后希望大家能点赞一个,这是对我最大的鼓励,谢谢啦!