1. 程式人生 > >美多商場

美多商場

QQ登入,亦即我們所說的第三方登入,是指使用者可以不在本專案中輸入密碼,而直接通過第三方的驗證,成功登入本專案。

1 使用QQ登入的流程

QQ登入流程

qq登入注意事項

使用者掃描登入qq,並不代表就登入了美多商城。

只有在qq賬號與美多賬號繫結之後,使用者掃描登入qq,qq伺服器驗證qq賬號沒問題之後,返給我們一個openid,這時候美多伺服器就可以做一個關聯登入。

建立模型類

建立一個新的應用oauth,用來實現QQ第三方認證登入。總路由字首 oauth/

python ../../manage.py startapp oauth

meiduo/meiduo_mall/utils/models.py

檔案中建立模型類基類,用於增加資料新建時間和更新時間。

from django.db import models

class BaseModel(models.Model):
    """為模型類補充欄位"""
    create_time = models.DateTimeField(auto_now_add=True, verbose_name="建立時間")
    update_time = models.DateTimeField(auto_now=True, verbose_name="更新時間")

    class Meta:
        abstract = True  # 說明是抽象模型類, 用於繼承使用,資料庫遷移時不會建立BaseModel的表

在oauth/models.py中定義QQ身份(openid)與使用者模型類User的關聯關係

from django.db import models
from meiduo_mall.utils.models import BaseModel

class OAuthQQUser(BaseModel):
    """
    QQ登入使用者資料
    """
    user = models.ForeignKey('users.User', on_delete=models.CASCADE, verbose_name='使用者')
    openid = models.CharField(max_length=64, verbose_name='openid', db_index=True)

    class Meta:
        db_table = 'tb_oauth_qq'
        verbose_name = 'QQ登入使用者資料'
        verbose_name_plural = verbose_name

進行資料庫遷移

python manage.py makemigrations
python manage.py migrate

問題:這個qq模型類有資料建立時間和修改時間,那麼之前的使用者模型類呢?也有,如下圖:

2 獲取QQ登入網址

處理第一步:點選qq登入之後,要跳轉到掃描登入介面,而我們現在就需要來獲取一下掃描登入介面的地址。

也就是如下圖第一步:(紫色1)

注意:其實這裡的第1步,是獲取這個qq.com?xxxxxx這個url,也就是qq使用者掃描登入的介面url:

  • 後端介面設計:

請求方式: GET /oauth/qq/authorization/?next=xxx

請求引數: 查詢字串

引數名 型別 是否必須 說明
next str 使用者QQ登入成功後進入美多商城的哪個網址

返回資料: JSON

{
    "login_url": "https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=101474184&redirect_uri=http%3A%2F%2Fwww.meiduo.site%3A8080%2Foauth_callback.html&state=%2F&scope=get_user_info"
}
返回值 型別 是否必須 說明
login_url str qq登入網址
  • 在配置檔案中新增關於QQ登入的應用開發資訊
# QQ登入引數
QQ_CLIENT_ID = '101474184'
QQ_CLIENT_SECRET = 'c6ce949e04e12ecc909ae6a8b09b637c'
QQ_REDIRECT_URI = 'http://www.meiduo.site:8080/oauth_callback.html'
QQ_STATE = '/'
  • 新建oauth/utils.py檔案,建立QQ登入輔助工具類

先來看一下qq登入的介面文件,關於第一步的介紹,獲取認證code:

這裡是獲取Access_token,根據Authorization_code獲取Access_token,但是要先獲取Authorization Code。

client_id如下:

redirect_url如下:

from urllib.parse import urlencode
from django.conf import settings
import logging

logger = logging.getLogger('django')


