Django学习笔记-Web端授权AcWing一键登录

  1. 1. 在Django中集成Redis
  2. 2. 申请授权码
  3. 3. 申请授权令牌和用户ID
  4. 4. 申请用户信息

本节内容是通过 OAuth2 实现网页端的 AcWing 一键登录功能。

1. 在Django中集成Redis

Redis 为内存数据库,目前我们使用的是 Django 自带的数据库 SQLite,且能够很容易地迁移到 MySQL,这些数据库的效率不如 Redis,其特点为:

  • Redis 存的内容为 key, value 对,而其它数据库存的是若干张表,每张表为若干条目;
  • Redis 为单线程的,不会出现读写冲突。

首先我们需要先安装 Redis:

1
2
3
4
sudo apt-get update
sudo apt-get install redis-server
sudo service redis-server status # 查看redis-server状态
pip install django_redis

接着配置一下 Django 的缓存机制,将下面这段代码复制到 settings.py 中:

1
2
3
4
5
6
7
8
9
10
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
},
}
USER_AGENTS_CACHE = 'default'

然后启动 redis-server,启动后可以使用 top 命令查看 redis-server 是否运行:

1
sudo redis-server /etc/redis/redis.conf

此时在项目根目录执行 python3 manage.py shell 进入 IPython 交互界面,输入以下代码测试一下 Redis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
In [1]: from django.core.cache import cache

In [2]: cache.keys('*') # 查找当前数据库的所有关键字,支持正则匹配
Out[2]: []

In [3]: cache.set('yyj', 1, 5) # 设置关键字,第三个参数表示多长时间过期,单位是秒,None表示不会过期
Out[3]: True

In [4]: cache.set('abc', 2, None)
Out[4]: True

In [5]: cache.has_key('abc') # 查询某个键是否存在
Out[5]: True

In [6]: cache.has_key('yyj')
Out[6]: False

In [7]: cache.get('abc') # 查询某个键的值
Out[7]: 2

In [8]: cache.delete('abc') # 删除某个关键字
Out[8]: True

注意如果出现报错:ConnectionError: Error 111 connecting to 127.0.0.1:6379. Connection refused. 说明 redis-server 没有启动。

2. 申请授权码

一键授权登录的流程是用户点击一键登录后弹出确认授权的页面,用户确认后就自动创建一个新的账号,且登录到网站里。

具体交互流程为:用户点击按钮后向网站服务器端(Web)发起申请,请求用 AcWing 账号登录,然后 Web 将自己的 AppID 报给 AcWing,AcWing 给用户返回一个页面询问用户是否要授权给刚刚的网站,如果用户同意,那么 AcWing 会将一个授权码 code(两小时有效期)发给 Web,Web 接到授权码后再加上自己的身份信息 AppSecret 以及 AppID 向 AcWing 申请一个授权令牌 access-token(两小时有效期)和用户的 openid(唯一辨别用户),Web 拿到令牌和用户 ID 后即可向 AcWing 申请用户的用户名和头像。

由于我们需要记录每个用户的 openid,因此我们需要在数据库(game/models/player/)的 Player 类中添加一个信息:

1
2
3
4
5
6
7
8
9
10
11
from django.db import models
from django.contrib.auth.models import User

# Player有两个关键字,user表示是和哪个User对应的,avatar表示头像
class Player(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) # 当删除User时也将其关联的Player一块删掉
avatar = models.URLField(max_length=256, blank=True) # 头像用链接存
openid = models.CharField(default='', max_length=50, blank=True, null=True)

def __str__(self): # 显示每个Player的数据
return str(self.user)

在根目录下更新一下数据库:

1
2
python3 manage.py makemigrations
python3 manage.py migrate

申请授权码的请求地址:https://www.acwing.com/third_party/api/oauth2/web/authorize/

请求方法为 GET,参考示例:

1
https://www.acwing.com/third_party/api/oauth2/web/authorize/?appid=APPID&redirect_uri=REDIRECT_URI&scope=SCOPE&state=STATE

参数说明:

  • appid:应用的唯一 ID,可以在 AcWing 编辑 AcApp 的界面里看到;
  • redirect_uri:接收授权码的地址,表示 AcWing 端要将授权码返回到哪个链接,需要对链接进行编码:Python3 中使用 urllib.parse.quote;Java 中使用 URLEncoder.encode
  • scope:申请授权的范围,目前只需填 userinfo
  • state:用于判断请求和回调的一致性,授权成功后原样返回该参数值,即接收授权码的地址需要判断是否是 AcWing 发来的请求(判断收到的 state 与发送出去的 state 是否相同),如果不是直接 Pass。该参数可用于防止 CSRF 攻击(跨站请求伪造攻击),建议第三方带上该参数,可设置为简单的随机数(如果是将第三方授权登录绑定到现有账号上,那么推荐用 随机数 + user_id 作为 state 的值,可以有效防止 CSRF 攻击)。此处 state 可以存到 Redis 中,设置两小时有效期。

