最近写的后端项目需要云盘,由于各种原因,最终选择了 Onedrive。Onedrive 的一个优点是文档是中文的,但缺点是中文也看不懂。。。。
于是借助文档、博客、示例代码等等,慢慢摸索过来,并把摸索的这个过程形成一篇博文。
# 注册应用、用户登录授权
参考博客:zhangguanzhang's Blog (opens new window)
参考文档:Microsoft Graph 中的 OneDrive 授权和登录 (opens new window)
Onedrive 的认证(以及 MS 家的其他功能)都统一使用 Microsoft Graph 进行认证,而 Microsoft Graph 似乎只支持 OAuth2(必须让用户在浏览器完成登录,不能拿到密码然后在后台登录),不支持 OAuth1(可以在后台利用用户密码发起请求完成登录),所以会麻烦一些。
# 在 Azure 创建应用
第一件事,是按照上面的博客所说,注册应用。这个应用其实就是一个 API,以下引用并修改了 zhangguanzhang's Blog (opens new window):
我们首先要创建一个应用程序。
https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade
到上面链接里去注册一个应用程序,属性为:
- 受支持的帐户类型记得选
任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox)
- 重定向 URI (可选) 我用的是
http://localhost:8000/getAToken
,在摸索的时候这个随便写就行。另外,这个值要存在代码里,命名为redirect_uri
。
然后设置权限。点击左边侧边栏的 “API 权限”,点击中间的“添加权限”,然后在右边点“Microsoft Graph” - “委托的权限”,搜索并找到以下三个权限,勾选上。
Files.ReadWrite
Files.ReadWrite.All
offline_access
然后在侧边栏的“概述”里面,把应用程序(客户端) ID 复制下来(在代码里存为 client_id
)。
然后在侧边栏“证书和密码”里面,点击“新客户端密码”,生成一个 ID 和值,把值存到代码里,命名为 client_secret
。
# 用户在浏览器登录,获取 auth_token
在 Azure 创建应用以后,就是授权直至拿到 refresh_token
的过程。这个过程就是参考 Microsoft Graph 中的 OneDrive 授权和登录 (opens new window) 了,这里我用 Python requests 实现。
现在文档迁移到了 Microsoft Graph 身份验证概述 (opens new window)而且更加详细了。而上述链接不再提供中文文档。
首先设置上述变量:
import requests
headers = {'Content-Type':'application/x-www-form-urlencoded'}
scope = "Files.ReadWrite offline_access"
client_id = "3aea792a-f5f5-49a6-b64d-e1a45d375323"
redirect_uri = "http://localhost:8000/getAToken"
client_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
然后是生成登录链接。这个链接需要在浏览器里访问然后输入账号密码,于是就不用 requests.get
而是把链接 print 出来,我们复制到浏览器里面。
login_link = f"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id={client_id}&scope={scope}&response_type=code&redirect_uri={redirect_uri}"
print(login_link)
打开 print 的链接,成功登录并授权后,跳转到 localhost
,其中的参数的 code
字段就是我们需要的 auth_token
,729个字符,有点长。
http://localhost:8000/getAToken?code=0.AAAApOutbSzcpEiz8KuV1mGJ2ip56jr19aZJtk3hpF03UyM-AI4.AQABAAIAAAD--DLA3VO7QrddgJg7Wevry9C8xxU0YmDl1t3kRL1Rt8c4ZYINbxBW_X7KGMwL80bFg2I99rHKlQuC_bwGWIMjn1C4GjZg8fR2v2r-9J6fffSYUVMmrA5tGJ-7AUyulg076ViCOLWvtDzUh1T09f3Tt2Q8TpgCgO8P-0PqGMPqNOivzAxQz8WH5ZKSTMaI7WeOSBGe9yrpdjskO4pJrZv62E-jl2udaTBSXJAG4hKc18feCvuhJk27gT4H1W7ZiONqwMMkpzK6nlMhBgRP7-hTBNIU82Y0ASNOsOu2aAzrCQJmmbDdPHvsEYIq5jDnlOqeoNNFh-0v_AEbf-YfUfvIxN79eGpgrXvH19sLstDqFagJaOayNm6sf4HHuo2ikAot5kLZoBwYus57BaWeLI2IA_jDKd9T899Pv_Gfc_fwkgI9PynaHfoDRHb9A-6fXaJYPE5IYkTEnunNDaBf6jKtoTPub5LFIZv3OP38c3zJrTBZL5Wr5dpo3-pa0FFkbYPgHGC5APlWFNiBx-OECd4OJnbdzM7hrA2YzCLa6Bwl7SG4KTVXv2fwW1gFLgCZdI_xYBEDHfYuKUnlC5eqcebWwtkfJ8roFj9p3hzJ_GQSgEKjTgrdSUMaxrYwhSnAzS06H4BqnLKL-FrKsDBWJXuAIAA&session_state=697ec6eb-a4b9-4567-9873-6065f156164b#
在之后开发 WebApp 时,我们需要把 redirect_url
设置为我们能路由到的链接,在路由到的 View 中获取 uri 参数,就可以获得 auth_token
了。不过现在还是手动赋值吧。
# 赋值
auth_token = "0.AAAApOutbSzcpEiz8KuV1mGJ2ip56jr19aZJtk3hpF03UyM-AI4.AQABAAIAAAD--DLA3VO7QrddgJg7WevrnDiEeOedZvv94_iCOZdHtunnh-HD4-yA7CnKCnlskkTcXuD_nVujxrzDErLOPO5Kdba-gLFaUgOCxScnphpTmaR-bJbb6KCOJ6WEsecTZhdoBVvhgG8SzPAmolHemGaOOymSx1L3opaGXSo6YolsgwSCYcokocf9jl9Jq8pOAfg4tuZTYh1uX6f-IvQgLoIhUhE8i7GoaLIFhel9ICjiWgO9imtgNiLIjMX-MMRxqEKbcvbjVi9vnat1LE29v7uX_0Ogs6feGFzKEZOvT8pNlbm1t0frSwSIktQeIELEU3kzbDX7TEp1b_Cguj2u7wqF-kLv3P9w2_Gzhi-lzdRph1h9SW-RFYyWtdoCfhQgFf5m8K7aUGUEKgDK0RomoPvuZ1jfoNxe5JWQdcHqNo-LcHblYGRI1azy-p1fp28aBB6qKZ_0pcRTb4BrU5qLi9ze4RJ5kPajodTSSloUSVkf64B9OEdPeBvT8XU8_to0aVeHXOUTlsgVSlliatLPx9pFhsOZ4ec2-7fQMr8_CBhPlY4rjMfl8plefsfwcIsDBc0PRPITcsxA7OYSxZXvSchHA6XmCSzdl1413mTh7d0cvLYnBmzJm1tc6z6PEW270mtL_enHuzbtZpK9D1Epu0HIIAA"
# 用 auth_token 换 access_token 和 refresh_token
赋值以后,就可以请求拿 access_token
和 refresh_token
了。
headers={'Content-Type':'application/x-www-form-urlencoded'}
response = requests.post(
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
data={
'client_id': client_id,
'redirect_uri': redirect_uri,
'client_secret': client_secret,
'code': auth_token,
'grant_type': 'authorization_code'
},
headers=headers)
response # <Response [200]>
eval(response.content)
# {'token_type': 'Bearer',
# 'scope': 'Files.ReadWrite profile openid email',
# 'expires_in': 3599,
# 'ext_expires_in': 3599,
# 'access_token': 'XXXXXXXX',
# 'refresh_token': 'XXXXXXXX'}
好耶!
MS 还提供了网址对 access_token 进行解密:https://jwt.ms/
。
# 用 refresh_token 换新的 access_token 和新的 refresh_token
然后,我们把上面 response 里的 refresh_token
取出来,然后请求刷新 access_token
和 refresh_token
:
refresh_token = eval(response.content)['refresh_token']
response_refresh = requests.post(
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
data = {
'client_id': client_id,
'redirect_uri': redirect_uri,
'client_secret': client_secret,
'refresh_token': refresh_token,
'grant_type': 'refresh_token'
},
headers = headers)
response # <Response [200]>
eval(response.content)
# {'token_type': 'Bearer',
# 'scope': 'Files.ReadWrite profile openid email',
# 'expires_in': 3599,
# 'ext_expires_in': 3599,
# 'access_token': 'XXXXXXXXXXXXXXXXXXXX',
# 'refresh_token': 'XXXXXXXXXXXXXXXXXXX'}
至此,关于 token 的申请方法就讲完了。
# Django 实现
我把上面的过程改成了一个 OnedriveAuthentication
类(代码见 GitHub (opens new window),views 部分在 views.py (opens new window) 中)。
另外,MS 也给了一个利用 requests_oauthlib.OAuth2Session
封装的示例 (opens new window),也可以参考。
另外需要注意的是,如果在 Django 后端中使用 requests
请求 Onedrive,会使得线程等待,在 Onedrive 相关请求并发量高的时候很容易造成数秒甚至数十秒的延迟。
可以在部署的时候使用更多线程缓解这个症状。想要根本改变这个问题,就不得不提到异步了。
异步请求可以使用 aiohttp
库。Django 3.1 也提供了异步视图的支持(但没有提供异步数据库访问的支持,不过也提供了临时解决办法)。但是,Django REST Framework 3.2.3 还不支持异步视图,想要使用异步视图,还得使用纯 Django 视图。
所以,目前我的解决办法,是把并发最高的一个 API 改写为纯 Django 视图然后异步化,并对该视图用到的 requests
请求改为 aiohttp
请求。
# 在前后端分离的情况下使用 Onedrive API
完成登录授权,拿到 Access Token 以后,就可以用 MS Graph 的 API 进行操作了。可以在 Graph Explorer (opens new window) 进行测试。
而具体使用方法,就直接看文档 (opens new window)啦。这文档蛮详细的,边写边看就行。
由于 REST API 的寻址部分比较麻烦,我使用 Python 对 API 进行了简单封装,代码在 GitHub (opens new window)。
这里只写了自己在开发过程中遇到的一些场景,以及解决方案。
项目的场景是使用一个账户的云盘作为整个项目的共有云盘,文件上传、删除等操作全部交给后端完成。后端判断前端登录的用户权限后,使用该账户的 access_token 执行操作。
# 上传
为了省去文件经过后端服务器的流量,后端可以使用 Onedrive 的通过上传会话上传大文件 (opens new window) 方案上传文件。前后端和 Onedrive 的交互过程如下:
- 在登陆状态下,前端向后端
POST /api/cloud/file
发起请求,后端请求 Onedrive 生成一个临时上传对话,并将 Onedrive 的应答(格式见上面的链接)转发给前端; - 前端按照上面链接所述方法,直接向 Onedrive 上传文件。上传完成后,Onedrive 返回文件的 id 等信息,文件将位于
/(应用文件夹)/temp/{userid}/
文件夹; - 前端根据需求(如上传沙龙相关文件、沙龙照片)向对应接口发起请求(请求需包含文件 id),后端将文件移动至每个功能对应的文件夹,并完成后续操作(录入数据库等)。
# 前端交互
前端交互原理很简单好像也不简单,对于错误处理就更麻烦了,虽然 MS Graph 最后给了对各种错误处理的详细策略,但是没有示例代码还是很麻烦。
因此,这里放一个我的 JavaScript 实现,其中 createUploadSession
是创建上传会话的接口。
// 调用后端接口,将文件上传至 onedrive。
// 参考文档:https://docs.microsoft.com/zh-cn/graph/api/driveitem-createuploadsession
export async function uploadFile(file) {
let res = await createUploadSession({"filename": file.name});
const uploadUrl = JSON.parse(res.data).uploadUrl;
let size = file.size;
const totalRetryTimes = 5; // 4xx 导致的上传失败的重试次数
const firstDelay = 1000; // 5xx 导致的上传失败的第一次等待时间(之后采用 * 2 的指数退避策略)
// MS 直接给了上传时错误处理的策略,nb!
// https://docs.microsoft.com/zh-cn/graph/api/driveitem-createuploadsession#best-practices
async function uploadPart(start, end, retryTimes = 0, nextDelay = firstDelay) {
// console.log(`uploadPart: start=${start}, end=${end}, retryTimes=${retryTimes}, nextDelay=${nextDelay}`);
let headers = {};
let status = 0;
headers['Content-Range'] = `bytes ${start}-${end - 1}/${size}`;
headers['Content-Type'] = file.type;
let config = {
withCredentials: false,
timeout: 0,
headers
};
try {
res = await axios.put(uploadUrl, file.slice(start, end), config);
status = res.status;
} catch (err) {
status = 500;
}
if (status >= 500) {
// 继续或重试由于连接中断或任意 5xx 错误而失败的上载
// 请使用指数退避战略
console.warn(`上传失败,等待 ${nextDelay / 1000}s 后上传...`);
await sleep(nextDelay);
return await uploadPart(start, end, retryTimes, nextDelay * 2);
} else if (status >= 200 && status < 300) {
// 成功上传该段,返回
return res;
} else {
// 对于其他错误,不应使用指数退避战略,而应限制尝试重试的次数
if (retryTimes > 0) {
if (retryTimes > totalRetryTimes) {
console.warn(`上传失败 ${retryTimes} 次,取消上传`);
throw res;
} else {
console.warn(`上传失败 ${retryTimes} 次,重试中...`);
return await uploadPart(start, end, retryTimes+1, nextDelay);
}
}
}
}
let status = 0;
let start = 0;
// 200 为上传成功(覆盖),201 为上传成功(新建)
while (status !== 200 && status !== 201) {
let end = Math.min(start + maxFileContentLength, file.size);
res = await uploadPart(start, end);
start = end;
status = res.status;
}
return res.data.id;
}
这段代码还没有加上传进度等功能,不过上传的大体思路就是这样的。
# 下载
为了省去文件经过后端服务器的流量,本后端只提供下载链接。由于 Onedrive API 限制,提供两种下载链接:
- 永久链接,但需在浏览器中访问
- 临时链接(Onedrive 链接有效期 15min,但本后端提供即时获取链接并重定向的 REST API)
# 永久链接
有网友发现,手动或利用 Onedrive API (opens new window) 生成分享链接后,在分享链接后追加 ?download=1
参数,在浏览器访问该链接即可自动下载文件。
但该链接对应的 html 需要运行 JavaScript,因此不能通过 Python requests
或 JavaScript XMLHTTPRequest
直接下载。推荐使用后面的 API,或者在 JavaScript 使用 window.open()
在新窗口打开这个链接。
# 临时链接
利用 Onedrive API 下载文件 (opens new window) 时,响应报文为 302 Found
,Location
为一个下载 URL。该 URL 仅在较短的一段时间 (几分钟后)内有效,不需要认证即可下载。
本后端提供 /cloud/file/{id}/download/
API,该 API 调用上述 API 后将报文返回给前端。