小程式開發:python sanic 實現小程式登入註冊

goodspeed發表於2017-07-30

開發微信小程式時,接入小程式的授權登入可以快速實現使用者註冊登入的步驟,是快速建立使用者體系的重要一步。這篇文章將介紹 python + sanic + 微信小程式實現使用者快速註冊登入全棧方案。

微信小程式登入時序圖如下:

登入時序圖
登入時序圖

這個流程分為兩大部分:

  1. 小程式使用 wx.login() API 獲取 code,呼叫 wx.getUserInfo() API 獲取 encryptedData 和 iv,然後將這三個資訊傳送給第三方伺服器。
  2. 第三方伺服器獲取到 code、encryptedData和 iv 後,使用 code 換取 session_key,然後將 session_key 利用 encryptedData 和 iv 解密在服務端獲取使用者資訊。根據使用者資訊返回 jwt 資料,完成登入。

下面我們先看一下小程式提供的 API。

小程式登入 API

在這個授權登入的過程中,用到的 API 如下:

  • wx.login
  • wx.getUserInfo

wx.chekSession 是可選的,這裡並沒有用到。

wx.login(OBJECT)

呼叫此介面可以獲取登入憑證(code),以用來換取使用者登入態資訊,包括使用者的唯一標識(openid) 及本次登入的 會話金鑰(session_key)。

如果介面呼叫成功,返回結果如下:

引數名 型別 說明
errMsg String 呼叫結果
code String 使用者允許登入後,回撥內容會帶上 code(有效期五分鐘),開發者需要將 code 傳送到開發者伺服器後臺,使用code 換取 session_key api,將 code 換成 openid 和 session_key

code 換取 session_key

開發者伺服器使用登入憑證 code 獲取 session_key 和 openid。其中 session_key 是對使用者資料進行加密簽名的金鑰。為了自身應用安全,session_key 不應該在網路上傳輸。所以這一步應該在伺服器端實現。

wx.getUserInfo

此介面用來獲取使用者資訊。

withCredentials 為 true 時,要求此前有呼叫過 wx.login 且登入態尚未過期,此時返回的資料會包含 encryptedData, iv 等敏感資訊;當 withCredentials 為 false 時,不要求有登入態,返回的資料不包含 encryptedData, iv 等敏感資訊。

介面success 時返回引數如下:

引數名 型別 說明
userInfo OBJECT 使用者資訊物件,不包含 openid 等敏感資訊
rawData String 不包括敏感資訊的原始資料字串,用於計算簽名。
signature String 使用 sha1( rawData + sessionkey ) 得到字串,用於校驗使用者資訊,參考文件 signature。
encryptedData String 包括敏感資料在內的完整使用者資訊的加密資料,詳細見加密資料解密演算法
iv String 加密演算法的初始向量,詳細見加密資料解密演算法

encryptedData 解密後為以下 json 結構,詳見加密資料解密演算法

{
    "openId": "OPENID",
    "nickName": "NICKNAME",
    "gender": GENDER,
    "city": "CITY",
    "province": "PROVINCE",
    "country": "COUNTRY",
    "avatarUrl": "AVATARURL",
    "unionId": "UNIONID",
    "watermark":
    {
        "appid":"APPID",
    "timestamp":TIMESTAMP
    }
}複製程式碼

由於解密 encryptedData 需要 session_key 和 iv 所以,在給伺服器端傳送授權驗證的過程中需要將 code、encryptedData 和 iv 一起傳送。

伺服器端提供的 API

伺服器端授權需要提供兩個 API:

  1. /oauth/token 通過小程式提供的驗證資訊獲取伺服器自己的 token
  2. /accounts/wxapp 如果登入使用者是未註冊使用者,使用此介面註冊為新使用者。

換取第三方 token(/oauth/token)

開始授權時,小程式呼叫此 API 嘗試換取jwt,如果使用者未註冊返回401,如果使用者傳送引數錯誤,返回403。

介面 獲取 jwt 成功時返回引數如下:

引數名 型別 說明
account_id string 當前授權使用者的使用者 ID
access_token string jwt(登入流程中的第三方 session_key
token_type string token 型別(固定Bearer)

小程式授權後應該先呼叫此介面,如果結果是使用者未註冊,則應該呼叫新使用者註冊的介面先註冊新使用者,註冊成功後再呼叫此介面換取 jwt。

新使用者註冊(/accounts/wxapp)

註冊新使用者時,伺服器端需要儲存當前使用者的 openid,所以和授權介面一樣,請求時需要的引數為 code、encryptedData 和 iv。

註冊成功後,將返回使用者的 ID 和註冊時間。此時,應該再次呼叫獲取 token 的介面去換取第三方 token,以用來下次登入。

實現流程

介面定義好之後,來看下前後端整體的授權登入流程。

小程式授權登入流程
小程式授權登入流程

這個流程需要注意的是,在 C 步(使用 code 換取 session )之後我們得到 session_key,然後需要用 session_key 解密得到使用者資料。

然後使用 openid 判斷使用者是否已經註冊,如果使用者已經註冊,生成 jwt 返回給小程式。
如果使用者未註冊返回401, 提示使用者未註冊。

jwt(3rd_session) 用於第三方伺服器和小程式之間做登入態校驗,為了保證安全性,jwt 應該滿足:

  1. 足夠長。建議有 2^128 組合
  2. 避免使用 srand(當前時間),然後 rand() 的方法,而是採用作業系統提供的真正隨機數機制。
  3. 設定一定的有效時間,

當然,在小程式中也可以使用手機號登入,不過這是另一個功能了,就不在這裡敘述了。

程式碼實現

說了這麼多,接下來看程式碼吧。

小程式端程式碼

程式碼邏輯為:

  1. 使用者在小程式授權
  2. 小程式將授權訊息傳送到伺服器,伺服器檢查使用者是否已經註冊,如果註冊返回 jwt,如果沒註冊提示使用者未註冊,然後小程式重新請求註冊介面,註冊使用者,註冊成功後重復這一步。

為了簡便,這裡在小程式 啟動的時候就請求授權。程式碼實現如下。

//app.js
var config = require('./config.js')

App({
    onLaunch: function() {
        //呼叫API從本地快取中獲取資料
        var jwt = wx.getStorageSync('jwt');
        var that = this;
        if (!jwt.access_token){ //檢查 jwt 是否存在 如果不存在呼叫登入
            that.login();
        } else {
            console.log(jwt.account_id);
        }
    },
    login: function() {
        // 登入部分程式碼
        var that = this;
        wx.login({
            // 呼叫 login 獲取 code
            success: function(res) {
                var code = res.code;
                wx.getUserInfo({
                    // 呼叫 getUserInfo 獲取 encryptedData 和 iv
                    success: function(res) {
                        // success
                        that.globalData.userInfo = res.userInfo;
                        var encryptedData = res.encryptedData || 'encry';
                        var iv = res.iv || 'iv';
                        console.log(config.basic_token);
                        wx.request({ // 傳送請求 獲取 jwt
                            url: config.host + '/auth/oauth/token?code=' + code,
                            header: {
                                Authorization: config.basic_token
                            },
                            data: {
                                username: encryptedData,
                                password: iv,
                                grant_type: "password",
                                auth_approach: 'wxapp',
                            },
                            method: "POST",
                            success: function(res) {
                                if (res.statusCode === 201) {
                                    // 得到 jwt 後儲存到 storage,
                                    wx.showToast({
                                        title: '登入成功',
                                        icon: 'success'
                                    });
                                    wx.setStorage({
                                        key: "jwt",
                                        data: res.data
                                    });
                                    that.globalData.access_token = res.data.access_token;
                                    that.globalData.account_id = res.data.sub;
                                } else if (res.statusCode === 401){
                                    // 如果沒有註冊呼叫註冊介面
                                    that.register();
                                } else {
                                    // 提示錯誤資訊
                                    wx.showToast({
                                        title: res.data.text,
                                        icon: 'success',
                                        duration: 2000
                                    });
                                }
                            },
                            fail: function(res) {
                                console.log('request token fail');
                            }
                        })
                    },
                    fail: function() {
                        // fail
                    },
                    complete: function() {
                        // complete
                    }
                })
            }
        })

    },
    register: function() {
        // 註冊程式碼
        var that = this;
        wx.login({ // 呼叫登入介面獲取 code
            success: function(res) {
                var code = res.code;
                wx.getUserInfo({
                    // 呼叫 getUserInfo 獲取 encryptedData 和 iv
                    success: function(res) {
                        // success
                        that.globalData.userInfo = res.userInfo;
                        var encryptedData = res.encryptedData || 'encry';
                        var iv = res.iv || 'iv';
                        console.log(iv);
                        wx.request({ // 請求註冊使用者介面
                            url: config.host + '/auth/accounts/wxapp',
                            header: {
                                Authorization: config.basic_token
                            },
                            data: {
                                username: encryptedData,
                                password: iv,
                                code: code,
                            },
                            method: "POST",
                            success: function(res) {
                                if (res.statusCode === 201) {
                                    wx.showToast({
                                        title: '註冊成功',
                                        icon: 'success'
                                    });
                                    that.login();
                                } else if (res.statusCode === 400) {
                                    wx.showToast({
                                        title: '使用者已註冊',
                                        icon: 'success'
                                    });
                                    that.login();
                                } else if (res.statusCode === 403) {
                                    wx.showToast({
                                        title: res.data.text,
                                        icon: 'success'
                                    });
                                }
                                console.log(res.statusCode);
                                console.log('request token success');
                            },
                            fail: function(res) {
                                console.log('request token fail');
                            }
                        })
                    },
                    fail: function() {
                        // fail
                    },
                    complete: function() {
                        // complete
                    }
                })
            }
        })

    },

    get_user_info: function(jwt) {
        wx.request({
            url: config.host + '/auth/accounts/self',
            header: {
                Authorization: jwt.token_type + ' ' + jwt.access_token
            },
            method: "GET",
            success: function (res) {
                if (res.statusCode === 201) {
                    wx.showToast({
                        title: '已註冊',
                        icon: 'success'
                    });
                } else if (res.statusCode === 401 || res.statusCode === 403) {
                    wx.showToast({
                        title: '未註冊',
                        icon: 'error'
                    });
                }

                console.log(res.statusCode);
                console.log('request token success');
            },
            fail: function (res) {
                console.log('request token fail');
            }
        })
    },

    globalData: {
        userInfo: null
    }
})複製程式碼

服務端程式碼

服務端使用 sanic 框架 + swagger_py_codegen 生成 rest-api。
資料庫使用 MongoDB,python-weixin 實現了登入過程中 code 換取 session_key 以及 encryptedData 解密的功能,所以使用python-weixin 作為 python 微信 sdk 使用。

為了過濾無效請求,伺服器端要求使用者在獲取 token 或授權時在 header 中帶上 Authorization 資訊。 Authorization 在登入前使用的是 Basic 驗證(格式 (Basic hashkey) 注 hashkey為client_id + client_secret 做BASE64處理),只是用來校驗請求的客戶端是否合法。不過Basic 基本等同於明文,並不能用它來進行嚴格的授權驗證。

jwt 原理及使用參見 理解JWT(JSON Web Token)認證及實踐

使用 swagger 生成程式碼結構如下:

由於程式碼太長,這裡只放獲取 jwt 的邏輯:

def get_wxapp_userinfo(encrypted_data, iv, code):
    from weixin.lib.wxcrypt import WXBizDataCrypt
    from weixin import WXAPPAPI
    from weixin.oauth2 import OAuth2AuthExchangeError
    appid = Config.WXAPP_ID
    secret = Config.WXAPP_SECRET
    api = WXAPPAPI(appid=appid, app_secret=secret)
    try:
        # 使用 code  換取 session key    
        session_info = api.exchange_code_for_session_key(code=code)
    except OAuth2AuthExchangeError as e:
        raise Unauthorized(e.code, e.description)
    session_key = session_info.get('session_key')
    crypt = WXBizDataCrypt(appid, session_key)
    # 解密得到 使用者資訊
    user_info = crypt.decrypt(encrypted_data, iv)
    return user_info


def verify_wxapp(encrypted_data, iv, code):
    user_info = get_wxapp_userinfo(encrypted_data, iv, code)
    # 獲取 openid
    openid = user_info.get('openId', None)
    if openid:
        auth = Account.get_by_wxapp(openid)
        if not auth:
            raise Unauthorized('wxapp_not_registered')
        return auth
    raise Unauthorized('invalid_wxapp_code')


def create_token(request):
    # verify basic token
    approach = request.json.get('auth_approach')
    username = request.json['username']
    password = request.json['password']
    if approach == 'password':
        account = verify_password(username, password)
    elif approach == 'wxapp':
        account = verify_wxapp(username, password, request.args.get('code'))
    if not account:
        return False, {}
    payload = {
        "iss": Config.ISS,
        "iat": int(time.time()),
        "exp": int(time.time()) + 86400 * 7,
        "aud": Config.AUDIENCE,
        "sub": str(account['_id']),
        "nickname": account['nickname'],
        "scopes": ['open']
    }
    token = jwt.encode(payload, 'secret', algorithm='HS256')
    # 由於 account 中 _id 是一個 object 需要轉化成字串
    return True, {'access_token': token, 'account_id': str(account['_id'])}複製程式碼

具體程式碼可以在 Metis:https://github.com/gusibi/Metis 檢視。

Note: 如果試用程式碼,請先設定 oauth2_client,使用自己的配置。

不要將私密配置資訊提交到 github。

參考連結


最後,感謝女朋友支援。

歡迎關注(April_Louisa) 請我喝芬達
歡迎關注
歡迎關注
請我喝芬達
請我喝芬達

相關文章