使用Python(Django)和Sveltekit的身份验证系统 - 用户配置文件和密码更新
#网络开发人员 #python #django #githubhack23

介绍

在大多数情况下,应用程序的每个用户都希望更新其详细信息,例如个人资料图片,名称,密码,如果他们注意到违规或忘记时,以及我们的应用程序允许他们提供的其他数据。在本文中,我们将通过允许应用程序的用户更新其个人资料名称(首先和最后),缩略图,github链接,出生日期和密码来做到这一点。我们将学习如何利用较少记录的(或更恰当,无证件)的MultiPartParserPATCH请求手动处理FormData

假设和建议

假定您熟悉Django。我还建议您仔细研究我们如何创建previous series的前端,因为我们只会在那里改变很少的事情,并且不会深入研究我们如何将所有内容拼凑在一起。我们将在这里构建的API反映了我们在该系列中构建的内容。

源代码

该系列的源代码通过:

在GitHub上托管

GitHub logo Sirneij / django-auth-backend

带有Sveltekit Frontend的基于Django会话的身份验证系统

django-auth-backend

CI Test coverage

基于Sveltekit Frontend和GitHub Actions的基于DJANGO会话的身份验证系统。

此应用程序使用最小依赖项(纯django-无REST API框架)来构建安全,性能和可靠(具有100%自动化测试覆盖范围,使用Python最佳统一代码标准执行静态分析) - 基于基于Sveltekit的前端应用程序消耗的基于身份验证的REST API。

用户的配置文件图像直接上传到AWS S3(在测试中,我们放弃了S3并使用Django的InMemoryStorage进行更快的测试)。

还合并了一个自定义密码重置过程,芹菜任务进行了电子邮件发送。

在本地运行

  • 要运行应用程序,请克隆:

     git克隆https://github.com/sirneij/django-auth-backend.git 

    您可以,如果需要,请抓住它的frontend counterpart

  • 将目录更改为文件夹,并使用Python 3.9、3.10或3.11(根据三个版本进行测试)创建虚拟环境。然后激活它:

执行

步骤1:用户配置文件更新

我们将从更新应用程序用户数据的某些非关键部分开始。在users/views/文件夹中创建profile_update.py,并用以下方式填充它:

# src/users/views/profile_update.py
import json
from io import BytesIO
from typing import Any

from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, JsonResponse
from django.http.multipartparser import MultiPartParser
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from users.models import UserProfile


