OTP 2FA аутентификация готова

This commit is contained in:
2025-08-29 15:00:52 +04:00
parent b932a5c891
commit 75fa993fdd
26 changed files with 335 additions and 16 deletions

View File

@@ -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)

View File

@@ -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
]

View File

@@ -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

View File

@@ -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]:

View File

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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)

View File

@@ -0,0 +1 @@
from .auth_manager import AuthManager

View File

@@ -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

View File

@@ -0,0 +1 @@

View File

@@ -1,2 +1,3 @@
from .user_model import User
from .base_model import DBModel
from .otp_model import OTP

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,3 +1,5 @@
from .abstract_repository import AbstractRepository
from .sqlalchemy_repository import SQLAlchemyRepository
from .users_repository import UsersRepository
from .otp_repository import OTPRepository

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -12,6 +12,7 @@ class UserCreateSchema(BaseCreateSchema):
class UserReadSchema(BaseReadSchema):
name: str
email: str
hashed_password: str = Field(exclude=True)
class UserUpdateSchema(BaseUpdateSchema):

View File

@@ -1 +1,4 @@
from .auth_service import AuthService
from .mail_service import MailService
from .otp_service import OTPService
from .users_service import UsersService

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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()

Binary file not shown.