OAuth2.0協議入門(一):OAuth2.0協議的基本概念以及使用授權碼模式(authorization code)實現百度賬號登入

zifangsky發表於2018-08-29

一 OAuth2.0協議的基本概念

(1)OAuth2.0協議

OAuth協議,是一種授權協議,不涉及具體的程式碼,只是表示一種約定的流程和規範。OAuth協議一般用於使用者決定是否把自己在某個服務商上面的資源(比如:使用者基本資料、照片、視訊等)授權給第三方應用訪問。此外,OAuth2.0協議是OAuth協議的升級版,現在已經逐漸成為單點登入(SSO)和使用者授權的標準。

不知道大家有沒有發現,目前主流的網際網路網站除了可以使用“使用者名稱+密碼”模式和“手機號+驗證碼”模式登入外,很多還提供了第三方賬號登入,比如最常見的QQ登入微博登入百度賬號登入GitHub登入。而這些第三方登入方式就是採用了OAuth2.0協議實現。

CSDN的登入介面

(2)為什麼使用OAuth2.0協議?

第一,使用者不再需要註冊大量賬號。在以前,我們每使用一個新的網站或者APP就需要註冊一個賬號,建立一套新的賬戶體系才能使用網站 / APP提供的服務。但是現在我們只需要擁有幾個主流應用的賬號,然後通過他們提供的第三方賬號登入就可以使用一個新的網站/APP了(當然,我們也可以不使用騰訊百度等公司提供的授權服務,開發自己的授權服務端,這方面的內容我將放在下篇文章中介紹)。

第二,用於單點登入。如果某個公司有很多個需要使用者登入才能提供服務的子產品(比如:官網、M網站、APP、微信公眾號、使用同一套賬戶體系的產品1、產品2等等),這種情況下為每個產品都開發一個登入、授權模組顯然是不太優雅,因此比較好的解決方案就是所有需要登入的產品都請求同一個登入授權中心,進行統一登入授權處理。而OAuth2.0協議就可以實現符合上述要求的單點登入功能。

第三,用於分散式系統的許可權控制。因為基於OAuth2.0協議獲得的令牌(Access Token)同時關聯了接入的第三方應用、授權使用者、許可權範圍等資訊。因此,在第三方應用拿著Token請求資源的時候,資源服務應用就可以很容易根據其訪問許可權返回相應的資料。

(3)OAuth2.0協議涉及到的幾個重要角色

  • 授權服務端應用(Authorization Server):服務提供商提供的專門用於處理授權的服務端應用,比如上面介紹的QQ登入、微博登入,當然也可以搭建自己的授權服務端。
  • 資源服務應用(Resource Server):服務提供商存放使用者及其他資源的應用,一般用於介面的形式返回第三方應用請求的資源。它可以與授權服務端屬於同一個應用,也可以分別屬於不同的應用。
  • 使用者(User):使用者在授權服務端登入,授權服務端記錄了使用者的賬戶體系。當然,有的網站會在你通過第三方賬號第一次登入成功後,要求繫結你的手機號並建立暱稱,這就是他們在建立自己的賬戶體系(跟OAuth2.0協議無關,這裡不作展開)了。
  • 接入的第三方應用(Third-party Application):接入認證的第三方應用又被稱為“客戶端”,比如一個普通的網站、APP。

(4)幾種授權模式

  • 授權碼模式(authorization code):這是功能最完整,流程最嚴密的模式。現在主流的使用OAuth2.0協議授權的服務提供商都採用了這種模式,我在下面舉例也將採取這種模式。
  • 簡化模式(implicit):跳過了請求授權碼(Authorization Code)的步驟,直接通過瀏覽器向授權服務端請求令牌(Access Token)。這種模式的特點是所有步驟都在瀏覽器中完成,Token對使用者可見,且請求令牌的時候不需要傳遞client_secret進行客戶端認證。
  • 密碼模式(resource owner password credentials):使用者向第三方客戶端提供自己在授權服務端的使用者名稱和密碼,客戶端通過使用者提供的使用者名稱和密碼向授權服務端請求令牌(Access Token)。

(5)授權碼模式(authorization code)授權的流程

採用Authorization Code獲取Access Token的授權驗證流程又被稱為Web Server Flow,適用於所有有Server端的應用,如Web/Wap站點、有Server端的手機/桌面客戶端應用等。一般來說總體流程包含以下幾個步驟:

  1. 通過client_id請求授權服務端,獲取Authorization Code
  2. 通過Authorization Codeclient_idclient_secret請求授權服務端,在驗證完Authorization Code是否失效以及接入的客戶端資訊是否有效(通過傳遞的client_idclient_secret資訊和服務端已經儲存的客戶端資訊進行匹配)之後,授權服務端生成Access TokenRefresh Token並返回給客戶端。
  3. 客戶端通過得到的Access Token請求資源服務應用,獲取需要的且在申請的Access Token許可權範圍內的資源資訊。

