如何通過 OAuth 2.0 使 iOS Apps 集成 LinkedIn 登錄功能?

社交網(wǎng)絡(luò)早已成為人們?nèi)粘I畹囊徊糠侄愀臁F鋵?shí)蜓洪,社交網(wǎng)絡(luò)也是編程生活的一部分,大多數(shù) App 必須通過某種方式與社交網(wǎng)絡(luò)交互坯苹,傳送或接收與用戶相關(guān)的數(shù)據(jù)蝠咆。大多數(shù)情況下,用戶需要登錄某種社交網(wǎng)絡(luò)北滥,授權(quán) App 代表自己進(jìn)行請求刚操。

目前,此類社交網(wǎng)絡(luò)的種類非常豐富再芋,以 Facebook 與 Twitter 最為常用菊霜。而且,iOS 系統(tǒng)內(nèi)置了對(duì)這兩款社交網(wǎng)絡(luò)的支持济赎。然而鉴逞,對(duì)于其他類型的社交網(wǎng)絡(luò)记某,開發(fā)者必須投入更多的努力,以使 App 獲得授權(quán)訪問這些社交網(wǎng)絡(luò)构捡,繼而運(yùn)行經(jīng)過授權(quán)的合法請求液南。LinkedIn 就是這樣一種社交網(wǎng)絡(luò),在本教程中勾徽,我們會(huì)了解如何為 App 授權(quán)滑凉,使之與 LinkedIn 的服務(wù)器交換受保護(hù)的數(shù)據(jù)。

為 iOS App 授權(quán)以訪問 LinkedIn喘帚,并根據(jù)后者提供的 API 運(yùn)行特定的操作畅姊,可以通過兩種方法實(shí)現(xiàn)。方法一:使用 LinkedIn 支持的 OAuth 2.0 協(xié)議吹由。方法二:使用 LinkedIn 提供的 iOS SDK若未。與所有第三方 SDK 一樣,LinkedIn 提供的 SDK 必須集成到項(xiàng)目中倾鲫,經(jīng)過合理的配置才能使用粗合。

linked-in-sign-in

在本文中,我們將僅專注于第一種方法乌昔。因此舌劳,我們將學(xué)習(xí) LinkedIn 與 OAuth 2.0 指南中與此相關(guān)的指定內(nèi)容,包括讓用戶通過 App(是任何 App玫荣,而不僅僅是 iOS 系統(tǒng))登錄 LinkedIn 并為 App 授權(quán)執(zhí)行進(jìn)一步請求的必要流程。盡管 LinkedIn iOS SDK 也是很好的選擇大诸,但筆者更喜歡 OAuth 方法捅厂,原因有三:

  1. 筆者個(gè)人更偏愛此類任務(wù):使用 REST API 調(diào)用與服務(wù)器進(jìn)行直接的交流,以順利完成授權(quán)過程资柔。
  2. LinkedIn 網(wǎng)站中有關(guān) LinkedIn iOS SDK 的介紹相當(dāng)明確詳盡焙贷,因此筆者認(rèn)為基于相同主題寫的教程恐怕益處不大。
  3. 筆者認(rèn)為贿堰,使用 LinkedIn iOS SDK 時(shí)存在一個(gè)缺陷:官方的 LinkedIn App 必須已經(jīng)安裝在設(shè)備中辙芍,否則登錄與授權(quán)過程就無法完成。如果某個(gè) App 需要獲得用戶的 LinkedIn 主頁信息羹与,但用戶并不想安裝 LinkedIn 官方應(yīng)用故硅,就會(huì)造成不便。

關(guān)于 OAuth 2.0 協(xié)議纵搁,能說的實(shí)在太多了吃衅。讀者最好還是登錄官網(wǎng)仔細(xì)研讀一下。簡而言之腾誉,為了成功完成登錄與授權(quán)過程徘层,本教程將會(huì)遵循以下步驟:

  • 必要地峻呕,我們將在 LinkedIn 開發(fā)者網(wǎng)站創(chuàng)建一個(gè)新的 App。從而得到完成后續(xù)過程必備的兩個(gè)重要密匙(Client ID 與 Client Secret)趣效。
  • 通過一個(gè) Web 視圖瘦癌,讓用戶登錄其 LinkedIn 賬戶。
  • 根據(jù)以上所得跷敬,再加上一些必要數(shù)據(jù)讯私,向 LinkedIn 服務(wù)器索取授權(quán)碼。
  • 與一個(gè)訪問令牌交換授權(quán)碼干花。

訪問令牌是與 OAuth 交互的必要條件妄帘。通過一個(gè)有效的令牌,我們便能向 LinkedIn 服務(wù)器發(fā)送經(jīng)過授權(quán)的請求池凄。并根據(jù) App 的性質(zhì)抡驼,“get”或“post” 數(shù)據(jù)到用戶的主頁。

在繼續(xù)閱讀之前肿仑,請確保你理解 OAuth 2.0 的工作原理致盟,以及它的流(flow)。如果必要尤慰,閱讀其他資源以獲取更多信息(比如這兒馏锡,這兒這兒)。

說了這么多伟端,讓我們進(jìn)入正題杯道,介紹本教程的演示應(yīng)用,然后進(jìn)入具體實(shí)現(xiàn)责蝠。筆者相信党巾,我們將要學(xué)習(xí)的內(nèi)容是趣味無窮的。

作為參考霜医,以下是 LinkedIn 官方文檔的鏈接:

演示 App 概覽

我們在本教程中將要實(shí)現(xiàn)的演示 App 由兩部分視圖控制器組成:第一個(gè)(默認(rèn)的 ViewController 類)只包含三個(gè)按鈕:

  1. 一個(gè)名為 LinkedIn Sign In(LinkedIn 登錄)的按鈕齿拂,用于啟動(dòng)登錄與授權(quán)流程。
  2. 一個(gè)名為 Get my profile URL(獲得我的主頁 URL)的按鈕肴敛,用于執(zhí)行一個(gè)經(jīng)過授權(quán)的請求署海,使用訪問令牌獲得用戶主頁的 URL。
  3. 一個(gè)展示主頁 URL 的按鈕医男,點(diǎn)擊之后會(huì)在 Safari 中打開用戶的 LinkedIn 主頁砸狞。