class OAuthQQ(object):
    """
    QQ認證輔助工具類
    """

    def __init__(self, client_id=None, client_secret=None, redirect_uri=None, state=None):
        self.client_id = client_id or settings.QQ_CLIENT_ID
        self.client_secret = client_secret or settings.QQ_CLIENT_SECRET
        self.redirect_uri = redirect_uri or settings.QQ_REDIRECT_URI
        self.state = state or settings.QQ_STATE  # 用於儲存登入成功後的跳轉頁面路徑

    def get_qq_login_url(self):
        """
        獲取qq登入的網址
        :return: url網址
        """
        params = {
            'response_type': 'code',
            'client_id': self.client_id,
            'redirect_uri': self.redirect_uri,
            'state': self.state,
            'scope': 'get_user_info',
        }
        url = 'https://graph.qq.com/oauth2.0/authorize?' + urlencode(params)
        return url

知識點補充:urllib使用說明

在後端介面中,我們需要向QQ伺服器傳送請求,查詢使用者的QQ資訊,Python提供了標準模組urllib可以幫助我們傳送http請求。

  • urllib.parse.urlencode(query)

    將query字典轉換為url路徑中的查詢字串

  • urllib.parse.parse_qs(qs)

    將qs查詢字串格式資料轉換為python的字典

  • urllib.request.urlopen(url, data=None)

    傳送http請求,如果data為None,傳送GET請求,如果data不為None,傳送POST請求

    返回response響應物件,可以通過read()讀取響應體資料,需要注意讀取出的響應體資料為bytes型別 

問題:第一步為啥是獲取認證code?

第一步不是要生成一個url麼?

其實這個url,就是用來向qq伺服器獲取認證code的。所以沒錯 

  • 在oauth/views.py中實現檢視
#  url(r'^qq/authorization/$', views.QQAuthURLView.as_view()), 
class QQAuthURLView(APIView):
    """
    獲取QQ登入的url
    """
    def get(self, request):
        """
        提供用於qq登入的url
        """
        next = request.query_params.get('next')
        oauth = OAuthQQ(state=next)
        login_url = oauth.get_qq_login_url()
        return Response({'login_url': login_url})

3 QQ登入回撥處理

3.1 測試

qq登入介面已經搞定,接下來我們測試一下:

掃描登入:

登入成功之後,按理說應該到如下介面:

但是咱們的報錯了:

使用者在QQ登入成功後,QQ會將使用者重定向回我們配置的回撥callback網址,在本專案中,我們申請QQ登入開發資質時配置的回撥地址為:

http://www.meiduo.site:8080/oauth_callback.html

但是咱們還沒有oauth_callback.html介面

我們在front_end_pc目錄中新建oauth_callback.html檔案,用於接收QQ登入成功的使用者回撥請求。在該頁面中,提供了用於使用者首次使用QQ登入時需要繫結使用者身份的表單資訊。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <title>美多商城-繫結使用者</title>
    <link rel="stylesheet" type="text/css" href="css/reset.css">
    <link rel="stylesheet" type="text/css" href="css/main.css">
    <script type="text/javascript" src="js/host.js"></script>
    <script type="text/javascript" src="js/vue-2.5.16.js"></script>
    <script type="text/javascript" src="js/axios-0.18.0.min.js"></script>
