当时处理这部分的动机是将edx与微信对接

如果你在处理与edx API相关的工作,这篇文章可能对你也有帮助。好比你在编译edx移动端(android和iOS), 这部分工作应该也是最主要的工作之一

##思路 我们首先简要做一下任务陈述:允许edx用户通过微信公众平台访问edX,登录以及请求相关的数据

这里假设读者们已经基本了解了OAuth2,包括它的一些基本概念和通信流程,如果还不了解,请先阅读OAuth2相关的材料。

在我们的任务中,我们先识别出OAuth中的参与实体,RO(resource owner),RS (resource server)和Client,至于AS(authorization server)在edx中和RS可以认为一体。

很显然我们的任务中,edx平台作为RS,而edx user是RO,而我们自己写的微信公众号后台便是Client。

由于微信后端和平台拥有者是相同的,所以我就不采用redirect的方式了。而假设Client是受信任的。

那么通信的流程是这样的,edx user在微信给微信公众号中给Client发送账号和密码,而后Client携带用户账号和密码去换取授权令牌(Access Token),且存下授权令牌,如此一来,概念上,用户在微信中便已经保持登录edX的状态了。

而后Client根据用户请求,携带Access Token去服务器请求资源返回给微信用户。

这里不应当混淆的是,使用微信账户登录edx,和在微信中以edx user身份访问edx,是两个完全不同的过程,使用微信账户登录edx本质上是个第三方社交账号登录edx的问题,RS是微信,而edx user在微信中访问edx,RS是edX。

好了,思路基本清晰了。

##先前的经验 之前写过一篇博客:让edx为手机端提供接口

本打算按照之前的经验,却发现,采用TokenAuthentication的解决方案除了侵入性太强,不够优雅之外,安全性也得不到保证

EdX API Authentication中有一句话,

OAuth 2.0 is an open standard used by many systems that require secure user authentication

我开始以为,secure只是个建议,稍后我们会发现,这是个强制要求。

无论是OAuth2Authentication, SessionAuthentication还是TokenAuthentication,本质都是个认证问题,而认证过程在django中间件里实现,对关注业务逻辑的开发者而言是透明的,而edx的api使用的统一是OAuth2Authentication和SessionAuthentication。

可选的路线只有一条,开始折腾OAuth2.

##目标定位 经过一番跟踪和分析,我们发现了edx/edx-oauth2-providerdjango-oauth2-provider与OAuth关系最大

而他们的关系是edx/edx-oauth2-provider依赖于edx/django-oauth2-provider

edx/django-oauth2-providerfork自caffeinehit/django-oauth2-provider

caffeinehit/django-oauth2-provider文档对我们很有助益,

##实验 定位到这两个关键库,其实接下来的工作就轻松多了。
首先做些试探性的实验。
先去/edx/app/edxapp/lms.env.json,在FEATURES里加上"ENABLE_OAUTH2_PROVIDER": true,以及"ENABLE_MOBILE_REST_API":true,,而后去admin里获取一个受信任的Client和Access Token,对应的地址分别是是/admin/oauth2_provider/trustedclient//admin/oauth2/accesstoken/add/,过期时间(Expires)可以设得远些,使其不易生效,你也通过设置OAUTH_ID_TOKEN_EXPIRATION来控制失效时间,这个数值衡量的是用户两次登录的时间间隔,好比你要求用户每七天需要登录一次。

那么激动人心的时刻来啦,我们开始请求接口

curl -k -H "Authorization: Bearer Your_Access_Token” http://example.com/api/user/v1/accounts/wwj

1
2
:::text
{"username": "wwj", "bio": null, "requires_parental_consent": true, "name": "wwj", "country": null, "is_active": true, "profile_image": {"image_url_full": "http://example.com/static/images/default-theme/default-profile_500.de2c6854f1eb.png", "image_url_large": "http://example.com/static/images/default-theme/default-profile_120.33ad4f755071.png", "image_url_medium": "http://example.com/static/images/default-theme/default-profile_50.5fb006f96a15.png", "image_url_small": "http://example.com/static/images/default-theme/default-profile_30.ae6a9ca9b390.png", "has_image": false}, "year_of_birth": null, "level_of_education": null, "goals": null, "language_proficiencies": [], "gender": null, "mailing_address": null, "email": "wwj@example.com", "date_joined": "2015-05-13T09:42:45Z"}

如果你使用httpie(推荐),那么返回的内容将以更易于阅读的形式(缩进高亮),返回给你.之后我们都只要httpie

http http://example.com/api/user/v1/accounts/wwj "Authorization: Bearer 1a17079824f66bfa5116bd8780b5a119e603a79c" (实际上是header参数)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
:::text
{
    "bio": null,
    "country": null,
    "date_joined": "2015-05-13T09:42:45Z",
    "email": "wwj@qq.com",
    "gender": null,
    "goals": null,
    "is_active": true,
    "language_proficiencies": [],
    "level_of_education": null,
    "mailing_address": null,
    "name": "wwj",
    "profile_image": {
        "has_image": false,
        "image_url_full": "http://example.com/static/images/default-theme/default-profile_500.de2c6854f1eb.png",
        "image_url_large": "http://example.com/static/images/default-theme/default-profile_120.33ad4f755071.png",
        "image_url_medium": "http://example.com/static/images/default-theme/default-profile_50.5fb006f96a15.png",
        "image_url_small": "http://example.com/static/images/default-theme/default-profile_30.ae6a9ca9b390.png"
    },
    "requires_parental_consent": true,
    "username": "wwj",
    "year_of_birth": null
}