默認(rèn)情況下,只會(huì)啟用第一個(gè)按鈕镀梭。實(shí)際上趾代,只要還未獲得訪問令牌,該按鈕就會(huì)一直可用丰辣。在其他情況下撒强,第一個(gè)按鈕會(huì)被禁用禽捆,同時(shí)啟用第二個(gè)按鈕。第三個(gè)按鈕是隱藏的飘哨,只有當(dāng)?shù)玫剑ㄍㄟ^第二個(gè)按鈕)用戶主頁的 URL 時(shí)胚想,才會(huì)可見。

view-controller-signin

第二個(gè)視圖控制器會(huì)包含一個(gè) Web 視圖芽隆。通過該試圖浊服,你可以登錄 LinkedIn 賬戶,這樣認(rèn)證與授權(quán)過程才能成功進(jìn)行胚吁。當(dāng)獲得用于向 LinkedIn 發(fā)送合法請求的訪問令牌后牙躺,該視圖控制器就會(huì)被移除。

t47_2_user_sign_in

與往常一樣腕扶,我們不需要從頭開始創(chuàng)建項(xiàng)目孽拷。你可以下載一個(gè)啟動(dòng)項(xiàng)目,在此基礎(chǔ)上繼續(xù)搭建半抱。

基本上脓恕,我們的主要努力將專注于獲取訪問令牌。我們會(huì)遵循 OAuth 2.0 協(xié)議以及 LinkedIn 指南的指定窿侈,一步一步地完成所有必備流程炼幔。獲得訪問令牌之后,我們會(huì)繼續(xù)解釋如何向 LinkedIn 發(fā)送合法請求史简,以獲得授權(quán)用戶公共主頁的 URL乃秀。成功得到 URL 之后,我們會(huì)請用前面提到的第三個(gè)按鈕圆兵,將主頁內(nèi)容顯示在 Safari 瀏覽器中跺讯。

在你繼續(xù)閱讀之前,請確保已經(jīng)下載啟動(dòng)項(xiàng)目衙傀,打開它并熟悉它的。準(zhǔn)備就緒之后萨咕,請繼續(xù)往下看统抬。

LinkedIn 開發(fā)者網(wǎng)站—— 創(chuàng)建新的 App

實(shí)現(xiàn) OAuth 2.0 登錄流程的第一步,是在 LinkedIn 開發(fā)者網(wǎng)站創(chuàng)建一個(gè)新的 App 記錄危队。為此聪建,你僅需訪問此鏈接。如果你還沒有登錄 LinkedIn 主頁茫陆,你將收到提示以完成登錄操作金麸。

注意:如果你在下面的步驟中使用 Safari 出現(xiàn)問題,請選擇其他瀏覽器簿盅。我使用的是 Chrome 瀏覽器挥下。

登錄之后揍魂,找到網(wǎng)站“我的應(yīng)用(My Applications)”部分,你會(huì)發(fā)現(xiàn)一個(gè)名為“創(chuàng)建應(yīng)用(Create Application)”的黃色按鈕棚瘟。點(diǎn)擊它開始創(chuàng)建新的應(yīng)用现斋,之后我們會(huì)將它與 iOS App 進(jìn)行聯(lián)結(jié)。

t47_3_create_app_button

在接下來出現(xiàn)的表格中偎蘸,填寫所有欄目庄蹋。如果需要填寫公司名稱或上傳應(yīng)用 logo,不用擔(dān)心迷雪,輸入一些虛假信息也可限书。之后,接受使用條款章咧,點(diǎn)擊提交按鈕倦西。請一定要在帶紅色星號(hào)的欄目中輸入內(nèi)容,否則你將無法繼續(xù)慧邮。以下為示例:

t47_4_create_new_app

我們的目標(biāo)是抵達(dá)下一個(gè)頁面:

t47_5_app_settings

如你在上面的截圖中所見调限,在此頁面可以看到 Client ID 與 Client Secret 的值。請不要關(guān)閉該窗口误澳,因?yàn)榻酉聛淼牟襟E中會(huì)用到它們耻矮。你可以使用窗口左側(cè)的菜單選項(xiàng),隨意探索應(yīng)用的設(shè)置忆谓。

此處裆装,除了得到 Client 密匙(Client ID 與 Client Secret 的值),我們還要完成的另一項(xiàng)重要任務(wù)倡缠,是在“合法重定向 URLs(Authorized Redirect URLs)”一欄填入合適的值哨免。當(dāng)客戶端 App 試圖刷新現(xiàn)有的訪問令牌,用戶無需通過 Web 瀏覽器重新登錄昙沦,使用合法重定向 URL 即可琢唾。OAuth 流會(huì)自動(dòng)使用該 URL 將 App 重定向。在正常的登錄過程中盾饮,客戶端 App 與 LinkedIn 服務(wù)器會(huì)交換該 URL采桃,同時(shí)取得授權(quán)碼與訪問令牌∏鹚穑總之普办,該值不能為空,稍后會(huì)用來與服務(wù)器進(jìn)行交換徘钥,因此必須定義它衔蹲。

重定向 URL 不需要是真實(shí)存在的 URL,可以是任何以 “https://” 開頭的值呈础。在此,筆者將其賦值如下。你可以將其改為任何你希望的值批幌。

https://com.appcoda.linkedin.oauth/oauth

如果你使用了一個(gè)不同的 URL, 千萬記得對(duì)后面出現(xiàn)的代碼進(jìn)行相應(yīng)的修改。

在“OAuth 2.0”一節(jié)寫入合法重定向 URL 后畴博,必須點(diǎn)擊添加按鈕,保證將其加入 App 中蓝仲。

t47_6_authorized_redirect_url

此外俱病,記得點(diǎn)擊屏幕底部的更新按鈕。

至于有關(guān)訪問權(quán)限的選項(xiàng)袱结,保留基本選項(xiàng)即可亮隙,因?yàn)槠渫耆珴M足本教程的需求。當(dāng)然垢夹,你也可以選擇更多權(quán)限溢吻,或在演示 App 準(zhǔn)備就緒之后再做修改。請注意果元,如果 App 請求的最初權(quán)限遭到改動(dòng)促王,用戶必須重新登錄以認(rèn)可這些改動(dòng)。

開始授權(quán)過程

現(xiàn)在而晒,打開 Xcode 中的啟動(dòng)項(xiàng)目蝇狼,我們即將開始實(shí)現(xiàn),并最終完成 OAuth 2.0 流倡怎。不過迅耘,在開始之前,請選擇項(xiàng)目導(dǎo)航欄(Project Navigator)中的 WebViewController.swift 文件监署,打開它颤专。在該類的頭部,你會(huì)看到兩個(gè)名為 linkedInKey 與 linkedInSecret 的變量钠乏。你需要將之前從 LinkedIn 開發(fā)者網(wǎng)站得到的 Client ID 與 Client Secret 值分別賦值給這兩個(gè)變量(簡單的復(fù)制栖秕、黏貼即可)。

t47_7_assigned_keys

本步的主要目的晓避,是準(zhǔn)備好用來獲取授權(quán)碼的請求簇捍,并通過一個(gè) Web 視圖加載它。界面生成器(Interface Builder)中的 WebViewController 已經(jīng)包含了一個(gè) Web 視圖够滑,因此我們將以 WebViewController 類為基礎(chǔ)構(gòu)建視圖垦写。用于獲取授權(quán)碼的請求必須包含以下參數(shù):

  • response_type:取值為恒定的標(biāo)準(zhǔn)值:code吕世。
  • client_id:取值為來自 LinkedIn 開發(fā)者網(wǎng)站的 Client ID彰触,之后會(huì)賦值給項(xiàng)目中的 linkedInKey 屬性。
  • redirect_uri:取值為在前一節(jié)指定的合法重定向 URL命辖。請確保在后面的代碼段中填入相應(yīng)的值况毅。
  • state:取值為唯一的字符串分蓖,用于預(yù)防跨站請求偽造(CSRF)。
  • scope:取值為 App 請求的訪問權(quán)限列表尔许,以 URL 形式編碼么鹤。

下面,介紹代碼的具體實(shí)現(xiàn)味廊。首先蒸甜,在 WebViewController 類下創(chuàng)建一個(gè)名為 startAuthorization() 的新函數(shù)。該函數(shù)的第一個(gè)任務(wù)是根據(jù)上文的描述為請求參數(shù)賦值余佛。

func startAuthorization() {
    // Specify the response type which should always be "code".
    let responseType = "code"
 
    // Set the redirect URL. Adding the percent escape characthers is necessary.
    let redirectURL = "https://com.appcoda.linkedin.oauth/oauth".stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.alphanumericCharacterSet())!
 
    // Create a random string based on the time interval (it will be in the form linkedin12345679).
    let state = "linkedin\(Int(NSDate().timeIntervalSince1970))"
 
    // Set preferred scope.
    let scope = "r_basicprofile"
 
 
}

請注意:不是簡單地將合法重定向 URL 賦值給 redirectURL 變量柠新。我們需要將 URL 中的特殊符號(hào)通過 URL 編碼替換為百分比編碼字符。因此辉巡,下面的鏈接:

https://com.appcoda.linkedin.oauth/oauth

將會(huì)轉(zhuǎn)換為:

https%3A%2F%2Fcom.appcoda.linkedin.oauth%2oauth

(See more about URL-encoding here).

點(diǎn)此了解 URL 編碼的更多信息恨憎。)

其次,state 變量必須包含一個(gè)唯一且莫測的字符串郊楣。在上面的代碼中憔恳,我們將“l(fā)inkedin”字符串與當(dāng)前時(shí)間戳(自1970年以來的時(shí)間間隔)的整數(shù)部分進(jìn)行聯(lián)結(jié),以此確保字符串的唯一性净蚤。你也可以生成隨機(jī)字符钥组,再將其附加到 state 字符串上。

最后塞栅,將 scope 賦值為“r_basicprofile”者铜,后者與之前在 LinkedIn 開發(fā)者網(wǎng)站設(shè)定的 App 訪問權(quán)限相匹配。當(dāng)你設(shè)置訪問權(quán)限時(shí)放椰,請確保與官方文檔中的規(guī)定一致作烟。

Our next step is to compose the authorization URL. Note that the https://www.linkedin.com/uas/oauth2/authorization URL must be used for the request, which is already assigned to the authorizationEndPoint property.

下一步,創(chuàng)建授權(quán) URL砾医。請注意拿撩,URL https://www.linkedin.com/uas/oauth2/authorization 必須用于該請求,而該 URL 已經(jīng)賦做 authorizationEndPoint 屬性的值如蚜。

回到代碼:

func startAuthorization() {
    ...

    // Create the authorization URL string.
    var authorizationURL = "\(authorizationEndPoint)?"
    authorizationURL += "response_type=\(responseType)&"
    authorizationURL += "client_id=\(linkedInKey)&"
    authorizationURL += "redirect_uri=\(redirectURL)&"
    authorizationURL += "state=\(state)&"
    authorizationURL += "scope=\(scope)"

    print(authorizationURL)
}

此處压恒,筆者添加了打印命令,是為了讓讀者親眼看到該請求最終是如何形成的错邦。

最終探赫,我們需要在 Web 視圖中加載該請求。請記住撬呢,只有前文所述的請求配置得當(dāng)伦吠,用戶才能通過 Web 視圖成功登錄。否則,LinkedIn 將返回錯(cuò)誤消息毛仪,導(dǎo)致無法進(jìn)行下一步操作搁嗓。

因此,請確保正確拷貝了 Client Key箱靴、Client Secret腺逛,以及統(tǒng)一的合法重定向 URL。

在 Web 視圖中加載該請求只需短短幾行代碼:

func startAuthorization() {
    ...

    // Create a URL request and load it in the web view.
    let request = NSURLRequest(URL: NSURL(string: authorizationURL)!)
    webView.loadRequest(request)
}

在結(jié)束本節(jié)之前衡怀,我們必須調(diào)用上面的函數(shù)棍矛。可以通過 viewDidLoad(_: ) 函數(shù)進(jìn)行調(diào)用:

override func viewDidLoad() {
    ...

    startAuthorization()
}

此時(shí)抛杨,你終于可以運(yùn)行 App茄靠,測試其是否成功了。如果你根據(jù)筆者的指導(dǎo)配置正確蝶桶,應(yīng)該可以看到以下頁面:

t47_2_user_sign_in

不過慨绳,先別急著登錄 LinkedIn 賬號(hào),本節(jié)還有一部分工作未完成真竖。然而脐雪,如果你看到了登錄表格,說明你已經(jīng)成功發(fā)送了獲取授權(quán)碼的請求恢共。登錄之后战秋,LinkedIn 會(huì)向?yàn)g覽器(在本例中,也即我們的 Web 視圖)返回一個(gè)授權(quán)碼讨韭。

除此之外脂信,還會(huì)在控制臺(tái)打印出 authorizationURL(授權(quán) URL)字符串:

t47_8_authorization_request

Getting an Authorization Code

獲取授權(quán)碼

授權(quán)碼請求函數(shù)準(zhǔn)備就緒,且在 Web 視圖中成功加載之后透硝,我們可以繼續(xù)執(zhí)行 webView(:shouldStartLoadWithRequest:navigationType) 委托函數(shù)狰闪。在此函數(shù)中,我們會(huì)捕獲來自 LinkedIn 的響應(yīng)濒生,并從中抽取出渴望已久的授權(quán)碼埋泵。

包含授權(quán)碼的響應(yīng)如下所示:

http://com.appcoda.linkedin.oauth/oauth?<strong>code=AQSetQ252oOM237XeXvUreC1tgnjR-VC1djehRxEUbyZ-sS11vYe0r0JyRbe9PGois7Xf42g91cnUOE5mAEKU1jpjogEUNynRswyjg2I3JG_pffOClk</strong>&state=linkedin1450703646

因此,我們需要將該字符串分為多個(gè)部分罪治,隔離出“code”的值丽声。不過,有兩點(diǎn)注意:其一觉义,我們必須確保委托函數(shù)中的 URL 是我們感興趣的雁社。其二,必須確保授權(quán)碼的確存在于該 LinkedIn 響應(yīng)中晒骇。代碼的實(shí)現(xiàn)如下:

func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
    let url = request.URL!
    print(url)

    if url.host == "com.appcoda.linkedin.oauth" {
        if url.absoluteString.rangeOfString("code") != nil {

        }
    }

    return true
}

首先霉撵,通過請求參數(shù)獲得該 URL滋饲。接著,檢查 URL 的主機(jī)屬性值以確保這是我們需要的 URL(也即在 LinkedIn 開發(fā)者網(wǎng)站設(shè)定的重定向 URL)喊巍。如果是,請求字符串中 “code” 所在的范圍箍鼓,以驗(yàn)證該 URL 是否真的包含授權(quán)碼崭参。如果返回不為空,則證明授權(quán)碼的確存在款咖。

將 URL 字符串分為多個(gè)部分并不難何暮。為了簡化步驟,筆者將該任務(wù)分為兩步:

func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
    let url = request.URL!
    print(url)

    if url.host == "com.appcoda.linkedin.oauth" {
        if url.absoluteString.rangeOfString("code") != nil {
            // Extract the authorization code.
            let urlParts = url.absoluteString.componentsSeparatedByString("?")
            let code = urlParts[1].componentsSeparatedByString("=")[1]

            requestForAccessToken(code)
        }
    }

    return true
}

除了上面新出現(xiàn)的兩行代碼铐殃,你也肯定注意到了對(duì) requestForAccessToken(_: ) 函數(shù)的調(diào)用海洼。這是我們將在下一部分實(shí)現(xiàn)的自定義函數(shù)。在此函數(shù)中富腊,我們會(huì)用此處獲得的授權(quán)碼讀取訪問令牌坏逢。

如你所見,只差一步赘被,我們就能使用 OAuth 2.0 流獲取訪問令牌了是整。此處扼要重述一下之前的步驟:首先,我們成功創(chuàng)建了獲取授權(quán)碼的請求民假。接著浮入,作為授權(quán)過程的一部分,用戶通過該請求連接他們的 LinkedIn 賬號(hào)羊异。最后事秀,得到并抽取出授權(quán)碼。

如果你想對(duì)目前的 App 進(jìn)行測試野舶,只需注釋掉 requestForAccessToken(_: ) 函數(shù)的調(diào)用部分即可易迹。你大可以在任意位置添加打印命令,從而深刻理解每個(gè)步驟的作用平道。

Requesting for the Access Token

信息結(jié)構(gòu)赴蝇,創(chuàng)作我們的信息就是如此,我已經(jīng)很是在此基礎(chǔ)上我們創(chuàng)作就是token整個(gè)結(jié)構(gòu)就是做這些事情的

請求訪問令牌

此前巢掺,我們與 LinkedIn 服務(wù)器的所有交流都是通過 Web 視圖進(jìn)行的句伶。從現(xiàn)在起,我們將僅通過簡便的 RESTful 請求(也即 POST 與 GET 請求)與服務(wù)器交流陆淀。更具體地說考余,我們會(huì)發(fā)起一個(gè) POST 請求來獲取訪問令牌,之后再用 GET 請求獲得用戶主頁的 URL轧苫。