@method_decorator(csrf_exempt, name='dispatch')
class UserUpdateView(View, LoginRequiredMixin):
    def patch(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Handle user updates."""
        if not request.user.is_authenticated:
            return JsonResponse(
                {'error': 'You are not logged in. Kindly ensure you are logged in and try again'}, status=401
            )
        data, files = MultiPartParser(
            request.META, BytesIO(request.body), request.upload_handlers, request.encoding
        ).parse()

        first_name = data.get('first_name')
        last_name = data.get('last_name')
        thumbnail = files.get('thumbnail')
        phone_number = data.get('phone_number')
        birth_date = data.get('birth_date')
        github_link = data.get('github_link')

        user_details = UserProfile.objects.filter(user=request.user).select_related('user').get()
        if first_name:
            user_details.user.first_name = first_name

        if last_name:
            user_details.user.last_name = last_name

        if thumbnail:
            user_details.user.thumbnail = thumbnail

        user_details.user.save(update_fields=['first_name', 'last_name', 'thumbnail'])

        if phone_number:
            user_details.phone_number = phone_number

        if birth_date:
            user_details.birth_date = birth_date

        if github_link:
            user_details.github_link = github_link

        user_details.save(update_fields=['phone_number', 'birth_date', 'github_link'])

        res_data = {
            'id': str(user_details.user.pk),
            'email': user_details.user.email,
            'first_name': user_details.user.first_name,
            'last_name': user_details.user.last_name,
            'is_staff': user_details.user.is_staff,
            'is_active': user_details.user.is_active,
            'date_joined': str(user_details.user.date_joined),
            'thumbnail': user_details.user.thumbnail.url if user_details.user.thumbnail else None,
            'profile': {
                'id': str(user_details.id),
                'user_id': str(user_details.user.pk),
                'phone_number': user_details.phone_number,
                'github_link': user_details.github_link,
                'birth_date': str(user_details.birth_date) if user_details.birth_date else None,
            },
        }

        response_data = json.loads(json.dumps(res_data))

        return JsonResponse(response_data, status=200)

像往常一样,我们首先要确保我们的API消费者在访问此页面之前不需要提供CSRF令牌。我应该告诉您,我们实际上可以使用其form actions的力量在Sveltekit中提供csrftoken。但是,我们只想遵守previous series所采用的设计原则。接下来,我们在通用View类中定义了PATCH方法。 PATCH是首选的,因为我们希望严格遵循HTTP verbs or methods的使用。 PATCH用于PARTIAL资源修改。身份验证的用户只能访问此端点,我们在方法的顶部执行了这一点。现在,通过使用PATCH,我们无法分别使用非常方便的request.POST.get('key')request.FILES.get('key') Django提供的POST数据和文件。如果您在这里使用其中的任何一个,它们将是空的!我们必须手动处理传入的PATCH FormData,为此,我们使用了令人敬畏但无证件的MultiPartParser,该koude16koude16中可用。该类期望在初始化的Queltization的METAbody(以原始文件般的性质),upload_handlers(通常默认为request.upload_handlers)和一个encoding(具有settings.DEFAULT_CHARSET的默认值)。 META的内容类型必须从multipart/开始,否则会出现错误。在提供的FormData成功的parse上,它返回数据和文件元组。然后,我们检索数据和文件,并检查是否提供了。相应地进行了更新。

您可以将此视图添加到我们的URL列表中。

步骤2:用户密码更改

接下来是更新用户的密码,以防其丢失,或者发生了一些不好的事情。我们将要求用户进行三步过程 - 请求使用已注册和经过验证的电子邮件地址进行更改,单击发送到电子邮件地址的链接,然后输入新密码。

到目前为止,我们非常熟悉允许用户请求某些内容,并向他们发送带有适当链接的电子邮件。这没有什么不同。在整个过程中,为了简洁的缘故,我们将在views软件包中创建一个名为password的子弹。在子软件包中,我们将创建request_change.pyconfirm_change_request.pychange_password.pysrc/users/views/password/request_change.py的内容是:

# src/users/views/password/request_change.py
import json
from datetime import timedelta
from typing import Any

from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest, JsonResponse
from django.urls.base import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from users.tasks import send_email_message
from users.token import account_activation_token
from users.utils import validate_email


@method_decorator(csrf_exempt, name='dispatch')
class RequestPasswordChangeView(View):
    async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Request user password change."""
        data = json.loads(request.body.decode("utf-8"))
        email = data.get('email')

        if email is None:
            return JsonResponse({'error': 'Email field is empty'}, status=400)

        is_valid, error_text = validate_email(email)
        if not is_valid:
            return JsonResponse({'error': error_text}, status=400)

        try:
            user = await get_user_model().objects.filter(email=email, is_active=True).aget()
        except get_user_model().DoesNotExist:
            return JsonResponse(
                {
                    'error': 'An active user with this e-mail address does not exist. '
                    'If you registered with this email, ensure you have activated your account. '
                    'You can check by logging in. If you have not activated it, '
                    f'visit {settings.FRONTEND_URL}/auth/regenerate-token to '
                    'regenerate the token that will allow you activate your account.'
                },
                status=404,
            )

        token = await sync_to_async(account_activation_token.make_token)(user)
        uid = urlsafe_base64_encode(force_bytes(user.pk))

        confirmation_link = (
            f"{request.scheme}://{get_current_site(request)}"
            f"{reverse('users:confirm_password_change_request', kwargs={'uidb64': uid, 'token': token})}",
        )

        subject = 'Password reset instructions'
        ctx = {
            'title': "(Django) RustAuth - Password Reset Instructions",
            'domain': settings.FRONTEND_URL,
            'confirmation_link': confirmation_link,
            'expiration_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).minute,
            'exact_time': (timezone.localtime() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT)).strftime(
                '%A %B %d, %Y at %r'
            ),
        }

        send_email_message.delay(
            subject=subject,
            template_name='password_reset_email.html',
            user_id=user.id,
            ctx=ctx,
        )

        return JsonResponse(
            {
                'message': 'Password reset instructions have been sent to your email address. '
                'Kindly take action before its expiration'
            },
            status=200,
        )

