微信小程序session管理

最近微信小程序开发很火。我们的移动端项目也开始使用小程序来实现,在这之前我们已经基于Html5实现了类似于小程序的应用。了解了小程序开发后觉得有很多相似之处,还是要用到js和css这些技术。但也有许多不同,jquery等这些js库不能直接使用了、http session也不支持、页面发起http请求小程序有自己的api。对于我们项目来说就不只是简单的将H5页面翻译成小程序的页面这么简单了。首先要解决的问题就是http session。在H5项目中,使用http session来关联微信openid这样每次http请求都能确定是哪个用户发起的请求。如果熟悉http session的原理,session问题就好解决了。常见的session保持方式是,当浏览器向服务端发起http请求时,服务端检查在http 头部cookie参数里是否包含sessionid,如果有sessionid就根据sessionid去查看存储在服务器端的session,session里保存的当前会话的一些信息。如果sessionid没有服务端就会分配一个,写到cookie字段里,浏览器下次发起其它请求的时候带上。而在小程序里所有的请求都通过wx.request API来发起的。如果对wx.request API包装一下,使其每次向服务端发起请求时也添加一个名称为Cookie的http header,这样也不用对服务端作改动。服务端分配的sessionid使用wx.setStorageSync API存储在微信客户端。
项目源码地址:https://github.com/it-man-cn/smallapp-session


1、客户端实现
客户端代码目录smallapp-session/views,客户端主要实现对wx.request的封装,在wafer-client-demo项目的基础上作了一些修改。
wx.request封装

var constants = require('./constants');
var utils = require('./utils');
var Session = require('./session');
var loginLib = require('./login');

var noop = function noop() {};

var buildAuthHeader = function buildAuthHeader(session) {
    var header = {};

    if (session && session.id) {
        header['Cookie'] =constants.WX_HEADER_ID+'='+session.id;
    }

    return header;
};

function request(options) {
    if (typeof options !== 'object') {
        var message = '请求传参应为 object 类型,但实际传了 ' + (typeof options) + ' 类型';
        throw new RequestError(constants.ERR_INVALID_PARAMS, message);
    }

    var requireLogin = options.login;
    var success = options.success || noop;
    var fail = options.fail || noop;
    var complete = options.complete || noop;
    var originHeader = options.header || {};

    // 成功回调
    var callSuccess = function () {
        success.apply(null, arguments);
        complete.apply(null, arguments);
    };

    // 失败回调
    var callFail = function (error) {
        fail.call(null, error);
        complete.call(null, error);
    };

    // 是否已经进行过重试
    var hasRetried = false;

    if (requireLogin) {
        doRequestWithLogin();
    } else {
        doRequest();
    }

    // 登录后再请求
    function doRequestWithLogin() {
        loginLib.login({ success: doRequest, fail: callFail });
    }

    // 实际进行请求的方法
    function doRequest() {
        var authHeader = buildAuthHeader(Session.get());
        console.log(authHeader)
        wx.request(utils.extend({}, options, {
            header: utils.extend({}, originHeader, authHeader),
            success: function (response) {
                var data = response.data;
                console.log("err:",data)
                console.log("errid:",data[constants.WX_SESSION_MAGIC_ID])
                // 如果响应的数据里面包含 SDK Magic ID,表示被服务端 SDK 处理过,此时一定包含登录态失败的信息
                if (data && data[constants.WX_SESSION_MAGIC_ID]) {
                    console.log("clear session")
                    // 清除登录态
                    Session.clear();
                    var error, message;
                    if (data.error === constants.ERR_INVALID_SESSION) {
                        // 如果是登录态无效,并且还没重试过,会尝试登录后刷新凭据重新请求
                        if (!hasRetried) {
                            hasRetried = true;
                            doRequestWithLogin();
                            return;
                        }
                        message = '登录态已过期';
                        error = new RequestError(data.error, message);
                    } else {
                        message = '鉴权服务器检查登录态发生错误(' + (data.error || 'OTHER') + '):' + (data.message || '未知错误');
                        error = new RequestError(constants.ERR_CHECK_LOGIN_FAILED, message);
                    }
                    callFail(error);
                    return;
                }
                callSuccess.apply(null, arguments);
            },
            fail: callFail,
            complete: noop,
        }));
    };
};

登录处理

/**
 * 微信登录,获取 code 和 encryptData
 */
var getWxLoginResult = function getLoginCode(callback) {
    wx.login({
        success: function (loginResult) {
            wx.getUserInfo({
                success: function (userResult) {
                    callback(null, {
                        code: loginResult.code,
                        encryptedData: userResult.encryptedData,
                        iv: userResult.iv,
                        userInfo: userResult.userInfo,
                    });
                },

                fail: function (userError) {
                    var error = new LoginError(constants.ERR_WX_GET_USER_INFO, '获取微信用户信息失败,请检查网络状态');
                    error.detail = userError;
                    callback(error, null);
                },
            });
        },

        fail: function (loginError) {
            var error = new LoginError(constants.ERR_WX_LOGIN_FAILED, '微信登录失败,请检查网络状态');
            error.detail = loginError;
            callback(error, null);
        },
    });
};

var noop = function noop() {};
var defaultOptions = {
    method: 'GET',
    success: noop,
    fail: noop,
    loginUrl: null,
};

/**
 * @method
 * 进行服务器登录,以获得登录会话
 *
 * @param {Object} options 登录配置
 * @param {string} options.loginUrl 登录使用的 URL,服务器应该在这个 URL 上处理登录请求
 * @param {string} [options.method] 请求使用的 HTTP 方法,默认为 "GET"
 * @param {Function} options.success(userInfo) 登录成功后的回调函数,参数 userInfo 微信用户信息
 * @param {Function} options.fail(error) 登录失败后的回调函数,参数 error 错误信息
 */
var login = function login(options) {
    options = utils.extend({}, defaultOptions, options);

    if (!defaultOptions.loginUrl) {
        options.fail(new LoginError(constants.ERR_INVALID_PARAMS, '登录错误:缺少登录地址,请通过 setLoginUrl() 方法设置登录地址'));
        return;
    }

    var doLogin = () => getWxLoginResult(function (wxLoginError, wxLoginResult) {
        if (wxLoginError) {
            options.fail(wxLoginError);
            return;
        }
        
        var userInfo = wxLoginResult.userInfo;
        // 构造请求头,包含 code、encryptedData 和 iv
        var code = wxLoginResult.code;
        var encryptedData = wxLoginResult.encryptedData;
        var iv = wxLoginResult.iv;
        var header = {};
        //加密用户信息,在服务端解密
        header[constants.WX_HEADER_CODE] = code;
        header[constants.WX_HEADER_ENCRYPTED_DATA] = encryptedData;
        header[constants.WX_HEADER_IV] = iv;

        // 请求服务器登录地址,获得会话信息
        wx.request({
            url: options.loginUrl,
            header: header,
            method: options.method,
            data: options.data,
            success: function (result) {
                var data = result.data;
                // 成功地响应会话信息
                if (data && data[constants.WX_SESSION_MAGIC_ID]) {
                    if (data.session) {
                        data.session.userInfo = userInfo;
                        console.log("set session")
                        Session.set(data.session);
                        options.success(userInfo);
                    } else {
                        var errorMessage = '登录失败(' + data.error + '):' + (data.message || '未知错误');
                        var noSessionError = new LoginError(constants.ERR_LOGIN_SESSION_NOT_RECEIVED, errorMessage);
                        options.fail(noSessionError);
                    }
                // 没有正确响应会话信息
                } else {
                    var errorMessage = '登录请求没有包含会话响应,请确保服务器处理 `' + options.loginUrl + '` 的时候正确使用了 SDK 输出登录结果';
                    var noSessionError = new LoginError(constants.ERR_LOGIN_SESSION_NOT_RECEIVED, errorMessage);
                    options.fail(noSessionError);
                }
            },
            // 响应错误
            fail: function (loginResponseError) {
                var error = new LoginError(constants.ERR_LOGIN_FAILED, '登录失败,可能是网络错误或者服务器发生异常');
                options.fail(error);
            },
        });
    });

    var session = Session.get();
    console.log("get session",session)
    if (session) {
        wx.checkSession({
            success: function () {
                options.success(session.userInfo);
            },
            fail: function () {
                Session.clear();
                doLogin();
            },
        });
    } else {
        doLogin();
    }
};

var setLoginUrl = function (loginUrl) {
    defaultOptions.loginUrl = loginUrl;
};

对wx.request完成封装后,小程序中所有http请求都使用qcloud.request来实现,如views/pages/index/index.js

var qcloud = require('../../vendor/qcloud-weapp-client-sdk/index');
Page({
  onLoad: function () {
    console.log('onLoad')
    var that = this
    qcloud.request({
      login:true,  //是否自动登录
      url:"https://smallapp.com/v1/user/query",
      success:function(res){
        console.log(res.data)
        that.setData({
           userInfo:res.data
        })
      }
    })
  }
})

2、服务端实现
服务端使用golang实现,使用了beego框架。
controllers/user.go

var WX_SESSION_MAGIC_ID = "F2C224D4-2BCE-4C64-AF9F-A6D872000D1A"

// Operations about Users
type UserController struct {
	beego.Controller
}

type LoginResult struct {
	ErrorCode int    `json:"errcode"`
	ErrorMsg  string `json:"errmsg,omitempty"`
	Session   `json:"session,omitempty"`
}

type Session struct {
	ID string `json:"id"`
}

