0%

Django自定义用户认证系统

参考文档:https://docs.djangoproject.com/en/3.0/topics/auth/customizing/

Django认证系统实现原理

Django自带的认证系统通过调用 django.contrib.auth.authenticate() 方法实现,该方法是对外提供的可调用的统一接口,Django内部维护了一个认证列表,通过 settings.AUTHENTICATION_BACKENDS 配置进行设置,默认调用的是 django.contrib.auth.backends.ModelBackend

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
# 源码位于 django/contrib/auth/__init__.py
def authenticate(request=None, **credentials):
"""
If the given credentials are valid, return a User object.
"""
# 通过 _get_backends 读取settings.AUTHENTICATION_BACKENDS中配置的认证类
for backend, backend_path in _get_backends(return_tuples=True):
try:
inspect.getcallargs(backend.authenticate, request, **credentials)
except TypeError:
# This backend doesn't accept these credentials as arguments. Try the next one.
continue
try:
# 调用获取到的认证类的 authenticate()方法进行验证
user = backend.authenticate(request, **credentials)
except PermissionDenied:
# This backend says to stop in our tracks - this user should not be allowed in at all.
break
# 如果返回的值是None(认证函数没有返回值时,得到的是None,或者有的认证函数就是返回的None)的话,说明当前调用的认证类认证失败,继续尝试下一个
if user is None:
continue
# 会为用户对象添加backend参数,用来记录认证时使用的认证类
# 该参数会在login()方法中,放到session中保存
user.backend = backend_path
return user

# The credentials supplied are invalid to all backends, fire signal
user_login_failed.send(sender=__name__, credentials=_clean_credentials(credentials), request=request)


# 默认调用的认证类 ModelBackend
# 源码位于 django/contrib/auth/backends.py
class ModelBackend:
# 提供认证方法authenticate()
def authenticate(self, request, username=None, password=None, **kwargs):
# 内部主要通过传递过来的用户名和密码完成身份的验证
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user

...

通过源码可以发现, 当调用 django.contrib.auth.authenticate() 时,会依次尝试调用设置中提供的认证方法,直到有认证方法成功,否则会一直尝试直到所有认证都失败,且一旦引发 PermissionDenied 异常,认证将立即失败。Django不会检查再后面的认证,此外AUTHENTICATION_BACKENDS中认证类的顺序很重要,当有一个认证成功则立即返回,同样不再进行后面的认证。

一旦用户被认证过,Django会在用户的session中存储他使用的认证后端,然后在session有效期中一直会为该用户提供此后端认证(通过登录方法login()进行该操作)。 所以如果你改变 AUTHENTICATION_BACKENDS, 迫使用户重新认证,需要清除掉 session 数据.一个简单的方式是使用这个方法: Session.objects.all().delete()。

自定义用户认证类

用户认证是Django中一切权限操作的基础,例如之前的djangorestframework-jwt在返回token时,也是调用了Django自带的auth认证完成了用户信息(username和password)的核对,但是Django默认使用的认证类ModelBackend是通过用户名和密码进行认证的,而我们在使用的过程中可能有其他的需求,如通过手机号码进行认证,此时就需要自定义我们自己的认证类了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# users/views.py
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
from django.db.models import Q

User = get_user_model() # 获得当前使用的User类的实例对象

class CustomBackend(ModelBackend):
# 重写ModelBackend的authenticate方法,添加用户手机验证
def authenticate(self, request, username=None, password=None, **kwargs):
try:
user = User.objects.get(Q(username=username) | Q(mobile=username))
# 传递过来的password是明文,Django数据库中保存的是密文,check_password()会先加密再进行比对
if user.check_password(password):
return user
except Exception as e:
return None

此外还需要在settings.py中进行配置,覆盖掉默认类

1
2
3
4
# 自定义auth验证函数
AUTHENTICATION_BACKENDS = (
'users.views.CustomBackend',
)

django的整套认证流程

在web应用中,我们需要对一些页面使用用户认证,但是不可能每次验证都要通过传递用户名和密码使用 authenticate() 进行认证,Django为web请求提供了一套登录认证系统,方便使用者做用户的验证。

Django使用会话(session)和中间件将认证系统放到了request对象中。它们在每个请求上提供一个 request.user 属性(通过django.contrib.auth.middleware.AuthenticationMiddleware中间件实现),存储当前的用户。如果当前的用户没有登入,该属性将设置成 AnonymousUser 的一个实例,否则它将是 User 的实例。
你可以通过 is_authenticated 区分它们,像这样:

1
2
3
4
5
6
if request.user.is_authenticated:
# Do something for authenticated users.
...
else:
# Do something for anonymous users.
...

注意:在Django1.10+之后,is_authenticated被设计为属性,通过 @property 实现,用户表的基类AbstractBaseUser中的该属性的返回值为True,匿名用户AnonymousUser中也实现了该属性,返回值为False。所以该属性可以用来判断request.user中的对象是否认证过(认证过的用户是用户表中的user实例,所以调用is_authenticated返回的值为True)。

如何保存认证信息

通过Django提供的 login() 函数,该函数会利用Django的session框架将用户(注意这里的用户一定是通过了 authenticate() 认证后的用户)的id存储到session中,之后在访问的时候通过中间件 django.contrib.sessions.middleware.SessionMiddlewaredjango.contrib.auth.middleware.AuthenticationMiddleware 完成认证信息的提取。

源码分析:

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
# 源码位置 django/contrib/auth/__init__.py 
def login(request, user, backend=None):
"""
Persist a user id and a backend in the request. This way a user doesn't
have to reauthenticate on every request. Note that data set during
the anonymous session is retained when the user logs in.
"""
session_auth_hash = ''
if user is None: # 如果传递的user参数是None的话,会自动赋值当前request.user中的值,可能是匿名用户AnonymousUser的实例对象
user = request.user # 由中间件AuthenticationMiddleware赋予,可能是存在的用户,也可能是匿名用户AnonymousUser
if hasattr(user, 'get_session_auth_hash'):
# get_session_auth_hash()是AbstractBaseUser类特有的方法,该类是django中用户表的最上层基础类,所以若是用户表中的对象就一定可以调用该方法
session_auth_hash = user.get_session_auth_hash() # 返回密码字段的HMAC值

if SESSION_KEY in request.session: # _auth_user_id,request.session由中间件SessionMiddleware赋予
if _get_user_session_key(request) != user.pk or (
session_auth_hash and
not constant_time_compare(request.session.get(HASH_SESSION_KEY, ''), session_auth_hash)):
# To avoid reusing another user's session, create a new, empty
# session if the existing session corresponds to a different
# authenticated user.
request.session.flush()
else:
request.session.cycle_key()

try:
# or 运算符,a or b, 如果a为真,表达式为a,反之,为b
# 所以在未传递该参数的情况下,当前表达式的值为user.backend
backend = backend or user.backend
except AttributeError:
# 异常只有一种情况,就是使用user.backend时,user没有backend属性,该属性是在使用authenticate()认证之后赋予的,为 backend_path 的值
# 此时用户可能是AnonymousUser(AnonymousUser对象没有该属性),也可能是直接从数据库取的未认证的对象(认证过的对象会添加backend参数)
# 此时就手动调用获得设置的 认证类,但是此时需要配置中只能有一个认证的类,否则报异常(理所当然,如果允许多个认证类,登录认证就没有意义了)
backends = _get_backends(return_tuples=True)
if len(backends) == 1:
_, backend = backends[0]
else:
raise ValueError(
'You have multiple authentication backends configured and '
'therefore must provide the `backend` argument or set the '
'`backend` attribute on the user.'
)
else:
if not isinstance(backend, str):
raise TypeError('backend must be a dotted import path string (got %r).' % backend)

# 运行到此处,两种情况:认证过的带有backend属性的用户,未认证过的用户
request.session[SESSION_KEY] = user._meta.pk.value_to_string(user)
request.session[BACKEND_SESSION_KEY] = backend
request.session[HASH_SESSION_KEY] = session_auth_hash
if hasattr(request, 'user'):
request.user = user # 如果当前request对象没有user属性,则使用当前传递的对象创建
rotate_token(request) # 更改csrftoken
user_logged_in.send(sender=user.__class__, request=request, user=user)

根据代码的分析,在使用login()登录的时候,需要优先使用authenticate()进行认证,因为authenticate()在User上设置一个属性backend名,标识哪种认证后台成功认证了该用户,且该信息在后面登录的过程中是需要的,所以必须有authenticate()认证的过程,所以正确的login()使用方式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class LoginView(View):
def post(self, request, *args, **kwargs):
"""
完成登录 验证的逻辑
"""
# Django提供的表单验证:只是根据定义的要求,对数据格式方法进行验证,对于正确性,用户名和密码是否对应这个验证还是需要交给authenticate
login_form = LoginForm(request.POST) # django自带的表单功能除了有验证功能,还可以对数据进行一定的处理
if login_form.is_valid(): # 会对字段进行LoginForm中设置的类型进行基本的验证,即是否有值,长度是否符合进行验证,错误信息显示在error属性中
user_name = login_form.cleaned_data["username"]
password = login_form.cleaned_data["password"]
user = authenticate(username=user_name, password=password) # 自带的用户名和密码验证功能,用于通过用户名和密码查询用户是否存在,不存在时为None
if user is not None:
# 查询到用户
login(request, user) # 自动完成session的设置,如何取到user对应的sessionid,以及如何将sessionid传递到cookie中都自动完成
return HttpResponseRedirect(reverse("index"))
else:
# 未查询到用户
return render(request, "login.html", {"msg": "用户名或 密码错误", "login_form": login_form)
else:
return render(request, "login.html", {"login_form": login_form)

此时就完成了用户登录的操作,在后续的认证中,系统将直接通过中间件 django.contrib.sessions.middleware.SessionMiddlewaredjango.contrib.auth.middleware.AuthenticationMiddleware 完成认证信息的提取,在系统中直接使用request.user获得存储的用户。

AuthenticationMiddleware中间件赋值user的时候,会通过session的判断,赋值认证过的user,或者是 AnyAnonymousUser, 调用的函数是 django/contrib/auth/__init__.py 下的get_user(request)方法,该方法下又是通过session来验证用户的。