基本上,这几乎就像令牌再生的逻辑外,除了这里,我们正在使用提供的电子邮件地址检查活跃的用户。我们还更改了电子邮件主题和HTML模板名称。模板具有此内容:

<!-- src/templates/password_reset_email.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{{ title }}</title>
  </head>

  <body>
    <table
      style="
        max-width: 555px;
        width: 100%;
        font-family: 'Open Sans', Segoe, 'Segoe UI', 'DejaVu Sans',
          'Trebuchet MS', Verdana, sans-serif;
        background: #fff;
        font-size: 13px;
        color: #323232;
      "
      cellspacing="0"
      cellpadding="0"
      border="0"
      bgcolor="#ffffff"
      align="center"
    >
      <tbody>
        <tr>
          <td align="left">
            <h1 style="text-align: center">
              <span style="font-size: 15px">
                <strong>{{ title }}</strong>
              </span>
            </h1>

            <p>
              Your request to reset your password was submitted. If you did not
              make this request, simply ignore this email. If you did make this
              request just click the button below:
            </p>

            <table
              style="
                max-width: 555px;
                width: 100%;
                font-family: 'Open Sans', arial, sans-serif;
                font-size: 13px;
                color: #323232;
              "
              cellspacing="0"
              cellpadding="0"
              border="0"
              bgcolor="#ffffff"
              align="center"
            >
              <tbody>
                <tr>
                  <td height="10">&nbsp;</td>
                </tr>
                <tr>
                  <td style="text-align: center">
                    <a
                      href="{{ confirmation_link }}"
                      style="
                        color: #fff;
                        background-color: hsla(199, 69%, 84%, 1);
                        width: 320px;
                        font-size: 16px;
                        border-radius: 3px;
                        line-height: 44px;
                        height: 44px;
                        font-family: 'Open Sans', Arial, helvetica, sans-serif;
                        text-align: center;
                        text-decoration: none;
                        display: inline-block;
                      "
                      target="_blank"
                      data-saferedirecturl="https://www.google.com/url?q={{ confirmation_link }}"
                    >
                      <span style="color: #000000">
                        <strong>Change password</strong>
                      </span>
                    </a>
                  </td>
                </tr>
              </tbody>
            </table>

            <table
              style="
                max-width: 555px;
                width: 100%;
                font-family: 'Open Sans', arial, sans-serif;
                font-size: 13px;
                color: #323232;
              "
              cellspacing="0"
              cellpadding="0"
              border="0"
              bgcolor="#ffffff"
              align="center"
            >
              <tbody>
                <tr>
                  <td height="10">&nbsp;</td>
                </tr>
                <tr>
                  <td align="left">
                    <p align="center">&nbsp;</p>
                    If the above button doesn't work, try copying and pasting
                    the link below into your browser. If you continue to
                    experience problems, please contact us.
                    <br />
                    {{ confirmation_link }}
                    <br />
                  </td>
                </tr>
                <tr>
                  <td>
                    <p align="center">&nbsp;</p>
                    <br />
                    <p style="padding-bottom: 15px; margin: 0">
                      Kindly note that this link will expire in
                      <strong>{{expiration_time}} minutes</strong>. The exact
                      expiration date and time is:
                      <strong>{{ exact_time }}</strong>.
                    </p>
                  </td>
                </tr>
              </tbody>
            </table>
          </td>
        </tr>
      </tbody>
    </table>
  </body>
</html>

接下来是src/users/views/password/confirm_change.py

# src/users/views/password/confirm_change.py
from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth import get_user_model
from django.http import HttpRequest, HttpResponseRedirect
from django.utils.encoding import force_bytes, force_str
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
from django.views import View