</head>
<body>
    <div id="app">
        <div v-if="is_show_waiting" class="pass_change_finish">請稍後...</div>
        <div v-else>
            <div class="register_con">
                <div class="l_con fl">
                    <a class="reg_logo"><img src="images/logo.png"></a>
                    <div class="reg_slogan">商品美 · 種類多 · 歡迎光臨</div>
                    <div class="reg_banner"></div>
                </div>

                <div class="r_con fr">
                    <div class="reg_title clearfix">
                        <h1>繫結使用者</h1>
                    </div>
                    <div class="reg_form clearfix" id="app" v-cloak>
                        <form id="reg_form" v-on:submit.prevent="on_submit">
                        <ul>
                            <li>
                                <label>手機號:</label>
                                <input type="text" v-model="mobile" v-on:blur="check_phone" name="phone" id="phone">
                                <span v-show="error_phone" class="error_tip">{{ error_phone_message }}</span>
                            </li>
                            <li>
                                <label>密碼:</label>
                                <input type="password" v-model="password" v-on:blur="check_pwd" name="pwd" id="pwd">
                                <span v-show="error_password" class="error_tip">密碼最少8位,最長20位</span>
                            </li>
                            <li>
                                <label>圖形驗證碼:</label>
                                <input type="text" v-model="image_code" v-on:blur="check_image_code" name="pic_code" id="pic_code" class="msg_input">
                                <img v-bind:src="image_code_url" v-on:click="generate_image_code" alt="圖形驗證碼" class="pic_code">
                                <span v-show="error_image_code" class="error_tip">{{ error_image_code_message }}</span>
                            </li>
                            <li>
                                <label>簡訊驗證碼:</label>
                                <input type="text" v-model="sms_code" v-on:blur="check_sms_code" name="msg_code" id="msg_code" class="msg_input">
                                <a v-on:click="send_sms_code" class="get_msg_code">{{ sms_code_tip }}</a>
                                <span v-show="error_sms_code" class="error_tip">{{ error_sms_code_message }}</span>
                            </li>
                            <li class="reg_sub">
                                <input type="submit" value="保 存" name="">
                            </li>
                        </ul>                
                        </form>
                    </div>
                </div>
            </div>

            <div class="footer no-mp">
                <div class="foot_link">
                    <a href="#">關於我們</a>
                    <span>|</span>
                    <a href="#">聯絡我們</a>
                    <span>|</span>
                    <a href="#">招聘人才</a>
                    <span>|</span>
                    <a href="#">友情連結</a>        
                </div>
                <p>CopyRight © 2016 北京美多商業股份有限公司 All Rights Reserved</p>
                <p>電話:010-****888    京ICP備*******8號</p>
            </div>
        </div>
    </div>
    <script type="text/javascript" src="js/oauth_callback.js"></script>
</body>
</html>

在js目錄中新建oauth_callback.js檔案

