缘起

对异构系统的整合是我的兴趣之一,Open edX的开放式设计使它很容易与其他系统整合,其中包括用户系统的整合

前前后后折腾了edx的各种登录和注册机制,在此理一下。前几天对python-social-auth做了探索,也一并做下笔记

主要内容包括:

  • CAS
  • OAuth2
    • OAuth2 client
    • OAuth2 server
  • 更改login机制,同时支持username/email登录
  • QQ/微信登录
  • 移动端跳转

CAS

关于CAS,我此前有写过一篇文章为什么CAS应该成为你的LMS的一部分

上边文章介绍了CAS的使用场景和它的原理,也给出了一些参考资料,对CAS不熟悉的小伙伴可以参考

看到这里就假设你基本弄懂CAS的原理啦,那我们直接讨论如何将CAS和edx对接

@MT说edx内置的cas client坑比较多,所以我试着改造了django-cas,这是改造后的:wwj718/django-cas,按照项目主页的引导,你就可以直接在Open edX里使用cas啦

具体的实现可以参考我的commit,代码很短,就几行

OAuth2

关于OAuth2的学习可以参考我的笔记:OAuth学习笔记

如果你对此熟悉 ,我们继续前进

CAS登录中,有一个中心,这个中心是CAS server,这种登录方式往往是集中式的。而OAuth2可以用于分布式登录的场景,尽管它的用途不只于此(还包括访问受限的资源)。

你一定使用过这种登录方式,许多网站都支持支持QQ/微信/google/facebook登录

这便使用了OAuth2

OAuth2 client

当我们访问网站A(比如我们的edx实例)时,使用了QQ登录,那么有以下事件发生

  • 用户访问A网站,选择QQ登录,网站将用户导向qq认证服务器。
  • 用户登录QQ,并选择是否给予网站A授权。
  • 若用户给予授权,QQ认证服务器将用户导向A网站事先指定的”重定向URI”(redirection URI),同时附上一个授权码。
  • A网站收到授权码,附上早先的”重定向URI”,向QQ认证服务器申请令牌。这一步是在A网站的后台的服务器上完成的,对用户不可见。
  • QQ认证服务器核对了授权码和重定向URI,确认无误后,向A网站发送访问令牌(access token)和更新令牌(refresh token)。

之后A网站在后端携带用户的access token,可能拿到用户资料了。至此A网站的后端可以知道用户A是否是A 网站的合法用户,对于采用QQ登录网站A而言,已经足够了。

如果你熟悉OAuth2,你会发现这就是使用最广泛的授权码模式

需要注意的是,本地用户系统如何与用户QQ资料挂钩,诸如展示用户名或者用户头像,这些不是oauth2该做的,即便走通了oauth2的流程只意味着,该用户在QQ认证服务器那边是合法用户,同时本地后台也可以拿到用户资料(QQ昵称/头像),可是如何把用户昵称和头像与网站A本地系统整合,好比username和昵称挂钩,或是与id挂钩,这需要在网站A注册系统里手写逻辑,一般在上边的最后一步做,注册完成之后,把用户重定向到dashboard。可以参考edx中既有的google/facebook/linkedin

你也可以参考这个案例:Logging-into-Django-w–Twitter,尽管这个案例和edx无关,但它基本把流程说清了

值得一提的事,edx整合外部登录到系统里的方式是采用用户绑定而不是直接注册,所以会导致以下问题

如果你使用python-social-auth,即便你好不容易,折腾半天,感觉已经没问题了,腾讯那边还会说点击QQ登录按钮提示登录失败或出现错误信息(无跳转、提示失败、出现错误信息),于是不让你审核通过,原因是edx的third_party_auth并不会自动将oauth2登录通过的用户注册到edx里,而是要求你使用edx既有用户绑定一下,之后才可以使用qq登录。

这样一来腾讯那边认为你并没有登录成功,所以没法审核通过

以上是Open edx使用OAuth2 client登录qq的场景

最后需要郑重提醒的是网站基本信息里回调地址得是http:xxx/auth/complete/qq/

OAuth2 server

lms的 OAuth2 server 用的是django-oauth2-provider