下面,我將通過基於授權碼模式的百度OAuth2.0授權來詳細介紹上面這三個步驟。當然,最後我會給出實際可執行的測試程式碼。

二 使用授權碼模式實現百度賬號登入

(1)在百度開發者中心新建一個應用

申請地址:developer.baidu.com/console#app…

接著需要記錄新建應用的API KeySecret Key

新建應用
新建應用

以及需要在安全設定裡面配置登入的回撥地址:

配置登入的回撥地址
配置登入的回撥地址

注:如果只是在瀏覽器中測試,可以把回撥地址改成https://www.baidu.com,這樣就可以直觀地在瀏覽器中看到重定向的結果了,比如請求https://openapi.baidu.com/oauth/2.0/authorize?client_id=n1pRXWNYFQ1MQLzpDfHyovFb&redirect_uri=https://www.baidu.com&response_type=code&scope=basic&display=popup,返回結果如下:

授權回撥示例

(2)獲取Authorization Code

其獲取方式是通過重定向使用者瀏覽器(或手機/桌面應用中的瀏覽器元件)到http://openapi.baidu.com/oauth/2.0/authorize地址,並帶上以下引數:

  • client_id:必須引數,註冊應用時獲得的API Key
  • response_type:必須引數,此值固定為“code”。
  • redirect_uri:必須引數,授權後要回撥的URI,即接收Authorization Code的URI。
  • scope:非必須引數,以空格分隔的許可權列表,若不傳遞此引數,代表請求使用者的預設許可權。
  • state:非必須引數,用於保持請求和回撥的狀態,授權伺服器在回撥時(重定向使用者瀏覽器到“redirect_uri”時),會在Query Parameter中原樣回傳該引數。OAuth2.0標準協議建議,利用state引數來防止CSRF攻擊。
  • display:非必須引數,登入和授權頁面的展現樣式,預設為“page”,具體引數定義請參考“自定義授權頁面”一節。
  • force_login:非必須引數,如傳遞“force_login=1”,則載入登入頁時強制使用者輸入使用者名稱和口令,不會從cookie中讀取百度使用者的登陸狀態。
  • confirm_login:非必須引數,如傳遞“confirm_login=1”且百度使用者已處於登陸狀態,會提示是否使用已當前登陸使用者對應用授權。
  • login_type:非必須引數,如傳遞“login_type=sms”,授權頁面會預設使用簡訊動態密碼註冊登陸方式。

例如:client_idn1pRXWNYFa4MQLzpDfHyovFb的應用要請求某個使用者的預設許可權和email訪問許可權,並在授權後需跳轉到http://localhost:7080/login,同時希望在彈出視窗中展現使用者登入、授權介面,則應用需要重定向使用者的瀏覽器到如下URL:

openapi.baidu.com/oauth/2.0/a…

響應資料包格式:

此時授權服務會根據應用傳遞引數的不同,為使用者展現不同的授權頁面。如果使用者在此頁面同意授權,授權服務則將重定向使用者瀏覽器到應用所指定的redirect_uri,並附帶上表示授權服務所分配的Authorization Code的code引數,以及state引數(如果請求authorization code時帶了這個引數)。

例如:繼續上面的例子,假設授權服務在使用者同意授權後生成的 Authorization Code 為71c279ccd145a3dff977b38e6a8e34b4,則授權服務將會返回如下響應包以重定向使用者瀏覽器到http://localhost:7080/login地址:

HTTP/1.1 302 Found Location: http://localhost:7080/login?code=71c279ccd145a3dff977b38e6a8e34b4

(3)通過Authorization Code獲取Access Token

通過上面獲得的Authorization Code,接下來便可以用其換取一個Access Token。獲取方式是:應用在其服務端程式中傳送請求(推薦使用POST)到 百度OAuth2.0授權服務的https://openapi.baidu.com/oauth/2.0/token地址,並帶上以下5個必須引數:

  • grant_type:必須引數,此值固定為authorization_code
  • code:必須引數,通過上面第一步所獲得的Authorization Code
  • client_id:必須引數,應用的API Key
  • client_secret:必須引數,應用的Secret Key
  • redirect_uri:必須引數,該值必須與獲取Authorization Code時傳遞的redirect_uri保持一致。

例如:

openapi.baidu.com/oauth/2.0/t…

響應資料包格式:

若引數無誤,伺服器將返回一段JSON文字,包含以下引數:

  • access_token:要獲取的Access Token。
  • expires_in:Access Token的有效期,以秒為單位(30天的有效期)。
  • refresh_token:用於重新整理Access Token 的 Refresh Token,所有應用都會返回該引數(10年的有效期)。
  • scope:Access Token最終的訪問範圍,即使用者實際授予的許可權列表(使用者在授權頁面時,有可能會取消掉某些請求的許可權)。
  • session_key:基於http呼叫Open API時所需要的Session Key,其有效期與Access Token一致。
  • session_secret:基於http呼叫Open API時計算引數簽名用的簽名金鑰。

例如:

{
    "expires_in": 2592000,
    "refresh_token": "22.247946a05a327ia929b74354c3670cb2.315360000.1847863585.321432378-13484254",
    "access_token": "21.e2eb8577t4a68a32y23b61300eda8811.2592000.1536795385.321432378-13484254",
    "session_secret": "e8f9ee40de92862cc35c343n5da2fcfb",
    "session_key": "9mnRIQsyTR+0yfB3liSUjqGvk8F369TRfHJidz9iA0wDg\/KDBKZtGHACpXfULPjeX1YBWkKAtHSG\/OLXYKQHCuO4Zg2JiBwFtA==",
    "scope": "basic"
}
複製程式碼

若請求錯誤,伺服器將返回一段JSON文字,包含以下引數:

  • error:錯誤碼,關於錯誤碼的詳細資訊請參考百度OAuth2.0錯誤響應
  • error_description:錯誤描述資訊,用來幫助理解和解決發生的錯誤。

(4)使用Access Token獲取百度使用者的基本資料

使用上面得到的Access Token獲取百度使用者的基本資料,包括:使用者名稱、性別、是否實名認證、是否驗證手機號等等。

相關的REST API介面可以參考官方文件:developer.baidu.com/wiki/index.…

請求示例(獲取使用者基本資訊)

openapi.baidu.com/rest/2.0/pa…

(5)在普通Java Web專案中實現百度OAuth2.0授權登入

提示:下面只會給出關鍵程式碼邏輯,完整可用程式碼可以參考:gitee.com/zifangsky/B…

首先建立兩個實體類,分別表示請求Access Token的返回資訊以及請求百度使用者基本資料的返回資訊。

AuthorizationResponse.java:

package cn.zifangsky.model;

/**
 * Authorization返回資訊
 *
 * @author zifangsky
 * @date 2018/7/25
 * @since 1.0.0
 */
public class AuthorizationResponse {

    /**
     * 要獲取的Access Token(30天的有效期)
     */
    private String access_token;

    /**
     * 用於重新整理Access Token 的 Refresh Token(10年的有效期)
     */
    private String refresh_token;

    /**
     * Access Token最終的訪問範圍
     */
    private String scope;

    /**
     * Access Token的有效期,以秒為單位(30天的有效期)
     */
    private Long expires_in;

    /**
     * 基於http呼叫Open API時所需要的Session Key,其有效期與Access Token一致
     */
    private String session_key;

    /**
     * 基於http呼叫Open API時計算引數簽名用的簽名金鑰
     */
    private String session_secret;

    /**
     * 錯誤資訊
     */
    private String error;

    /**
     * 錯誤描述
     */
    private String error_description;

    //省略setter和getter

    @Override
    public String toString() {
        return "AuthorizationResponse{" +
                "access_token='" + access_token + '\'' +
                ", refresh_token='" + refresh_token + '\'' +
                ", scope='" + scope + '\'' +
                ", expires_in=" + expires_in +
                ", session_key='" + session_key + '\'' +
                ", session_secret='" + session_secret + '\'' +
                ", error='" + error + '\'' +
                ", error_description='" + error_description + '\'' +
                '}';
    }
}
複製程式碼

BaiduUser.java:

package cn.zifangsky.model;

/**
 * 百度返回的使用者基本資訊
 *
 * @author zifangsky
 * @date 2018/7/25
 * @since 1.0.0
 */
public class BaiduUser {

    /**
     * 百度的userId
     */
    private String userid;

    /**
     * 使用者名稱
     */
    private String username;

    /**
     * 使用者性別,0表示女性,1表示男性
     */
    private Integer sex;

    /**
     * 使用者生日
     */
    private String birthday;

    /**
     * 使用者描述
     */
    private String userdetail;

    /**
     * 是否繫結手機號
     */
    private Integer is_bind_mobile;