var vm = new Vue({
    el: '#app',
    data: {
        host: host,
        is_show_waiting: true,

        error_password: false,
        error_phone: false,
        error_image_code: false,
        error_sms_code: false,
        error_image_code_message: '',
        error_phone_message: '',
        error_sms_code_message: '',

        image_code_id: '', // 圖片驗證碼id
        image_code_url: '',

        sms_code_tip: '獲取簡訊驗證碼',
        sending_flag: false, // 正在傳送簡訊標誌

        password: '',
        mobile: '', 
        image_code: '',
        sms_code: '',
        access_token: ''
    },
    mounted: function(){

    },
    methods: {
        // 獲取url路徑引數    
        get_query_string: function(name){ 
            var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i');
            var r = window.location.search.substr(1).match(reg);
            if (r != null) {
                return decodeURI(r[2]);
            }
            return null;
        },
        // 生成uuid
        generate_uuid: function(){
            var d = new Date().getTime();
            if(window.performance && typeof window.performance.now === "function"){
                d += performance.now(); //use high-precision timer if available
            }
            var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
                var r = (d + Math.random()*16)%16 | 0;
                d = Math.floor(d/16);
                return (c =='x' ? r : (r&0x3|0x8)).toString(16);
            });
            return uuid;
        },
        // 生成一個圖片驗證碼的編號,並設定頁面中圖片驗證碼img標籤的src屬性
        generate_image_code: function(){
            // 生成一個編號
            // 嚴格一點的使用uuid保證編號唯一, 不是很嚴謹的情況下,也可以使用時間戳
            this.image_code_id = this.generate_uuid();

            // 設定頁面中圖片驗證碼img標籤的src屬性
            this.image_code_url = this.host + "/image_codes/" + this.image_code_id + "/";
        },
        check_pwd: function (){
            var len = this.password.length;
            if(len<8||len>20){
                this.error_password = true;
            } else {
                this.error_password = false;
            }        
        },
        check_phone: function (){
            var re = /^1[345789]\d{9}$/;
            if(re.test(this.mobile)) {
                this.error_phone = false;
            } else {
                this.error_phone_message = '您輸入的手機號格式不正確';
                this.error_phone = true;
            }
        },
        check_image_code: function (){
            if(!this.image_code) {
                this.error_image_code_message = '請填寫圖片驗證碼';
                this.error_image_code = true;
            } else {
                this.error_image_code = false;
            }    
        },
        check_sms_code: function(){
            if(!this.sms_code){
                this.error_sms_code_message = '請填寫簡訊驗證碼';
                this.error_sms_code = true;
            } else {
                this.error_sms_code = false;
            }
        },
        // 傳送手機簡訊驗證碼
        send_sms_code: function(){
            if (this.sending_flag == true) {
                return;
            } 
            this.sending_flag = true;

            // 校驗引數,保證輸入框有資料填寫
            this.check_phone();
            this.check_image_code();

            if (this.error_phone == true || this.error_image_code == true) {
                this.sending_flag = false;
                return;
            }

            // 向後端介面傳送請求,讓後端傳送簡訊驗證碼
            axios.get(this.host + '/sms_codes/' + this.mobile + '/?text=' + this.image_code+'&image_code_id='+ this.image_code_id, {
                    responseType: 'json'
                })
                .then(response => {
                    // 表示後端傳送簡訊成功
                    // 倒計時60秒,60秒後允許使用者再次點擊發送簡訊驗證碼的按鈕
                    var num = 60;
                    // 設定一個計時器
                    var t = setInterval(() => {
                        if (num == 1) {
                            // 如果計時器到最後, 清除計時器物件
                            clearInterval(t);
                            // 將點選獲取驗證碼的按鈕展示的文本回覆成原始文字
                            this.sms_code_tip = '獲取簡訊驗證碼';
                            // 將點選按鈕的onclick事件函式恢復回去
                            this.sending_flag = false;
                        } else {
                            num -= 1;
                            // 展示倒計時資訊
                            this.sms_code_tip = num + '秒';
                        }
                    }, 1000, 60)
                })
                .catch(error => {
                    if (error.response.status == 400) {
                        this.error_image_code_message = '圖片驗證碼有誤';
                        this.error_image_code = true;
                    } else {
                        console.log(error.response.data);
                    }
                    this.sending_flag = false;
                })
        },
        // 儲存
        on_submit: function(){
            this.check_pwd();
            this.check_phone();
            this.check_sms_code();

        }
    }
});

在QQ將使用者重定向到此網頁的時候,重定向的網址會攜帶QQ提供的code引數,用於獲取使用者資訊使用,我們需要將這個code引數傳送給後端,在後端中使用code引數向QQ請求使用者的身份資訊,並查詢與該QQ使用者繫結的使用者。

重新測試,如下:

在這裡介面的url中是包含code和state的。

接下來,我們就可以處理第二步了(紫色2)

也就是根據code獲取access_token

3.2 後端介面設計

請求方式 : GET /oauth/qq/user/?code=xxx

請求引數: 查詢字串引數

引數 型別 是否必傳 說明
code str qq返回的授權憑證code

返回資料: JSON

{
    "access_token": xxxx,
}
或
{
    "token": "xxx",
    "username": "python",
    "user_id": 1
}
返回值 型別 是否必須 說明
access_token str 使用者是第一次使用QQ登入時返回,其中包含openid,用於繫結身份使用,注意這個是我們自己生成的
token str 使用者不是第一次使用QQ登入時返回,登入成功的JWT token
username str 使用者不是第一次使用QQ登入時返回,使用者名稱
user_id int 使用者不是第一次使用QQ登入時返回,使用者id

注意:這個access_token是自己生成的 

為啥呢要自己生成access_token呢?

首先返回這個access_token是在未繫結的時候,顯示如下介面的時候,返回的:

在這個介面是需要openid的(因為點選儲存時,後臺需要拿著使用者手機號與openid進行繫結),而我們返回的access_token中包含openid。這個access_token與qq伺服器返回的不一樣,這個是我們拿著qq伺服器返回的openid做了一個處理,避免前端拿到openid修改。因為如果直接將openid給前端,那麼前端是可以對openid進行修改的。本來openid是A使用者的,如果將openid修改為B使用者的openid,那麼點選儲存的時候,我們就將A使用者的美多賬號與B使用者的openid進行了繫結。所以避免這種事情的發生,我們就對openid進行一個處理,如果前端修改,在繫結的時候,我們後端可以知道修改了。

使用itsdangerous生成憑據access_token

安裝

pip install itsdangerous

TimedJsonWebSignatureSerializer的用法與Json的用法類似:

json
dict -> json str
json.dumps()
json str -> dict
json.loads()

使用TimedJSONWebSignatureSerializer可以生成帶有有效期的token

from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from django.conf import settings

# serializer = Serializer(祕鑰, 有效期秒)
serializer = Serializer(settings.SECRET_KEY, 300)
# serializer.dumps(資料), 返回bytes型別
token = serializer.dumps({'mobile': '18512345678'})
token = token.decode()

# 檢驗token
# 驗證失敗,會丟擲itsdangerous.BadData異常
serializer = Serializer(settings.SECRET_KEY, 300)
try:
    data = serializer.loads(token)
except BadData:
    return None

3.3 獲取access_token 

介面介紹

先來看一下qq官方文件如何定義此介面的:

還是在這個介面,看第二步驟即可。

3.4 後端實現

檢視邏輯分析如下:

class QQAuthUserView(APIView):
    """
    QQ登入的使用者  ?code=xxxx
    """
    def get(self ) :
        # 獲取code

        # 憑藉code 獲取access_ token

        # 憑藉access_token獲取openid

        # 根據openid查詢資料庫0AuthoQuser  判斷資料是否存在

        # 如果資料存在,表示使用者已經繫結過身份,  簽發JWT token

        # 如果資料不存在,處理openid並返回