再演示一个使用requests的做法

1
2
3
4
import requests
headers = {"Authorization": "bearer 1a17079824f66bfa5116bd8780b5a119e603a79c", "User-Agent": "ChangeMeClient/0.1 by YourUsername"}
response = requests.get("http://127.0.0.1/api/user/v1/accounts/wwj", headers=headers)
response.json()

得到

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
:::text
{u'bio': None,
 u'country': None,
 u'date_joined': u'2015-05-13T09:42:45Z',
 u'email': u'wwj@qq.com',
 u'gender': None,
 u'goals': None,
 u'is_active': True,
 u'language_proficiencies': [],
 u'level_of_education': None,
 u'mailing_address': None,
 u'name': u'wwj',
 u'profile_image': {u'has_image': False,
  u'image_url_full': u'http://127.0.0.1/static/images/default-theme/default-profile_500.de2c6854f1eb.png',
  u'image_url_large': u'http://127.0.0.1/static/images/default-theme/default-profile_120.33ad4f755071.png',
  u'image_url_medium': u'http://127.0.0.1/static/images/default-theme/default-profile_50.5fb006f96a15.png',
  u'image_url_small': u'http://127.0.0.1/static/images/default-theme/default-profile_30.ae6a9ca9b390.png'},
 u'requires_parental_consent': True,
 u'username': u'wwj',
 u'year_of_birth': None}

###下边演示请求Access Token的过程 使用requests

1
2
3
4
5
6
import requests
import requests.auth
client_auth = requests.auth.HTTPBasicAuth('dc107056a5335b3a7c74', '4e3f1fad6e0583fc80d78541f2ca6cfad8a93bed')
post_data = {"grant_type": "password", "username": "wwj", "password": "wwjtest"}
response = requests.post("http://127.0.0.1/oauth2/access_token", auth=client_auth, data=post_data)
response.json()

得到{u'error': u'invalid_request', u'error_description': u'A secure connection is required.'}

网站需要使用https,nmap查看443端口是close状态。

配置nginx。

##启用https

Remember that you should always use HTTPS for all your OAuth 2 requests otherwise you won’t be secured.

OAuth2要求使用https。所以我们为edx做https支持

###生成证书

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
:::text
cd /edx/app/nginx/
mkdir conf
chown -R 777 conf #好像不大好
cd conf
#创建服务器私钥,命令会让你输入一个口令
openssl genrsa -des3 -out server.key 1024
#创建签名请求的证书(CSR)
openssl req -new -key server.key -out server.csr
#在加载SSL支持的Nginx并使用上述私钥时除去必须的口令:
cp server.key server.key.org
openssl rsa -in server.key.org -out server.key

###配置nginx openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt

/edx/app/nginx/sites-enabled里,将lms复制为lms_https

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
:::text
sudo diff lms lms_https
1,3c1
< upstream lms-backend {
<             server 127.0.0.1:8000 fail_timeout=0;
<     }server {
---
> server {
12,13c10,13
<
<     listen 80 default;
---
>     listen 443;
>     ssl on;
>     ssl_certificate /edx/app/nginx/conf/server.crt;
>     ssl_certificate_key /edx/app/nginx/conf/server.key;

/edx/app/nginx/sites-enabled/lms的server结尾里加上

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
:::text
  # Forward to HTTPS if we're an HTTP request...
  if ($http_x_forwarded_proto = "http") {
    set $do_redirect "true";
  }

  # Run our actual redirect...
  if ($do_redirect = "true") {
    rewrite ^ https://$host$request_uri? permanent;
  }

重启nginx,https方面的设置就好了,你可以访问,https://example.com 啦

##https下请求Access Token

1
2
3
4
5
6
import requests
import requests.auth
client_auth = requests.auth.HTTPBasicAuth('dc107056a5335b3a7c74', '4e3f1fad6e0583fc80d78541f2ca6cfad8a93bed')
post_data = {"grant_type": "password", "username": "wwj", "password": "wwjtest"}
response = requests.post("https://127.0.0.1/oauth2/access_token", auth=client_auth, data=post_data, verify=False)
response.json()

``

ok

1
2
3
4
5
:::text
{u'access_token': u'e751c317435986b2a00425ed7a93a789fbcbeccd',
 u'expires_in': 2591999,
 u'scope': u'',
 u'token_type': u'Bearer'}

#微信后端 暂不方便公开源码

#todo

  • 将mobile api相关的请求全部redirect倒https
  • https证书相关

#2015.07.15更新 开发群里有小伙伴提到在用android客户端去访问服务器时,会出现这样的错误。javax.net.ssl.SSLPeerUnverifiedException: No peer certificate (文后评论中也有人提到)

这是ssl证书的问题,我此前的做法是不验证。这只是绕过了问题,而没有解决它,在此正面解决它,分以下步骤:

  • 申请ssl证书,我用的是免费的startssl。可参考www.startssl.com
  • 将申请来的证书加入到lms_https里:
1
2
3
4
:::text
ssl on;
ssl_certificate /etc/nginx/conf/your-ssl-unified.crt;
ssl_certificate_key /etc/nginx/conf/your-ssl.key;
  • sudo killall -HUP nginx

#2015.09.23更新 Cypress中的/edx/app/nginx目录有微调,移除了/edx/app/nginx/sites-enabled,所以我们需要把/edx/app/nginx/sites-available/中新建的文件软链接到/etc/nginx/sites-enabled/