Сервис регистрации/авторизации на fastapi
from app.models import users, auth  # type: ignore

app/__init__.py
m-molecula741/auth
from datetime import datetime

from pydantic import BaseModel


class ObjSchema(BaseModel):
    class Config:
        json_encoders = {datetime: lambda v: v.strftime("%Y-%m-%dT%H:%M:%SZ")}
        arbitrary_types_allowed = True
        allow_population_by_field_name = True
        use_enum_values = True
        validate_assignment = True
        orm_mode = True

app/core/base_schemas.py
m-molecula741/auth
"""Установка глобальных конфигов из файла .env."""
from __future__ import annotations

from pydantic import BaseSettings, Field


class Settings(BaseSettings):
    """Загружаем глобальные конфиги переменных окружения"""

    version: str = Field(default="0.0.0")

    debug: bool = Field(default=False)
    log_level: str = Field(default="INFO")
    environment: str | None = Field(default=None)

    postgres_host: str = Field(default="localhost")
    postgres_port: int = Field(default=5432)
    postgres_user: str = Field(default="postgres")
    postgres_password: str = Field(default="postgres")
    postgres_db: str = Field(default="auth")
    postgres_db_schema: str | None = Field(default=None)

    project_name: str = Field(default="auth")

    redis_host: str = Field(default="localhost")
    redis_port: int = Field(default=6379)

    redis_cache_time: int = Field(default=600)
    access_token_expire_minutes: int = 15
    refresh_token_expire_days: int = 30
    secret_key: str
    algorithm: str
    domain: str

    smtp_host: str
    smtp_port: int
    smtp_user: str
    smtp_pass: str

    @property
    def pg_db_creds(self) -> str:
        """Формируем строку с кредами"""
        return f"{self.postgres_user}:{self.postgres_password}"

    @property
    def db_url(self) -> str:
        """DSN c параметрами подключения к БД"""
        url = (
            f"postgresql+asyncpg://"
            f"{self.pg_db_creds}@"
            f"{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
        )

        return url

    @property
    def redis_cache_url(self) -> str:
        return f"redis://{self.redis_host}:{self.redis_port}/1"

    @property
    def celery_broker_url(self) -> str:
        return f"redis://{self.redis_host}:{self.redis_port}/0"

    class Config:
        """Путь к файлу .env."""

        env_file = ".env"
        # в случае если запускаю через docker-compose:
        # env_file = ".env-non-dev"


config = Settings()

app/core/config.py
m-molecula741/auth
import logging
from datetime import datetime

from pythonjsonlogger import jsonlogger

from app.core.config import config


class CustomJsonFormatter(jsonlogger.JsonFormatter):
    def add_fields(self, log_record, record, message_dict):
        super(CustomJsonFormatter, self).add_fields(log_record, record, message_dict)
        if not log_record.get("timestamp"):
            now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            log_record["timestamp"] = now
        if log_record.get("level"):
            log_record["level"] = log_record["level"].upper()
        else:
            log_record["level"] = record.levelname


class MyLogger:
    def __init__(self):
        # Создаем логгер
        self.logger = logging.getLogger(config.project_name)
        self.logger.setLevel(config.log_level)

        # Создаем обработчик, который выводит сообщения в консоль
        console_handler = logging.StreamHandler()
        console_handler.setLevel(config.log_level)
        formatter = CustomJsonFormatter(
            "%(timestamp)s %(level)s %(message)s %(module)s %(funcName)s"
        )
        console_handler.setFormatter(formatter)

        # Добавляем обработчик к логгеру
        self.logger.addHandler(console_handler)

    def error(self, message):
        self.logger.error(message, stacklevel=2)

    def warning(self, message):
        self.logger.warning(message, stacklevel=2)

    def info(self, message):
        self.logger.info(message, stacklevel=2)

    def debug(self, message):
        self.logger.debug(message, stacklevel=2)


logger = MyLogger()

app/core/logger.py
m-molecula741/auth
import time

from fastapi import Request

from app.core.logger import logger


async def add_process_time_handler(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    process_time = time.time() - start
    logger.info(f"Request process time: {process_time} ")
    return response

app/core/middleware.py
m-molecula741/auth
"""Настройка подключения к базе данных."""
import json

from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase

from app.core.config import config

# Создание подключения к БД
engine = create_async_engine(
    config.db_url,
    echo=config.debug,
    json_serializer=lambda obj: json.dumps(obj, ensure_ascii=False),
)
# Создание сессии БД
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)


class Base(DeclarativeBase):
    metadata = MetaData(schema=config.postgres_db_schema)

app/db/database.py
m-molecula741/auth
from app.db.repositories.base_repo import BaseRepository, ModelType
from app.models.auth import AuthModel
from app.core.logger import logger
from sqlalchemy.sql.expression import delete
from uuid import UUID
from sqlalchemy import select


class AuthRepository(BaseRepository[AuthModel]):
    async def find_refresh_session(
        self, **filter_by
    ) -> tuple[ModelType | None, str | None]:
        try:
            stmt = select(self.model).filter_by(**filter_by)  # type: ignore
            res = await self.session.scalar(stmt)  # type: ignore
        except Exception as e:
            logger.error(f"DB error: {e}")
            return None, f"DB error: {e}"
        if res is None:
            return None, None

        return res, None

    async def delete_by_user_id(
        self, user_id: int | UUID
    ) -> tuple[bool | None, str | None]:
        stmt = delete(self.model).where(self.model.user_id == user_id)
        try:
            await self.session.execute(stmt)
        except Exception as e:
            logger.error(f"DB error for {stmt}: {str(e)}")
            return None, f"DB error for {stmt}: {str(e)}"
        return True, None

app/db/repositories/auth_repository.py
m-molecula741/auth
from typing import Generic, Sequence, TypeVar, get_args
from uuid import UUID

from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import delete

from app.core.base_schemas import ObjSchema
from app.core.logger import logger
from app.db.database import Base

ModelType = TypeVar("ModelType", bound=Base)


class BaseRepository(Generic[ModelType]):
    def __init__(self, session: AsyncSession):
        self.session = session
        self.model = get_args(self.__orig_bases__[0])[0]  # type: ignore

    async def find_one(self, **filter_by) -> tuple[ModelType | None, str | None]:
        try:
            stmt = select(self.model).filter_by(**filter_by)  # type: ignore
            res = await self.session.scalar(stmt)  # type: ignore
        except Exception as e:
            logger.error(f"DB error: {e}")
            return None, f"DB error: {e}"
        if res is None:
            return None, "Data not found"

        return res, None

    async def find_all(self) -> tuple[Sequence[ModelType] | None, str | None]:
        try:
            stmt = select(self.model)
            result = await self.session.execute(stmt)
            res = result.scalars().all()
        except Exception as e:
            logger.error(f"DB error : {e}")
            return None, f"DB error : {e}"
        if res is None:
            return None, "Data not found"

        return res, None

    async def add(self, obj_in: ObjSchema) -> tuple[ModelType | None, str | None]:
        try:
            db_obj = self.model(**obj_in.dict())
            self.session.add(db_obj)
            return db_obj, None
        except Exception as e:
            logger.error(f"DB error for add object: {str(e)}")
            return None, f"DB error for add object: {str(e)}"

    async def bulk_add(
        self, obj_ins: Sequence[ObjSchema]
    ) -> tuple[list[ModelType] | None, str | None]:
        try:
            db_objs = [self.model(**obj_in.dict()) for obj_in in obj_ins]
            self.session.add_all(db_objs)
            return db_objs, None
        except Exception as e:
            logger.error(f"DB error for insert object: {str(e)}")
            return None, f"DB error for insert object: {str(e)}"

    async def update(
        self, id: int | UUID, obj_in: ObjSchema
    ) -> tuple[bool | None, str | None]:
        try:
            stmt = update(self.model).values(**obj_in.dict()).filter_by(id=id)  # type: ignore
            await self.session.execute(stmt)
        except Exception as e:
            logger.error(f"DB error from update: {e}")
            return None, f"DB error from update: {e}"
        return True, None

    async def delete(self, id: int | UUID) -> tuple[bool | None, str | None]:
        stmt = delete(self.model).where(self.model.id == id)
        try:
            await self.session.execute(stmt)
        except Exception as e:
            logger.error(f"DB error for {stmt}: {str(e)}")
            return None, f"DB error for {stmt}: {str(e)}"
        return True, None

app/db/repositories/base_repo.py
m-molecula741/auth
from app.db.repositories.base_repo import BaseRepository, ModelType
from app.models.users import UserModel
from app.core.logger import logger
from sqlalchemy import select


class UsersRepository(BaseRepository[UserModel]):
    async def find_user(self, **filter_by) -> tuple[ModelType | None, str | None]:
        try:
            stmt = select(self.model).filter_by(**filter_by)  # type: ignore
            res = await self.session.scalar(stmt)  # type: ignore
        except Exception as e:
            logger.error(f"DB error: {e}")
            return None, f"DB error: {e}"
        if res is None:
            return None, None

        return res, None

app/db/repositories/users_repository.py
m-molecula741/auth
from __future__ import annotations

from abc import ABC, abstractmethod
from types import TracebackType
from typing import Type

from app.db.database import async_session_maker
from app.db.repositories import users_repository, auth_repository
from pydantic import ValidationError
from fastapi import HTTPException, status
from app.core.logger import logger


class AbstractUOW(ABC):
    async def __aenter__(self) -> AbstractUOW:
        """Точка входа в контекстный менеджер"""
        raise NotImplementedError

    async def __aexit__(
        self,
        err_type: Type[BaseException] | None,
        err: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        """Точка выхода из контекстного менеджера"""
        raise NotImplementedError

    @abstractmethod
    async def commit(self) -> None:
        """Сохраняем транзакцию"""
        raise NotImplementedError

    @abstractmethod
    async def rollback(self) -> None:
        """Откатываем транзакцию"""
        raise NotImplementedError


class SqlAlchemyUnitOfWork(AbstractUOW):
    """SQLAlchemy реализация Unit of work"""

    def __init__(self) -> None:
        """Конструктор"""
        self.session = async_session_maker()

    async def __aenter__(self) -> SqlAlchemyUnitOfWork:
        """Точка входа в контекстный менеджер"""
        return self

    async def __aexit__(
        self,
        err_type: Type[BaseException] | None,
        err: BaseException | None,
        traceback: TracebackType | None,
    ) -> None:
        """Точка выхода из контекстного менеджера"""
        if err is None:
            await self.commit()
            await self.session.close()
        else:
            await self.rollback()
            await self.session.close()
            if err_type == ValidationError:
                logger.error(str(err))
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(err)
                )

    async def commit(self) -> None:
        """Сохраняем транзакцию"""
        await self.session.commit()

    async def rollback(self) -> None:
        """Откатываем транзакцию"""
        await self.session.rollback()

    @property
    def users(self) -> users_repository.UsersRepository:
        """Доступ к репозиторию юзеров"""
        return users_repository.UsersRepository(self.session)

    @property
    def refresh_sessions(self) -> auth_repository.AuthRepository:
        """Доступ к репозиторию сессий"""
        return auth_repository.AuthRepository(self.session)

app/db/uow.py
m-molecula741/auth
import uvicorn
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.backends.redis import RedisBackend

from app.core.config import config
from app.core.logger import logger
from app.core.middleware import add_process_time_handler
from app.routers.routers import router_private, router_public
from redis import asyncio as aioredis


def get_app() -> FastAPI:
    app = FastAPI(
        title=config.project_name,
        debug=config.debug,
        version=config.version,
        default_response_class=ORJSONResponse,
    )

    app.middleware("http")(add_process_time_handler)

    @app.on_event("startup")
    async def startup():
        app.state.redis = aioredis.from_url(config.redis_cache_url)
        FastAPICache.init(RedisBackend(app.state.redis), prefix="cache")
        logger.info("Conected to redis")

    @app.on_event("shutdown")
    async def shutdown():
        await app.state.redis.close()
        FastAPICache.reset()
        logger.info("Redis connection close")

    app.include_router(router_private, prefix="/private")
    app.include_router(router_public, prefix="/public")

    return app


app = get_app()

if __name__ == "__main__":
    uvicorn.run(app="main:app", reload=True)

app/main.py
m-molecula741/auth
from __future__ import annotations

import sqlalchemy as sa
from pydantic import Field
from sqlalchemy.orm import Mapped, mapped_column

from app.core.base_schemas import ObjSchema
from app.db.database import Base
from sqlalchemy.dialects.postgresql import UUID
from uuid import UUID as py_UUID
from datetime import datetime


class AuthModel(Base):
    __tablename__ = "auth"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    refresh_token: Mapped[py_UUID] = mapped_column(UUID, nullable=False, index=True)
    expires_in: Mapped[int] = mapped_column(sa.Integer, nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        sa.DateTime, default=datetime.utcnow, nullable=False
    )
    user_id: Mapped[py_UUID] = mapped_column(
        UUID, sa.ForeignKey("user.id", ondelete="CASCADE")
    )


class Token(ObjSchema):
    access_token: str
    refresh_token: str
    token_type: str


class AuthSessionCreate(ObjSchema):
    refresh_token: py_UUID
    expires_in: int
    user_id: py_UUID


class AuthSessionUpdate(ObjSchema):
    user_id: py_UUID | None = Field(default=None)
    refresh_token: py_UUID
    expires_in: int

app/models/auth.py
m-molecula741/auth
from __future__ import annotations

import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column

from app.core.base_schemas import ObjSchema
from app.db.database import Base
from sqlalchemy.dialects.postgresql import UUID
from uuid import UUID as py_UUID
from pydantic import EmailStr


class UserModel(Base):
    __tablename__ = "user"

    id: Mapped[py_UUID] = mapped_column(
        UUID(as_uuid=True),
        primary_key=True,
        index=True,
    )
    email: Mapped[str] = mapped_column(
        sa.String, nullable=False, unique=True, index=True
    )
    hashed_password: Mapped[str] = mapped_column(sa.String, nullable=False)
    name: Mapped[str] = mapped_column(sa.String(50), nullable=False)
    surname: Mapped[str] = mapped_column(sa.String(50), nullable=False)
    is_active: Mapped[bool] = mapped_column(sa.Boolean, default=True)
    is_verified: Mapped[bool] = mapped_column(sa.Boolean, default=False)
    is_superuser: Mapped[bool] = mapped_column(sa.Boolean, default=False)


class UserCreateRequest(ObjSchema):
    email: EmailStr
    name: str
    surname: str
    password: str


class UserCreate(ObjSchema):
    id: py_UUID
    email: EmailStr
    name: str
    surname: str
    hashed_password: str
    is_active: bool = False
    is_verified: bool = False
    is_superuser: bool = False


class UserInDb(ObjSchema):
    id: py_UUID
    email: EmailStr
    name: str
    surname: str
    is_active: bool = False
    is_verified: bool = False
    is_superuser: bool = False


class UserDeactivate(ObjSchema):
    is_active: bool = False


class BaseUserUpdate(ObjSchema):
    name: str | None = None
    surname: str | None = None


class UserUpdateIn(BaseUserUpdate):
    password: str | None = None


class UserUpdate(BaseUserUpdate):
    is_active: bool = True
    hashed_password: str


class UserResponse(ObjSchema):
    email: EmailStr
    name: str
    surname: str


class UserVerifyEmail(ObjSchema):
    code: str


class ActivateUser(ObjSchema):
    is_active: bool = True
    is_verified: bool = True


class UserResendingEmail(ObjSchema):
    id: py_UUID
    email: EmailStr


class UserEmail(ObjSchema):
    email: EmailStr


class UserPassword(ObjSchema):
    hashed_password: str

app/models/users.py
m-molecula741/auth
from fastapi import Request


def get_redis(request: Request):
    return request.app.state.redis

app/redis.py
m-molecula741/auth
from typing import Annotated
from app.db.uow import SqlAlchemyUnitOfWork as UOW
import uuid
from fastapi import Depends, HTTPException, status
from jose import jwt

from app.core.config import config
from app.models.users import UserModel
from app.services.users_service import UserService
from app.utils.users_utils import OAuth2PasswordBearerWithCookie


oauth2_scheme = OAuth2PasswordBearerWithCookie(token_url="/private/users/auth/login")

UOWDep = Annotated[UOW, Depends(UOW)]


async def get_current_user(
    uow: UOW = Depends(UOW), token: str = Depends(oauth2_scheme)
) -> UserModel | None:
    try:
        payload = jwt.decode(token, config.secret_key, algorithms=[config.algorithm])
        user_id = payload.get("sub")
        if user_id is None:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
            )
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
        )
    current_user = await UserService.get_user(uuid.UUID(user_id), uow)
    if not current_user.is_verified:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="Verify email"
        )
    return current_user


async def get_current_active_user(
    current_user: UserModel = Depends(get_current_user),
) -> UserModel:
    if not current_user.is_active:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="User is not active"
        )
    return current_user

app/routers/dependencies.py
m-molecula741/auth
from fastapi import (
    APIRouter,
    status,
    Response,
    Request,
    Depends,
)
from app.models.users import UserModel
from app.routers.dependencies import UOWDep, get_current_active_user
from app.services.auth_service import AuthService
from app.core.config import config
from uuid import UUID

router = APIRouter()


@router.post(path="/logout", status_code=status.HTTP_200_OK, response_model=dict)
async def logout(
    uow: UOWDep,
    request: Request,
    response: Response,
    user: UserModel = Depends(get_current_active_user),
) -> dict:
    response.delete_cookie("access_token", httponly=True, domain=config.domain)
    response.delete_cookie("refresh_token", httponly=True, domain=config.domain)

    await AuthService.logout(UUID(request.cookies.get("refresh_token")), uow)
    content = {"message": "Logged out successfully"}
    return content

app/routers/private/auth.py
m-molecula741/auth
from fastapi import APIRouter, status, Depends, Response

from app.models.users import (
    UserUpdateIn,
    UserResponse,
    UserModel,
)
from app.routers.dependencies import UOWDep, get_current_active_user
from app.services.users_service import UserService
from app.services.auth_service import AuthService
from app.core.base_schemas import ObjSchema
from fastapi.responses import ORJSONResponse


router = APIRouter()


@router.post(path="/deactivate", status_code=status.HTTP_200_OK, response_model=dict)
async def deactivate_user(
    response: Response,
    uow: UOWDep,
    current_user: UserModel = Depends(get_current_active_user),
) -> dict:
    response.delete_cookie("access_token")
    response.delete_cookie("refresh_token")
    await AuthService.abort_all_sessions(current_user.id, uow)
    await UserService.deactivate_user(current_user.id, uow)
    return {"message": "User status is not active already"}


@router.patch(path="/update", status_code=status.HTTP_200_OK, response_model=bool)
async def update_user(
    user_in: UserUpdateIn,
    uow: UOWDep,
    current_user: UserModel = Depends(get_current_active_user),
) -> ORJSONResponse:
    is_ok = await UserService.update_user(current_user.id, user_in, uow)
    return ORJSONResponse(status_code=status.HTTP_200_OK, content=is_ok)


@router.get(path="/me", status_code=status.HTTP_200_OK, response_model=UserResponse)
async def get_user(
    uow: UOWDep,
    current_user: UserModel = Depends(get_current_active_user),
) -> ObjSchema:
    db_user = await UserService.get_user(current_user.id, uow)
    return UserResponse(email=db_user.email, name=db_user.name, surname=db_user.surname)

app/routers/private/users.py
m-molecula741/auth
from fastapi import (
    APIRouter,
    status,
    Response,
    Request,
    Depends,
    HTTPException,
)
from app.models.auth import Token
from app.routers.dependencies import UOWDep
from app.services.auth_service import AuthService
from app.core.base_schemas import ObjSchema
from fastapi.security import OAuth2PasswordRequestForm
from app.core.config import config
from uuid import UUID

router = APIRouter()


@router.post(path="/login", status_code=status.HTTP_201_CREATED, response_model=Token)
async def login(
    uow: UOWDep,
    response: Response,
    credentials: OAuth2PasswordRequestForm = Depends(),
) -> ObjSchema:
    user = await AuthService.authenticate_user(  # type: ignore
        credentials.username, credentials.password, uow
    )

    token = await AuthService.create_token(user.id, uow)
    response.set_cookie(
        "access_token",
        token.access_token,
        max_age=config.access_token_expire_minutes * 60,
        httponly=True,
    )
    response.set_cookie(
        "refresh_token",
        token.refresh_token,
        max_age=config.refresh_token_expire_days * 30 * 24 * 60,
        httponly=True,
    )
    return token


@router.post(path="/refresh", status_code=status.HTTP_200_OK, response_model=Token)
async def refresh_token(
    uow: UOWDep,
    request: Request,
    response: Response,
) -> ObjSchema:
    if not request.cookies.get("refresh_token"):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="no refresh token"
        )
    new_token = await AuthService.refresh_token(
        UUID(request.cookies.get("refresh_token")), uow
    )

    response.set_cookie(
        "access_token",
        new_token.access_token,
        max_age=config.access_token_expire_minutes * 60,
        httponly=True,
    )
    response.set_cookie(
        "refresh_token",
        new_token.refresh_token,
        max_age=config.refresh_token_expire_days * 30 * 24 * 60,
        httponly=True,
    )
    return new_token

app/routers/public/auth.py
m-molecula741/auth
from fastapi import APIRouter, status, Depends
from fastapi.responses import ORJSONResponse

from app.models.users import (
    UserCreateRequest,
    UserInDb,
    UserVerifyEmail,
    UserResendingEmail,
    UserEmail,
)
from app.redis import get_redis
from app.routers.dependencies import UOWDep
from app.services.users_service import UserService
from app.core.base_schemas import ObjSchema
from redis.asyncio import Redis


router = APIRouter()


@router.post(
    path="/registration", status_code=status.HTTP_201_CREATED, response_model=UserInDb
)
async def create_user(
    user: UserCreateRequest, uow: UOWDep, redis: Redis = Depends(get_redis)
) -> ObjSchema:
    return await UserService.register_new_user(uow, user, redis)


@router.patch(
    path="/registration/confirm", status_code=status.HTTP_200_OK, response_model=bool
)
async def verification_and_activation_user(
    code: UserVerifyEmail, uow: UOWDep, redis: Redis = Depends(get_redis)
) -> ORJSONResponse:
    is_ok = await UserService.verify_email_and_activate_user(code, uow, redis)
    return ORJSONResponse(status_code=status.HTTP_200_OK, content=is_ok)


@router.post(path="/resending", status_code=status.HTTP_200_OK, response_model=bool)
async def resending_email_to_user(
    resending_data: UserResendingEmail, redis: Redis = Depends(get_redis)
) -> ORJSONResponse:
    await UserService.resending_email(resending_data, redis)
    return ORJSONResponse(status_code=status.HTTP_200_OK, content=True)


@router.patch(
    path="/password/reset", status_code=status.HTTP_200_OK, response_model=bool
)
async def reset_password(reset_data: UserEmail, uow: UOWDep) -> ORJSONResponse:
    await UserService.reset_password_and_send_new(reset_data, uow)
    return ORJSONResponse(status_code=status.HTTP_200_OK, content=True)


@router.post(path="/activate", status_code=status.HTTP_200_OK, response_model=bool)
async def activate_user(
    user_data: UserEmail, uow: UOWDep, redis: Redis = Depends(get_redis)
) -> ORJSONResponse:
    await UserService.activate_user(user_data, uow, redis)
    return ORJSONResponse(status_code=status.HTTP_200_OK, content=True)


@router.patch(
    path="/activate/confirm", status_code=status.HTTP_200_OK, response_model=bool
)
async def verification_and_activation(
    code: UserVerifyEmail, uow: UOWDep, redis: Redis = Depends(get_redis)
) -> ORJSONResponse:
    is_ok = await UserService.confirm_activate(code, uow, redis)
    return ORJSONResponse(status_code=status.HTTP_200_OK, content=is_ok)

app/routers/public/users.py
m-molecula741/auth
from fastapi import APIRouter

from app.routers.private import (
    users as private_users,
    auth as private_auth,
)
from app.routers.public import (
    auth as public_auth,
    users as public_users,
)


router_public = APIRouter()
router_public.include_router(
    public_auth.router, tags=["Public auth routers"], prefix="/auth"
)
router_public.include_router(
    public_users.router, tags=["Public users routers"], prefix="/users"
)

router_private = APIRouter()
router_private.include_router(
    private_users.router, tags=["Private users routers"], prefix="/users"
)
router_private.include_router(
    private_auth.router, tags=["Private auth routers"], prefix="/user/auth"
)

app/routers/routers.py
m-molecula741/auth
from fastapi import HTTPException, status

from app.db.repositories.base_repo import ModelType
from app.db.uow import SqlAlchemyUnitOfWork as UOW
from app.models.auth import Token
from uuid import UUID
from datetime import datetime, timedelta, timezone
from uuid_extensions import uuid7
from app.core.config import config
from jose import jwt
from app.models.auth import AuthSessionCreate, AuthSessionUpdate
from app.utils.users_utils import is_valid_password


class AuthService:
    @classmethod
    async def create_token(cls, user_id: UUID, uow: UOW) -> Token:
        access_token = cls._create_access_token(user_id)
        refresh_token_expires = timedelta(days=config.refresh_token_expire_days)
        refresh_token = cls._create_refresh_token()

        async with uow:
            ref_session, err = await uow.refresh_sessions.add(
                AuthSessionCreate(
                    user_id=user_id,
                    refresh_token=refresh_token,
                    expires_in=refresh_token_expires.total_seconds(),
                ),
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

        return Token(
            access_token=access_token, refresh_token=refresh_token, token_type="bearer"
        )

    @classmethod
    async def logout(cls, token: UUID, uow: UOW) -> None:
        async with uow:
            refresh_session, err = await uow.refresh_sessions.find_one(
                refresh_token=token
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
            is_ok, err = await uow.refresh_sessions.delete(
                id=refresh_session.id  # type: ignore
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

    @classmethod
    async def refresh_token(cls, token: UUID, uow: UOW) -> Token:
        async with uow:
            refresh_session, err = await uow.refresh_sessions.find_refresh_session(
                refresh_token=token
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

            if refresh_session is None:
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
                )
            refresh_session_created_at = refresh_session.created_at.astimezone(
                timezone.utc
            )

            if datetime.now(timezone.utc) >= refresh_session_created_at + timedelta(
                seconds=refresh_session.expires_in
            ):
                _, err = await uow.refresh_sessions.delete(id=refresh_session.id)
                if err:
                    raise HTTPException(
                        status_code=status.HTTP_400_BAD_REQUEST, detail=err
                    )
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired"
                )

            user, err = await uow.users.find_user(id=refresh_session.user_id)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

            if user is None:
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
                )

            access_token = cls._create_access_token(user.id)
            refresh_token_expires = timedelta(days=config.refresh_token_expire_days)
            refresh_token = cls._create_refresh_token()

            _, err = await uow.refresh_sessions.update(
                id=refresh_session.id,
                obj_in=AuthSessionUpdate(
                    refresh_token=refresh_token,
                    expires_in=refresh_token_expires.total_seconds(),
                    user_id=refresh_session.user_id,
                ),
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

        return Token(
            access_token=access_token, refresh_token=refresh_token, token_type="bearer"
        )

    @classmethod
    async def authenticate_user(cls, email: str, password: str, uow: UOW) -> ModelType:
        async with uow:
            db_user, err = await uow.users.find_one(email=email)
            if err:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST, detail="User not found"
                )

        if is_valid_password(password, db_user.hashed_password):  # type: ignore
            if db_user.is_active:  # type: ignore
                return db_user  # type: ignore
            else:
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED,
                    detail="User is not activate",
                )
        else:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid username or password",
            )

    @classmethod
    async def abort_all_sessions(cls, user_id: UUID, uow: UOW) -> None:
        async with uow:
            _, err = await uow.refresh_sessions.delete_by_user_id(user_id=user_id)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

    @classmethod
    def _create_access_token(cls, user_id: UUID) -> str:
        to_encode = {
            "sub": str(user_id),
            "exp": datetime.utcnow() + timedelta(
                minutes=config.access_token_expire_minutes
            ),
        }
        encoded_jwt = jwt.encode(
            to_encode, config.secret_key, algorithm=config.algorithm
        )
        return f"Bearer {encoded_jwt}"

    @classmethod
    def _create_refresh_token(cls) -> str:
        return str(uuid7())

app/services/auth_service.py
m-molecula741/auth
from fastapi import HTTPException, status

from app.core.base_schemas import ObjSchema
from app.db.uow import SqlAlchemyUnitOfWork as UOW
from app.models.users import (
    UserCreate,
    UserCreateRequest,
    UserInDb,
    UserDeactivate,
    UserModel,
    UserUpdateIn,
    UserUpdate,
    UserVerifyEmail,
    ActivateUser,
    UserResendingEmail,
    UserEmail,
    UserPassword,
)
from app.utils.users_utils import get_password_hash
from uuid_extensions import uuid7
from uuid import UUID
import random
from app.tasks.tasks import (
    send_register_confirmation_email,
    send_new_password_by_email,
    send_activate_code_by_email,
)
from redis.asyncio import Redis
import json


class UserService:
    @classmethod
    async def register_new_user(
        cls, uow: UOW, user: UserCreateRequest, redis: Redis
    ) -> ObjSchema:
        async with uow:
            user_exist, err = await uow.users.find_user(email=user.email)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
            elif user_exist:
                if user_exist.is_verified:
                    raise HTTPException(
                        status_code=status.HTTP_409_CONFLICT,
                        detail="User already exists",
                    )
                else:
                    _, err = await uow.users.delete(user_exist.id)
                    if err:
                        raise HTTPException(
                            status_code=status.HTTP_400_BAD_REQUEST, detail=err
                        )

            db_user, err = await uow.users.add(
                UserCreate(
                    id=uuid7(),
                    email=user.email,
                    name=user.name,
                    surname=user.surname,
                    hashed_password=get_password_hash(user.password),
                ),
            )
            if err:
                raise HTTPException(
                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=err
                )
        code = str(random.randrange(100000, 1000000))
        await redis.set(code, json.dumps(str(db_user.id)), 60 * 10)  # type: ignore
        send_register_confirmation_email.delay(code, db_user.email)  # type: ignore

        return UserInDb.from_orm(db_user)

    @classmethod
    async def get_user(cls, user_id: UUID, uow: UOW) -> UserModel:
        async with uow:
            db_user, err = await uow.users.find_user(id=user_id)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
        if db_user is None:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
            )
        return db_user

    @classmethod
    async def deactivate_user(cls, user_id: UUID, uow: UOW) -> None:
        async with uow:
            db_user, err = await uow.users.find_user(id=user_id)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

            if db_user is None:
                raise HTTPException(
                    status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
                )
            _, err = await uow.users.update(id=user_id, obj_in=UserDeactivate())
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

    @classmethod
    async def update_user(cls, user_id: UUID, user: UserUpdateIn, uow: UOW) -> bool:
        async with uow:
            db_user, err = await uow.users.find_user(id=user_id)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
            if db_user is None:
                raise HTTPException(
                    status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
                )

            if user.password:
                user_in = UserUpdate(
                    **user.dict(exclude_unset=True),
                    hashed_password=get_password_hash(user.password),
                )
            else:
                user_in = UserUpdate(
                    **user.dict(exclude_unset=True),
                    hashed_password=db_user.hashed_password,
                )

            is_ok, err = await uow.users.update(id=user_id, obj_in=user_in)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)

        return is_ok

    @classmethod
    async def verify_email_and_activate_user(
        cls, code: UserVerifyEmail, uow: UOW, redis: Redis
    ) -> bool:
        async with uow:
            user_id = await redis.get(code.code)
            if user_id:
                db_user, err = await uow.users.find_one(id=UUID(json.loads(user_id)))
                if err:
                    raise HTTPException(
                        status_code=status.HTTP_400_BAD_REQUEST, detail=err
                    )
                _, err = await uow.users.update(
                    id=UUID(json.loads(user_id)), obj_in=ActivateUser()
                )
                if err:
                    raise HTTPException(
                        status_code=status.HTTP_400_BAD_REQUEST, detail=err
                    )
                return True
            else:
                return False

    @classmethod
    async def resending_email(
        cls, resending_data: UserResendingEmail, redis: Redis
    ) -> None:
        code = str(random.randrange(100000, 1000000))
        await redis.set(code, json.dumps(str(resending_data.id)), 60 * 10)
        send_register_confirmation_email.delay(code, resending_data.email)

    @classmethod
    async def reset_password_and_send_new(cls, reset_data: UserEmail, uow: UOW) -> None:
        async with uow:
            db_user, err = await uow.users.find_one(email=reset_data.email)
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
            if not db_user.is_verified or not db_user.is_active:  # type: ignore
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail="Account not verified or deleted",
                )
            new_password = str(random.randrange(100000, 1000000))
            _, err = await uow.users.update(
                id=db_user.id,  # type: ignore
                obj_in=UserPassword(hashed_password=get_password_hash(new_password)),
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
            send_new_password_by_email.delay(new_password, db_user.email)  # type: ignore

    @classmethod
    async def activate_user(cls, user_data: UserEmail, uow: UOW, redis: Redis) -> None:
        async with uow:
            db_user, err = await uow.users.find_user(
                email=user_data.email, is_verified=True
            )
            if err:
                raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=err)
            if db_user is None:
                raise HTTPException(
                    status_code=status.HTTP_400_BAD_REQUEST,
                    detail="account has not been verified",
                )

        code = str(random.randrange(100000, 1000000))
        await redis.set(code, json.dumps(str(db_user.id)), 60 * 10)
        send_activate_code_by_email.delay(code, db_user.email)

    @classmethod
    async def confirm_activate(
        cls, code: UserVerifyEmail, uow: UOW, redis: Redis
    ) -> bool:
        async with uow:
            user_email = await redis.get(code.code)
            if user_email:
                db_user, err = await uow.users.find_one(id=json.loads(user_email))
                if err:
                    raise HTTPException(
                        status_code=status.HTTP_400_BAD_REQUEST, detail=err
                    )
                if db_user.is_verified:  # type: ignore
                    _, err = await uow.users.update(
                        id=db_user.id, obj_in=ActivateUser()  # type: ignore
                    )
                    if err:
                        raise HTTPException(
                            status_code=status.HTTP_400_BAD_REQUEST, detail=err
                        )
                return True
            else:
                return False

app/services/users_service.py
m-molecula741/auth
from app.core.config import config
from celery import Celery

celery_app = Celery(config.project_name, include=["app.tasks.tasks"])
celery_app.conf.broker_url = config.celery_broker_url

app/tasks/celery_app.py
m-molecula741/auth
from email.message import EmailMessage
from pydantic import EmailStr
from app.core.config import config


def create_register_confirmation_template(
    code: str,
    email_to: EmailStr,
):
    email = EmailMessage()
    email["Subject"] = "Подтверждение почты"
    email["From"] = config.smtp_user
    email["To"] = email_to

    email.set_content(
        f"""
        <h2>Ваш код подтверждения</h2>
        <h2>{code}</h2>
        <h2>Не сообщайте код никому!</h2>
        """,
        subtype="html",
    )
    return email


def sending_new_password_template(
    new_password: str,
    email_to: EmailStr,
):
    email = EmailMessage()
    email["Subject"] = "Новый пароль"
    email["From"] = config.smtp_user
    email["To"] = email_to

    email.set_content(
        f"""
        <h2>Ваш новый пароль</h2>
        <h2>{new_password}</h2>
        <h2>Вы его сможете изменить в настройках аккаунта</h2>
        <h2>Не сообщайте пароль никому!</h2>
        """,
        subtype="html",
    )
    return email


def sending_activate_code_template(
    code: str,
    email_to: EmailStr,
):
    email = EmailMessage()
    email["Subject"] = "Восстановление аккаунта"
    email["From"] = config.smtp_user
    email["To"] = email_to

    email.set_content(
        f"""
        <h2>Ваш код для восстановления</h2>
        <h2>{code}</h2>
        <h2>Не сообщайте код никому!</h2>
        """,
        subtype="html",
    )
    return email

app/tasks/email_templates.py
m-molecula741/auth
from app.tasks.celery_app import celery_app
from pydantic import EmailStr
from app.tasks.email_templates import (
    create_register_confirmation_template,
    sending_new_password_template,
    sending_activate_code_template,
)
import smtplib
from app.core.config import config


@celery_app.task
def send_register_confirmation_email(code: str, email_to: EmailStr):
    message_content = create_register_confirmation_template(code, email_to)
    with smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) as server:
        server.login(config.smtp_user, config.smtp_pass)
        server.send_message(message_content)


@celery_app.task
def send_new_password_by_email(new_password: str, email_to: EmailStr):
    message_content = sending_new_password_template(new_password, email_to)
    with smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) as server:
        server.login(config.smtp_user, config.smtp_pass)
        server.send_message(message_content)


@celery_app.task
def send_activate_code_by_email(code: str, email_to: EmailStr):
    message_content = sending_activate_code_template(code, email_to)
    with smtplib.SMTP_SSL(config.smtp_host, config.smtp_port) as server:
        server.login(config.smtp_user, config.smtp_pass)
        server.send_message(message_content)

app/tasks/tasks.py
m-molecula741/auth
from passlib.context import CryptContext
from typing import Dict, Optional
from fastapi import HTTPException, Request, status
from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel
from fastapi.security import OAuth2
from fastapi.security.utils import get_authorization_scheme_param


pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def is_valid_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password: str) -> str:
    return pwd_context.hash(password)


class OAuth2PasswordBearerWithCookie(OAuth2):
    def __init__(
        self,
        token_url: str,
        scheme_name: Optional[str] = None,
        scopes: Optional[Dict[str, str]] = None,
        auto_error: bool = True,
    ):
        if not scopes:
            scopes = {}
        flows = OAuthFlowsModel(password={"tokenUrl": token_url, "scopes": scopes})
        super().__init__(flows=flows, scheme_name=scheme_name, auto_error=auto_error)

    async def __call__(self, request: Request) -> Optional[str]:
        authorization: str = request.cookies.get("access_token")  # type: ignore

        scheme, param = get_authorization_scheme_param(authorization)
        if not authorization or scheme.lower() != "bearer":
            if self.auto_error:
                raise HTTPException(
                    status_code=status.HTTP_401_UNAUTHORIZED,
                    detail="Not authenticated",
                    headers={"WWW-Authenticate": "Bearer"},
                )
            else:
                return None
        return param

app/utils/users_utils.py
m-molecula741/auth
"""Клиентская системная утилита"""
import os
from datetime import datetime

import click
from alembic import command as alembic_command
from alembic.config import Config as AlembicConfig

ALEMBIC_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "alembic.ini")


@click.group()
def app_cli() -> None:
    """Контейнер для команд"""


@app_cli.group("database")
def app_database() -> None:
    """Команды ДБ"""


@app_database.command("migrate")
def migrate() -> None:
    """Миграция БД"""
    alembic_cfg = AlembicConfig(ALEMBIC_PATH)
    alembic_command.upgrade(alembic_cfg, "head")

    click.secho("Successfully migrated!", fg="green")


@app_database.command("makemigrations")
def create_migrations() -> None:
    """Create migrations migration"""
    alembic_cfg = AlembicConfig(ALEMBIC_PATH)
    alembic_command.revision(
        alembic_cfg, message=str(datetime.now().isoformat()), autogenerate=True
    )

    click.secho("Success!", fg="green")


def entrypoint() -> None:
    """Эндпоинт клиентской утилтты"""
    app_cli()


if __name__ == "__main__":
    entrypoint()

cli.py
m-molecula741/auth
import asyncio
from logging.config import fileConfig
from typing import Any

from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlalchemy.engine.base import Connection
from sqlalchemy.ext.asyncio import AsyncEngine

from app.core.config import config as app_config
from app.db.database import Base
from app.models.users import UserModel
from app.models.auth import AuthModel

config = getattr(context, "config")

fileConfig(config.config_file_name)

target_metadata = Base.metadata

config.set_main_option("sqlalchemy.url", app_config.db_url)


def include_object(
    _object: Any, name: str, type_: str, reflected: Any, compare_to: Any
) -> bool:
    """Оперируем только схемой приложения"""
    if type_ == "table" and _object.schema != app_config.postgres_db_schema:
        return False
    else:
        return True


def run_migrations_offline() -> None:
    """Запуск миграции в оффлайн режиме"""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection: Connection) -> None:
    """Запуск миграции

    Args:
        connection (Connection): [Сессия с БД]
    """
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        compare_type=True,
        include_schemas=True,
        include_object=include_object,
        version_table_schema=target_metadata.schema,
    )

    with context.begin_transaction():
        context.run_migrations()


async def run_migrations_online() -> None:
    """Запускаем миграцию в онлайн режиме"""
    engine = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
        future=True,
    )
    connectable = AsyncEngine(engine)  # error_type: ignore

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)  # error_type: ignore


if context.is_offline_mode():
    run_migrations_offline()
else:
    asyncio.run(run_migrations_online())

migrations/env.py
m-molecula741/auth
"""2023-09-06T22:42:10.789939

Revision ID: 9d54a1ed68e5
Revises: 
Create Date: 2023-09-06 22:42:11.032304

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "9d54a1ed68e5"
down_revision = None
branch_labels = None
depends_on = None


def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table(
        "user",
        sa.Column("id", sa.UUID(), nullable=False),
        sa.Column("email", sa.String(), nullable=False),
        sa.Column("hashed_password", sa.String(), nullable=False),
        sa.Column("name", sa.String(length=50), nullable=False),
        sa.Column("surname", sa.String(length=50), nullable=False),
        sa.Column("is_active", sa.Boolean(), nullable=False),
        sa.Column("is_verified", sa.Boolean(), nullable=False),
        sa.Column("is_superuser", sa.Boolean(), nullable=False),
        sa.PrimaryKeyConstraint("id"),
        schema="public",
    )
    op.create_index(
        op.f("ix_public_user_email"), "user", ["email"], unique=True, schema="public"
    )
    op.create_index(
        op.f("ix_public_user_id"), "user", ["id"], unique=False, schema="public"
    )
    op.create_table(
        "auth",
        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
        sa.Column("refresh_token", sa.UUID(), nullable=False),
        sa.Column("expires_in", sa.Integer(), nullable=False),
        sa.Column("created_at", sa.DateTime(), nullable=False),
        sa.Column("user_id", sa.UUID(), nullable=False),
        sa.ForeignKeyConstraint(["user_id"], ["public.user.id"], ondelete="CASCADE"),
        sa.PrimaryKeyConstraint("id"),
        schema="public",
    )
    op.create_index(
        op.f("ix_public_auth_refresh_token"),
        "auth",
        ["refresh_token"],
        unique=False,
        schema="public",
    )
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(
        op.f("ix_public_auth_refresh_token"), table_name="auth", schema="public"
    )
    op.drop_table("auth", schema="public")
    op.drop_index(op.f("ix_public_user_id"), table_name="user", schema="public")
    op.drop_index(op.f("ix_public_user_email"), table_name="user", schema="public")
    op.drop_table("user", schema="public")
    # ### end Alembic commands ###

migrations/versions/9d54a1ed68e5_2023_09_06t22_42_10_789939.py
m-molecula741/auth
import pytest
from fastapi.testclient import TestClient

from app.main import app


@pytest.fixture(scope="function", autouse=True)
def client():
    # Возвращаем тестового клиента
    with TestClient(app) as client:
        yield client

tests/conftest.py
m-molecula741/auth