from users.token import account_activation_token


class ConfirmPasswordChangeRequestView(View):
    async def get(self, request: HttpRequest, uidb64: str, token: str) -> HttpResponseRedirect:
        """Confirm password change requests."""
        try:
            uid = force_str(urlsafe_base64_decode(uidb64))
            user = await get_user_model().objects.aget(pk=uid, is_active=True)
        except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
            user = None

        if user is not None and account_activation_token.check_token(user, token):
            # Generate a new token
            token = await sync_to_async(account_activation_token.make_token)(user)
            uid = urlsafe_base64_encode(force_bytes(user.pk))
            combined = f'{uid}:{token}'
            return HttpResponseRedirect(f'{settings.FRONTEND_URL}/auth/password/change-password?token={combined}')

        return HttpResponseRedirect(
            f'{settings.FRONTEND_URL}/auth/regenerate-token?reason=It appears that '
            'your confirmation token has expired or previously used. Kindly generate a new token',
        )

也很熟悉。豁免是我们还在这里检查活跃的用户。然后,如果成功检索了用户,我们创建了一个新的令牌并编码了用户的ID。然后,我们使用:组合了两者。这将有助于我们检测到以后在密码更改视图中要求密码更改的人员。然后,我们将用户重定向到前端末端页面,以将密码用“组合”代币作为查询参数更改。前端将此令牌发送回后端,并与用户的新密码一起发送。让我们立即编写更改密码的逻辑:

# src/users/views/password/change_password.py
import json
from typing import Any

from django.contrib.auth import get_user_model
from django.http import HttpRequest, JsonResponse
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_decode
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from users.token import account_activation_token


@method_decorator(csrf_exempt, name='dispatch')
class ChangePasswordView(View):
    async def post(self, request: HttpRequest, **kwargs: dict[str, Any]) -> JsonResponse:
        """Change user password."""
        data = json.loads(request.body.decode("utf-8"))
        password = data.get('password')
        combined = data.get('token')

        try:
            uidb64, token = combined.split(':')
            uid = force_str(urlsafe_base64_decode(uidb64))
            user = await get_user_model().objects.aget(pk=uid, is_active=True)
        except (TypeError, ValueError, OverflowError, get_user_model().DoesNotExist):
            user = None

        if user is not None and account_activation_token.check_token(user, token):
            user.set_password(password)
            await user.asave(update_fields=['password'])

            return JsonResponse(
                {
                    'message': 'Your password has been changed successfully. Kindly login with the new password',
                }
            )
        return JsonResponse(
            {
                'error': 'It appears that your password request token has expired or previously used',
            }
        )

我们只是检索了我们先前发送的新密码和“组合”令牌。然后,我们试图破坏“组合”令牌以获取编码的用户ID和令牌本身。从那里,从数据库中获取了所涉及的用户,检查了令牌的正确性,并在一切正常的情况下保存了用户的密码。那很简单。

我们现在可以将这些视图添加到我们的URL中以测试它们。

# src/users/urls.py
...
from users.views.password import change_password, confirm_change_request, request_change
...
urlpatterns = [
    ...
    # Password change
    path(
        'password-change/request-password-change/',
        request_change.RequestPasswordChangeView.as_view(),
        name='request_password_change',
    ),
    path(
        'password-change/confirm/change-password/<uidb64>/<token>/',
        confirm_change_request.ConfirmPasswordChangeRequestView.as_view(),
        name='confirm_password_change_request',
    ),
    path(
        'password-change/change-user-password/',
        change_password.ChangePasswordView.as_view(),
        name='change_password',
    ),
    ...
]

就是本文。我希望你喜欢它。接下来是具有GITHUB动作的自动化测试和静态分析方面。我们还将尝试将我们的应用程序自由部署在Vercel上。然后见。

其他

喜欢这篇文章?考虑contacting me for a job, something worthwhile or buying a coffee ☕。您也可以在LinkedInTwitter上与我联系/关注我。如果您帮助分享本文以获得更广泛的报道,那还不错。我会很感激...