下边我们讨论Open edX作为OAuth2 server的场景,这时Open edx相当于我们上边提到的QQ认证服务器的角色,此时B网站就可以使用Open edx的用户登录他们的网站,诸如insights就是这样做的,insights本身是个独立完整的网站,为了与lms/cms整合在一起,采用了OAuth2来关联用户,这时候lms就扮演了OAuth2 server的角色,只要你拥有lms的用户,就可以直接登录insights,用户的感觉是只有一个用户系统。

整个认证的流程和上边基本相同,具体的操作可以参考edX Analytics Installation

不同的地方主要是OAuth2 server(lms)和OAuth2 client(insights)都是自己的,所以OAuth2 client是受信任的client,这是一种客户端模式,比前头提到的授权码模式来得简单,关于这些模式的对比,可以参考我的这篇文章:OAuth学习笔记

关于客户端模式的代码,参考enable Open edX REST APIs(work with mobile),通过客户端模式,我们可以轻易了解用户和密码的正确性

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": "wwj"}
response = requests.post("https://127.0.0.1/oauth2/access_token", auth=client_auth, data=post_data, verify=False)
response.json()

产生受信任客户端的核心就是我们能控制OAuth2 server,在其中执行:

sudo /edx/bin/python.edxapp /edx/bin/manage.edxapp lms --setting=aws create_oauth2_client http://insight:18110 http://insight:18110/complete/edx-oidc/ confidential --client_name insights --client_id YOUR_OAUTH2_KEY --client_secret secret --trusted

其中http://insight:18110/complete/edx-oidc/是回调地址,这一步往往是两个平台用户关联的核心所在,有兴趣的同学可以直接翻源码,出于篇幅限制,在此不详述。这个url,来自python-social-auth

这里给我们的一个启示是:如果你想拓展edx,所做的拓展并不需要再界面上与edx整合(内嵌的整合可以用djangoapp/xblock),而又希望两者能看起来像一个系统(用户打通),那么采用insights的这种架构就很好,实际上,open edx的整个项目就是由若干服务组成的,edx本身的就够就是由若干异构系统拼成的,这个今天人气很高的微服务有异曲同工之处

顺便再提一下,insights中有许多地方需要lms的数据,诸如题目统计里需要统计各类题目的正误情况,所以我们需要题目的信息,而这些信息insights里是不包括的,实际上这也是通过rest接口完成的,认证机制也是OAuth2,接口在这里Course Structure API

回到OAuth2 server的话题,lms作为OAuth2 server,我们首先需要启动它,通过在lms.env.json里设置(FEATURES)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
:::text
...
"FEATURES: {
	...
	"ENABLE_OAUTH2_PROVIDER": true,
	"OAUTH_ENFORCE_SECURE": false,
	...
}
"JWT_ISSUER": "http://LMS/oauth2",
"OAUTH_OIDC_ISSUER": "http://LMS/oauth2",
"OAUTH_ENFORCE_SECURE": false, #这个放在FEATURES里?
...

而在insight里,确保/edx/etc/insights.yml(如果跑脚本的时候设置正确,这些会自动生成)

1
2
3
4
5
:::text
CMS_COURSE_SHORTCUT_BASE_URL: http://LMS/course
COURSE_API_URL: http://LMS/api/course_structure/v0/
MODULE_PREVIEW_URL: http://LMS/xblock
SOCIAL_AUTH_EDX_OIDC_URL_ROOT: http://LMS/oauth2

外部登录细节

我们从insights开始,我们把insights看做一个oauth2 client,登录入口为/accounts/login/

1
2
3
4
:::text
    url(r'^accounts/login/$',
        RedirectView.as_view(url=reverse_lazy('social:begin', args=['edx-oidc']), permanent=False, query_string=True),
        name='login'),

通信的过程是标准的oauth2登录方式,具体的请求地址可以参考:auth-backends

其中EdXOpenIdConnect是关键所在

相关请求url为:

我们可以试试手动获取用户数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# get access_token
import requests
import requests.auth
client_auth = requests.auth.HTTPBasicAuth('key', 'secret')
post_data = {"grant_type": "password", "username": "wwj", "password": "wwj"}
response = requests.post("http://LMS/oauth2/access_token", auth=client_auth, data=post_data, verify=False)
response.json()

