创建钉钉H5应用

顾名思义,钉钉H5应用,和微信WEB应用一样,本质都是一个有前端有后端的网站,由平台本身对网站基础功能进行扩充,提供专用接口满足开发者各式各样的和平台相关的需求。

开发者平台:https://open-dev.dingtalk.com/

先决条件:公司管理员和子管理员权限

创建应用的流程很简单,开发者平台里新建一个应用,再为应用配置域名、IP白名单、接口权限等信息即可。

关于免登

免登的关键在于如何识别用户,微信网页也好,微信小程序也好,钉钉也好,都开放了获取用户信息的接口,在这基础上做免登的流程是:向平台获取用户信息 -> 为用户登录。

微信网页获取用户信息的流程是:用户同意授权(scope=snsapi_userinfo时) -> 获取code -> 通过code换取网页授权access_token -> 拉取用户信息。在获取code时,本质是由微信客户端刷新页面,并在URL中添加CODE参数;此外,获取access_token时,scope参数如果是snsapi_base,可以进行无感知获取用户openid,所以只有当需要获取详细信息时,才会用scope=snsapi_userinfo来显示请求授权,其它场景中(不需要获取用户信息,或已经获取了对应openid的用户信息)只要使用snsapi_base即可。(官方文档地址:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html

钉钉流程与之类似,区别如下:

  • 微信通过URL传递code,钉钉通过JSAPI的dd.runtime.permission.requestAuthCode接口获取code;
  • 不需要用户授权(真正意义上的无感知);
  • 直接获取用户信息而不需要scope字段。

此外,因为平台性质的差异,钉钉的用户字段包含了丰富的真实个人信息。

签名校验

微信的wx.config参数配置,和钉钉H5的dd.config参数配置,不管是校验流程、签名参数的参数名和,还是校验算法都完全一样。

在整个过程中(包括其它开发步骤里),有一个非常重要的原则需要格外留意:敏感参数绝对不能出现在前端(比如jsapi_ticket、access_token)。

流程如下:

  • 获取access_token
  • 获取jsapi_ticket
  • 计算签名(微信和钉钉均为jsapi_ticket, nonceStr, timeStamp, url)
  • 将生成签名的参数nonceStr,timeStamp, url和最终生成的签名Signature传到前端,供config接口配置和注册权限;除了这几个参数,dd.config还需要用到agentId(即应用ID)、corpId(即公司ID),wx.config需要用到appId,本质上都是用来标识一个应用。

TOKEN的维护

钉钉的Token有一个服务端缓存刷新机制,只要在失效前请求接口,access_token的过期时间会恢复为7200秒,借由这个机制,可以在后台跑一个定时任务,隔一段时间请求一下,就可以保证当前access_token一直有效。

开发、部署流程(与微信WEB应用一样):

  • 开发阶段
    • 可以用自己熟悉的环境、熟悉的框架按普通的WEB开发过程进行前后端开发;
    • 在需要使用功能的前端页面引入核心JS-SDK;
    • 通过dd.config接口注入权限验证配置;
    • 调用钉钉JSAPI接口时,需要发起者的IP存在于H5应用后台配置的服务器出口IP列表中。
  • 部署阶段
    • 常规:把网站部署到服务器,配置DNS解析指向网站;
    • 登入开发者平台,为应用配置应用首页地址

关于H5 DEMO

页面和微信WEB版完全一样,只有接口调用方式不一样。

为了便于解析Token、Ticket、GetUser接口的结果,创建专门的类用于反序列化HTTPResponse。


    public class BaseResponse
    {
        public int errcode { get; set; }
        public string errmsg { get; set; }
    }
    public class TokenResponse : BaseResponse
    {
        public string access_token { get; set; }
    }
    public class TicketResponse : BaseResponse
    {
        public string ticket { get; set; }
        public int expires_in { get; set; }
    }
    public class GetUserBase : BaseResponse
    {//多余的属性用不到
        public string userid { get; set; }
    }

新增DDUser类,并创建一个对应的WxUser对象,作为网站用户。出于隐私考虑,Nickname由userid取Hash而来,避免暴露真实ID。


//DDHelper的GetUserInfo方法
        public static DDUser GetUserInfo(string code)
        {//先借code取userid,再借userid取详细信息
            try
            {
                string userid = JsonConvert.DeserializeObject<GetUserBase>(
                    ApiGet($"https://oapi.dingtalk.com/user/getuserinfo?access_token={Token}&code={code}")).userid;
                string res = ApiGet($"https://oapi.dingtalk.com/user/get?access_token={Token}&userid={userid}");
                return JsonConvert.DeserializeObject<DDUser>(res);
            }
            catch (Exception)
            {
                return null;
            }
        }
        
    public class DDUser
    {//删掉了一大堆用不到的属性
        public string userid { get; set; }
        public string errmsg { get; set; }
        public string avatar { get; set; }
        public string name { get; set; }
        public WxUser WxUser => new WxUser()
        {
            Avatar = string.IsNullOrEmpty(avatar) ? "/ding.png" : avatar,
            Created = DateTime.Now,
            LastUpdate = DateTime.Now,
            Message = 0,
            Nickname = "Ding-" + (Convert.ToInt64(userid.GetHashCode()) + int.MaxValue).ToString("x2"),
            Openid = userid,
            X = 10000,
            Y = 0
        };
    }

与微信项目类似,为了方便生成统一的ConfigData,创建一个专门的类,自动生成nonceStr和timeStamp,并在构造函数里直接计算签名。


//DDHelper的GetTicket方法,获取jsapi_ticket
        static string _ticket = "";
        static DateTime ticket_exp;
        public static string GetTicket()
        {
            if (ticket_exp < DateTime.Now || string.IsNullOrEmpty(_ticket))
            {
                TicketResponse res =
                    JsonConvert.DeserializeObject<TicketResponse>(
                        ApiGet($"https://oapi.dingtalk.com/get_jsapi_ticket?access_token={Token}"));
                _ticket = res.ticket;
                ticket_exp = DateTime.Now.AddSeconds(res.expires_in);
            }
            return _ticket;
        }
        
    public class DDConfigData
    {
        public string TimeStamp;
        public string NonceStr;
        public string Signature;
        public string Url;
        public DDConfigData(string url = "")
        {//参数生成以后,直接计算结果
            Url = url;
            NonceStr = Guid.NewGuid().ToString("N").Substring(0, 16);
            TimeStamp = Convert.ToInt64((DateTime.Now - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds).ToString();
            var data = $"jsapi_ticket={DDHelper.GetTicket()}&noncestr={NonceStr}&timestamp={TimeStamp}&url={url}";
            Console.WriteLine(data);
            Signature = General.SHA1(data).ToLower();
        }
    }

DDHelper.GetToken(),定时任务,用于access_token有效期刷新,需要手动触发一次(比如放到Startup.cs):


        public static void GetToken()
        {
            //后台任务无限刷新Token
            Task.Run(() =>
            {
                while (true)
                {
                    try
                    {
                        string res = ApiGet($"https://oapi.dingtalk.com/gettoken?appkey={AppKey}&appsecret={AppSecret}");
                        Token = JsonConvert.DeserializeObject<TokenResponse>(res).access_token;
                        Thread.Sleep(600000);
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine("GETTOKEN ERROR: " + ex.ToString());
                        GetToken();
                        break;
                    }
                }
            });
        }

DDHelper剩余部分


        public static string ApiGet(string url)
        {
            using WebClient client = new WebClient();
            try
            {
                string res = client.DownloadString(url);
                Console.WriteLine($"【APIGET:\r\n{url}\r\nRESULT:\t{res}】");
                return res;
            }
            catch (Exception) { throw; }
        }
        public static string ApiPost(string url, string content)
        {
            using WebClient client = new WebClient();
            client.Headers["Content-Type"] = "application/json;charset=utf8";
            string res =
                 Encoding.UTF8.GetString(client.UploadData(url, Encoding.UTF8.GetBytes(content)));
            Console.WriteLine($"【APIPOST:\r\n{url}\r\n{content}\r\nRESULT:\t{res}】");
            return res;
        }

首页做微调,识别不同浏览器并调用不同视图进行渲染


        public async Task<IActionResult> Index()
        {
            WxUser user = General.GetUser(HttpContext);
            if (General.Users.Count(u => u.Openid == user?.Openid) == 0 &&
                HttpContext.User.Identity.IsAuthenticated)
            {
                //用户登录状态还在,但用户列表里不存在该用户,直接登出并刷新
                //原因是demo环境用户列表没有做持久化+开发环境用户状态未清空,
                //正式环境不会出现这种问题。
                await HttpContext.SignOutAsync();
                return RedirectToAction("Index");
            }

            ViewBag.User = user;
            switch (General.UA(Request.Headers["User-Agent"]))
            {
                case UserAgents.Dingtalk:
                    return View("IndexDingtalk");
                case UserAgents.Wechat:
                    return View("IndexWx");
                default:
                    return Content("BROWSER_NOT_SUPPORTED");
            }
        }

Action – DDAuth,作为接口使用,前端页面调用后,通过钉钉接口获取用户信息,并在成功后自动登录。


        public async Task<IActionResult> DDAuth(string code = "")
        {
            DDUser user = DDHelper.GetUserInfo(code);
            if (user.userid is null)
            {
                return Content("登录失败");
            }
            var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
            WxUser wxuser = user.WxUser;
            identity.AddClaim(new Claim(ClaimTypes.Sid, wxuser.Openid));
            await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)).ConfigureAwait(false);
            General.Users.Add(wxuser);
            return Content("succ");
        }

