阅读 4112

OAuth2.0协议入门(一):OAuth2.0协议的基本概念以及使用授权码模式(authorization code)实现百度账号登录

一 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授权服务端,敬请期待!

参考: