OAuth2簡介
閱讀時長45分鐘,Demo涉及iOS的SwiftUI和Swift,用到了原生ASWebAuthenticationSession類來進行授權(quán)服務锣光,涉及MVVM架構(gòu)踪危,并采用了原生的Combine響應式框架。
在傳統(tǒng)的開發(fā)中猪落,服務器(例如Github
)給客戶端授權(quán)時(授權(quán)用戶登錄以及授權(quán)用戶獲取某些存在服務器上的資源)一般都是保存用戶名
和密碼
贞远,就是我們登錄Github
網(wǎng)站輸入的用戶名
和密碼
;但假如有一個第三方App(咱們自己開發(fā)的移動端App)也需要訪問服務器(Github
)的資源時笨忌,就會產(chǎn)生以下問題:
- 咱們自己開發(fā)的App得安全保存用戶的
Github
賬號和密碼蓝仲,這是很麻煩的事情。 - 假如咱們的App采用密碼與賬號登錄了
Github
官疲,萬一App被破解了或者說自己開發(fā)的登錄界面不安全袱结,這很可能導致用戶Github
賬戶泄漏了。 - 服務器無法控制第三方App的權(quán)限途凫,因為使用
密碼
和賬號
登錄等價于是有獲取服務器所有資源的權(quán)限垢夹,一般資源提供方可不太樂意給這么大的權(quán)限。
以上問題如何解決呢维费,這時候OAuth就閃亮登場了果元,OAuth
其實就是一套保證第三方App通過網(wǎng)絡服務能夠獲取資源所有者的資源的機制,看不懂這句話可以忽略犀盟,其實第三方App無需進行用戶驗證(就是所說的登錄)而晒,既然想獲取資源的服務器已經(jīng)有完整的安全的登錄機制(別人登錄的早寫好了,而且還安全)阅畴,這些事情交給他們做就好倡怎,畢竟咱們是大哥,這些小事不必親自出馬贱枣,比如:
- 想利用微信進行第三方登錄监署,那就直接讓微信App完成登錄,然后讓微信告訴我們的App用戶已登錄冯事,至于怎么通知焦匈,后續(xù)會細說。
- 想在咱們自己開發(fā)的App登錄Github昵仅,那就直接調(diào)用Github官方的登錄界面就好缓熟,用戶登錄完同樣讓Github通知咱們的App用戶已登錄,至于怎么通知摔笤,后續(xù)會細說够滑。
登錄完事后,要獲取資源咋辦吕世,這時候在Github
登錄完后會給一個access_token
到咱們的App彰触,這就是去拿資源的鑰匙當然這把鑰匙是有效期的而且還很短,其實一個access_token
不夠命辖,因為不可能鑰匙失效了况毅,你就讓用戶再次登錄分蓖,這樣體驗感巨差,其實Github
還給了一把refresh_token
尔许,有效期稍微長點么鹤,顧名思義就是當access token失效時用這把refresh_token
來獲取新的access_token
,假如refresh_token
也失效味廊,那對不起了蒸甜,趕緊讓用戶登錄來獲取新一輪的access_token
和refresh_token
。
????怕你還是不明白(其實我想要點贊余佛!)柠新,咱再來取個粗暴的例子吧,就比如你現(xiàn)在住在了上海的湯臣一品高端小區(qū)內(nèi)(當然作為新生代農(nóng)民工的你我是不可能的)辉巡,小區(qū)的大門有一個超高的安保系統(tǒng)的恨憎,想進入小區(qū)得輸入
房號
和密碼
才能進入,假如要點外賣吃了(畢竟程序員是不做飯的)郊楣,美團騎手準時到達了你的小區(qū)大門框咙,然而你住32樓頂層,穿著大褲衩痢甘,不想下去迎接外賣小哥喇嘱,畢竟是高檔小區(qū)的住戶,畢竟是叫的上門服務塞栅,下去到小區(qū)大門開門迎接有失身份者铜,這時美團騎手瘋狂用過微信語音你,讓你告訴大門的房號
和密碼
他好進去放椰,你愿意給嗎作烟?那我肯定不得給啊,我給了不就他天天可以來砾医?(這對于單身的你多危險呀)拿撩,就算我不點外賣他也能進來,這時候聰明的你決定給他授權(quán)
如蚜,對沒錯是授權(quán)
压恒,你在家遠程操控門禁系統(tǒng),別大驚小怪错邦,現(xiàn)在的小區(qū)都能遠程操控探赫,你輸入了你的房號
和密碼
,然后生成了一個授權(quán)碼
撬呢,比如5201314伦吠,但是這個授權(quán)碼是有時間的,你設置了只有10分鐘有效,美團騎手高興的輸入了你給的授權(quán)碼
門開了毛仪,這樣你就吃上了美味(但愿)的外賣而不用下樓搁嗓,且不用擔心美團騎手能隨便進來,畢竟你只給授權(quán)碼
設置了10分鐘的有效期箱靴。
????上面的例子中谱姓,騎手就好像是第三方APP,小區(qū)是某個擁有APP想要資源的服務器刨晴,騎手要進入小區(qū)不用輸入房號
和密碼
,只需要得到服務器授權(quán)
面的例子中路翻,騎手就好像是第三方APP狈癞,小區(qū)是某個擁有App想要資源的服務器妄均,騎手要進入小區(qū)不用輸入房號
和密碼
还棱,待安保系統(tǒng)驗證后就頒發(fā)了5201314的授權(quán)碼。
例子也舉了爱榔,在得來一遍官方的定義讓你更加深刻掉冶,在OAuth
一共有四個角色:
-
Resource Owner
可以給資源是否能被獲取授權(quán)的 --小區(qū)大門門禁
-
Resource Server
擁有資源的服務器 --小區(qū)
-
Client
想去獲取資源的第三方APP --美團騎手
-
Authorization Server
在驗證通過后頒發(fā)token的 --手機上生成token的軟件真竖,搞不好是個公眾號
讀到這想必你已了解了OAuth
的大致原理,接下來我們進行二個實戰(zhàn)
- 開發(fā)一個iOS App用來顯示Github用戶的遠程倉庫
- 微信第三方登錄
Github App獲取遠程倉庫代碼傳送門
首先打開Github官網(wǎng)并進行登錄(需要翻墻)厌小,然后點擊賬戶找到Setting > Developer Setting > New Github App
然后在圖示的地方填上相應的信息
- 在1處填上我們所創(chuàng)建App的名字恢共,這個會保存在
Github
上以便區(qū)分不同的App - 在2處填上App的描述信息
- 在3處
Homepage URL
填上能鏈接到App主頁面的鏈接,這里沒有就隨便填百度 - 在4處填上
回調(diào)URL
璧亚,這個有啥用待會解釋讨韭,可以按照圖中的填
我怕你不賴煩看到后面了,提前解釋吧癣蟋,
callBackUrl
是用來在請求用戶登錄的服務器返回的重定向URL
透硝,簡單來說就是我這邊已確認登錄了,你想去到哪個頁面疯搅,這個就看咱們App邏輯怎么定濒生,網(wǎng)頁端是跳轉(zhuǎn)至重定向的網(wǎng)頁,而在移動端一般是回退到App界面幔欧,比如微信第三方登錄罪治,登陸完你得回到App呀,這里也一樣礁蔗,Github
登錄完也需要回到App规阀,這個回調(diào)URL
就是起這個作用。
- 在5處把勾選上瘦麸,用于獲取
refresh_token
接下來按照如下視圖填即可谁撼,
注意Webhook的Avtive勾不用打上
接下來的
Permissions Setting
就是給資源授權(quán)的,為了方便起見,將Contents
設置為Read-only
最后勾選
Only on this account
點擊創(chuàng)建按鈕后厉碟,會來到如下的詳細頁面喊巍,點擊
Generate a new client secret
生成客戶端秘鑰,用來授權(quán)能使用Github
的API箍鼓,這個界面請截圖保留崭参,Github不會讓你看到第二次
Xcode項目的創(chuàng)建
Demo采用swift和swiftUI進行創(chuàng)建,不熟悉的朋友可以看注釋款咖,只需要理解代碼的邏輯即可何暮,同時項目用到了
ASWebAuthenticationSession
這個類,這是Apple’s Authentication Services framework
的一個API铐殃,用來給用戶通過網(wǎng)路服務進行授權(quán)海洼。
UI的搭建,主要代碼如下:
import SwiftUI
struct SignInView: View {
// 綁定了SignInViewModel用于跳轉(zhuǎn)至個人倉庫列表頁
@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()// 請求登錄的用戶的相關(guān)信息
}
}
}
private var buttonBackground: some View {
RoundedRectangle(cornerRadius: 8)
.fill(Color("rw-green"))
}
}
代碼解讀:
- 通過綁定
SignInViewModel
來決定顯示登陸頁還是倉庫詳情頁富腊,綁定的操作需要了解Swift的原生響應式編程Combine
框架 - 通過判斷
ViewModel
的isLoading
屬性來是否展示Progress
進度條 - 當
ViewModel
的isShowingRepositoriesView
屬性為true
時觸發(fā)NavigationLink
跳轉(zhuǎn)至倉庫詳情頁 - 在界面出現(xiàn)時請求登錄的用戶的相關(guān)信息坏逢,當然在沒登錄時打印錯誤信息
網(wǎng)絡請求
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 = "填入自己的"
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<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
}
// 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()
}
}
對網(wǎng)絡的請求進行了簡單的封裝,由于需要在獲取資源的過程中有登錄
Github
賬戶赘被,請求token
和請求資源的操作是整,所以采用了RequestType
這個枚舉類型來定義網(wǎng)絡請求的類型,并根據(jù)httpMethod()
方法來根據(jù)請求的類型來選用是Get
還是Post
請求民假,同時利用url()
方法根據(jù)不同的請求類型生成相應的請求URL
代碼解讀:
- 請求
URL
的生成采用了urlComponents
這個類浮入,沒用過的可自行了解,看一下代碼其實也能明白是啥回事 - 由于請求
token
需要使用注冊所用的clientSecret
羊异,clientID
和callbackURLScheme
等舵盈,在MARK: Private Constants
處填入自己上面注冊的 - 將返回的
refresh_token
和access_token
進行了本地持久化存儲(實際開發(fā)中最好使用保存在鑰匙串中的方式),二個token
的作用上文已說明球化,用于在Github
上作為令牌請求數(shù)據(jù)使用 - 所有請求上送的參數(shù)需要去看
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<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() // 界面出現(xiàn)時請求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
的數(shù)據(jù)源 - 退出登錄后觸發(fā)
NetworkRequest.signOut()
即清空accessToken
,refreshToken
和username
總結(jié):
- 進入登錄頁面時嘗試調(diào)用
getUser
方法請求用戶信息筒愚,由于第一次肯定沒有登錄赴蝇,會打印Failed to get user, or there is no valid/active session錯誤信息。- 點擊
SignIn
登錄按鈕后巢掺,觸發(fā)NetworkRequest
的signIn
請求句伶,這時會調(diào)用原生的ASWebAuthenticationSession
這個類,其實就是提供了一個Safari
頁面展示Github
的登錄界面陆淀,并負責拿到請求返回的結(jié)果考余,同時根據(jù)callbackURL
來回到我們的App
,將控制權(quán)交回App
轧苫。- 登錄成功后拿到
Code
并利用此Code
去請求access_token
和refresh_token
楚堤,并進行本地化保存- 請求
token
成功后直接觸發(fā)getUser()
請求用戶數(shù)據(jù),請求成功后設置SignInViewModel
的isShowingRepositoriesView
為true
,跳轉(zhuǎn)至RepositoriesView
身冬。RepositoriesView
開始顯示時觸發(fā)getRepos
請求衅胀,展示個人Repo
信息。
微信第三方登錄代碼傳送門
看完了上面的例子酥筝,再來看微信第三方登錄就非常簡單滚躯,由于篇幅有限,就只闡述實現(xiàn)的主要步驟嘿歌,并貼出主要代碼掸掏,具體實現(xiàn)看Demo,只要知道了
OAuth2
原理宙帝,這些套路都一樣
上面這個配方熟悉嗎丧凤,是不是一看就懂!Nice茄唐,記得點贊
微信開放平臺注冊App
和Github
一樣,微信也需要知道是誰要訪問它的資源蝇更,這需要到微信開放平臺注冊App
(需要審核的)沪编,然后拿到appID
和appSecret
,這個簡單的小事就在此略過年扩。
集成微信登錄SDK
這個沒啥好說的蚁廓,支持手動集成和CocoaPods
集成,具體集成看微信官方文檔微信SDK接入文檔
設置URLscheme
按照下圖設置URL Schemes
厨幻,這是用來微信客戶端返回我們App
用的相嵌,原則就是保住唯一性,可以把申請的appID
填寫在這里
開始擼代碼
Demo采用了OC况脆,由于這里只是說明第三方微信登錄的實現(xiàn)過程饭宾,只貼出主要邏輯代碼
#import "AppDelegate.h"
#import "WXApi.h"
#import "ViewController.h"
@interface AppDelegate ()<WXApiDelegate>
@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<UIApplicationOpenURLOptionsKey,id> *)options{
return [WXApi handleOpenURL:url delegate:self];
}
// 微信的回調(diào)
- (void)onResp:(BaseResp *)resp{
if([resp isKindOfClass:[SendAuthResp class]]){
SendAuthResp *resp2 = (SendAuthResp *)resp;
[[NSNotificationCenter defaultCenter] postNotificationName:@"wxLogin" object:resp2];
}else{
NSLog(@"授權(quán)失敗");
}
}
代碼解讀:
- 先向微信客戶端注冊
App
- 實現(xiàn)
openURL
方法格了,并設置代理 - 在微信回調(diào)里拿到授權(quán)消息體
- (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];
}
}
代碼解讀:
- 判斷是否安裝了微信客戶端
- 安裝了則向微信客戶端發(fā)送登錄請求看铆,這會拉起微信客戶端,跳轉(zhuǎn)至微信客戶端
- (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"];
//因為微信返回的參數(shù)是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
去請求用戶數(shù)據(jù)棠隐,包括用戶微信名,圖像等等檐嚣,在實際開發(fā)中會將這些數(shù)據(jù)保存在后臺助泽。 -
access_token
失效后利用refresh_token
去重新請求的代碼這里沒貼出,具體看Demo
。
總結(jié):
本文通過二個實例
Demo
來說明了OAuth2
的工作原理报咳,只要理解透了OAuth2
的工作原理侠讯,類似于新浪,淘寶等第三方登錄實現(xiàn)都差不多暑刃,當然現(xiàn)在友盟可以一鍵集成這些登錄厢漩,但是內(nèi)部的實現(xiàn)原理還是逃不出OAuth2
的,看到這里岩臣,希望大家能理解透OAuth2
的作用溜嗜,最后希望大家能點贊一個,這是對我最大的鼓勵架谎,謝謝啦!