From 46a7f90e867062dcbe95b48c75f9a3b3706fd625 Mon Sep 17 00:00:00 2001 From: urec56 Date: Sat, 24 Feb 2024 20:58:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8F=20=D0=B8=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20=D0=BF=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=20=D0=B8=D0=B7=20=D0=BF=D0=BE=D1=87=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/exceptions.py | 9 +- app/tasks/email_templates.py | 43 +++++++++ app/tasks/tasks.py | 36 +++++++- app/tests/integration_tests/users_api_test.py | 47 +++++++++- app/users/auth.py | 4 +- app/users/chat/websocket.py | 8 +- app/users/dao.py | 21 +++-- app/users/models.py | 3 +- app/users/router.py | 90 +++++++++++++------ app/users/schemas.py | 22 +++-- 10 files changed, 214 insertions(+), 69 deletions(-) diff --git a/app/exceptions.py b/app/exceptions.py index 98adb7f..3e0fb93 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -26,8 +26,8 @@ class IncorrectAuthDataException(BlackPhoenixException): class IncorrectPasswordException(BlackPhoenixException): - status_code = status.HTTP_401_UNAUTHORIZED - detail = "Введён не верные пароль" + status_code = status.HTTP_409_CONFLICT + detail = "Введён не верный пароль" class IncorrectTokenFormatException(BlackPhoenixException): @@ -59,11 +59,6 @@ class MessageNotFoundException(BlackPhoenixException): detail = "Сообщение не найдено" -class IncorrectPasswordException(BlackPhoenixException): - status_code = status.HTTP_409_CONFLICT - detail = "Введён не верный пароль" - - class PasswordsМismatchException(BlackPhoenixException): status_code = status.HTTP_409_CONFLICT detail = "Пароли не совпадают" diff --git a/app/tasks/email_templates.py b/app/tasks/email_templates.py index fa58ed2..8521ba7 100644 --- a/app/tasks/email_templates.py +++ b/app/tasks/email_templates.py @@ -24,3 +24,46 @@ def create_registration_confirmation_template( subtype="html" ) return email + + +def create_password_change_confirmation_template( + username: str, + email_to: EmailStr, +): + email = EmailMessage() + + email["Subject"] = "Смена пароля к аккаунту" + email["From"] = settings.SMTP_USER + email["To"] = email_to + + email.set_content( + f""" +

{username}, ты менял пароль?

+

Если нет, то пидора ответ

+ """, + subtype="html" + ) + return email + + +def create_password_recover_template( + username: str, + email_to: EmailStr, + confirmation_code: str, +): + email = EmailMessage() + + email["Subject"] = "Восстановление пароля" + email["From"] = settings.SMTP_USER + email["To"] = email_to + + email.set_content( + f""" +

{username}, ты тут хотел восстановить пароль?

+

{confirmation_code}

+ """, + subtype="html" + ) + return email + + diff --git a/app/tasks/tasks.py b/app/tasks/tasks.py index 7b0b9b6..95a4f62 100644 --- a/app/tasks/tasks.py +++ b/app/tasks/tasks.py @@ -8,7 +8,8 @@ from pydantic import EmailStr from app.config import settings from app.tasks.celery import celery -from app.tasks.email_templates import create_registration_confirmation_template +from app.tasks.email_templates import create_registration_confirmation_template, \ + create_password_change_confirmation_template, create_password_recover_template def generate_confirmation_code(length=6): @@ -34,3 +35,36 @@ def send_registration_confirmation_email( return confirmation_code + +@celery.task +def send_password_change_email( + username: str, + email_to: EmailStr, +): + msg_content = create_password_change_confirmation_template( + username=username, email_to=email_to + ) + + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server: + server.login(settings.SMTP_USER, settings.SMTP_PASS) + server.send_message(msg_content) + + return True + + +@celery.task +def send_password_recover_email( + username: str, + email_to: EmailStr, +): + confirmation_code = generate_confirmation_code() + + msg_content = create_password_recover_template( + username=username, email_to=email_to, confirmation_code=confirmation_code + ) + + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server: + server.login(settings.SMTP_USER, settings.SMTP_PASS) + server.send_message(msg_content) + + return confirmation_code diff --git a/app/tests/integration_tests/users_api_test.py b/app/tests/integration_tests/users_api_test.py index 1ec5cb3..e0d18dc 100644 --- a/app/tests/integration_tests/users_api_test.py +++ b/app/tests/integration_tests/users_api_test.py @@ -48,6 +48,12 @@ async def test_login_user(email_or_username: str, password: str, status_code: in assert "black_phoenix_access_token" in response.cookies +async def test_logout_user(ac: AsyncClient): + response = await ac.post("/users/logout") + assert response.status_code == 200 + assert "black_phoenix_access_token" not in response.cookies + + async def test_get_user(ac: AsyncClient): await ac.post("/users/login", json={ "email_or_username": "urec@urec.com", @@ -59,7 +65,40 @@ async def test_get_user(ac: AsyncClient): assert response.json()["black_phoenix"] == False -async def test_logout_user(ac: AsyncClient): - response = await ac.post("/users/logout") - assert response.status_code == 200 - assert "black_phoenix_access_token" not in response.cookies +@pytest.mark.parametrize("username,password,statuscode", [ + ("urec", "12311231", 409), + ("neurec", "12311231", 200), + ("urec", "adw", 409), + ("urec", "12311231", 200), +]) +async def test_rename_user(username: str, password: str, statuscode: int, ac: AsyncClient): + await ac.post("/users/login", json={ + "email_or_username": "urec@urec.com", + "password": "12311231" + }) + response = await ac.patch("/users/rename", json={ + "username": username, + "password": password + }) + assert response.status_code == statuscode + if response.status_code == 200: + assert response.json() == username + + +@pytest.mark.parametrize("avatar_url,password,statuscode", [ + ("https://images.black-phoenix.ru/static/images/avatars/v6BtxTxfCFi2dBAl_avatar.png", "12311231", 200), + ("https://images.black-phoenix.ru/static/images/%D1%82%D1%8B%20%D1%83%D0%B6%D0%B5%20%D0%BF%D0%B5%D1%88%D0%BA%D0%B0%20BP.png", "adw", 409), + ("https://images.black-phoenix.ru/static/images/%D1%82%D1%8B%20%D1%83%D0%B6%D0%B5%20%D0%BF%D0%B5%D1%88%D0%BA%D0%B0%20BP.png", "12311231", 200), +]) +async def test_change_avatar(avatar_url: str, password: str, statuscode: int, ac: AsyncClient): + await ac.post("/users/login", json={ + "email_or_username": "urec@urec.com", + "password": "12311231" + }) + response = await ac.patch("/users/change_avatar", json={ + "new_avatar_image": avatar_url, + "password": password + }) + assert response.status_code == statuscode + if response.status_code == 200: + assert response.json() == avatar_url diff --git a/app/users/auth.py b/app/users/auth.py index 3d063d1..a11fa33 100644 --- a/app/users/auth.py +++ b/app/users/auth.py @@ -99,6 +99,4 @@ async def validate_user_admin(user_id: int): return False -def get_user_codes_list(user_codes: list[dict], sort_description: str) -> list[str]: - user_codes_list = [user_code['code'] for user_code in user_codes if user_code['description'] == sort_description] - return user_codes_list + diff --git a/app/users/chat/websocket.py b/app/users/chat/websocket.py index e301ab9..8ba135e 100644 --- a/app/users/chat/websocket.py +++ b/app/users/chat/websocket.py @@ -1,13 +1,7 @@ -import json -from typing import Dict, List - -from fastapi import WebSocket, Depends, WebSocketDisconnect -from pydantic import parse_obj_as +from fastapi import WebSocket, WebSocketDisconnect from app.users.chat.dao import ChatDAO from app.users.auth import validate_user_access_to_chat -from app.users.chat.shemas import SMessageSchema, SMessage -from app.users.models import Users from app.users.chat.router import router diff --git a/app/users/dao.py b/app/users/dao.py index b1360e7..30ae392 100644 --- a/app/users/dao.py +++ b/app/users/dao.py @@ -70,8 +70,8 @@ class UserDAO(BaseDAO): chats_with_avatars.c.chat_name, chats_with_avatars.c.avatar_image ) - .select_from(chats_with_avatars) - .where( + .select_from(chats_with_avatars) + .where( and_( chats_with_avatars.c.id == user_id, chats_with_avatars.c.visibility == True @@ -93,9 +93,9 @@ class UserCodesDAO(BaseDAO): model = UsersVerificationCodes @classmethod - async def set_user_codes(cls, user_id: int, code: str): + async def set_user_codes(cls, user_id: int, code: str, description: str): query = (insert(UsersVerificationCodes) - .values(user_id=user_id, code=code, description="Код подтверждения почты") + .values(user_id=user_id, code=code, description=description) .returning(cls.model.code)) async with async_session_maker() as session: result = await session.execute(query) @@ -103,7 +103,7 @@ class UserCodesDAO(BaseDAO): return result.scalar() @classmethod - async def get_user_codes(cls, user_id: int) -> list[dict[str, str | int | datetime]]: + async def get_user_codes(cls, **filter_by) -> list[dict | None]: """ SELECT usersverificationcodes.id, @@ -116,13 +116,12 @@ class UserCodesDAO(BaseDAO): usersverificationcodes.user_id = 20 AND now() - usersverificationcodes.date_of_creation < INTERVAL '30 minutes' """ - query = select(UsersVerificationCodes.__table__.columns).where( - and_( - UsersVerificationCodes.user_id == user_id, - (func.now() - UsersVerificationCodes.date_of_creation) < text("INTERVAL '30 minutes'") - )) + query = (select(UsersVerificationCodes.__table__.columns) + .where((func.now() - UsersVerificationCodes.date_of_creation) < text("INTERVAL '30 minutes'")) + .filter_by(**filter_by)) + async with async_session_maker() as session: - # print(query.compile(engine, compile_kwargs={"literal_binds": True})) # Проверка SQL запроса + # print(query.compile(engine, compile_kwargs={"literal_binds": True})) # Проверка SQL запроса result = await session.execute(query) result = result.mappings().all() return result diff --git a/app/users/models.py b/app/users/models.py index f457e22..aa8059d 100644 --- a/app/users/models.py +++ b/app/users/models.py @@ -17,7 +17,8 @@ class Users(Base): role: Mapped[int] = mapped_column(server_default=f'0') black_phoenix: Mapped[bool] = mapped_column(server_default='false') avatar_image: Mapped[Optional[str]] = mapped_column( - server_default='https://images.black-phoenix.ru/static/images/%D1%82%D1%8B%20%D1%83%D0%B6%D0%B5%20%D0%BF%D0%B5%D1%88%D0%BA%D0%B0%20BP.png') + server_default='https://images.black-phoenix.ru/static/images/%D1%82%D1%8B%20%D1%83%D0%B6%D0%B5%20%D0%BF%D0%B5%D1%88%D0%BA%D0%B0%20BP.png' + ) date_of_birth: Mapped[date] date_of_registration: Mapped[date] = mapped_column(server_default=func.now()) diff --git a/app/users/router.py b/app/users/router.py index eb17eb4..ad16e2b 100644 --- a/app/users/router.py +++ b/app/users/router.py @@ -1,21 +1,20 @@ -from typing import Annotated - -from fastapi import APIRouter, Response, Depends, UploadFile, File, Body, Form +from fastapi import APIRouter, Response, Depends from fastapi.responses import RedirectResponse +from sqlalchemy.sql.functions import current_user from starlette import status -from app.config import settings -from app.exceptions import UserAlreadyExistsException, UsernameAlreadyInUseException, \ +from app.exceptions import UsernameAlreadyInUseException, \ IncorrectPasswordException, PasswordsМismatchException, WrongCodeException, UserNotFoundException -from app.images.router import upload_file, upload_file_returning_str from app.users.auth import get_password_hash, authenticate_user_by_email, \ - create_access_token, verify_password, REGISTRATED_USER, get_user_codes_list, VERIFICATED_USER, authenticate_user, \ + create_access_token, verify_password, VERIFICATED_USER, authenticate_user, \ check_existing_user from app.users.dao import UserDAO, UserCodesDAO from app.users.dependencies import get_current_user from app.users.models import Users -from app.users.schemas import SUserLogin, SUserRegister, SUser, SUserPassword, SUserRename, SUserAvatar -from app.tasks.tasks import send_registration_confirmation_email +from app.users.schemas import SUserLogin, SUserRegister, SUser, SUserPassword, SUserRename, SUserAvatar, \ + SUserPasswordRecover, SUserCode, SUserPasswordChange +from app.tasks.tasks import send_registration_confirmation_email, send_password_change_email, \ + send_password_recover_email router = APIRouter( prefix="/users", @@ -48,21 +47,23 @@ async def register_user(response: Response, user_data: SUserRegister): result = send_registration_confirmation_email.delay(username=user_data.username, email_to=user_data.email) result = result.get() - if await UserCodesDAO.set_user_codes(user_id=user_id, code=result) == result: + if await UserCodesDAO.set_user_codes( + user_id=user_id, code=result, description="Код подтверждения почты" + ) == result: user = await authenticate_user_by_email(user_data.email, user_data.password) access_token = create_access_token({"sub": str(user.id)}) response.set_cookie(key="black_phoenix_access_token", value=access_token, httponly=True) return {"access_token": access_token} -@router.post('/email_verification', response_model=bool) -async def email_verification(user_code: str, user: Users = Depends(get_current_user)): - user_codes = await UserCodesDAO.get_user_codes(user_id=user.id) - user_codes = get_user_codes_list(user_codes=user_codes, sort_description="Код подтверждения почты") - if user_code in user_codes: - if await UserDAO.change_data(user_id=user.id, role=VERIFICATED_USER): - return True - raise WrongCodeException +@router.post('/email_verification', status_code=status.HTTP_200_OK, response_model=bool) +async def email_verification(user_code: SUserCode, user: Users = Depends(get_current_user)): + user_codes = await UserCodesDAO.get_user_codes( + user_id=user.id, description="Код подтверждения почты", code=user_code.user_code + ) + if not user_codes or not await UserDAO.change_data(user_id=user.id, role=VERIFICATED_USER): + raise WrongCodeException + return True @router.post("/login", response_model=dict[str, str]) @@ -95,17 +96,12 @@ async def rename_user(new_username: SUserRename, current_user: Users = Depends(g @router.patch("/change_avatar", response_model=str) -async def change_avatar( - new_avatar: Annotated[UploadFile, File()], - password: Annotated[SUserAvatar, Body()], - current_user: Users = Depends(get_current_user) -): - if not verify_password(password.password, current_user.hashed_password): +async def change_avatar(user_data: SUserAvatar, current_user: Users = Depends(get_current_user)): + if not verify_password(user_data.password, current_user.hashed_password): raise IncorrectPasswordException - new_avatar_url = await upload_file_returning_str(new_avatar, "upload_avatar") - if await UserDAO.change_data(current_user.id, avatar_image=new_avatar_url): - return new_avatar_url - raise UserNotFoundException # Надо подумать + if await UserDAO.change_data(current_user.id, avatar_image=user_data.new_avatar_image): + return user_data.new_avatar_image + raise UserNotFoundException @router.patch("/change_password", status_code=status.HTTP_200_OK) @@ -117,3 +113,41 @@ async def change_password(new_password: SUserPassword, current_user: Users = Dep raise PasswordsМismatchException hashed_password = get_password_hash(new_password.new_password) await UserDAO.change_data(current_user.id, hashed_password=hashed_password) + send_password_change_email.delay(current_user.username, current_user.email) + + +@router.patch("/send_recovery_email", status_code=status.HTTP_200_OK) +async def send_recovery_email(email: SUserPasswordRecover): + existing_user = await UserDAO.find_one_or_none(email=email.email) + if not existing_user: + raise UserNotFoundException + result = send_password_recover_email.delay(existing_user.username, existing_user.email) + result = result.get() + + if await UserCodesDAO.set_user_codes( + user_id=existing_user.user_id, code=result, description="Код восстановления пароля" + ) == result: + return True + + +@router.post("/confirm_password_recovery", status_code=status.HTTP_200_OK, response_model=int) +async def confirm_password_recovery(user_code: SUserCode): + user_codes = await UserCodesDAO.get_user_codes( + description="Код восстановления пароля", code=user_code.user_code + ) + if not user_codes: + raise WrongCodeException + return user_codes[0]['user_id'] + + +@router.post("/password_recovery", status_code=status.HTTP_200_OK, response_model=str) +async def password_recovery(passwords: SUserPasswordChange): + if passwords.password1 != passwords.password2: + raise PasswordsМismatchException + hashed_password = get_password_hash(passwords.password1) + username = await UserDAO.change_data(passwords.user_id, hashed_password=hashed_password) + user = await UserDAO.find_one_or_none(username=username, id=passwords.user_id) + if not user: + raise UserNotFoundException + send_password_change_email.delay(user.username, user.email) + return username diff --git a/app/users/schemas.py b/app/users/schemas.py index b5b7772..925d9c2 100644 --- a/app/users/schemas.py +++ b/app/users/schemas.py @@ -49,16 +49,24 @@ class SUserRename(BaseModel): class SUserAvatar(BaseModel): password: str - - @model_validator(mode="before") - @classmethod - def auto_loads_json_string(cls, data): - if isinstance(data, str) and data.startswith("{"): - data = json.loads(data) - return data + new_avatar_image: str class SUserPassword(BaseModel): password: str = Query(None, min_length=8) new_password: str = Query(None, min_length=8) new_password2: str = Query(None, min_length=8) + + +class SUserPasswordRecover(BaseModel): + email: EmailStr + + +class SUserCode(BaseModel): + user_code: str + + +class SUserPasswordChange(BaseModel): + user_id: int + password1: str + password2: str