用户同意授权后会重定向到 redirect_uri,返回参数为 codestate。链接格式如下:

1
redirect_uri?code=CODE&state=STATE

如果用户拒绝授权,则不会发生重定向。

我们在 game/views/settings 目录中创建一个 acwing 目录表示 AcWing 授权登录,然后在该目录中先创建一个 __init__.py,再创建两个子目录 webacapp 分别表示 Web 端的 AcWing 一键登录以及 AcApp 端的 AcWing 一键登录,最后同样在这两个子目录中创建 __init__.py 文件。

web 目录下创建申请授权码的 API apply_code.py

1
2
3
4
5
6
7
from django.http import JsonResponse

def apply_code(request):
appid = '4007'
return JsonResponse({
'result': 'success',
})

接着创建接收授权码的 API receive_code.py

1
2
3
4
from django.shortcuts import redirect

def receive_code(request):
return redirect('index') # 重定向回index

然后在 game/urls/settings 目录中也创建一个 acwing 目录,在该目录中创建 __init__.pyindex.pyindex.py 内容如下:

1
2
3
4
5
6
7
8
from django.urls import path
from game.views.settings.acwing.web.apply_code import apply_code
from game.views.settings.acwing.web.receive_code import receive_code

urlpatterns = [
path('web/apply_code/', apply_code, name='settings_acwing_web_apply_code'),
path('web/receive_code/', receive_code, name='settings_acwing_web_receive_code'),
]

然后需要将该目录 includesettings 目录的 index.py 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
from django.urls import path, include
from game.views.settings.getinfo import getinfo
from game.views.settings.login import mylogin
from game.views.settings.logout import mylogout
from game.views.settings.register import register

urlpatterns = [
path('getinfo/', getinfo, name='settings_getinfo'),
path('login/', mylogin, name='settings_login'),
path('logout/', mylogout, name='settings_logout'),
path('register/', register, name='settings_register'),
path('acwing/', include('game.urls.settings.acwing.index')),
]

重启项目后即可访问 https://<公网IP>/settings/acwing/web/apply_code/ 以及 https://<公网IP>/settings/acwing/web/receive_code/ 测试效果。

现在来看看 urllib.parse.quote 的作用,它能够将链接重新编码,替换掉原本的部分特殊字符防止出现 BUG:

1
2
3
4
5
6
In [1]: from urllib.parse import quote

In [2]: url = 'https://app4007.acapp.acwing.com.cn/settings/acwing/web/receive_code/?args=yyj'

In [3]: quote(url)
Out[3]: 'https%3A//app4007.acapp.acwing.com.cn/settings/acwing/web/receive_code/%3Fargs%3Dyyj'

现在我们完善一下 apply_code.py 的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.http import JsonResponse
from django.core.cache import cache
from urllib.parse import quote
from random import randint

def get_state(): # 获得8位长度的随机数
res = ''
for i in range(8):
res += str(randint(0, 9))
return res

def apply_code(request):
appid = '4007'
redirect_uri = quote('https://app4007.acapp.acwing.com.cn/settings/acwing/web/receive_code/')
scope = 'userinfo'
state = get_state()
cache.set(state, True, 7200) # 有效期2小时
apply_code_url = 'https://www.acwing.com/third_party/api/oauth2/web/authorize/'
return JsonResponse({
'result': 'success',
'apply_code_url': apply_code_url + '?appid=%s&redirect_uri=%s&scope=%s&state=%s' % (appid, redirect_uri, scope, state)
})

然后修改一下前端代码(Settings 类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class Settings {
constructor(root) {
...

this.$acwingoption = this.$login.find('.ac_game_settings_acwingoption img');

...
}

start() { // 在初始化时需要从服务器端获取用户信息
...
}

add_listening_events() { // 绑定监听函数
this.add_listening_events_login();
this.add_listening_events_register();
}

add_listening_events_login() {
let outer = this;
this.$login_register.click(function() {
outer.register();
});
this.$login_submit.click(function() {
outer.login_on_remote();
});
this.$acwingoption.click(function() {
outer.acwing_login();
});
}

add_listening_events_register() {
...
}

login_on_remote() { // 在远程服务器上登录
...
}

register_on_remote() { // 在远程服务器上注册
...
}

logout_on_remote() { // 在远程服务器上登出
...
}

acwing_login() {
$.ajax({
url: 'https://app4007.acapp.acwing.com.cn/settings/acwing/web/apply_code/',
type: 'GET',
success: function(resp) {
console.log(resp);
if (resp.result === 'success') {
window.location.replace(resp.apply_code_url); // 将当前页面重定向
}
}
})
}

register() { // 打开注册界面
...
}

login() { // 打开登录界面
...
}

getinfo() {
...
}

hide() {
...
}

show() {
...
}
}

3. 申请授权令牌和用户ID

请求地址:https://www.acwing.com/third_party/api/oauth2/access_token/

请求方法为 GET,参考示例:

1
https://www.acwing.com/third_party/api/oauth2/access_token/?appid=APPID&secret=APPSECRET&code=CODE

参数说明:

  • appid:应用的唯一 ID,可以在 AcWing 编辑 AcApp 的界面里看到;
  • secret:应用的秘钥,可以在 AcWing 编辑 AcApp 的界面里看到;
  • code:上一步中获取的授权码。

申请成功的返回内容示例:

1
2
3
4
5
6
7
{ 
"access_token": "ACCESS_TOKEN",
"expires_in": 7200,
"refresh_token": "REFRESH_TOKEN",
"openid": "OPENID",
"scope": "SCOPE",
}

申请失败的返回内容示例:

1
2
3
4
{
"errcode": 40001,
"errmsg": "code expired", # 授权码过期
}

返回参数说明:

  • access_token:授权令牌,有效期2小时;
  • expires_in:授权令牌还有多久过期,单位(秒);
  • refresh_token:用于刷新 access_token 的令牌,有效期30天;
  • openid:用户的 ID。每个 AcWing 用户在每个 AcApp 中授权的 openid 是唯一的,可用于识别用户;
  • scope:用户授权的范围。目前范围为 userinfo,包括用户名、头像。

现在我们点击 AcWing 一键登录按钮即可跳出请求授权的页面,AcWing 会向 receive_code 函数发送 code 以及 state,现在我们完善 receive_code.py 接到授权后的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from django.shortcuts import redirect
from django.core.cache import cache
import requests

def receive_code(request):
data = request.GET
code = data.get('code')
state = data.get('state')

if not cache.has_key(state):
return redirect('index')
cache.delete(state)

apply_access_token_url = 'https://www.acwing.com/third_party/api/oauth2/access_token/'
params = {
'appid': '4007',
'secret': '0edf233ee876407ea3542220e2a8d83e',
'code': code
}

access_token_res = requests.get(apply_access_token_url, params=params).json() # 申请授权令牌
print(access_token_res)

return redirect('index') # 重定向回index

现在我们点击一键登录按钮即可在后台看到接收到的授权令牌内容。

4. 申请用户信息

请求地址:https://www.acwing.com/third_party/api/meta/identity/getinfo/

请求方法为 GET,参考示例:

1
https://www.acwing.com/third_party/api/meta/identity/getinfo/?access_token=ACCESS_TOKEN&openid=OPENID

参数说明:

  • access_token:上一步中获取的授权令牌;
  • openid:上一步中获取的用户 openid

申请成功的返回内容示例:

1
2
3
4
{
'username': "USERNAME",
'photo': "https:cdn.acwing.com/xxxxx"
}

申请失败的返回内容示例:

1
2
3
4
{
'errcode': "40004",
'errmsg': "access_token expired" # 授权令牌过期
}

现在我们用授权令牌向 AcWing 申请用户的用户名和头像,进一步完善 receive_code.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from django.shortcuts import redirect
from django.core.cache import cache
from django.contrib.auth.models import User
from django.contrib.auth import login
from game.models.player.player import Player
from random import randint
import requests

def receive_code(request):
data = request.GET
code = data.get('code')
state = data.get('state')

if not cache.has_key(state):
return redirect('index')
cache.delete(state)

apply_access_token_url = 'https://www.acwing.com/third_party/api/oauth2/access_token/'
params = {
'appid': '4007',
'secret': '0edf233ee876407ea3542220e2a8d83e',
'code': code
}

access_token_res = requests.get(apply_access_token_url, params=params).json() # 申请授权令牌
access_token = access_token_res['access_token']
openid = access_token_res['openid']

player = Player.objects.filter(openid=openid) # 查看当前用户的openid是否已经存在
if player.exists():
login(request, player[0].user) # 如果用户的账号已经存在直接登录即可
return redirect('index')

get_userinfo_url = 'https://www.acwing.com/third_party/api/meta/identity/getinfo/'
params = {
'access_token': access_token,
'openid': openid
}

get_userinfo_res = requests.get(get_userinfo_url, params=params).json() # 申请获取用户信息
username = get_userinfo_res['username']
avatar = get_userinfo_res['photo']

while User.objects.filter(username=username).exists(): # 如果当前用户的用户名已经存在则在其后面添加若干位随机数
username += str(randint(0, 9))

user = User.objects.create(username=username) # 创建该用户,没有密码
player = Player.objects.create(user=user, avatar=avatar, openid=openid)
login(request, user) # 创建好后直接登录

return redirect('index') # 重定向回index

至此 Web 端的 AcWing 一键登录已经实现。

上一章:Django学习笔记-用户名密码登录

下一章:Django学习笔记-VS Code本地运行项目