話雖如此楚堤,現(xiàn)在要先創(chuàng)建在上一部分末尾提過的新的自定義函數(shù):requestForAccessToken()疫蔓。在此函數(shù)內(nèi)部,我們將執(zhí)行三個(gè)任務(wù):

  1. 準(zhǔn)備好 POST 請求的參數(shù)身冬。
  2. 初始化并配置一個(gè)可變的 URL 請求對(duì)象(NSMutableURLRequest)衅胀。
  3. 實(shí)例化一個(gè) NSURLSession 對(duì)象,繼而執(zhí)行一個(gè)數(shù)據(jù)任務(wù)請求酥筝。在得到恰當(dāng)?shù)捻憫?yīng)之后滚躯,我們將訪問令牌存儲(chǔ)在用戶默認(rèn)的字典中。

準(zhǔn)備 POST 請求參數(shù)

與獲取授權(quán)碼的請求準(zhǔn)備相似嘿歌,為了獲得訪問令牌掸掏,我們需要在請求中 POST 特定的參數(shù)與其對(duì)應(yīng)的值。這些參數(shù)包括:

  • grant_type: It’s a standard value that should always be: authorization_code.
  • code: The authorization code acquired in the previous part.
  • redirect_uri: It’s the authorized redirection URL we’ve talked about many times earlier.
  • client_id: The Client Key value.
  • client_secret: The Client Secret Value.
  • grant_type:取值為恒定的標(biāo)準(zhǔn)值:authorization_code宙帝。
  • code:取值為在上一部分獲得的授權(quán)碼丧凤。
  • redirect_uri:取值為前面多次提到的合法重定向 URL。
  • client_id:取值為 Client Key 的值步脓。
  • client_secret:取值為 Client Secret 的值愿待。

在上一部分得到的授權(quán)碼將在新函數(shù)中用作參數(shù)。首先靴患,讓我們?yōu)閰?shù)“grant_type”與“redirect_uri”賦值:

   func requestForAccessToken(authorizationCode:     String) {
    let grantType = "authorization_code"

    let redirectURL = "https://com.appcoda.linkedin.oauth/oauth".stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.alphanumericCharacterSet())!
}

其他所有參數(shù)的值 App 都已經(jīng)知道了呼盆,因此,我們可以將其整合為一個(gè)字符串:

func requestForAccessToken(authorizationCode: String) {
    ...

    // Set the POST parameters.
    var postParams = "grant_type=\(grantType)&"
    postParams += "code=\(authorizationCode)&"
    postParams += "redirect_uri=\(redirectURL)&"
    postParams += "client_id=\(linkedInKey)&"
    postParams += "client_secret=\(linkedInSecret)"
}

如果你曾用 NSMutableURLRequest 類創(chuàng)建過 POST 請求蚁廓,那你一定知道 POST 參數(shù)無法以字符串的形式傳送访圃。它們必須轉(zhuǎn)化為 NSData 對(duì)象,再賦值給請求的 HTTPBody 部分(后文會(huì)有解釋)相嵌。因此腿时,讓我們按照要求轉(zhuǎn)化 postParams:

func requestForAccessToken(authorizationCode: String) {
    ...

    // Convert the POST parameters into a NSData object.
    let postData = postParams.dataUsingEncoding(NSUTF8StringEncoding)
}

準(zhǔn)備請求對(duì)象

準(zhǔn)備好 POST 參數(shù)之后,我們可以繼續(xù)初始化并配置 NSMutableURLRequest 對(duì)象饭宾。初始化時(shí)會(huì)用到獲取訪問令牌所需的 URL(https://www.linkedin.com/uas/oauth2/accessToken) 批糟,而后者已經(jīng)賦值給 accessTokenEndPoint 屬性。

func requestForAccessToken(authorizationCode: String) {
    ...    

    // Initialize a mutable URL request object using the access token endpoint URL string.
    let request = NSMutableURLRequest(URL: NSURL(string: accessTokenEndPoint)!)
}

Next, it’s time to “say” to the request object what kind of request we want to make, as well as to pass it the POST parameters:

接下來看铆,告訴請求對(duì)象我們想要?jiǎng)?chuàng)建的請求類型徽鼎,并傳入 POST 參數(shù):

func requestForAccessToken(authorizationCode: String) {
    ...

    // Indicate that we're about to make a POST request.
    request.HTTPMethod = "POST"

    // Set the HTTP body using the postData object created above.
    request.HTTPBody = postData
}

根據(jù) LinkedIn 文檔,請求的 Content-Type 部分需要設(shè)置為 application/x-www-form-urlencoded:

func requestForAccessToken(authorizationCode: String) {
    ...

    // Add the required HTTP header field.
    request.addValue("application/x-www-form-urlencoded;", forHTTPHeaderField: "Content-Type")
}

終于弹惦,請求對(duì)象的必要配置完成了》裼伲現(xiàn)在可以使用它了。

Performing the request

執(zhí)行請求

我們將把用于獲取訪問令牌的請求實(shí)現(xiàn)為 NSURLSession 類的對(duì)象棠隐。通過該對(duì)象石抡,創(chuàng)建一個(gè)數(shù)據(jù)任務(wù)請求,并在完成處理程序(completion handler)內(nèi)部處理 LinkedIn 服務(wù)器的響應(yīng):

func requestForAccessToken(authorizationCode: String) {
    ...

    // Initialize a NSURLSession object.
    let session = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())

    // Make the request.
    let task: NSURLSessionDataTask = session.dataTaskWithRequest(request) { (data, response, error) -> Void in

    }

    task.resume()
}

如果請求成功助泽,LinkedIn 服務(wù)器將會(huì)返回包含訪問令牌的 JSON 數(shù)據(jù)啰扛。因此嚎京,我們的任務(wù)是得到該 JSON 數(shù)據(jù),將之轉(zhuǎn)化為字典對(duì)象隐解,然后抽取出訪問令牌鞍帝。當(dāng)然,這一切只有在返回的 HTTP 狀態(tài)碼是 200煞茫,也即請求成功時(shí)帕涌,才能進(jìn)行。