import requests
headers = {"Authorization": "bearer xxx", "User-Agent": "ChangeMeClient/0.1 by YourUsername"}
response = requests.get("http:/LMS/api/mobile/v0.5/my_user_info", headers=headers)
response.json()

以上采用的是受信任客户端的模式


这一块的单步调试非常错综复杂,是应为oauth2的url部分写得奇蠢无比。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
    urlpatterns += (
        # These URLs dispatch to django-oauth-toolkit or django-oauth2-provider as appropriate.
        # Developers should use these routes, to maintain compatibility for existing client code
        url(r'^oauth2/', include('lms.djangoapps.oauth_dispatch.urls')),
        # These URLs contain the django-oauth2-provider default behavior.  It exists to provide
        # URLs for django-oauth2-provider to call using reverse() with the oauth2 namespace, and
        # also to maintain support for views that have not yet been wrapped in dispatch views.
        url(r'^oauth2/', include('edx_oauth2_provider.urls', namespace='oauth2')), 
        # The /_o/ prefix exists to provide a target for code in django-oauth-toolkit that
        # uses reverse() with the 'oauth2_provider' namespace.  Developers should not access these
        # views directly, but should rather use the wrapped views at /oauth2/
        url(r'^_o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
    )

官方采取了url覆盖的方法,而不是明确指出,以至于你如果不深入源码丛林就找不到url对应的view,而由于这些是外部库,所以ack也很不方便

我们只好求助一些工具: sudo /edx/bin/python.edxapp /edx/app/edxapp/edx-platform/manage.py lms show_urls --settings devstack | grep user_info

1
2
3
:::text
/api/mobile/v0.5/my_user_info   rest_framework.decorators.my_user_info
/oauth2/user_info/      oauth2_provider.views.UserInfoView      oauth2:user_info

从中我们找到了user_info对于的方法,它来自edx_oauth2_provider,不要问题为何知道。。

如果你要手动处理oauth2,试试:requests-oauthlib,分步调试的话,可以看这个:examples/google,不过需要https

更多的调试细节,比如各个参数的含义,那么你需要了解oauth2协议本身,那样可以从http层面调试,否则会很艰难,还是尽量使用oauth2 client吧

如果你对过程参数感兴趣可以参考使用Authorization_Code获取Access_Token

对原理说的最清楚的为使用 OAuth 2.0 访问豆瓣 API

当携带code请求access_token是,使用http –form提交,例子如:

1
http --form http://LMS/oauth2/access_token  client_id=key client_secret=secret code=xxx grant_type=authorization_code redirect_uri=http://domain_test

至于如何拿到code

1
http --form http://LMS/oauth2/access_token  client_id=key client_secret=secret code=xxx grant_type=authorization_code redirect_uri=http://domain_test

```

,然后你将得到access token,其中有id_token参数,这个参数是jwt加密后的,需要解密

更改login机制

我们可能处于各种原因需要修改login机制,诸如想同时支持email和username,诸如不想验证用户的有效性,诸如允许某些用户携带秘钥直接登录

在此分享一下同时支持email和username的登录方式的思路。更改登录逻辑当然是核心

登录逻辑在common/djangoapps/student/views.py中的login_user函数,更改这部分倒是容易,麻烦反而在前端

前端并不写在template里,而是写在openedx/core/djangoapps/user_api/views.py中,找到LoginSessionView更改即可

QQ/微信登录

关于QQ登录大体流程在OAuth2 client中已经说了,如果你在这部分困难重重,除了QQ本身的坑之外(对此我们无能为力),你最好确保自己熟悉OAuth2,这样方便单步调试,只要你走通了一个OAuth2认证,其他的基本没有难度

调试的细节建议参考开发攻略_Client-side

客户端注册:connect.qq.com

移动端跳转

如果你不采用彼岸准的oauth2的方式来认证用户,而是由于各种历史原因想走捷径(最好不要这么干!很脏乱,不好维护)。好吧你还是不听,那我建议你采用jwt的方式来携带用户信息,应为有加密,起码它至少是安全的

关于jwt你可以参考我的这篇文章:JWT学习笔记

总结

关于用户系统,我最喜欢的一种设计是,它应该是可插拔式的

工具