OTP 2FA аутентификация готова
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
60
backend/app/api/routers/auth_router.py
Normal file
60
backend/app/api/routers/auth_router.py
Normal 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
|
||||
@@ -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]:
|
||||
|
||||
0
backend/app/common/__init__.py
Normal file
0
backend/app/common/__init__.py
Normal file
18
backend/app/common/security.py
Normal file
18
backend/app/common/security.py
Normal 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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
1
backend/app/managers/__init__.py
Normal file
1
backend/app/managers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .auth_manager import AuthManager
|
||||
45
backend/app/managers/auth_manager.py
Normal file
45
backend/app/managers/auth_manager.py
Normal 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
|
||||
1
backend/app/managers/base_manager.py
Normal file
1
backend/app/managers/base_manager.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .user_model import User
|
||||
from .base_model import DBModel
|
||||
from .otp_model import OTP
|
||||
12
backend/app/models/otp_model.py
Normal file
12
backend/app/models/otp_model.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from .abstract_repository import AbstractRepository
|
||||
from .sqlalchemy_repository import SQLAlchemyRepository
|
||||
from .users_repository import UsersRepository
|
||||
|
||||
from .otp_repository import OTPRepository
|
||||
@@ -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
|
||||
|
||||
37
backend/app/repositories/otp_repository.py
Normal file
37
backend/app/repositories/otp_repository.py
Normal 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)
|
||||
10
backend/app/schemas/auth_schemas.py
Normal file
10
backend/app/schemas/auth_schemas.py
Normal 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
|
||||
12
backend/app/schemas/otp_schemas.py
Normal file
12
backend/app/schemas/otp_schemas.py
Normal 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
|
||||
@@ -12,6 +12,7 @@ class UserCreateSchema(BaseCreateSchema):
|
||||
class UserReadSchema(BaseReadSchema):
|
||||
name: str
|
||||
email: str
|
||||
hashed_password: str = Field(exclude=True)
|
||||
|
||||
|
||||
class UserUpdateSchema(BaseUpdateSchema):
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
from .auth_service import AuthService
|
||||
from .mail_service import MailService
|
||||
from .otp_service import OTPService
|
||||
from .users_service import UsersService
|
||||
|
||||
24
backend/app/services/auth_service.py
Normal file
24
backend/app/services/auth_service.py
Normal 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
|
||||
30
backend/app/services/mail_service.py
Normal file
30
backend/app/services/mail_service.py
Normal 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))
|
||||
24
backend/app/services/otp_service.py
Normal file
24
backend/app/services/otp_service.py
Normal 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
|
||||
@@ -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.
Reference in New Issue
Block a user