func requestForAccessToken(authorizationCode: String) {    
    ...

    // Make the request.
    let task: NSURLSessionDataTask = session.dataTaskWithRequest(request) { (data, response, error) -> Void in
        // Get the HTTP status code of the request.
        let statusCode = (response as! NSHTTPURLResponse).statusCode

        if statusCode == 200 {
            // Convert the received JSON data into a dictionary.
            do {
                let dataDictionary = try NSJSONSerialization.JSONObjectWithData(data!, options: NSJSONReadingOptions.MutableContainers)

                let accessToken = dataDictionary["access_token"] as! String
            }
            catch {
                print("Could not convert JSON data into a dictionary.")
            }
        }
    }

    task.resume()
}
我很好奇

此類操作可能拋出異常溜嗜,將json 數(shù)據(jù)的倒換就是加載我們呢自身的SDK,接入信息就是從來不會(huì)有很多的考很多事情就是自己做起來用戶就是會(huì)考
請注意架谎,轉(zhuǎn)化發(fā)生在一個(gè) do-catch 語句內(nèi)部炸宵,因?yàn)閺?Swift 2.0 開始,此類操作可能拋出異常(并不存在錯(cuò)誤參數(shù))谷扣。在我們的演示 App 中土全,無需特別考慮出現(xiàn)異常的情況,因此可以向控制器發(fā)送一條信息会涎,表示轉(zhuǎn)化失敗裹匙。如果一切運(yùn)行順利,我們就將 JSON 數(shù)據(jù)(閉包中的數(shù)據(jù)參數(shù))轉(zhuǎn)化為字典(dataDictionary 對(duì)象)末秃,之后就可以直接讀取訪問令牌概页。

接下來做什么呢?將字典保存在用戶默認(rèn)的字典中练慕,然后移除視圖控制器:

func requestForAccessToken(authorizationCode: String) {    
    ...

    // Make the request.
    let task: NSURLSessionDataTask = session.dataTaskWithRequest(request) { (data, response, error) -> Void in
        // Get the HTTP status code of the request.
        let statusCode = (response as! NSHTTPURLResponse).statusCode

        if statusCode == 200 {
            // Convert the received JSON data into a dictionary.
            do {
                ...

                NSUserDefaults.standardUserDefaults().setObject(accessToken, forKey: "LIAccessToken")
                NSUserDefaults.standardUserDefaults().synchronize()

                dispatch_async(dispatch_get_main_queue(), { () -> Void in
                    self.dismissViewControllerAnimated(true, completion: nil)
                })                
            }
            catch {
                print("Could not convert JSON data into a dictionary.")
            }
        }
    }

    task.resume()
}

注意惰匙,視圖控制器會(huì)在主線程中移除。請永遠(yuǎn)牢記铃将,與 UI 相關(guān)的改動(dòng)必須發(fā)生在 App 的主線程中项鬼,而不是背景線程中。而上面顯示的完成處理程序(閉包)則永遠(yuǎn)在背景線程中執(zhí)行劲阎。

Our ultimate goal has been finally achieved! We managed to acquire the access token that will “unlock” several API features.

我們的終極目標(biāo)終于完成啦绘盟!得到訪問令牌之后,可以“解鎖”許多 API 功能悯仙。

獲得用戶主頁的 URL

接下來龄毡,我們將演示如何用訪問令牌獲得用戶主頁的 URL,并在 Safari 瀏覽器中打開它锡垄。然而稚虎,在此之前,讓我們先討論一點(diǎn)別的問題偎捎。當(dāng)你啟動(dòng) App 時(shí)蠢终,你有兩個(gè)選擇序攘,如下圖所示:

view-controller-signin

默認(rèn)情況下,LinkedIn Sign In (LinkedIn 登錄)按鈕是啟用的寻拂,而 Get my profile URL(獲得我的主頁 URL)按鈕是禁用的程奠。既然現(xiàn)在已經(jīng)得到了訪問令牌,我們需要啟用第二個(gè)按鈕祭钉,同時(shí)禁用第一個(gè)按鈕瞄沙。這要如何完成呢?

一種實(shí)現(xiàn)方式是使用委托模式慌核,通過一個(gè)委托函數(shù)通知 ViewController 類:訪問令牌已經(jīng)得到距境,請啟用第二個(gè)按鈕。另一種方式是從 WebViewController 類中 Post 一個(gè)自定義通知(NSNotification 對(duì)象)垮卓,在 ViewController 類中監(jiān)聽該通知垫桂。其實(shí),兩種方法都可以實(shí)現(xiàn)粟按。但是诬滩,還有一種更為簡單的方法三:在 ViewController 出現(xiàn)時(shí),檢查訪問令牌是否存在于用戶默認(rèn)的字典中灭将。如果存在疼鸟,就禁用登錄按鈕,啟用第二個(gè)按鈕庙曙。否則空镜,就保持不變。

此處捌朴,我們會(huì)在 ViewController 類中實(shí)現(xiàn)一個(gè)新的小函數(shù)來進(jìn)行檢查姑裂。請注意,我們還設(shè)置了第三個(gè)默認(rèn)隱藏的按鈕(也即 btnOpenProfile IBOutlet 屬性)男旗。當(dāng)?shù)玫接脩糁黜摰?URL 時(shí)舶斧,該按鈕就會(huì)變?yōu)榭梢姡⒁源?URL 字符串作為其標(biāo)題(后文會(huì)有示例)察皇。

Now, let’s define this new function:

現(xiàn)在茴厉,先來定義這個(gè)新函數(shù):

func checkForExistingAccessToken() {
    if NSUserDefaults.standardUserDefaults().objectForKey("LIAccessToken") != nil {
        btnSignIn.enabled = false
        btnGetProfileInfo.enabled = true
    }
}

我們會(huì)在 viewWillAppear(_: ) 方法中調(diào)用該函數(shù):

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)

    checkForExistingAccessToken()
}

之后,App 就能合理地啟用或禁用 ViewController 中的兩個(gè)按鈕了什荣。

