-
Notifications
You must be signed in to change notification settings - Fork 28
feat(backend): password recovery #33 #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5a9f3f9
ab98d82
94dfddd
c174420
73ddfb0
f9c1d00
cf0996c
27a8f3e
5c0db7e
3fe56f7
cf13592
bdf23ef
0b87e21
c9ca8a1
c431bc8
10c36c6
a39f12a
6f96455
4194bf2
475dc7c
6c12cc7
f2aefe3
66451f7
ac2bc3b
179e69f
c5f7658
fa95789
113c4ac
99200c2
fc3fef2
1149631
cdd02c1
51ef101
a9977aa
ef50157
fb24cc6
8a5b7aa
d2416cc
3f84b98
b3b8986
929686c
5cff017
85d5678
7a72501
8797f7c
07b7332
1ee9727
d4cde1b
9e8ed66
6392ca5
6cc3dd2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,18 @@ | ||
| SECRET_KEY=23984794924798@39485973@h76h@dfhighudfgh | ||
| SECRET_KEY=your_secret_key | ||
| DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend | ||
| ALLOWED_HOSTS=127.0.0.1,localhost,yourdomain.com | ||
|
|
||
| EMAIL_HOST=smtp.example.com | ||
| EMAIL_PORT=587 | ||
| EMAIL_USE_TLS=True | ||
| EMAIL_HOST_USER=user@example.com | ||
| EMAIL_HOST_PASSWORD=secret123 | ||
| EMAIL_HOST_USER=your_email_here | ||
| EMAIL_HOST_PASSWORD=your_password_here | ||
| EMAIL_TIMEOUT=10 | ||
| DEFAULT_FROM_EMAIL=your_email_for_sending | ||
|
|
||
| DATABASE_NAME="postgres" | ||
| DATABASE_USER="postgres" | ||
| DATABASE_PASSWORD="password" | ||
| DATABASE_PORT="5432" | ||
| DATABASE_HOST="db" | ||
| DATABASE_ENGINE="postgresql" | ||
| DATABASE_NAME=your_database_name | ||
| DATABASE_USER=your_database_user_name | ||
| DATABASE_PASSWORD=your_database_user_password | ||
| DATABASE_PORT=database_port | ||
| DATABASE_HOST=database_host | ||
| DATABASE_ENGINE=postgresql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from .celery import app as celery_app | ||
|
|
||
| __all__ = ("celery_app",) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import os | ||
|
|
||
| from celery import Celery | ||
|
|
||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") | ||
| app = Celery("app") | ||
| app.config_from_object("django.conf:settings", namespace="CELERY") | ||
| app.autodiscover_tasks() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # from django.contrib import admin | ||
|
|
||
| # Register your models here. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| from django.apps import AppConfig | ||
|
|
||
|
|
||
| class PasswordResetConfig(AppConfig): | ||
| default_auto_field = "django.db.models.BigAutoField" | ||
| name = "app.services.auth.password_reset" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # Token expiration time in seconds | ||
| PASSWORD_RESET_TIMEOUT = 3600 | ||
| # Maximum number of retries | ||
| MAX_RETRIES = 3 |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Caution Проблемы миграции следуют из проблем моделей. Так как это первая миграция, я бы порекомендовал пересоздать миграцию, чтобы было меньше > мусорных файлов и меньше операций по созданию таблиц. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # Generated by Django 5.2.7 on 2025-12-24 16:23 | ||
|
|
||
| import django.db.models.deletion | ||
| from django.conf import settings | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| initial = True | ||
|
|
||
| dependencies = [ | ||
| migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name='PasswordResetToken', | ||
| fields=[ | ||
| ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
| ('token_hash', models.CharField()), | ||
| ('expires_at', models.DateTimeField()), | ||
| ('is_used', models.BooleanField(default=False)), | ||
| ('user_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), | ||
| ], | ||
| ), | ||
| ] |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Caution Модели |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| from django.db import models | ||
|
|
||
| from app.services.auth.users.models import User | ||
|
|
||
|
|
||
| class PasswordResetToken(models.Model): | ||
| user_id = models.ForeignKey(User, on_delete=models.CASCADE) | ||
| token_hash = models.CharField(null=True) | ||
| expires_at = models.DateTimeField() | ||
| is_used = models.BooleanField(default=False) | ||
|
|
||
| def mark_as_used(self): | ||
| self.is_used = True | ||
| self.save() | ||
|
|
||
| @classmethod | ||
| def mark_all_as_used(cls, user: User): | ||
| queryset = cls.objects.filter(is_used=False, user_id=user) | ||
| return queryset.update(is_used=True) |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip Докстринг тут очень большой для task-функции. Я бы сократил до 5–10 самых важных строк, а подробности вынес в документацию. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import logging | ||
| from typing import Sequence | ||
|
|
||
| from django.core.mail import send_mail | ||
|
|
||
| from app.celery import app | ||
| from app.services.auth.password_reset import configs | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @app.task( | ||
| bind=True, | ||
| max_retries=configs.MAX_RETRIES, | ||
| default_retry_delay=60, | ||
| autoretry_for=(Exception,), | ||
| retry_backoff=True, | ||
| retry_jitter=True, | ||
| ) | ||
| def send_mail_task( | ||
| self, | ||
| subject: str, | ||
| message: str, | ||
| html_message: str, | ||
| from_email: str, | ||
| recipient_list: Sequence[str], | ||
| fail_silently: bool = False, | ||
| ) -> None: | ||
| """ | ||
| Асинхронная отправка email через Celery с автоматическими повторами. | ||
|
|
||
| Использует настройки Django EMAIL_* и конфиг `configs.MAX_RETRIES` | ||
| для повторных попыток. | ||
| Поддерживает plain text и HTML версии письма. | ||
| """ | ||
| current_retry = self.request.retries | ||
| max_retries = self.max_retries | ||
| try: | ||
| send_mail( | ||
| subject=subject, | ||
| message=message, | ||
| html_message=html_message, | ||
| from_email=from_email, | ||
| recipient_list=recipient_list, | ||
| fail_silently=fail_silently, | ||
| ) | ||
| logger.info(f"Password reset email sent to '{recipient_list}'") | ||
| except Exception as e: | ||
| logger.warning( | ||
| f"Attempt {current_retry + 1} failed to send password " | ||
| f"reset email to '{recipient_list}': {str(e)}" | ||
| ) | ||
| if current_retry >= max_retries - 1: | ||
| logger.error( | ||
| f"Failed to send password reset email to '{recipient_list}' " | ||
| f"after [{max_retries}] attempts" | ||
| ) | ||
| if not fail_silently: | ||
| raise |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| from datetime import timedelta | ||
| from unittest.mock import patch | ||
|
|
||
| from django.contrib.auth import get_user_model | ||
| from django.test import Client | ||
| from django.urls import reverse, reverse_lazy | ||
| from django.utils import timezone | ||
| from inertia.test import InertiaTestCase # type: ignore | ||
|
|
||
| from app.services.auth.password_reset import configs | ||
| from app.services.auth.password_reset.models import PasswordResetToken | ||
| from app.services.auth.password_reset.views import PasswordResetView | ||
|
|
||
| User = get_user_model() | ||
|
|
||
|
|
||
| class PasswordResetTestCase(InertiaTestCase): | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.client = Client() | ||
| self.url = reverse_lazy("password_reset") | ||
| user_data = { | ||
| "email": "testuser@example.com", | ||
| "password": "password123", | ||
| } | ||
| self.user = User.objects.create_user(**user_data) | ||
| current_time = timezone.now() | ||
| time_out = timedelta(configs.PASSWORD_RESET_TIMEOUT) | ||
| expires_at = current_time + time_out | ||
| token_data = { | ||
| "user_id": self.user, | ||
| "token_hash": hash("test_token"), | ||
| "expires_at": expires_at, | ||
| } | ||
| self.token = PasswordResetToken.objects.create(**token_data) | ||
|
|
||
|
|
||
| @patch.object(PasswordResetView, "send_reset_email") | ||
| class PasswordResetTests(PasswordResetTestCase): | ||
| def test_post_valid_email(self, mock_get_stats): | ||
| mock_get_stats.return_value = None | ||
| response = self.client.post(self.url, {"email": "testuser@example.com"}) | ||
| assert response.props["status_code"] == 200 | ||
| token = PasswordResetToken.objects.filter(user_id=self.user.id).first() | ||
| assert token.token_hash | ||
| assert token.expires_at > timezone.now() | ||
|
|
||
| def test_post_invalid_email(self, mock_get_stats): | ||
| mock_get_stats.return_value = None | ||
| response = self.client.post(self.url, {"email": "invalid@example.com"}) | ||
| assert response.props["status_code"] == 200 | ||
| assert response.props["status"] == "ok" | ||
|
|
||
|
|
||
| class PasswordResetConfirmTests(PasswordResetTestCase): | ||
| def test_get_valid_token(self): | ||
| url = reverse("password_reset_confirm") + "?token=test_token" | ||
| self.client.get(url) | ||
| self.assertIncludesProps({"status_code": 200}) | ||
|
|
||
| def test_get_expired_token(self): | ||
| PasswordResetToken.objects.create( | ||
| user_id=self.user, | ||
| token_hash=hash("expired_token"), | ||
| expires_at=timezone.now() - timedelta(hours=1), | ||
| ) | ||
|
|
||
| url = reverse_lazy("password_reset_confirm") + "?token=expired_token" | ||
| self.client.get(url) | ||
| self.assertIncludesProps({"status_code": 400}) | ||
|
|
||
| def test_post_valid_password(self): | ||
| url = reverse_lazy("password_reset_confirm") | ||
| data = { | ||
| "token": self.token.token_hash, | ||
| "newPassword": "StrongPass123!", | ||
| } | ||
| response = self.client.post(url, data) | ||
| assert response.props["status_code"] == 200 | ||
|
|
||
| self.user.refresh_from_db() | ||
| self.token.refresh_from_db() | ||
| assert self.user.check_password("StrongPass123!") | ||
| assert self.token.is_used | ||
|
|
||
| def test_post_weak_password(self): | ||
| url = reverse_lazy("password_reset_confirm") | ||
| data = {"token": self.token.token_hash, "newPassword": "weak"} | ||
|
|
||
| self.client.post(url, data) | ||
| self.assertIncludesProps({"status_code": 422}) | ||
|
|
||
| def test_post_invalid_token(self): | ||
| url = reverse_lazy("password_reset_confirm") | ||
| data = {"token": "invalid_token", "newPassword": "StrongPass123!"} | ||
|
|
||
| self.client.post(url, data) | ||
| self.assertIncludesProps({"status_code": 400}) |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip Я бы сделал маршруты со слешем: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| from django.urls import path | ||
|
|
||
| from . import views | ||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "forgot/", | ||
| views.PasswordResetView.as_view(), | ||
| name="password_reset", | ||
| ), | ||
| path( | ||
| "reset/", | ||
| views.PasswordResetConfirmView.as_view(), | ||
| name="password_reset_confirm", | ||
| ), | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note
Круто, что добавили
*.sessionи.mypy_cacheв.gitignore— меньше мусора в репо.