From 75fa993fdd534269d4ee1d9d4bc516c32f9b6d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=9F=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=B2?= Date: Fri, 29 Aug 2025 15:00:52 +0400 Subject: [PATCH] =?UTF-8?q?OTP=202FA=20=D0=B0=D1=83=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/dependencies.py | 7 +- backend/app/api/routers/__init__.py | 2 + backend/app/api/routers/auth_router.py | 60 ++++++++++++++++++ backend/app/api/routers/users_router.py | 8 ++- backend/app/common/__init__.py | 0 backend/app/common/security.py | 18 ++++++ backend/app/config.py | 18 ++++++ backend/app/main.py | 5 +- backend/app/managers/__init__.py | 1 + backend/app/managers/auth_manager.py | 45 +++++++++++++ backend/app/managers/base_manager.py | 1 + backend/app/models/__init__.py | 1 + backend/app/models/otp_model.py | 12 ++++ backend/app/models/user_model.py | 2 +- backend/app/repositories/__init__.py | 2 + .../app/repositories/abstract_repository.py | 16 ++++- backend/app/repositories/otp_repository.py | 37 +++++++++++ backend/app/schemas/auth_schemas.py | 10 +++ backend/app/schemas/otp_schemas.py | 12 ++++ backend/app/schemas/user_schemas.py | 1 + backend/app/services/__init__.py | 3 + backend/app/services/auth_service.py | 24 +++++++ backend/app/services/mail_service.py | 30 +++++++++ backend/app/services/otp_service.py | 24 +++++++ backend/app/services/users_service.py | 12 ++-- backend/requirements.txt | Bin 2102 -> 2130 bytes 26 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 backend/app/api/routers/auth_router.py create mode 100644 backend/app/common/__init__.py create mode 100644 backend/app/common/security.py create mode 100644 backend/app/managers/__init__.py create mode 100644 backend/app/managers/auth_manager.py create mode 100644 backend/app/managers/base_manager.py create mode 100644 backend/app/models/otp_model.py create mode 100644 backend/app/repositories/otp_repository.py create mode 100644 backend/app/schemas/auth_schemas.py create mode 100644 backend/app/schemas/otp_schemas.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/mail_service.py create mode 100644 backend/app/services/otp_service.py diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py index 7bb0978..ba34c52 100644 --- a/backend/app/api/dependencies.py +++ b/backend/app/api/dependencies.py @@ -1,6 +1,11 @@ -from app.repositories import UsersRepository +from app.managers.auth_manager import AuthManager +from app.repositories import UsersRepository, OTPRepository from app.services import UsersService def get_users_service(): return UsersService(UsersRepository) + + +def get_auth_manager(): + return AuthManager(OTPRepository, UsersRepository) diff --git a/backend/app/api/routers/__init__.py b/backend/app/api/routers/__init__.py index c57d9cc..02d184e 100644 --- a/backend/app/api/routers/__init__.py +++ b/backend/app/api/routers/__init__.py @@ -1,5 +1,7 @@ +from .auth_router import router as auth_router from .users_router import router as router_users all_routers = [ router_users, + auth_router ] diff --git a/backend/app/api/routers/auth_router.py b/backend/app/api/routers/auth_router.py new file mode 100644 index 0000000..b403038 --- /dev/null +++ b/backend/app/api/routers/auth_router.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from authx import TokenPayload +from fastapi import APIRouter, Depends, HTTPException, status, Response + +from app.api.dependencies import get_auth_manager, get_users_service +from app.common.security import jwt_security +from app.managers.auth_manager import AuthManager +from app.schemas.auth_schemas import AuthLoginSchema, AuthOTPLoginSchema +from app.services import UsersService + +router = APIRouter( + prefix="/auth", + tags=["Auth"], +) + + +@router.post("/login") +async def login_user( + schema: AuthLoginSchema, + auth_manager: Annotated[AuthManager, Depends(get_auth_manager)], +): + user = await auth_manager.login_user(schema) + + if user is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) + + return {"status": "success", "detail": "Код подтверждения выслан на указанную вами почту"} + + +@router.post("/otp") +async def login_user( + schema: AuthOTPLoginSchema, + auth_manager: Annotated[AuthManager, Depends(get_auth_manager)], + response: Response +): + token = await auth_manager.login_otp(schema) + + if token is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) + + response.set_cookie(jwt_security.config.JWT_ACCESS_COOKIE_NAME, token) + + return {"status": "success", "token": token} + + +@router.post("/logout") +async def logout_user(response: Response): + response.delete_cookie(jwt_security.config.JWT_ACCESS_COOKIE_NAME) + + +@router.get("/current") +async def get_current_user(users_service: Annotated[UsersService, Depends(get_users_service)], + payload: TokenPayload = Depends(jwt_security.access_token_required)): + user = await users_service.get_user(int(payload.sub)) + + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + return user diff --git a/backend/app/api/routers/users_router.py b/backend/app/api/routers/users_router.py index 975dc7a..9a32599 100644 --- a/backend/app/api/routers/users_router.py +++ b/backend/app/api/routers/users_router.py @@ -1,8 +1,9 @@ from typing import Annotated, List -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, status from app.api.dependencies import get_users_service +from app.common.security import jwt_security from app.schemas.user_schemas import UserCreateSchema, UserReadSchema from app.services import UsersService @@ -11,8 +12,9 @@ router = APIRouter( tags=["Users"], ) +# dependencies=[Depends(jwt_security.access_token_required)] -@router.post("") +@router.post("", status_code=status.HTTP_201_CREATED) async def add_user( user: UserCreateSchema, users_service: Annotated[UsersService, Depends(get_users_service)], @@ -21,7 +23,7 @@ async def add_user( return new_user -@router.get("") +@router.get("", dependencies=[Depends(jwt_security.access_token_required)]) async def get_users( users_service: Annotated[UsersService, Depends(get_users_service)], ) -> List[UserReadSchema]: diff --git a/backend/app/common/__init__.py b/backend/app/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/common/security.py b/backend/app/common/security.py new file mode 100644 index 0000000..9b3d148 --- /dev/null +++ b/backend/app/common/security.py @@ -0,0 +1,18 @@ +import hashlib + +from authx import AuthXConfig, AuthX + +import app.config + + +def get_password_hash(password: str) -> str: + return hashlib.sha512(password.encode()).hexdigest() + + +jwt_config = AuthXConfig( + JWT_ALGORITHM=app.config.jwt_algorithm, + JWT_SECRET_KEY=app.config.swt_secret_key, + JWT_TOKEN_LOCATION=app.config.jwt_token_location, +) + +jwt_security = AuthX(config=jwt_config) diff --git a/backend/app/config.py b/backend/app/config.py index 37e0cd8..18cb726 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1 +1,19 @@ database_url = "sqlite+aiosqlite:///data/database.sqlite" + +smtp_host = "smtp.beget.com" +smtp_port = 2525 +smtp_username = "university@nspotapov.ru" +smtp_from = "university@nspotapov.ru" +smtp_password = "V%P2WUe2wnwL" + +otp_code_expired_time = 5 # minutes + +redis_host = "127.0.0.1" +redis_port = 6379 +redis_username = None +redis_password = None +redis_db = 0 + +jwt_algorithm = "HS256" +swt_secret_key = "SECRET_KEY" +jwt_token_location = ["headers", "cookies", "query"] diff --git a/backend/app/main.py b/backend/app/main.py index feebb08..a4e6d73 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,11 @@ from fastapi import FastAPI, APIRouter +from app.common.security import jwt_security + app = FastAPI() +jwt_security.handle_errors(app) + main_router = APIRouter(prefix="/api") from app.api.routers import all_routers @@ -10,4 +14,3 @@ for router in all_routers: main_router.include_router(router) app.include_router(main_router) - diff --git a/backend/app/managers/__init__.py b/backend/app/managers/__init__.py new file mode 100644 index 0000000..cd516ad --- /dev/null +++ b/backend/app/managers/__init__.py @@ -0,0 +1 @@ +from .auth_manager import AuthManager diff --git a/backend/app/managers/auth_manager.py b/backend/app/managers/auth_manager.py new file mode 100644 index 0000000..d63b2fe --- /dev/null +++ b/backend/app/managers/auth_manager.py @@ -0,0 +1,45 @@ +from typing import Type + +import app.config +from app.common.security import jwt_security +from app.repositories import SQLAlchemyRepository +from app.schemas.auth_schemas import AuthLoginSchema, AuthOTPLoginSchema +from app.schemas.user_schemas import UserReadSchema +from app.services import AuthService, OTPService, MailService + + +class AuthManager: + def __init__(self, otp_repo: Type[SQLAlchemyRepository], users_repo: Type[SQLAlchemyRepository]): + self._auth_service = AuthService(users_repo) + self._otp_service = OTPService(otp_repo) + self._mail_service = MailService() + + async def login_user(self, schema: AuthLoginSchema) -> UserReadSchema | None: + user = await self._auth_service.login_user(schema) + + if user is None: + return None + + otp = await self._otp_service.create_otp_code(user.id) + + msg_text = f"Ваш код подтверждения: {otp.code}\n\nКод действителен {app.config.otp_code_expired_time} минут" + msg_subject = "Подтверждение входа на портале университета" + + await self._mail_service.send_mail(user.email, msg_subject, msg_text) + + return user + + async def login_otp(self, schema: AuthOTPLoginSchema) -> str | None: + user = await self._auth_service.login_user(schema) + + if user is None: + return None + + is_verified = await self._otp_service.verify_code(user.id, schema.otp_code) + + if not is_verified: + return None + + token = jwt_security.create_access_token(uid=str(user.id)) + + return token diff --git a/backend/app/managers/base_manager.py b/backend/app/managers/base_manager.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/managers/base_manager.py @@ -0,0 +1 @@ + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b473a80..5afe969 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,2 +1,3 @@ from .user_model import User from .base_model import DBModel +from .otp_model import OTP \ No newline at end of file diff --git a/backend/app/models/otp_model.py b/backend/app/models/otp_model.py new file mode 100644 index 0000000..70c0e50 --- /dev/null +++ b/backend/app/models/otp_model.py @@ -0,0 +1,12 @@ +import datetime + +from app.schemas.otp_schemas import OTPReadSchema + + +class OTP: + user_id: int + code: str + created_at: datetime.datetime + + def to_read_schema(self) -> OTPReadSchema: + return OTPReadSchema(user_id=self.user_id, code=self.code) diff --git a/backend/app/models/user_model.py b/backend/app/models/user_model.py index 996663f..e0d13e5 100644 --- a/backend/app/models/user_model.py +++ b/backend/app/models/user_model.py @@ -13,4 +13,4 @@ class User(DBModel): hashed_password: Mapped[str] = mapped_column(String(256), nullable=False) def to_read_schema(self) -> UserReadSchema: - return UserReadSchema(id=self.id, name=self.name, email=self.email) + return UserReadSchema(id=self.id, name=self.name, email=self.email, hashed_password=self.hashed_password) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py index 4ad00a8..d120ed5 100644 --- a/backend/app/repositories/__init__.py +++ b/backend/app/repositories/__init__.py @@ -1,3 +1,5 @@ from .abstract_repository import AbstractRepository from .sqlalchemy_repository import SQLAlchemyRepository from .users_repository import UsersRepository + +from .otp_repository import OTPRepository \ No newline at end of file diff --git a/backend/app/repositories/abstract_repository.py b/backend/app/repositories/abstract_repository.py index 0e9424c..31e1ea9 100644 --- a/backend/app/repositories/abstract_repository.py +++ b/backend/app/repositories/abstract_repository.py @@ -1,11 +1,23 @@ from abc import ABC, abstractmethod +from typing import Any class AbstractRepository(ABC): @abstractmethod - async def add_one(self, data: dict): + async def add_one(self, data: dict[str, Any]): raise NotImplementedError - @abstractmethod async def get_all(self): raise NotImplementedError + + async def edit_one(self, id: int, data: dict[str, Any]): + raise NotImplementedError + + async def get_one(self, id: int): + raise NotImplementedError + + async def delete_one(self, id: int): + raise NotImplementedError + + async def find_all(self, **filter_by): + raise NotImplementedError diff --git a/backend/app/repositories/otp_repository.py b/backend/app/repositories/otp_repository.py new file mode 100644 index 0000000..1f71cb5 --- /dev/null +++ b/backend/app/repositories/otp_repository.py @@ -0,0 +1,37 @@ +import uuid + +import redis.asyncio as redis + +import app.config +import app.config +from app.schemas.otp_schemas import OTPReadSchema + + +class OTPRepository: + def __init__(self): + self._redis = redis.Redis( + host=app.config.redis_host, + port=app.config.redis_port, + username=app.config.redis_username, + password=app.config.redis_password, + decode_responses=True + ) + + async def add_one(self, user_id: int, code: str) -> OTPReadSchema: + key = ":".join(["user", str(user_id), "otp_code", str(uuid.uuid4())]) + await self._redis.setex(key, app.config.otp_code_expired_time * 60, code) + return OTPReadSchema(id=key, user_id=user_id, code=code) + + async def find_all(self, user_id: int) -> list[OTPReadSchema]: + key = ":".join(["user", str(user_id), "otp_code", "*"]) + + otp_codes = [] + + async for key in self._redis.scan_iter(key): + code = await self._redis.get(key) + otp_codes.append(OTPReadSchema(id=key, user_id=user_id, code=code)) + + return otp_codes + + async def delete_one(self, id: str): + await self._redis.delete(id) diff --git a/backend/app/schemas/auth_schemas.py b/backend/app/schemas/auth_schemas.py new file mode 100644 index 0000000..a2056b5 --- /dev/null +++ b/backend/app/schemas/auth_schemas.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, EmailStr, Field + + +class AuthLoginSchema(BaseModel): + email: EmailStr = Field(examples=["ns.potapov@yandex.ru"]) + password: str + + +class AuthOTPLoginSchema(AuthLoginSchema): + otp_code: str diff --git a/backend/app/schemas/otp_schemas.py b/backend/app/schemas/otp_schemas.py new file mode 100644 index 0000000..5a63cc2 --- /dev/null +++ b/backend/app/schemas/otp_schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class OTPReadSchema(BaseModel): + id: str + user_id: int + code: str + + +class OTPCreateSchema(BaseModel): + user_id: int + code: str diff --git a/backend/app/schemas/user_schemas.py b/backend/app/schemas/user_schemas.py index c9c7ae7..dc0f7c1 100644 --- a/backend/app/schemas/user_schemas.py +++ b/backend/app/schemas/user_schemas.py @@ -12,6 +12,7 @@ class UserCreateSchema(BaseCreateSchema): class UserReadSchema(BaseReadSchema): name: str email: str + hashed_password: str = Field(exclude=True) class UserUpdateSchema(BaseUpdateSchema): diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 68b5fae..a10676e 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1 +1,4 @@ +from .auth_service import AuthService +from .mail_service import MailService +from .otp_service import OTPService from .users_service import UsersService diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..b6db98c --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,24 @@ +from typing import Type + +from app.common.security import get_password_hash +from app.repositories import SQLAlchemyRepository +from app.schemas.auth_schemas import AuthLoginSchema +from app.schemas.user_schemas import UserReadSchema + + +class AuthService: + def __init__(self, users_repo: Type[SQLAlchemyRepository]): + self.users_repo: SQLAlchemyRepository = users_repo() + + async def login_user(self, schema: AuthLoginSchema) -> UserReadSchema | None: + users = await self.users_repo.find_all(email=schema.email) + + if len(users) == 0: + return None + + user = users[0] + + if get_password_hash(schema.password) != user.hashed_password: + return None + + return user diff --git a/backend/app/services/mail_service.py b/backend/app/services/mail_service.py new file mode 100644 index 0000000..c35b81b --- /dev/null +++ b/backend/app/services/mail_service.py @@ -0,0 +1,30 @@ +import logging +import smtplib +from email.message import EmailMessage +from typing import Sequence + +from app import config + + +class MailService: + def __init__(self): + self._server = None + + async def send_mail(self, to_addr: str | Sequence[str], subject: str, content: str): + try: + self._server = smtplib.SMTP(config.smtp_host, config.smtp_port) + + # self._server.starttls() + self._server.login(config.smtp_username, config.smtp_password) + + email_msg = EmailMessage() + email_msg.set_content(content) + email_msg["Subject"] = subject + email_msg["From"] = config.smtp_from + + self._server.send_message(email_msg, to_addrs=to_addr, from_addr=config.smtp_from) + + self._server.quit() + + except Exception as e: + logging.warn(str(e)) diff --git a/backend/app/services/otp_service.py b/backend/app/services/otp_service.py new file mode 100644 index 0000000..789404d --- /dev/null +++ b/backend/app/services/otp_service.py @@ -0,0 +1,24 @@ +import random +from typing import Type + +from app.repositories import OTPRepository +from app.schemas.otp_schemas import OTPReadSchema + + +class OTPService: + def __init__(self, otp_repo: Type[OTPRepository]): + self.otp_repo: OTPRepository = otp_repo() + + async def create_otp_code(self, user_id: int) -> OTPReadSchema: + code = str(random.randint(1000, 9999)) + return await self.otp_repo.add_one(user_id, code) + + async def verify_code(self, user_id: int, code: str) -> bool: + user_otp_codes = await self.otp_repo.find_all(user_id=user_id) + + for user_otp_code in user_otp_codes: + if user_otp_code.code == code: + await self.otp_repo.delete_one(user_otp_code.id) + return True + + return False diff --git a/backend/app/services/users_service.py b/backend/app/services/users_service.py index a92ea5e..c885346 100644 --- a/backend/app/services/users_service.py +++ b/backend/app/services/users_service.py @@ -1,8 +1,8 @@ -from hashlib import sha512 from typing import Type, List, Any -from fastapi import HTTPException +from fastapi import HTTPException, status +from app.common.security import get_password_hash from app.repositories import SQLAlchemyRepository from app.schemas.user_schemas import UserCreateSchema, UserReadSchema @@ -15,7 +15,7 @@ class UsersService: user_dict = user.model_dump() if not await self.__check_email_unique(user_dict): - raise HTTPException(status_code=400, detail="Email is already used") + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already used") user_dict = self.__encrypt_user_password(user_dict) user_id = await self.users_repo.add_one(user_dict) @@ -36,10 +36,6 @@ class UsersService: @classmethod def __encrypt_user_password(cls, user_dict: dict[str, Any]) -> dict[str, Any]: - user_dict["hashed_password"] = cls.__hash_password(user_dict["password"]) + user_dict["hashed_password"] = get_password_hash(user_dict["password"]) del user_dict["password"] return user_dict - - @classmethod - def __hash_password(cls, password: str) -> str: - return sha512(password.encode()).hexdigest() diff --git a/backend/requirements.txt b/backend/requirements.txt index 96c81397de8a13defb6983dc1da20ef0b46e06b5..5ca5a48bbcb6c44c75f6685cab87f52c9b029653 100644 GIT binary patch delta 34 mcmdlca7kc;5SwT!LkdGCLotIb5SlUQF_-|c!DenYZ6*MVo(7Bn delta 12 Tcmca4uuWit5Zh)wHWelS9Q6Z!