一、背景
近期实现微信招聘公众号的需求,需要在微信用户同意公众号授权后,获取到微信用户信息。这一步操作在前端无法完成,所以这里我们使用了C# WebApi项目,通过接口实现后台获取微信用户数据再重定向到前端页面。
二、思路
具体而言,微信网页授权流程分为四步:
1、引导用户进入授权页面同意授权,获取code;
2、通过code换取网页授权access_token(与基础支持中的access_token不同);
3、如果需要,开发者可以刷新网页授权access_token,避免过期;
4、通过网页授权access_token和openid获取用户基本信息(支持UnionID机制);
首先,我们需要明确传递给后台的参数,以及后台返回的数据是什么。根据上述思路,前端第一次请求后台是需要引导用户进入授权页面同意授权,获取code,只要这一过程发生在微信端并且用户点击同意,就可以获取到code;此时操作仍然在后台,我们还需要进一步获取用户信息,所以我们在后台需要伪跳转(在后台接口进行跳转至另一信息获取接口),调用微信接口获取到用户信息。这个时候鉴权、获取用户信息都完成了,后台如何把用户信息给前端、如何定位前端页面呢?所以我们需要在前端传递给后台的参数里,加上鉴权、获取用户信息后的重定向URL,并将用户信息拼接到URL上。
看到这里,思路就很清晰了。前端请求后台鉴权并带上重定向URL、后台获取用户信息并将用户信息添加到URL上、后台重定向此URL,最终微信端展示页面的就是每个用户的个人数据了。
三、代码实现
代码实现主要分两部分,微信辅助类、微信接口。
1、微信辅助类。主要是接口请求类、生成随机字符串类(可用于生成请求code的state字符串,但本项目使用URL MD5加密后的密文作为state)、参数model类。
///<summary>
///微信帮助类
///</summary>
public class WXHelper
{
private static string appId = ConfigurationManager.AppSettings["appid"];
private static string secret = ConfigurationManager.AppSettings["appsecret"];
/// <summary>
/// 发起请求
/// </summary>
/// <param name="url">地址</param>
/// <param name="data">数据</param>
/// <param name="reqtype">请求类型</param>
/// <returns></returns>
public String Request(string url, string data, string reqtype)
{
HttpWebRequest web = (HttpWebRequest)HttpWebRequest.Create(url);
web.ContentType = "application/json";
web.Method = reqtype;
if (data.Length > 0 && reqtype.Trim().ToUpper() == "POST")
{
byte[] postBytes = Encoding.UTF8.GetBytes(data);
web.ContentLength = postBytes.Length;
using (Stream reqStream = web.GetRequestStream())
{
reqStream.Write(postBytes, 0, postBytes.Length);
}
}
string html = string.Empty;
using (HttpWebResponse response = (HttpWebResponse)web.GetResponse())
{
Stream responseStream = response.GetResponseStream();
StreamReader streamReader = new StreamReader(responseStream, Encoding.UTF8);
html = streamReader.ReadToEnd();
}
return html;
}
///<summary>
///生成随机字符串
///</summary>
///<param name="length">目标字符串的长度</param>
///<param name="useNum">是否包含数字,1=包含,默认为包含</param>
///<param name="useLow">是否包含小写字母,1=包含,默认为包含</param>
///<param name="useUpp">是否包含大写字母,1=包含,默认为包含</param>
///<param name="useSpe">是否包含特殊字符,1=包含,默认为不包含</param>
///<param name="custom">要包含的自定义字符,直接输入要包含的字符列表</param>
///<returns>指定长度的随机字符串</returns>
public static string GetRandomString(int length, bool useNum, bool useLow, bool useUpp, bool useSpe, string custom)
{
byte[] b = new byte[4];
new System.Security.Cryptography.RNGCryptoServiceProvider().GetBytes(b);
Random r = new Random(BitConverter.ToInt32(b, 0));
string s = null, str = custom;
if (useNum == true) { str += "0123456789"; }
if (useLow == true) { str += "abcdefghijklmnopqrstuvwxyz"; }
if (useUpp == true) { str += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; }
if (useSpe == true) { str += "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; }
for (int i = 0; i < length; i++)
{
s += str.Substring(r.Next(0, str.Length - 1), 1);
}
return s;
}} ///<summary>
///鉴权token
///</summary>
public class OAuthToken
{
///<summary>
///access_token
///</summary>
public string access_token { get; set; }
///<summary>
///超时时间
///</summary>
public int expires_in { get; set; }
///<summary>
///刷新token
///</summary>
public string refresh_token { get; set; }
///<summary>
///用户openid
///</summary>
public string openid { get; set; }
///<summary>
///授权范围scope
///</summary>
public string scope { get; set; }
}
///<summary>
///AccessToken
///</summary>
public class AccessToken
{
///<summary>
///access_token
///</summary>
public string access_token { get; set; }
///<summary>
///超时时间
///</summary>
public int expires_in { get; set; }
}
///<summary>
///用户信息
///</summary>
public class OAuthUserInfo
{
///<summary>
///用户openid
///</summary>
public string openid { get; set; }
///<summary>
///用户昵称
///</summary>
public string nickname { get; set; }
///<summary>
///用户性别
///</summary>
public int sex { get; set; }
///<summary>
///用户省区
///</summary>
public string province { get; set; }
///<summary>
///用户城市
///</summary>
public string city { get; set; }
///<summary>
///用户县市
///</summary>
public string country { get; set; }
///<summary>
///用户头像链接
///</summary>
public string headimgurl { get; set; }
///<summary>
///用户权限
///</summary>
public string privilege { get; set; }
///<summary>
///用户unionid
///</summary>
public string unionid { get; set; }
}
2、微信接口。
ConfigurationManager.AppSettings["appid"]配置为微信公众号的AppID;
ConfigurationManager.AppSettings["appsecret"]配置为微信公众号的AppSecret;
ConfigurationManager.AppSettings["apppath"]配置为后台接口服务器域名,这个是需要在公众号后台配置的;如下图
/// <summary>
/// 微信公众号用户信息获取
/// </summary>
public class AuthController : Controller
{
private static string appId = ConfigurationManager.AppSettings["appid"];
private static string secret = ConfigurationManager.AppSettings["appsecret"];
/// <summary>
/// 微信公众号引导页
/// </summary>
/// <returns>成功时,返回带有用户信息的URL并重定向</returns>
public ActionResult GetUserInfo()
{
string code = Request.QueryString["code"];
string state = Request.QueryString["state"];
try
{
if (!string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(state))
{
OAuthToken oauthToken = JsonConvert.DeserializeObject<OAuthToken>(new WXHelper().Request(string.Format("https://api.weixin.qq.com/sns/oauth2/access_token?appid={0}&secret={1}&code={2}&grant_type=authorization_code", appId, secret, code), "", "GET"));
string accesstoken = string.Empty;
AccessToken token = JsonConvert.DeserializeObject<AccessToken>(new WXHelper().Request(string.Format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={0}&secret={1}", appId, secret), "", "GET"));
if (token != null && !string.IsNullOrEmpty(token.access_token))
{
accesstoken = token.access_token;
}
if (oauthToken != null && !string.IsNullOrEmpty(oauthToken.openid))
{
OAuthUserInfo userInfo = JsonConvert.DeserializeObject<OAuthUserInfo>(new WXHelper().Request(string.Format("https://api.weixin.qq.com/cgi-bin/user/info?access_token={0}&openid={1}&lang=zh_CN", accesstoken, oauthToken.openid), "", "GET"));
if (userInfo != null)
{
ViewData["headImage"] = userInfo.headimgurl;
ViewData["openid"] = userInfo.openid;
ViewData["nickName"] = userInfo.nickname;
if (userInfo.sex == 0)
{
ViewData["sex"] = "未知";
}
else if (userInfo.sex == 1)
{
ViewData["sex"] = "男";
}
else
{
ViewData["sex"] = "女";
}
ViewData["province"] = userInfo.province;
ViewData["city"] = userInfo.city;
object objUrl = CacheHelper.GetCache(state);//通过state从缓存中获取到保存的重定向url
if (objUrl != null)
{
UriBuilder URL = new UriBuilder(objUrl.ToString());
if (!string.IsNullOrEmpty(URL.Query))//将参数拼接至url中
URL.Query += string.Format("&unionid={0}&openid={1}&appid={2}",
userInfo.unionid, userInfo.openid, appId);
else
URL.Query += string.Format("unionid={0}&openid={1}&appid={2}",
userInfo.unionid, userInfo.openid, appId);
return Redirect(URL.ToString());
}
else
ViewData["errmsg"] = "重定向URL获取失败!";
}
else
{
ViewData["errmsg"] = "用户信息获取失败!";
}
}
else
{
ViewData["errmsg"] = "Token获取失败!";
}
}
else
{
ViewData["errmsg"] = "用户code获取失败!";
}
}
catch (Exception ex)
{
ViewData["errmsg"] = ex.Message;
}
return View();
}
/// <summary>
/// 微信公众号引导页
/// </summary>
/// <param name="url">微信前端传递的跳转url</param>
/// <returns>成功时,重定向至获取用户信息</returns>
public ActionResult Index(string url)
{
if (!string.IsNullOrEmpty(url))
{
url = WXHelper.DecodeBase64(url);//微信通常会将传递的URL中的参数丢失,所以前端调用接口时最好将URL使用Base64加密,然后后台通过Base64解密
string state = EncryptHelper.MD5Encrypt(url);//因为获取code的接口中必须要传递重定向url,而微信的接口是不允许增加参数的。所以我们将重定向url MD5加密后的值作为微信接口中的state,再将state、url作为key、value存在缓存中,这样我们在请求微信接口完毕时仍然可以获取到重定向url。
//判断url根据MD5生成的密文在缓存中是否存在
object objUrl = CacheHelper.GetCache(state);
if (objUrl == null)
CacheHelper.AddCache(state, url, 5);//不存在则将url和对应的密文存储在缓存中,存储时长为5分钟
else
CacheHelper.SetCache(state, url, 5);//存在则将url和对应的密文在缓存中更新,更新存储时长为5分钟
//使用微信接口,重定向地址为本接口中的另一方法
return Redirect(string.Format("https://open.weixin.qq.com/connect/oauth2/authorize?appid={0}&redirect_uri={1}&response_type=code&scope=snsapi_base&state={2}#wechat_redirect", appId, ConfigurationManager.AppSettings["apppath"] + "/**/**/GetUserInfo", state));
}
else
ViewData["errmsg"] = "重定向url不能为空!";
return View();
}
}
3、其他帮助类。
/// <summary>
/// 缓存帮助类
/// </summary>
public class CacheHelper {
/// <summary>
/// 内存缓存对象
/// </summary>
protected static ObjectCache cache;
/// <summary>
/// 静态构造函数
/// </summary>
static CacheHelper() {
cache = MemoryCache.Default;
}
/// <summary>
/// 添加缓存如果已存在则不添加
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
/// <param name="Minutes">缓存时间(分钟)</param>
/// <returns></returns>
public static bool AddCache(string key , object value , int Minutes) {
bool resultBl = false;
CacheItemPolicy policy = new CacheItemPolicy();
policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(Minutes);
resultBl = cache.Add(key , value , policy);
return resultBl;
}
/// <summary>
/// 设置缓存已存在则覆盖
/// </summary>
/// <param name="key"></param>
/// <param name="value"></param>
/// <param name="Minutes">缓存时间(分钟)</param>
public static void SetCache(string key , object value , int Minutes) {
CacheItemPolicy policy = new CacheItemPolicy();
policy.AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(Minutes);
cache.Set(key , value , policy);
}
/// <summary>
/// 获取缓存对象
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static object GetCache(string key) {
object resultObj = null;
if (cache.Count() > 0) {
resultObj = cache.Get(key);
}
return resultObj;
}
/// <summary>
/// 删除缓存
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static object DeleteCache(string key) {
return cache.Remove(key);
}
/// <summary>
/// 查看缓存是否存在
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static bool IsExistCache(string key) {
return cache.Contains(key);
}
/// <summary>
/// 获取当前缓存对象数量
/// </summary>
/// <returns></returns>
public static int GetCacheCount() {
return cache.Count();
}
}
四、思考
在实际项目中做微信网页授权会因为场景不同而产生不同的代码实现,但是基本的微信授权逻辑,在上述代码中都有体现,希望能给各位开发者在类似业务场景下带来些许帮助。微信接口的严格性(局限性),会给开发者带来一些麻烦,但是办法总比困难多。通过不断的发现问题、思考问题、解决问题,才是开发者的生存之道。