接下來矾缓,讓我們聚焦于 getProfileInfo(_: ) IBAction 方法。此方法會(huì)在 Get my profile URL(獲得我的主頁 URL)按鈕被點(diǎn)擊時(shí)執(zhí)行稻爬。屆時(shí)嗜闻,我們可以向 LinkedIn 服務(wù)器發(fā)送 GET 請求,使用訪問令牌獲得用戶主頁的 URL桅锄。此處采用的方法與在上一部分創(chuàng)建獲取訪問令牌的請求時(shí)所用的方法非常相似琉雳。

現(xiàn)在样眠,讓我們從指定請求的 URL 字符串開始吧。請注意翠肘,當(dāng)你不是很確定自己需要什么 URL檐束,或者指定哪些參數(shù)時(shí),大可以尋求官方文檔的幫助束倍。

@IBAction func getProfileInfo(sender: AnyObject) {
    if let accessToken = NSUserDefaults.standardUserDefaults().objectForKey("LIAccessToken") {
        // Specify the URL string that we'll get the profile info from.
        let targetURLString = "https://api.linkedin.com/v1/people/~:(public-profile-url)?format=json"
    }
}

此處被丧,作為額外措施,我們再一次檢查了訪問令牌是否存在绪妹。通過 if-let 語句甥桂,如果訪問令牌存在,我們便將其賦值給 accessToken 常量邮旷。而且黄选,上面的 URL 會(huì)返給我們用戶主頁的 URL。不要忘記廊移,在執(zhí)行這類請求之前糕簿,必須獲得適當(dāng)?shù)臋?quán)限探入。在本演示案例中狡孔,我們已經(jīng)獲得了訪問用戶基本介紹信息的權(quán)限。

接下來蜂嗽,創(chuàng)建一個(gè)新的 NSMutableURLRequest 對(duì)象苗膝,并以“GET”方法作為理想的 HTTP 方法。此外植旧,還需指定一個(gè) HTTP 頭字段辱揭,此處將用訪問令牌為其賦值。

@IBAction func getProfileInfo(sender: AnyObject) {
    if let accessToken = NSUserDefaults.standardUserDefaults().objectForKey("LIAccessToken") {
        ...

        // Initialize a mutable URL request object.
        let request = NSMutableURLRequest(URL: NSURL(string: targetURLString)!)

        // Indicate that this is a GET request.
        request.HTTPMethod = "GET"

        // Add the access token as an HTTP header field.
        request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")        
    }
}

最后病附,再一次地问窃,使用 NSURLSession 與 NSURLSessionDataTask 類創(chuàng)建該請求:

@IBAction func getProfileInfo(sender: AnyObject) {
    if let accessToken = NSUserDefaults.standardUserDefaults().objectForKey("LIAccessToken") {
        ...

        // Initialize a NSURLSession object.
        let session = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())

        // Make the request.
        let task: NSURLSessionDataTask = session.dataTaskWithRequest(request) { (data, response, error) -> Void in

        }

        task.resume()
    }
}

如果請求成功(也即 HTTP 狀態(tài)碼為 200),閉包中的數(shù)據(jù)參數(shù)將會(huì)包含服務(wù)器返回的 JSON 數(shù)據(jù)完沪。與之前一樣域庇,我們必須將此 JSON 數(shù)據(jù)轉(zhuǎn)化為字典,才能最終抽取出用戶主頁的 URL 字符串覆积。

@IBAction func getProfileInfo(sender: AnyObject) {
    if let accessToken = NSUserDefaults.standardUserDefaults().objectForKey("LIAccessToken") {
        ...

        // Make the request.
        let task: NSURLSessionDataTask = session.dataTaskWithRequest(request) { (data, response, error) -> Void in
            // Get the HTTP status code of the request.
            let statusCode = (response as! NSHTTPURLResponse).statusCode

            if statusCode == 200 {
                // Convert the received JSON data into a dictionary.
                do {
                    let dataDictionary = try NSJSONSerialization.JSONObjectWithData(data!, options: NSJSONReadingOptions.MutableContainers)

                    let profileURLString = dataDictionary["publicProfileUrl"] as! String
                }
                catch {
                    print("Could not convert JSON data into a dictionary.")
                }
            }
        }

        task.resume()
    }
}

現(xiàn)在听皿,回到之前提到過的一點(diǎn)內(nèi)容:profileURLString 的值將會(huì)賦給 btnOpenProfile 按鈕的標(biāo)題,該按鈕也會(huì)變成可見宽档。還記得不尉姨?我們現(xiàn)在的工作都是在背景線程中進(jìn)行的,因此吗冤,我們還需將其加入主線程中:

@IBAction func getProfileInfo(sender: AnyObject) {
    if let accessToken = NSUserDefaults.standardUserDefaults().objectForKey("LIAccessToken") {
        ...

        // Make the request.
        let task: NSURLSessionDataTask = session.dataTaskWithRequest(request) { (data, response, error) -> Void in
            // Get the HTTP status code of the request.
            let statusCode = (response as! NSHTTPURLResponse).statusCode

            if statusCode == 200 {
                // Convert the received JSON data into a dictionary.
                do {
                    ...

                    dispatch_async(dispatch_get_main_queue(), { () -> Void in
                        self.btnOpenProfile.setTitle(profileURLString, forState: UIControlState.Normal)
                        self.btnOpenProfile.hidden = false

                    })
                }
                catch {
                    print("Could not convert JSON data into a dictionary.")
                }
            }
        }

        task.resume()
    }
}

現(xiàn)在又厉,運(yùn)行 App九府,如果你成功得到了訪問令牌,在點(diǎn)擊 Get my profile URL(獲得我的主頁 URL)按鈕后不久馋没,你就能看到自己主頁的 URL 顯示在第三個(gè)按鈕的位置昔逗。

t47_9_get_profile_url

在 Safari 瀏覽器查看主頁

