钉钉企业应用C#开发笔记之一(免登)
關于釘釘
釘釘是阿里推出的企業移動OA平臺,本身提供了豐富的通用應用,同時其強大的后臺API接入能力讓企業接入自主開發的應用成為可能,可以讓開發者實現幾乎任何需要的功能。
近期因為工作需要研究了一下釘釘的接入,發現其接入文檔、SDK都是基于java編寫的,而我們的企業網站使用Asp.Net MVC(C#)開發,所以接入只能從頭自己做SDK。
接入主要包括免登、獲取數據、修改數據等接口。
免登流程
首先需要理解一下釘釘的免登流程,借用官方文檔的圖片:
是不是很熟悉?是的,基本是按照OAUTH的原理來的,版本嘛,里面有計算簽名的部分,我覺得應該是OAUTH1.0。
有的讀者會問,那第一步是不是應該跳轉到第三方認證頁面啊。我覺得“魔法”就藏在用來打開頁面的釘釘內置瀏覽器里,在dd.config()這一步里,“魔法”就生效了。
其實簡單來說,主要分為五步:
在你的Web服務器端調用api,傳入CorpId和CorpSecret,獲取accessToken,即訪問令牌。
在服務器端調用api,傳入accessToken,獲取JsApiTicket,即JsApi的訪問許可(門票)。
按照既定規則,在后臺由JsApiTicket、NonceStr、Timestamp、本頁面Url生成字符串,計算SHA1消息摘要,即簽名Signature。
將AgentId、CorpId、Timestamp、NonceStr、Signature等參數傳遞到前臺,在前臺調用api,得到authCode,即授權碼。
根據授權碼,在前臺或后臺調用api,獲得userId,進而再根據userId,調用api獲取用戶詳細信息。
PS:為什么需要在后臺完成一些api的調用呢?應該是因為js跨域調用的問題,我具體沒有深究。
實踐方法
理解了上述步驟,我對登陸過程的實現也大致有了一個設想,既然免登需要前后端一起來完成,那就添加一個專門的登陸頁面,將登陸過程都在里面實現,將登陸結果寫入到Session,并重定向回業務頁面,即算完成。圖示如下:
其中每個api的調用方式,在官方文檔中都有說明。同時,我在阿里云開發者論壇找到了網友提供的SDK,有興趣可以下載:釘釘非官方.Net SDK
另外,GitHub上還有官方的JQuery版免登開發Demo,可以參考:GitHub JQuery免登。
我參考的是.Net SDK,將其中的代碼,提取出了我所需要的部分,做了簡化處理。基本原理就是每次調用API都是發起HttpRequest,將結果做JSON反序列化。
核心代碼如下:
1 using System;
2 using System.Collections.Generic;
3 using System.Linq;
4 using System.Web;
5 using System.IO;
6 using Newtonsoft.Json;
7 using Newtonsoft.Json.Linq;
8 using DDApi.Model;
9
10 namespace DDApi
11 {
12 public static class DDHelper
13 {
14 public static string GetAccessToken(string corpId, string corpSecret)
15 {
16 string url = string.Format("https://oapi.dingtalk.com/gettoken?corpid={0}&corpsecret={1}", corpId, corpSecret);
17 try
18 {
19 string response = HttpRequestHelper.Get(url);
20 AccessTokenModel oat = Newtonsoft.Json.JsonConvert.DeserializeObject<AccessTokenModel>(response);
21
22 if (oat != null)
23 {
24 if (oat.errcode == 0)
25 {
26 return oat.access_token;
27 }
28 }
29 }
30 catch (Exception ex)
31 {
32 throw;
33 }
34 return string.Empty;
35 }
36
37 /* https://oapi.dingtalk.com/get_jsapi_ticket?access_token=79721ed2fc46317197e27d9bedec0425
38 *
39 * errmsg "ok"
40 * ticket "KJWkoWOZ0BMYaQzWFDF5AUclJOHgO6WvzmNNJTswpAMPh3S2Z98PaaJkRzkjsmT5HaYFfNkMdg8lFkvxSy9X01"
41 * expires_in 7200
42 * errcode 0
43 */
44 public static string GetJsApiTicket(string accessToken)
45 {
46 string url = string.Format("https://oapi.dingtalk.com/get_jsapi_ticket?access_token={0}", accessToken);
47 try
48 {
49 string response = HttpRequestHelper.Get(url);
50 JsApiTicketModel model = Newtonsoft.Json.JsonConvert.DeserializeObject<JsApiTicketModel>(response);
51
52 if (model != null)
53 {
54 if (model.errcode == 0)
55 {
56 return model.ticket;
57 }
58 }
59 }
60 catch (Exception ex)
61 {
62 throw;
63 }
64 return string.Empty;
65 }
66
67 public static long GetTimeStamp()
68 {
69 TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
70 return Convert.ToInt64(ts.TotalSeconds);
71 }
72
73 public static string GetUserId(string accessToken, string code)
74 {
75 string url = string.Format("https://oapi.dingtalk.com/user/getuserinfo?access_token={0}&code={1}", accessToken, code);
76 try
77 {
78 string response = HttpRequestHelper.Get(url);
79 GetUserInfoModel model = Newtonsoft.Json.JsonConvert.DeserializeObject<GetUserInfoModel>(response);
80
81 if (model != null)
82 {
83 if (model.errcode == 0)
84 {
85 return model.userid;
86 }
87 else
88 {
89 throw new Exception(model.errmsg);
90 }
91 }
92 }
93 catch (Exception ex)
94 {
95 throw;
96 }
97 return string.Empty;
98 }
99
100 public static string GetUserDetailJson(string accessToken, string userId)
101 {
102 string url = string.Format("https://oapi.dingtalk.com/user/get?access_token={0}&userid={1}", accessToken, userId);
103 try
104 {
105 string response = HttpRequestHelper.Get(url);
106 return response;
107 }
108 catch (Exception ex)
109 {
110 throw;
111 }
112 return null;
113 }
114
115 public static UserDetailInfo GetUserDetail(string accessToken, string userId)
116 {
117 string url = string.Format("https://oapi.dingtalk.com/user/get?access_token={0}&userid={1}", accessToken, userId);
118 try
119 {
120 string response = HttpRequestHelper.Get(url);
121 UserDetailInfo model = Newtonsoft.Json.JsonConvert.DeserializeObject<UserDetailInfo>(response);
122
123 if (model != null)
124 {
125 if (model.errcode == 0)
126 {
127 return model;
128 }
129 }
130 }
131 catch (Exception ex)
132 {
133 throw;
134 }
135 return null;
136 }
137
138 public static List<DepartmentInfo> GetDepartmentList(string accessToken, int parentId = 1)
139 {
140 string url = string.Format("https://oapi.dingtalk.com/department/list?access_token={0}", accessToken);
141 if (parentId >= 0)
142 {
143 url += string.Format("&id={0}", parentId);
144 }
145 try
146 {
147 string response = HttpRequestHelper.Get(url);
148 GetDepartmentListModel model = Newtonsoft.Json.JsonConvert.DeserializeObject<GetDepartmentListModel>(response);
149
150 if (model != null)
151 {
152 if (model.errcode == 0)
153 {
154 return model.department.ToList();
155 }
156 }
157 }
158 catch (Exception ex)
159 {
160 throw;
161 }
162 return null;
163 }
164 }
165 }
1 using System.IO;
2 using System.Net;
3
4 namespace DDApi
5 {
6 public class HttpRequestHelper
7 {
8 public static string Get(string url)
9 {
10 WebRequest request = HttpWebRequest.Create(url);
11 WebResponse response = request.GetResponse();
12 Stream stream = response.GetResponseStream();
13 StreamReader reader = new StreamReader(stream);
14 string content = reader.ReadToEnd();
15 return content;
16 }
17
18 public static string Post(string url)
19 {
20 WebRequest request = HttpWebRequest.Create(url);
21 request.Method = "POST";
22 WebResponse response = request.GetResponse();
23 Stream stream = response.GetResponseStream();
24 StreamReader reader = new StreamReader(stream);
25 string content = reader.ReadToEnd();
26 return content;
27 }
28 }
29 }
HttpRequestHelperView Code
其中的Model,就不再一一貼出來了,大家可以根據官方文檔自己建立,這里只舉一個例子,即GetAccessToken的返回結果:
public class AccessTokenModel
{
public string access_token { get; set; }
public int errcode { get; set; }
public string errmsg { get; set; }
}
我創建了一個類DDApiService,將上述方法做了封裝:
using DDApi.Model;
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Security.Cryptography;
using System.Text;
namespace DDApi
{
/// <summary>
///
/// </summary>
public class DDApiService
{
public static readonly DDApiService Instance = new DDApiService();
public string CorpId { get; private set; }
public string CorpSecret { get; private set; }
public string AgentId { get; private set; }
private DDApiService()
{
CorpId = ConfigurationManager.AppSettings["corpId"];
CorpSecret = ConfigurationManager.AppSettings["corpSecret"];
AgentId = ConfigurationManager.AppSettings["agentId"];
}
/// <summary>
/// 獲取AccessToken
/// 開發者在調用開放平臺接口前需要通過CorpID和CorpSecret獲取AccessToken。
/// </summary>
/// <returns></returns>
public string GetAccessToken()
{
return DDHelper.GetAccessToken(CorpId, CorpSecret);
}
public string GetJsApiTicket(string accessToken)
{
return DDHelper.GetJsApiTicket(accessToken);
}
public string GetUserId(string accessToken, string code)
{
return DDHelper.GetUserId(accessToken, code);
}
public UserDetailInfo GetUserDetail(string accessToken, string userId)
{
return DDHelper.GetUserDetail(accessToken, userId);
}
public string GetUserDetailJson(string accessToken, string userId)
{
return DDHelper.GetUserDetailJson(accessToken, userId);
}
public UserDetailInfo GetUserDetailFromJson(string jsonString)
{
UserDetailInfo model = Newtonsoft.Json.JsonConvert.DeserializeObject<UserDetailInfo>(jsonString);
if (model != null)
{
if (model.errcode == 0)
{
return model;
}
}
return null;
}
public string GetSign(string ticket, string nonceStr, long timeStamp, string url)
{
String plain = string.Format("jsapi_ticket={0}&noncestr={1}×tamp={2}&url={3}", ticket, nonceStr, timeStamp, url);
try
{
byte[] bytes = Encoding.UTF8.GetBytes(plain);
byte[] digest = SHA1.Create().ComputeHash(bytes);
string digestBytesString = BitConverter.ToString(digest).Replace("-", "");
return digestBytesString.ToLower();
}
catch (Exception e)
{
throw;
}
}
public List<DepartmentInfo> GetDepartmentList(string accessToken, int parentId = 1)
{
return DDHelper.GetDepartmentList(accessToken, parentId);
}
}
}
DDApiService View Code
以上是底層核心部分。登錄頁面的實現在控制器DDController中,代碼如下:
using DDApi;
using DDApi.Model;
using System;
using System.Web.Mvc;
namespace AppointmentWebApp.Controllers
{
public class DDController : Controller
{
//
// GET: /DD/
public ActionResult GetUserInfo(string accessToken, string code, bool setCurrentUser = true)
{
try
{
string userId = DDApiService.Instance.GetUserId(accessToken, code);
string jsonString = DDApiService.Instance.GetUserDetailJson(accessToken, userId);
UserDetailInfo userInfo = DDApiService.Instance.GetUserDetailFromJson(jsonString);
if (setCurrentUser)
{
Session["AccessToken"] = accessToken;
Session["CurrentUser"] = userInfo;
}
return Content(jsonString);
}
catch (Exception ex)
{
return Content(string.Format("{{'errcode': -1, 'errmsg':'{0}'}}", ex.Message));
}
}
public ActionResult Login()
{
BeginDDAutoLogin();
return View();
}
private void BeginDDAutoLogin()
{
string nonceStr = "helloDD";//todo:隨機
ViewBag.NonceStr = nonceStr;
string accessToken = DDApiService.Instance.GetAccessToken();
ViewBag.AccessToken = accessToken;
string ticket = DDApiService.Instance.GetJsApiTicket(accessToken);
long timeStamp = DDHelper.GetTimeStamp();
string url = Request.Url.ToString();
string signature = DDApiService.Instance.GetSign(ticket, nonceStr, timeStamp, url);
ViewBag.JsApiTicket = ticket;
ViewBag.Signature = signature;
ViewBag.NonceStr = nonceStr;
ViewBag.TimeStamp = timeStamp;
ViewBag.CorpId = DDApiService.Instance.CorpId;
ViewBag.CorpSecret = DDApiService.Instance.CorpSecret;
ViewBag.AgentId = DDApiService.Instance.AgentId;
}
}
}
DDController View Code
視圖View的代碼:
@{
ViewBag.Title = "Login";
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title</title>
</head>
<body>
<h2 id="notice">正在登錄...</h2>
<script src="http://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
<script type="text/javascript" src="http://g.alicdn.com/dingding/open-develop/1.5.1/dingtalk.js"></script>
<script type="text/javascript">
var _config = [];
_config.agentId = "@ViewBag.AgentId";
_config.corpId = "@ViewBag.CorpId";
_config.timeStamp = "@ViewBag.TimeStamp";
_config.nonceStr = "@ViewBag.NonceStr";
_config.signature = "@ViewBag.Signature";
dd.config({
agentId: _config.agentId,
corpId: _config.corpId,
timeStamp: _config.timeStamp,
nonceStr: _config.nonceStr,
signature: _config.signature,
jsApiList: ['runtime.info', 'biz.contact.choose',
'device.notification.confirm', 'device.notification.alert',
'device.notification.prompt', 'biz.ding.post',
'biz.util.openLink']
});
dd.ready(function () {
dd.runtime.info({
onSuccess: function (info) {
logger.e('runtime info: ' + JSON.stringify(info));
},
onFail: function (err) {
logger.e('fail: ' + JSON.stringify(err));
}
});
dd.runtime.permission.requestAuthCode({
corpId: _config.corpId,
onSuccess: function (info) {//成功獲得code值,code值在info中
//alert('authcode: ' + info.code);
//alert('token: @ViewBag.AccessToken');
/*
*$.ajax的是用來使得當前js頁面和后臺服務器交互的方法
*參數url:是需要交互的后臺服務器處理代碼,這里的userinfo對應WEB-INF -> classes文件中的UserInfoServlet處理程序
*參數type:指定和后臺交互的方法,因為后臺servlet代碼中處理Get和post的doGet和doPost
*原本需要傳輸的參數可以用data來存儲的,格式為data:{"code":info.code,"corpid":_config.corpid}
*其中success方法和error方法是回調函數,分別表示成功交互后和交互失敗情況下處理的方法
*/
$.ajax({
url: '@Url.Action("GetUserInfo", "DD")?code=' + info.code + '&accessToken=@ViewBag.AccessToken',//userinfo為本企業應用服務器后臺處理程序
type: 'GET',
/*
*ajax中的success為請求得到相應后的回調函數,function(response,status,xhr)
*response為響應的數據,status為請求狀態,xhr包含XMLHttpRequest對象
*/
success: function (data, status, xhr) {
alert(data);
var info = JSON.parse(data);
if (info.errcode != 0) {
alert(data);
} else {
//alert("當前用戶:" + info.name);
$('#notice').text("歡迎您:" + info.name + "。瀏覽器正在自動跳轉...");
location.href = "@Url.Action("Index", "Home")";
}
},
error: function (xhr, errorType, error) {
logger.e("嘗試獲取用戶信息失敗:" + info.code);
alert(errorType + ', ' + error);
}
});
},
onFail: function (err) {//獲得code值失敗
alert('fail: ' + JSON.stringify(err));
}
});
});
dd.error(function (err) {
alert('dd error: ' + JSON.stringify(err));
});
</script>
</body>
</html>
Login.cshtml View Code
其中nonstr理論上最好應該每次都隨機,留待讀者去完成吧:-)
釘釘免登就是這樣,只要弄懂了就會覺得其實不難,還順便理解了OAUTH。
后續改進
這個流程沒有考慮到AccessToken、JsApiTicket的有效期時間(2小時),因為整個過程就在一個頁面中都完成了。如果想要進一步擴展,多次調用api的話,需要考慮到上述有效期。
如果為了圖簡便每都去獲取AccessToken也是可以的,但是會增加服務器負擔,而且api的調用頻率是有限制的(1500次/s好像),所以應當采取措施控制。例如可以將AccessToken、JsApiTicket存放在this.HttpContext.Application["accessToken"]中,每次判斷有效期是否過期,如果過期就調用api重新申請一個。
以上就是這樣,感謝閱讀。
20170710編輯,更新mvc免登流程圖片,修正一處錯誤。
總結
以上是生活随笔為你收集整理的钉钉企业应用C#开发笔记之一(免登)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 不好的习惯
- 下一篇: 《廖雪峰 . Git 教程》学习总结