// @Title login
// @Description Logs user into the system
// @Param	code	query 	string	true		"wx.Login response code"
// @Success 200 {string} login success
// @Failure 403 user not exist
// @router /login [get]
func (u *UserController) Login() {
	fmt.Println(u.Ctx.Input.CruSession)
	session := u.Ctx.Input.CruSession
	code := u.Ctx.Input.Header("X-WX-Code")
	encryptedData := u.Ctx.Input.Header("X-WX-Encrypted-Data")
	iv := u.Ctx.Input.Header("X-WX-IV")
	if len(code) > 0 && len(encryptedData) > 0 && len(iv) > 0 {
		userInfo, err := services.Login(code, encryptedData, iv)
		if err != nil {
			u.Data["json"] = LoginResult{
				ErrorCode: 1,
				ErrorMsg:  "invaliad params",
			}
		} else {
			result := make(map[string]interface{})
			result["session"] = Session{ID: session.SessionID()}
			result[WX_SESSION_MAGIC_ID] = 1
			u.Data["json"] = result
			//save userinfo into session
			session.Set("userinfo", userInfo)
		}
	} else {
		u.Data["json"] = LoginResult{
			ErrorCode: 1,
			ErrorMsg:  "invaliad params",
		}
	}
	u.ServeJSON()
}

// @Title login
// @Description Logs user into the system
// @Param	code	query 	string	true		"wx.Login response code"
// @Success 200 {string} login success
// @Failure 403 user not exist
// @router /query [get]
func (u *UserController) Query() {
	session := u.Ctx.Input.CruSession
	if val := session.Get("userinfo"); val != nil {
		u.Data["json"] = val.(services.UserInfo)
	} else {
		u.Data["json"] = LoginResult{
			ErrorCode: 1,
			ErrorMsg:  "need login",
		}
	}
	u.ServeJSON()
}

services/user.go

type Code2sessionResult struct {
	ErrorCode  int    `json:"errcode"`
	ErrorMsg   string `json:"errmsg,omitempty"`
	SessionKey string `json:"session_key,omitempty"`
	ExpiresIn  int    `json:"expires_in,omitempty"`
	Openid     string `json:"openid,omitempty"`
}

type WaterMark struct {
	AppID     string `json:"appid"`
	Timestamp int    `json:"timestamp"`
}

type UserInfo struct {
	Openid    string `json:"openid"`
	NickName  string `json:"nickName"`
	Gender    int    `json:"gender"`
	City      string `json:"city"`
	Province  string `json:"province"`
	Country   string `json:"country"`
	AvatarURL string `json:"avatarUrl"`
	UnionID   string `json:"unionId"`
	WaterMark `json:"watermark"`
}

var APPID = beego.AppConfig.String("APPID")
var SECRET = beego.AppConfig.String("SECRET")

func Login(code, encrytedData, iv string) (userInfo UserInfo, err error) {
	//get openid and session_key
	url := "https://api.weixin.qq.com/sns/jscode2session?appid=" + APPID + "&secret=" + SECRET + "&js_code=" + code + "&grant_type=authorization_code"
	r, err := util.HttpGet(url)
	if err != nil {
		//auth error
		fmt.Println(err)
		return
	}
	code2session := Code2sessionResult{}
	err = json.Unmarshal(r, &code2session)
	if err != nil {
		fmt.Println(err)
		return
	}
	if code2session.ErrorCode > 0 {
		err = fmt.Errorf("%d=>%s", code2session.ErrorCode, code2session.ErrorMsg)
		return
	}
	//code2session success,check signature

	//aes decrypt
	decrypted, err := util.WXBizDataDecrypt(code2session.SessionKey, encrytedData, iv)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(string(decrypted))
	err = json.Unmarshal(decrypted, &userInfo)
	if err != nil {
		fmt.Println(err)
		return
	}
	return
}

加密数据解密

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/sha1"
	"encoding/base64"
	"fmt"
)

func WXBizDataDecrypt(sessionKey, encryptedData, iv string) (decrypted []byte, err error) {
	var (
		decryptedkey, decryptedIV, decryptedData []byte
	)
	decryptedkey, err = base64.StdEncoding.DecodeString(sessionKey)
	if err != nil {
		return
	}
	decryptedData, err = base64.StdEncoding.DecodeString(encryptedData)
	if err != nil {
		return
	}
	decryptedIV, err = base64.StdEncoding.DecodeString(iv)
	if err != nil {
		return
	}
	block, err := aes.NewCipher(decryptedkey)
	if err != nil {
		fmt.Println(err)
		return
	}
	blockMode := cipher.NewCBCDecrypter(block, decryptedIV)
	decrypted = make([]byte, len(decryptedData))
	// origData := crypted
	blockMode.CryptBlocks(decrypted, decryptedData)
	decrypted = PKCS5UnPadding(decrypted)
	return
}

func WXBizDataSignature(sessionKey, rawData string) string {
	hash := sha1.New()
	_, err := hash.Write([]byte(rawData + sessionKey))
	if err != nil {
		return ""
	}
	sign := hash.Sum(nil)
	return string(sign)
}

func PKCS5UnPadding(origData []byte) []byte {
	length := len(origData)
	// 去掉最后一个字节 unpadding 次
	unpadding := int(origData[length-1])
	return origData[:(length - unpadding)]
}

发表评论

电子邮件地址不会被公开。 必填项已用*标注

您可以使用这些HTML标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>