然後補充如下程式碼:

    def get(self, request):
        """
        獲取qq登入的使用者資料
        """
        # 獲取code
        code = request.query_params.get('code')
        
        if not code:
            return Response({'message': '缺少code'}, status=status.HTTP_400_BAD_REQUEST)
        
        # 憑藉code 獲取access_ token  獲取使用者openid
        oauth = OAuthQQ()
        try:
            access_token = oauth.get_access_token(code)
            openid = oauth.get_openid(access_token)
        except QQAPIError:
            return Response({'message': 'QQ服務異常'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
        # 根據openid查詢資料庫0AuthoQuser  判斷資料是否存在

        # 如果資料存在,表示使用者已經繫結過身份,  簽發JWT token

        # 如果資料不存在,處理openid並返回

這裡呼叫了get_access_token方法,此方法程式碼如下:

    def get_access_token(self, code):
        """
        獲取access_token
        :param code: qq提供的code
        :return: access_token
        """
        params = {
            'grant_type': 'authorization_code',
            'client_id': self.client_id,
            'client_secret': self.client_secret,
            'code': code,
            'redirect_uri': self.redirect_uri
        }
        url = 'https://graph.qq.com/oauth2.0/token?' + urlencode(params)
        try:
            # 傳送請求
            response = urlopen(url)
            # 讀取響應體資料
            response_data = response.read()  # bytes
            response_data = response.decode()  # str
            # 解析 access_token
            data = parse_qs(response_data)
        except Exception as e:
            logger.error("獲取access_token異常 %s" % e)
            raise OAuthQQAPIError
        else:
            access_token = data.get('access_token', None)

            return access_token

此方法中用到的client_secret屬性如下:

呼叫了settings配置檔案中的常量如下:

還丟擲了一個自定義異常,此異常程式碼如下:

還用到日誌logger:

3.5 獲取openid實現

接下來處理第三步,獲取openid。

3.5.1 獲取openid介面介紹

首先先來看qq官方介面:

3.5.2 具體操作

檢視邏輯程式碼如下:

class QQAuthUserView(APIView):
    """
    QQ登入的使用者
    """
    def get(self, request):
        """
        獲取qq登入的使用者資料
        """
        code = request.query_params.get('code')
        if not code:
            return Response({'message': '缺少code'}, status=status.HTTP_400_BAD_REQUEST)

        oauth = OAuthQQ()

        # 獲取使用者openid
        try:
            access_token = oauth.get_access_token(code)
            openid = oauth.get_openid(access_token)
        except OAuthQQAPIError:
            return Response({'message': 'QQ服務異常'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)

        # 判斷使用者是否存在
        try:
            qq_user = OAuthQQUser.objects.get(openid=openid)
        except OAuthQQUser.DoesNotExist:
            # 使用者第一次使用QQ登入
            token = oauth.generate_save_user_token(openid)
            return Response({'access_token': token})
        else:
            # 找到使用者, 生成token
            user = qq_user.user
            jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
            jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER

            payload = jwt_payload_handler(user)
            token = jwt_encode_handler(payload)

            response = Response({
                'token': token,
                'user_id': user.id,
                'username': user.username
            })
            return response

呼叫的generate_bind_user_access_token如下:

    @staticmethod
    def generate_save_user_token(openid):
        """
        生成儲存使用者資料的token
        :param openid: 使用者的openid
        :return: token
        """
        serializer = Serializer(settings.SECRET_KEY, expires_in=constants.SAVE_QQ_USER_TOKEN_EXPIRES)
        data = {'openid': openid}
        token = serializer.dumps(data)
        return token.decode()

用到的常量:

18_獲取openid前端實現與測試     p231

在OAuthQQ輔助類中新增方法:


    def get_openid(self, access_token):
        """
        獲取使用者的openid
        :param access_token: qq提供的access_token
        :return: open_id
        """
        url = 'https://graph.qq.com/oauth2.0/me?access_token=' + access_token
        response = urlopen(url)
        response_data = response.read().decode()
        try:
            # 返回的資料 callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} )\n;
            data = json.loads(response_data[10:-4])
        except Exception:
            data = parse_qs(response_data)
            logger.error('code=%s msg=%s' % (data.get('code'), data.get('msg')))
            raise QQAPIError
        openid = data.get('openid', None)
        return openid

    @staticmethod
    def generate_save_user_token(openid):
        """
        生成儲存使用者資料的token
        :param openid: 使用者的openid
        :return: token
        """
        serializer = Serializer(settings.SECRET_KEY, expires_in=constants.SAVE_QQ_USER_TOKEN_EXPIRES)
        data = {'openid': openid}
        token = serializer.dumps(data)
        return token.decode()

在oauth/views.py中實現檢視

class QQAuthUserView(APIView):
    """
    QQ登入的使用者
    """
    def get(self, request):
        """
        獲取qq登入的使用者資料
        """
        code = request.query_params.get('code')
        if not code:
            return Response({'message': '缺少code'}, status=status.HTTP_400_BAD_REQUEST)

        oauth = OAuthQQ()

        # 獲取使用者openid
        try:
            access_token = oauth.get_access_token(code)
            openid = oauth.get_openid(access_token)
        except QQAPIError:
            return Response({'message': 'QQ服務異常'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)

        # 判斷使用者是否存在
        try:
            qq_user = OAuthQQUser.objects.get(openid=openid)
        except OAuthQQUser.DoesNotExist:
            # 使用者第一次使用QQ登入
            token = oauth.generate_save_user_token(openid)
            return Response({'access_token': token})
        else:
            # 找到使用者, 生成token
            user = qq_user.user
            jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
            jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER

            payload = jwt_payload_handler(user)
            token = jwt_encode_handler(payload)

            response = Response({
                'token': token,
                'user_id': user.id,
                'username': user.username
            })
            return response