Action – DDConfig,mime类型为text/javascript,验证用户登录状态,生成dd.config参数并返回dd.config配置js到前端。


        public ContentResult DDConfig(string url)
        {
            ContentResult js = new ContentResult
            {
                ContentType = "text/javascript"
            };
            if (HttpContext.User.Identity.IsAuthenticated)
            {
                //string url = Request.Headers["Referer"].FirstOrDefault();
                DDConfigData config = new DDConfigData(url);
                Console.WriteLine(JsonConvert.SerializeObject(config));
                js.Content = "dd.config({" +
$"    agentId: '{DDHelper.AgentId}'," +
$"    corpId: '{DDHelper.CorpId}'," +
$"    timeStamp: '{config.TimeStamp}'," +
$"    nonceStr: '{config.NonceStr}'," +
$"    signature: '{config.Signature}'," +
"    type: 0," +
"    jsApiList: [ 'device.geolocation.get' ]" +
"});";
            }
            else
            {
                js.Content = "var result='BAD_REQUEST.'";
            }
            return js;
        }

前端部分和微信WEB应用几乎一样


    <script>
        var words=@Html.Raw(JsonConvert.SerializeObject(General.Words))
        $.getScript("/Home/DDConfig?url="+encodeURIComponent(window.location.href));
        dd.ready(function () {
            if ('@(login?"Y":"N")' == 'N')
            {
                dd.runtime.permission.requestAuthCode({
                    corpId: '@DDHelper.CorpId',
                    onSuccess: function (result) {
                        $.get("/Home/DDAuth?code=" + result.code, function (e) {
                            if (e == "succ") {
                                window.location.reload();
                            } 
                        });
                    },
                    onFail: function (err) {                    }
                });}
        });
        dd.error(function (error) {        });
        var userlist =@Html.Raw(JsonConvert.SerializeObject(General.Users));
        function getusers() {
            $.get("/Home/Nearby", function (e) {
                $("#users li").remove();
                $.each(e, function (i, val) {
                    $("#users").append('<li class="list-group-item">            <div class="media">' +
                        '                <div class="media-left">' +
                        '                    <a href="#">' +
                        '                        <img class="media-object img32" src="' + val.avatar + '" alt="' + val.nickname + '">' +
                        '                    </a>' +
                        '                </div>' +
                        '                <div class="media-body">' +
                        '<h5 class="media-heading">' + val.nickname +
                        '<small> <span class="glyphicon glyphicon-map-marker"></span>(' + val.distance + ')</small></h5>' +
                        '                    <p>' + words[val.message] + '</p>' +
                        '                </div>' +
                        '            </div></li>');
                });
            });
        }
        function upload(msg) {
            dd.device.geolocation.get({
                targetAccuracy: 200,
                coordinate: 0,
                withReGeocode: Boolean,
                useCache: false,
                onSuccess: function (res) {
                    $.post("/Home/Upload",
                        {
                            X: res.latitude,
                            Y: res.longitude,
                            Message: msg
                        }, function (e) {
                            if (e == "succ") {
                                window.location.reload();
                            }
                        });
                },
                onFail: function (err) {
                    dd.device.notification.alert({
                        message: JSON.stringify(err),
                        title: "UPLOAD ERROR",
                        buttonName: "OK",
                        onSuccess: function () {
                        },
                        onFail: function (err) { }
                    });
                }
            });
        }
        getusers();
</script>

最终效果

钉钉版:

微信版: