iT邦幫忙

2024 iThome 鐵人賽

DAY 10
0
Software Development

Django 2024: 從入門到SaaS實戰系列 第 11

Django in 2024: 深入瞭解Django內置的用戶管理系統

  • 分享至 

  • xImage
  •  

我們在之前建立強大的Django AdminDjango Admin二次開發,打造屬於你的後台中就有建立超級用戶以及一般用戶的經驗,透過shell命令與UI介面我們可以很容易達成註冊、登入、登出與修改密碼等功能。而Django主要是透過User model來達成,而在背後又是靠什麼原理來處理的呢?今天就來進行探討

今日重點如下:

  • 探討User模型
    • 簡單實現註冊、登入、登出
    • Django如何管理密碼與驗證
  • User模型的擴展與開發
    • 繼承AbstractUser

探討User模型

Django的User模型是認證系統的核心

User模型有以下基礎欄位

  • username
  • password
  • email
  • is_staff

這些之前設定過的就不特別說明,其他更詳細的可以看官方文檔:

https://docs.djangoproject.com/en/4.2/ref/contrib/auth/#fields

我們今天主要關心其中的方法,來幫助我們完成相關的業務邏輯。接下來透過demo整個流程來了解相關的操作,這裡不會使用內建的form表單,因為他在表單驗證的時候就會做內部的處理,我們主要是想透過流程來了解User模型會用到哪些方法來處理相關流程

簡單實現註冊、登入、修改密碼、登出

  • 建立app並註冊
# bash
python3 manage.py startapp my_user

# settings.py
INSTALLED_APPS = [
    # my apps
    "article",
    "chat",
    "my_user",
]

# urls.py 注意這裡我沒有使用i18n_patterns了
urlpatterns += [
		...
    path("my_user/", include(("my_user.urls", "my_user"), namespace="my_user")),
    ...
]
  • 配置路由
# my_user.urls.py
from django.urls import path

from .views import (
    user_view,
)

urlpatterns = [
    path("user/", user_view, name="user"),
]
  • 設置模板
{% extends 'base.html' %}
{% block title %}
<title>{{ title }}</title>
{% endblock title %}

{% block content %}
<section class="container">
	<h1>{{ page_title }}</h1>
	<div class="row">
		<div class="col">
			{% if msg %}
			{% if msg.code == 200 %}
			<h2 class="p-2 text-success">
				{{ msg.msg }}
			</h2>
			{% else %}
			<h2 class="p-2 text-danger">
				{{ msg.msg }}
			</h2>
			{% endif %}
			
			{% endif %}
			<form action="{% url "my_user:user" %}" method="post">
				{% csrf_token %}
				<div class="mb-3">
					<label for="username" class="form-label">Username</label>
					<input type="text" class="form-control" id="username" aria-describedby="usernameHelp" name="username">
					<div id="usernameHelp" class="form-text">輸入用戶名</div>
				  </div>
				  <div class="mb-3">
					<label for="password" class="form-label">Password</label>
					<input type="password" class="form-control" id="password" name="password">
				  </div>
				  <button type="submit" class="btn btn-primary bg-success border-0" name="register">註冊</button>
				  <button type="submit" class="btn btn-primary bg-warning border-0" name="login">登入</button>
				  <button type="submit" class="btn btn-primary border-0" name="logout">登出</button>
			</form>
		</div>
	</div>
</section>
{% endblock content %}

  • 視圖:註冊邏輯
from django.shortcuts import render
from django.contrib.auth.models import User

def user_view(request):
    title = "User Action"
    page_title = "User Action"

    if request.method == "POST":
        username = request.POST.get("username", "")
        password = request.POST.get("password", "")

        if "register" in request.POST:
            # 確認用戶是否已經註冊
            if User.objects.filter(username=username).exists():
                msg = {
                    "code": 400,
                    "msg": "User already exists",
                }
            else:
                new_user = User.objects.create_user(
                    username=username,
                    password=password,
                    is_staff=True,
                    is_superuser=True,
                )
                msg = {
                    "code": 200,
                    "msg": "User created successfully",
                }

    return render(request, "my_user.html", locals())

在接收表單數據時,會用username來確認是否有相同用戶,沒有的話,會調用create_user方法

至少需要提供username與password,至於is_staff與is_superuser則是可選項,源碼如下:

def create_user(self, username, email=None, password=None, **extra_fields):
    extra_fields.setdefault("is_staff", False)
    extra_fields.setdefault("is_superuser", False)
    return self._create_user(username, email, password, **extra_fields)

輸入資料後,並點擊註冊按鈕,可以去後台看確實我們成功建立用戶了

https://ithelp.ithome.com.tw/upload/images/20240922/201618660mcY1Z9ZVd.png

  • 視圖:登入邏輯
from django.contrib.auth import authenticate, login

elif "login" in request.POST:
            user = authenticate(request, username=username, password=password)
            if user is not None:
                login(request, user)
                msg = {
                    "code": 200,
                    "msg": "Login successfully",
                }
            else:
                msg = {
                    "code": 400,
                    "msg": "Login failed",
                }

可以看到使用了authenticate與login兩個方法:

  • authenticate:接收請求與憑證,憑證通常會包含username與password,並且會去每個settings.AUTHENTICATION_BACKENDS中確認憑證能不能找到對應的User對象,成功返回該對象,反之則為None

  • login:接收請求、剛剛已驗證過的User對象,並且將用戶ID存到session中,登入後可以去後台看用戶已經變成我們剛剛登入的用戶

  • 視圖:登出邏輯

from django.contrib.auth import logout

elif "logout" in request.POST:
            logout(request)
            msg = {
                "code": 200,
                "msg": "Logout successfully",
            }

logout方法會將請求中的session資料刪除,沒有返回值,並且如果用戶沒有登入也不會觸發任何錯誤

Django如何管理密碼與驗證

前面看到Django可以藉由驗證用戶的帳號密碼來確認用戶,那Django是怎麼處理這些密碼的呢?

我們可以先來看User Model繼承的AbstractBaseUser

class AbstractBaseUser(models.Model):
    ...

    def set_password(self, raw_password):
        self.password = make_password(raw_password)
        self._password = raw_password

可以看到set_password中接收初始密碼後,調用了make_password,並把初始密碼當作參數,

make_password 可以將明文密碼轉換成安全雜湊值的核心方法

def make_password(password, salt=None, hasher="default"):
    """
    Turn a plain-text password into a hash for database storage

    Same as encode() but generate a new random salt. If password is None then
    return a concatenation of UNUSABLE_PASSWORD_PREFIX and a random string,
    which disallows logins. Additional random string reduces chances of gaining
    access to staff or superuser accounts. See ticket #20079 for more info.
    """
    if password is None:
        return UNUSABLE_PASSWORD_PREFIX + get_random_string(
            UNUSABLE_PASSWORD_SUFFIX_LENGTH
        )
    if not isinstance(password, (bytes, str)):
        raise TypeError(
            "Password must be a string or bytes, got %s." % type(password).__qualname__
        )
    hasher = get_hasher(hasher)
    salt = salt or hasher.salt()
    return hasher.encode(password, salt)

make_password接收下列參數:

  1. 密碼,如果沒有設置密碼,或是密碼不是字符串或字節,都會阻止用戶設置密碼
  2. 可選的鹽,如果沒傳,會調用hasher.salt()生成
  3. 要使用的密碼雜湊法,預設為settings.PASSWORD_HASHERS中第一個算法
    "django.contrib.auth.hashers.PBKDF2PasswordHasher"
  4. 最終返回密鑰

這包含以下知識點:

  1. 鹽是隨機生成的字符串,因為鹽值的不同,即使用戶用相同的密碼產生的雜湊值也不同,在進行雜湊之前,鹽會添加到密碼中
  2. 雜湊是將任意長度的輸入轉成固定長度的輸出,並且有不可逆特性,確定性與雪崩效應
    • 不可逆:無法從雜湊值反推出原始密碼
    • 確定性:相同的輸入總是產生相同的輸出,所以驗證時就是將明文密碼進行雜湊,看是否跟儲存的雜湊值相同
    • 雪崩效應:一點不同就會有很大變化的輸出
  3. 雜湊算法,Django這邊默認算法為PBKDF2:
    • 輸入:密碼、鹽值、迭代次數、所需的密鑰長度
    • 迭代過程:將密碼和鹽值組合,重複應用指定的迭代次數
    • 輸出:合併多次迭代的結果,截取所需長度的輸出作為最終密鑰或雜湊

我們已經知道在建立用戶時,密碼是怎麼生成的,那做密碼驗證又是怎麼做呢?一樣回到AbstractBaseUser

class AbstractBaseUser(models.Model):
    ...
    def check_password(self, raw_password):
        """
        Return a boolean of whether the raw_password was correct. Handles
        hashing formats behind the scenes.
        """

        def setter(raw_password):
            self.set_password(raw_password)
            # Password hash upgrades shouldn't be considered password changes.
            self._password = None
            self.save(update_fields=["password"])

        return check_password(raw_password, self.password, setter)
def check_password(password, encoded, setter=None, preferred="default"):
    """
    Return a boolean of whether the raw password matches the three
    part encoded digest.

    If setter is specified, it'll be called when you need to
    regenerate the password.
    """
    if password is None or not is_password_usable(encoded):
        return False

    preferred = get_hasher(preferred)
    try:
        hasher = identify_hasher(encoded)
    except ValueError:
        # encoded is gibberish or uses a hasher that's no longer installed.
        return False

    hasher_changed = hasher.algorithm != preferred.algorithm
    must_update = hasher_changed or preferred.must_update(encoded)
    is_correct = hasher.verify(password, encoded)

    # If the hasher didn't change (we don't protect against enumeration if it
    # does) and the password should get updated, try to close the timing gap
    # between the work factor of the current encoded password and the default
    # work factor.
    if not is_correct and not hasher_changed and must_update:
        hasher.harden_runtime(password, encoded)

    if setter and is_correct and must_update:
        setter(password)
    return is_correct

首先我們可以看到AbstractBaseUser 中的check_password,會將表單傳過來的密碼(以下簡稱raw password),跟實際用戶雜湊後的密碼,以及一個setter方法傳入另一個check_password方法中(前後是不同的)

而後者的check_password(隸屬於auth.hasher.py中)做了幾件事情:

  1. 初步檢查傳進來raw password是否可以使用,如果不行就返回False,驗證密碼失敗
  2. 拿到雜湊法,並確認可不可以使用,如果是不再支持或是格式錯誤也返回False
  3. 確認雜湊法跟settings.PASSWORD_HASHERS 是否相同
  4. 如果密碼不正確,雜湊法沒有更新但是需要更新時,會讓驗證密碼成功與失敗的時間一致來防護
  5. 如果密碼正確且需要更新時,用剛剛的setter來更新密碼的雜湊法
  6. 最後分回密碼是否正確

透過上面的源碼我們可以知道:

  1. 用戶要登入時,authenticate方法會讓找到的User model調用它內部的check_password,其內部再調用全局的check_password方法來驗證
  2. 如果要修改密碼可以使用User model中的set_password,那如果要自定義其中的雜湊方法或是鹽值,可以調用make_password

User模型的擴展與開發

預設的User中的欄位可能沒有辦法滿足我們的開發需求,如果我們想要讓用戶註冊時能夠添加手機號碼等其他資訊,通常能怎麼做?

  1. 新建另一個表,並且讓他與內建的User model成一對一關係
  2. 繼承AbstractBaseUser,可以自定義欄位還有定義建立用戶的方法,當現有的User model大多配置不滿足你的需求時可以考慮
  3. 繼承AbstractUser,在原有User model的基礎上,可以額外添加一些自定義的欄位或是方法

我們這邊就以繼承AbstractUser為出發點進行開發

前置作業

因為我們想要修改掉原本內置的User,也因為admin是依賴原本內置的User,如果直接遷移會有許多migrate上的衝突,因此我們需要在另一個django專案去處理

poetry new django_auth
poetry add Django==4.2 psycopg2-binary load_dotenv

settings.py中的配置我就不列上來了,但是要注意先不要進行資料庫的遷移

可以直接去看程式碼:

https://github.com/class83108/django_auth/blob/main/django_auth/django_auth/settings.py

那就開始接下來的步驟吧

繼承AbstractUser

  • 配置自定義模型
# bash
python3 manage.py startapp user

# user.models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class MyUser(AbstractUser):
    phone = models.CharField(max_length=11, null=True, blank=True)

    def __str__(self):
        return self.username
  • 需要取代原本的auth_user,所以需要在settings.py中配置
AUTH_USER_MODEL = "user.MyUser"
  • 進行遷移
python3 manage.py makemigrations
python3 manage.py migrate

https://ithelp.ithome.com.tw/upload/images/20240922/20161866i6Bdg0QchR.png

https://ithelp.ithome.com.tw/upload/images/20240922/20161866963ZWSpqVG.png

下圖為我們上一個專案的資料庫結構,而上圖則是這次專案的資料庫結構

可以看出有關User設定的部分所屬的app的確不同了

但是我們如果想要讓Django Admin中也顯示的話,就需要在admin.py中進行註冊,並且需要修改fieldsets,來讓用戶編輯頁可以顯示我們新添加的欄位

# user.admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext_lazy as _

from .models import MyUser

@admin.register(MyUser)
class MyUserAdmin(UserAdmin):
    list_display = ("username", "email", "phone")

    fieldsets = list(UserAdmin.fieldsets)
    fieldsets[1] = (
        _("Personal info"),
        {"fields": ("first_name", "last_name", "email", "phone")},
    )

https://ithelp.ithome.com.tw/upload/images/20240922/20161866ocFY92LAXT.png

不過如果是想要在註冊的時候就能添加我們要的phone欄位呢?

我們可以借助Django內建的UserCreationForm來達成

UserCreationForm中的表單欄位有包含username, password1, password2
因此我們只需要再添加phone就能達成了

  • 建立表單
from django.contrib.auth.forms import UserCreationForm
from .models import MyUser

class MyUserCreationForm(UserCreationForm):
    class Meta(UserCreationForm.Meta):
        model = MyUser
        # 這裡加上我們自定義的 phone 欄位
        fields = UserCreationForm.Meta.fields + ("phone",)
  • 配置視圖
from django.shortcuts import render

from .forms import MyUserCreationForm

def register_view(request):
    if request.method == "POST":
        form = MyUserCreationForm(request.POST)
        # print(form)
        print(form.is_valid())
        print(form.errors)
        if form.is_valid():
            form.save()
            # 加入註冊成功的訊息
            msg = "註冊成功"

        else:
            msg = "註冊失敗"
    form = MyUserCreationForm()
    return render(request, "user/register.html", {"form": form})
  • 配置模板,這裡的base.html如同以下程式碼:

https://github.com/class83108/django_auth/blob/main/django_auth/templates/base.html

{% extends "base.html" %}

{% block title %}
<title>Register</title>
{% endblock title %}

{% block content %}
<div class="container">
	<div class="row">
		<div class="col-md-6 offset-md-3">
			<h2>Register</h2>
			{% if msg %}
				<div class="mb-5 text-sucess">{{ msg }}</div>
			{% endif %}
			<form method="POST" action="{% url "user:register" %}">
				{% csrf_token %}
				<div class="mb-3">
					<label for="username" class="form-label">Username</label>
					<input type="text" class="form-control" id="username" name="username">
					{% if form.helptext %}
						<div class="form-text">{{ form.helptext }}</div>
					{% endif %}
				</div>
				<div class="mb-3">
					<label for="phone" class="form-label">Phone</label>
					<input type="tel" class="form-control" id="phone" name="phone">
				</div>
				<div class="mb-3">
					<label for="password1" class="form-label">Password</label>
					<input type="password" class="form-control" id="password1" name="password1">
				</div>
				<div class="mb-3">
					<label for="password2" class="form-label">Confirm Password</label>
					<input type="password" class="form-control" id="password2" name="password2">
				</div>
				<button type="submit" class="btn btn-primary">Register</button>
			</form>
		</div>
	</div>
</div>
{% endblock content %}
  • 配置路由
# 根目錄urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    # 將 user app 的 urls.py 包含進來
    path("", include(("user.urls", "user"), namespace="user")),
]

# user.ursl.py
from django.urls import path
from .views import register_view

urlpatterns = [
    path("", register_view, name="register"),
]

最後依照欄位填上資料就能建立成功了

今日重點

我們透過瞭解Django內建的User model來了解用戶建立與驗證的相關方法

  • 註冊時調用create_user ,並且透過set_password建立雜湊後的密碼取代明文密碼存進資料庫
  • 登入時調用authenticate 且驗證通過後調用login ,驗證時則仰賴check_password
  • 登出時調用logout

並且我們也透過繼承AbstractUser的方式來在原本內建User model的基礎上增加我們自定義的欄位

而明天我們會再透過Django allauth來完善我們的用戶模型

參考資料

  • User:https://docs.djangoproject.com/en/5.1/topics/auth/default/#user-objects

上一篇
Django in 2024: 那些你可能會想在後台使用的第三方庫
下一篇
Django in 2024: 沒有第三方登入怎麼行!django-allauth登場
系列文
Django 2024: 從入門到SaaS實戰16
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言