    /**
     * 是否已經實名認證
     */
    private Integer is_realname;

    //省略setter和getter

    @Override
    public String toString() {
        return "BaiduUser{" +
                "userid='" + userid + '\'' +
                ", username='" + username + '\'' +
                ", sex=" + sex +
                ", birthday='" + birthday + '\'' +
                ", userdetail='" + userdetail + '\'' +
                ", is_bind_mobile=" + is_bind_mobile +
                ", is_realname=" + is_realname +
                '}';
    }
}
複製程式碼

最後就是最關鍵的使用者登入邏輯了:

package cn.zifangsky.controller;

import cn.zifangsky.common.Constants;
import cn.zifangsky.model.AuthorizationResponse;
import cn.zifangsky.model.BaiduUser;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.text.MessageFormat;

/**
 * 登入
 * @author zifangsky
 * @date 2018/7/9
 * @since 1.0.0
 */
@Controller
public class LoginController {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${baidu.oauth2.client-id}")
    private String clientId;

    @Value("${baidu.oauth2.scope}")
    private String scope;

    @Value("${baidu.oauth2.client-secret}")
    private String clientSecret;

    @Value("${baidu.oauth2.user-authorization-uri}")
    private String authorizationUri;

    @Value("${baidu.oauth2.access-token-uri}")
    private String accessTokenUri;

    @Value("${baidu.oauth2.resource.userInfoUri}")
    private String userInfoUri;

    /**
     * 登入驗證(實際登入呼叫認證伺服器)
     * @author zifangsky
     * @date 2018/7/25 16:42
     * @since 1.0.0
     * @param request HttpServletRequest
     * @return org.springframework.web.servlet.ModelAndView
     */
    @RequestMapping("/login")
    public ModelAndView login(HttpServletRequest request){
        //當前系統登入成功之後的回撥URL
        String redirectUrl = request.getParameter("redirectUrl");
        //當前系統請求認證伺服器成功之後返回的Authorization Code
        String code = request.getParameter("code");

        //最後重定向的URL
        String resultUrl = "redirect:";
        HttpSession session = request.getSession();
        //當前請求路徑
        String currentUrl = request.getRequestURL().toString();

        //code為空,則說明當前請求不是認證伺服器的回撥請求,則重定向URL到百度OAuth2.0登入
        if(StringUtils.isBlank(code)){
            //如果存在回撥URL,則將這個URL新增到session
            if(StringUtils.isNoneBlank(redirectUrl)){
                session.setAttribute("redirectUrl",redirectUrl);
            }

            resultUrl += authorizationUri + MessageFormat.format("?client_id={0}&response_type=code&scope=basic&display=popup&redirect_uri={1}"
            ,clientId,currentUrl);
        }else{
            //1. 通過Authorization Code獲取Access Token
            AuthorizationResponse response = restTemplate.getForObject(accessTokenUri + "?client_id={1}&client_secret={2}&grant_type=authorization_code&code={3}&redirect_uri={4}"
                    ,AuthorizationResponse.class
                    , clientId, clientSecret, code,currentUrl);

            //2. 如果正常返回
            if(response != null && StringUtils.isNoneBlank(response.getAccess_token())){
                System.out.println(response);

                //2.1 將Access Token存到session
                session.setAttribute(Constants.SESSION_ACCESS_TOKEN,response.getAccess_token());

                //2.2 再次查詢使用者基礎資訊,並將使用者ID存到session
                BaiduUser baiduUser = restTemplate.getForObject(userInfoUri + "?access_token={1}"
                        ,BaiduUser.class
                        ,response.getAccess_token());

                if(baiduUser != null &&  StringUtils.isNoneBlank(baiduUser.getUserid())){
                    System.out.println(baiduUser);

                    session.setAttribute(Constants.SESSION_USER_ID,baiduUser.getUserid());
                }
            }

            //3. 從session中獲取回撥URL,並返回
            redirectUrl = (String) session.getAttribute("redirectUrl");
            session.removeAttribute("redirectUrl");
            if(StringUtils.isNoneBlank(redirectUrl)){
                resultUrl += redirectUrl;
            }else{
                resultUrl += "/user/userIndex";
            }
        }

        return new ModelAndView(resultUrl);
    }

}
複製程式碼

上面程式碼裡面的註釋已經很詳細了,這裡我就不多做解釋了,詳細程式碼可以自行參考上面給出的示例原始碼。本篇文章到此結束,我將在下篇文章中介紹如何自己手動實現OAuth2.0授權服務端,敬請期待!

參考:

相關文章