現(xiàn)在,通過使用訪問令牌與 LinkedIn API篷朵,我們得到了用戶主頁的 URL勾怒。接下來就該驗(yàn)證其是否正確了。既然已將此 URL 設(shè)置為一個(gè)按鈕的標(biāo)題声旺,最快的驗(yàn)證方法莫過于打開它了笔链。具體的實(shí)現(xiàn)方法箱單簡單,因此筆者也不必要多言:

@IBAction func openProfileInSafari(sender: AnyObject) {
    let profileURL = NSURL(string: btnOpenProfile.titleForState(UIControlState.Normal)!)
    UIApplication.sharedApplication().openURL(profileURL!)
}

The last line above will trigger the appearance of Safari, which will load and display the profile webpage.

上面最后一行代碼會(huì)觸發(fā) Safari 瀏覽器腮猖,后者會(huì)加載并展示用戶的主頁鉴扫。

t47_10_open_profile

你可能已經(jīng)發(fā)現(xiàn),教程已經(jīng)步入尾聲澈缺,卻仍然沒有提及廢除或刷新訪問令牌的內(nèi)容坪创。其實(shí)原因如下:關(guān)于廢除訪問令牌,LinkedIn 并未提供任何相關(guān)的 API姐赡。因此莱预,如果你需要停止 App 發(fā)送合法請求,最好的做法應(yīng)該是從存儲(chǔ)機(jī)制(數(shù)據(jù)庫项滑,用戶默認(rèn)設(shè)置等)中刪除之依沮。除此之外,一個(gè)訪問令牌的有效期大約為60天(在筆者撰寫本文之時(shí)枪狂,官網(wǎng)文檔是如此規(guī)定的)危喉。LinkedIn 建議,在此時(shí)間范圍到期之前州疾,刷新訪問令牌辜限。刷新的操作非常簡單,你只需要從頭進(jìn)行驗(yàn)證與授權(quán)過程即可严蓖。刷新時(shí)薄嫡,如果訪問令牌有效,用戶便無需再次輸入登錄信息谈飒,一切都會(huì)在后臺(tái)進(jìn)行岂座,訪問令牌會(huì)自動(dòng)刷新,延遲有效期60天杭措。然而费什,對(duì)于大多數(shù) Web 應(yīng)用,存在一個(gè)常見情況:后臺(tái)刷新的一個(gè)基本前提,是用戶已經(jīng)登錄了他們的 LinkedIn 賬號(hào)鸳址,而對(duì)于 App 中的內(nèi)部 Web 視圖瘩蚪,這一條件無法滿足。因此稿黍,在訪問令牌快要到期之前疹瘦,你很可能要讓用戶再走一遍登錄流程。想要了解更多信息巡球,可以點(diǎn)擊此處言沐,查看“刷新訪問令牌”一節(jié)。好了酣栈,說再見的時(shí)候到了险胰。筆者希望本教程對(duì)你有所幫助,并成功向 LinkedIn 發(fā)送經(jīng)過授權(quán)的請求矿筝。

作為參考端铛,你可以從 GitHub 下載本案例完整的 Xcode 項(xiàng)目文件档押。

OneAPM Mobile Insight 以真實(shí)用戶體驗(yàn)為度量標(biāo)準(zhǔn)進(jìn)行 Crash 分析纲辽,監(jiān)控網(wǎng)絡(luò)請求及網(wǎng)絡(luò)錯(cuò)誤绿店,提升用戶留存。訪問 OneAPM 官方網(wǎng)站感受更多應(yīng)用性能優(yōu)化體驗(yàn)铸史,想閱讀更多技術(shù)文章鼻疮,請?jiān)L問 OneAPM 官方技術(shù)博客

本文轉(zhuǎn)自 OneAPM 官方博客

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末沛贪,一起剝皮案震驚了整個(gè)濱河市陋守,隨后出現(xiàn)的幾起案子震贵,更是在濱河造成了極大的恐慌利赋,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,331評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件猩系,死亡現(xiàn)場離奇詭異媚送,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)寇甸,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,372評(píng)論 3 398
  • 文/潘曉璐 我一進(jìn)店門塘偎,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人拿霉,你說我怎么就攤上這事吟秩。” “怎么了绽淘?”我有些...
    開封第一講書人閱讀 167,755評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵涵防,是天一觀的道長。 經(jīng)常有香客問我沪铭,道長壮池,這世上最難降的妖魔是什么偏瓤? 我笑而不...
    開封第一講書人閱讀 59,528評(píng)論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮椰憋,結(jié)果婚禮上厅克,老公的妹妹穿的比我還像新娘。我一直安慰自己橙依,他們只是感情好证舟,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,526評(píng)論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著窗骑,像睡著了一般褪储。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上慧域,一...
    開封第一講書人閱讀 52,166評(píng)論 1 308
  • 那天鲤竹,我揣著相機(jī)與錄音,去河邊找鬼昔榴。 笑死辛藻,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的互订。 我是一名探鬼主播吱肌,決...
    沈念sama閱讀 40,768評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼仰禽!你這毒婦竟也來了氮墨?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,664評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤吐葵,失蹤者是張志新(化名)和其女友劉穎规揪,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體温峭,經(jīng)...
    沈念sama閱讀 46,205評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡猛铅,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,290評(píng)論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了凤藏。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片奸忽。...
    茶點(diǎn)故事閱讀 40,435評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖揖庄,靈堂內(nèi)的尸體忽然破棺而出栗菜,到底是詐尸還是另有隱情,我是刑警寧澤蹄梢,帶...
    沈念sama閱讀 36,126評(píng)論 5 349
  • 正文 年R本政府宣布疙筹,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏腌歉。R本人自食惡果不足惜蛙酪,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,804評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望翘盖。 院中可真熱鬧桂塞,春花似錦、人聲如沸馍驯。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,276評(píng)論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽汰瘫。三九已至狂打,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間混弥,已是汗流浹背趴乡。 一陣腳步聲響...
    開封第一講書人閱讀 33,393評(píng)論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留蝗拿,地道東北人晾捏。 一個(gè)月前我還...
    沈念sama閱讀 48,818評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像哀托,于是被迫代替她去往敵國和親惦辛。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,442評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容