Create authorization system

This commit is contained in:
2026-01-05 23:31:36 +03:00
parent d438f7bf5b
commit 2e14a7f364
39 changed files with 2500 additions and 9 deletions
+62
View File
@@ -0,0 +1,62 @@
from typing import Literal, List
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
COMPANY_NAME: str
MODE: Literal["DEV", "TEST", "PROD"]
LOG_LEVEL: Literal["ERROR", "WARNING", "INFO", "DEBUG"]
HOST: str
PORT: int
WORKERS: int
URL: str
CORS_ORIGINS: List[str] = ["*"]
CORS_HEADERS: List[str] = ["*"]
CORS_METHODS: List[str] = ["*"]
SECRET_KEY: str
ALGORITHM: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
SMTP_SERVER: str
SMTP_PORT: int
SMTP_EMAIL: str
SMTP_PASS: str
DB_HOST: str
DB_PORT: int
DB_PASS: str
DB_USER: str
DB_NAME: str
@property
def DATABASE_URL(self):
return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6397
REDIS_PASS: str = ""
REDIS_DB: int = 0
@property
def REDIS_URL(self):
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
RMQ_HOST: str
RMQ_USER: str
RMQ_PASS: str
RMQ_PORT: int
@property
def RABBITMQ_URL(self) -> str:
return f"amqp://{self.RMQ_USER}:{self.RMQ_PASS}@{self.RMQ_HOST}:{self.RMQ_PORT}//"
model_config = SettingsConfigDict(env_file=".env", extra="allow")
settings: Settings = Settings()
+9
View File
@@ -0,0 +1,9 @@
from enum import Enum
DB_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key",
"ck": "%(table_name)s_%(constraint_name)s_check",
"fk": "%(table_name)s_%(column_0_name)s_fkey",
"pk": "%(table_name)s_pkey",
}
+133
View File
@@ -0,0 +1,133 @@
from typing import TypeVar, Generic, Optional, List, Union, Dict, Any
import logging
from sqlalchemy import delete, insert, select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from pydantic import BaseModel
from app.database import Base
log = logging.getLogger(__name__)
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class BaseDAO(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
model = None
@classmethod
async def find_one_or_none(cls, session: AsyncSession, *filter, **filter_by) -> Optional[ModelType]:
stmt = select(cls.model).filter(*filter).filter_by(**filter_by)
result = await session.execute(stmt)
return result.scalars().one_or_none()
@classmethod
async def find_all(
cls,
session: AsyncSession,
offset: Optional[int],
limit: Optional[int],
*filter,
**filter_by
) -> List[ModelType]:
stmt = select(cls.model).filter(*filter).filter_by(**filter_by)
if offset is not None:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
@classmethod
async def add(
cls,
session: AsyncSession,
obj_in: Union[CreateSchemaType, Dict[str, Any]]
) -> Optional[ModelType]:
if isinstance(obj_in, dict):
create_data = obj_in
else:
create_data = obj_in.model_dump(exclude_unset=True)
try:
stmt = insert(cls.model).values(**create_data).returning(cls.model)
result = await session.execute(stmt)
return result.scalars().first()
except (SQLAlchemyError, Exception) as ex:
if isinstance(ex, SQLAlchemyError):
msg = "Database Exc: Cannot insert data into table"
elif isinstance(ex, Exception):
msg = "Unknown Exc: Cannot insert data into table"
log.error(msg, extra={"table": cls.model.__tablename__}, exc_info=True)
@classmethod
async def delete(cls, session: AsyncSession, *filter, **filter_by) -> None:
stmt = delete(cls.model).filter(*filter).filter_by(**filter_by)
await session.execute(stmt)
@classmethod
async def update(
cls,
session: AsyncSession,
*where,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> Optional[ModelType]:
if isinstance(obj_in, Dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
stmt = update(cls.model).where(*where).values(update_data).returning(cls.model)
result = await session.execute(stmt)
return result.scalars().one()
@classmethod
async def add_bulk(cls, session: AsyncSession, data: List[Dict[str, Any]]):
try:
result = await session.execute(
insert(cls.model).returning(cls.model),
data
)
return result.scalars().all()
except (SQLAlchemyError, Exception) as e:
if isinstance(e, SQLAlchemyError):
msg = "Database Exc"
elif isinstance(e, Exception):
msg = "Unknown Exc"
msg += ": Cannot bulk insert data into table"
log.error(msg, extra={"table": cls.model.__tablename__}, exc_info=True)
return None
@classmethod
async def update_bulk(cls, session: AsyncSession, data: List[Dict[str, Any]]):
try:
stmt = update(cls.model)
await session.execute(update(cls.model), data)
except (SQLAlchemyError, Exception) as e:
if isinstance(e, SQLAlchemyError):
msg = "Database Exc"
elif isinstance(e, Exception):
msg = "Unknown Exc"
msg += ": Cannot bulk update data into table"
log.error(msg, extra={"table": cls.model.__tablename__}, exc_info=True)
return None
@classmethod
async def count(cls, session: AsyncSession, *filter, **filter_by):
stmt = select(func.count()).select_from(
cls.model).filter(*filter).filter_by(**filter_by)
result = await session.execute(stmt)
return result.scalar()
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from sqlalchemy.engine import create_engine
from sqlalchemy import MetaData, NullPool, func
from app.config import settings
from app.constants import DB_NAMING_CONVENTION
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=DB_NAMING_CONVENTION)
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
if settings.MODE == "TEST":
DATABASE_URL = settings.TEST_DATABASE_URL
DATABASE_PARAMS = {"poolclass": NullPool}
else:
DATABASE_URL = settings.DATABASE_URL
DATABASE_PARAMS = {}
async_engine = create_async_engine(DATABASE_URL, **DATABASE_PARAMS)
async_session_maker = async_sessionmaker(async_engine, expire_on_commit=False)
+16
View File
@@ -0,0 +1,16 @@
from fastapi import HTTPException, status
class InvalidTokenException(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
class TokenExpiredException(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired")
class InvalidCredentialsException(HTTPException):
def __init__(self):
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password")
+38
View File
@@ -0,0 +1,38 @@
from logging.config import dictConfig
from app.config import settings
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"colored": {
"()": "colorlog.ColoredFormatter",
"format": "[%(asctime)s] %(log_color)s%(levelname)s%(reset)s:"
" (%(module)s) %(message)s",
"log_colors": {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "purple",
},
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "colored",
},
},
"root": {
"level": settings.LOG_LEVEL,
"handlers": ["console"],
},
}
def set_logging():
dictConfig(LOGGING_CONFIG)
Regular → Executable
+75 -4
View File
@@ -1,14 +1,85 @@
from contextlib import asynccontextmanager
import uvicorn
import logging
from fastapi import FastAPI
from fastapi import FastAPI, APIRouter, Request, Response
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
from app.utils.redis import close_redis, init_redis
from app.users.router import user_router, auth_router
from app.log_config import set_logging
from app.config import settings
set_logging()
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_redis()
log.info("Redis connected")
yield
await close_redis()
log.info("Redis disconnected")
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(user_router)
api_router.include_router(auth_router)
app = FastAPI(
title=settings.COMPANY_NAME,
description="## Backend messenger aether",
lifespan=lifespan
)
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=settings.CORS_METHODS,
allow_headers=settings.CORS_HEADERS,
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
response: Response = await call_next(request)
log.info(
"method=%s path=%s status=%s",
request.method,
request.url.path,
response.status_code,
extra={
"method": request.method,
"path": request.url.path,
"status": response.status_code
}
)
return response
@app.get("/health")
async def test_health():
return {"status": "ok"}
return {"status": True}
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8080, reload=True, workers=3)
if settings.MODE == "PROD":
UVICORN_PARAMS = dict(
host=settings.HOST,
port=settings.PORT,
reload=False,
workers=settings.WORKERS,
access_log=False
)
else:
UVICORN_PARAMS = dict(
host=settings.HOST,
port=settings.PORT,
reload=True,
access_log=False
)
log.info("app is starting")
uvicorn.run("app.main:app", **UVICORN_PARAMS)
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.
+93
View File
@@ -0,0 +1,93 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.users.models import UserModel
from app.database import Base
from app.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
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:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,48 @@
"""ADD: user table
Revision ID: 4d00c9b0516e
Revises: 52b7263bba19
Create Date: 2026-01-04 14:04:33.143440
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4d00c9b0516e'
down_revision: Union[str, Sequence[str], None] = '52b7263bba19'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'birth_day',
existing_type=sa.DATE(),
nullable=True)
op.alter_column('user', 'description',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('user', 'avatar_url',
existing_type=sa.VARCHAR(),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'avatar_url',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('user', 'description',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('user', 'birth_day',
existing_type=sa.DATE(),
nullable=False)
# ### end Alembic commands ###
@@ -0,0 +1,47 @@
"""ADD: user table
Revision ID: 52b7263bba19
Revises: 9b013a15d8fb
Create Date: 2026-01-04 13:48:55.058538
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '52b7263bba19'
down_revision: Union[str, Sequence[str], None] = '9b013a15d8fb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('refresh_session_id_idx'), table_name='refresh_session')
op.drop_index(op.f('refresh_session_refresh_token_idx'), table_name='refresh_session')
op.drop_table('refresh_session')
op.add_column('user', sa.Column('hashed_password', sa.String(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'hashed_password')
op.create_table('refresh_session',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('refresh_token', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('expires_in', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('user_ud', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['user_ud'], ['user.id'], name=op.f('refresh_session_user_ud_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('refresh_session_pkey'))
)
op.create_index(op.f('refresh_session_refresh_token_idx'), 'refresh_session', ['refresh_token'], unique=False)
op.create_index(op.f('refresh_session_id_idx'), 'refresh_session', ['id'], unique=False)
# ### end Alembic commands ###
@@ -0,0 +1,67 @@
"""Initial revision
Revision ID: 9b013a15d8fb
Revises:
Create Date: 2025-12-21 17:27:03.170318
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9b013a15d8fb'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('display_name', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('birth_day', sa.DATE(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('avatar_url', sa.String(), 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.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('user_pkey'))
)
op.create_index(op.f('user_email_idx'), 'user', ['email'], unique=True)
op.create_index(op.f('user_id_idx'), 'user', ['id'], unique=False)
op.create_index(op.f('user_username_idx'), 'user', ['username'], unique=True)
op.create_table('refresh_session',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('refresh_token', sa.UUID(), nullable=False),
sa.Column('expires_in', sa.Integer(), nullable=False),
sa.Column('user_ud', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_ud'], ['user.id'], name=op.f('refresh_session_user_ud_fkey'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('refresh_session_pkey'))
)
op.create_index(op.f('refresh_session_id_idx'), 'refresh_session', ['id'], unique=False)
op.create_index(op.f('refresh_session_refresh_token_idx'), 'refresh_session', ['refresh_token'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('refresh_session_refresh_token_idx'), table_name='refresh_session')
op.drop_index(op.f('refresh_session_id_idx'), table_name='refresh_session')
op.drop_table('refresh_session')
op.drop_index(op.f('user_username_idx'), table_name='user')
op.drop_index(op.f('user_id_idx'), table_name='user')
op.drop_index(op.f('user_email_idx'), table_name='user')
op.drop_table('user')
# ### end Alembic commands ###
+33
View File
@@ -0,0 +1,33 @@
import logging
from app.utils.email_client import EmailClient
from app.config import settings
log = logging.getLogger(__name__)
class EmailService:
@classmethod
def send_verify_email(cls, email: str, username: str, url: str):
log.debug("Sending email to %s", email, extra={"email": email})
try:
subject = "Подтверждение эл. почты"
html = EmailClient.render(
template_path="confirm_email.html",
username=username,
url=url,
expire_minutes=60,
company_name=settings.COMPANY_NAME
)
body = EmailClient.render(
template_path="confirm_email.txt",
username=username,
url=url,
company_name=settings.COMPANY_NAME
)
EmailClient.send_email(to=email, subject=subject, html=html, body=body)
log.info("Verify email sent to %s", email, extra={"email": email})
except Exception as e:
log.error("Failed to send email to %s", email, extra={"email": email})
raise e
+1
View File
@@ -0,0 +1 @@
from .email_tasks import *
+13
View File
@@ -0,0 +1,13 @@
import logging
from app.utils.celery_app import celery_app
from app.services.email_service import EmailService
log = logging.getLogger(__name__)
class EmailTasks:
@staticmethod
@celery_app.task
def send_verify_email_task(email: str, username: str, url: str):
EmailService.send_verify_email(email, username, url)
+163
View File
@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подтверждение email — {{company_name}}</title>
<style>
/* Подключаем шрифты (Lora для заголовков, Inter для текста) */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,600;1,600&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', Arial, sans-serif;
line-height: 1.6;
color: #2C2C2C; /* Антрацит из концепта */
background-color: #F5F5F1; /* Слоновая кость */
padding: 40px 20px;
}
.email-wrapper {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 16px; /* Мягкие скругления */
overflow: hidden;
box-shadow: 0 10px 30px rgba(107, 112, 92, 0.08); /* Легкая оливковая тень */
}
.logo-section {
text-align: center;
padding: 40px 30px 20px;
background-color: #EBEBE6; /* Песочный серый для разделения */
}
.logo-text {
font-family: 'Lora', serif;
font-size: 24px;
letter-spacing: 3px;
color: #6B705C; /* Оливковый акцент */
text-transform: uppercase;
}
.email-body {
padding: 40px 50px;
background: #ffffff;
}
h1 {
font-family: 'Lora', serif;
font-size: 26px;
color: #2C2C2C;
margin-bottom: 24px;
font-weight: 600;
text-align: center;
}
p {
color: #555555;
margin-bottom: 20px;
font-size: 16px;
}
.username {
color: #D27D56; /* Терракотовый акцент */
font-weight: 600;
}
.button-container {
text-align: center;
margin: 40px 0;
}
.cta-button {
display: inline-block;
padding: 18px 44px;
background-color: #D27D56; /* Терракотовый */
color: #ffffff !important;
text-decoration: none;
border-radius: 30px; /* Очень круглые кнопки */
font-weight: 600;
font-size: 16px;
transition: background-color 0.3s ease;
}
.divider {
margin: 40px auto;
width: 100%;
height: 1px;
background-color: #EBEBE6;
}
.link-box {
background-color: #F9F9F7;
border: 1px dashed #6B705C; /* Оливковая пунктирная рамка */
border-radius: 8px;
padding: 20px;
margin-top: 30px;
}
.link-label {
font-size: 12px;
color: #6B705C;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
}
.link-text {
color: #D27D56;
word-break: break-all;
font-size: 13px;
font-family: monospace;
}
.info-box {
text-align: center;
color: #888888;
font-size: 13px;
margin-top: 20px;
}
.footer {
padding: 40px 50px;
text-align: center;
background-color: #F9F9F7;
color: #8B8B8B;
font-size: 13px;
}
.footer-brand {
font-family: 'Lora', serif;
color: #6B705C;
font-weight: 600;
font-size: 18px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="logo-section">
<div class="logo-text">{{company_name}}</div>
</div>
<div class="email-body">
<h1>Подтверждение почты</h1>
<p>Здравствуйте, <span class="username">{{username}}</span>!</p>
<p>Мы рады приветствовать вас в <b>{{company_name}}</b> — пространстве для спокойного общения. Чтобы начать путь, пожалуйста, подтвердите ваш электронный адрес.</p>
<div class="button-container">
<a href="{{url}}" class="cta-button">Активировать аккаунт</a>
</div>
<div class="info-box">
Ссылка будет активна в течение {{expire_minutes}} минут.
</div>
<div class="divider"></div>
<div class="link-box">
<div class="link-label">Или скопируйте ссылку в браузер:</div>
<div class="link-text">{{url}}</div>
</div>
<p style="font-size: 14px; color: #999; margin-top: 30px; text-align: center; font-style: italic;">
Если вы не регистрировались в {{company_name}}, просто проигнорируйте это письмо.
</p>
</div>
<div class="footer">
<div class="footer-brand">{{company_name}}</div>
<div>Сделано с заботой о вашем спокойствии</div>
</div>
</div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
Здравствуйте, {{username}}!
Спасибо за регистрацию!
Для подтверждения вашего email адреса перейдите по ссылке:
{{url}}
Если вы не создавали аккаунт на нашем сайте, просто проигнорируйте это письмо.
Внимание: Эта ссылка действительна в течение {{expire_minutes}} минут
С уважением,
Команда {{company_name}}
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Восстановление пароля — {{company_name}}</title>
<style>
/* Подключаем шрифты концепта Natural Sand */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Lora:ital,wght@0,600;1,600&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', Arial, sans-serif;
line-height: 1.6;
color: #2C2C2C; /* Антрацит */
background-color: #F5F5F1; /* Слоновая кость */
padding: 40px 20px;
}
.email-wrapper {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(107, 112, 92, 0.08); /* Оливковая тень */
}
.logo-section {
text-align: center;
padding: 40px 30px 20px;
background-color: #EBEBE6; /* Песочный серый */
}
.logo-text {
font-family: 'Lora', serif;
font-size: 24px;
letter-spacing: 3px;
color: #6B705C; /* Оливковый */
text-transform: uppercase;
}
.email-body {
padding: 40px 50px;
background: #ffffff;
}
h1 {
font-family: 'Lora', serif;
font-size: 26px;
color: #2C2C2C;
margin-bottom: 24px;
font-weight: 600;
text-align: center;
}
p {
color: #555555;
margin-bottom: 20px;
font-size: 16px;
}
.username {
color: #D27D56; /* Терракотовый */
font-weight: 600;
}
.button-container {
text-align: center;
margin: 40px 0;
}
.cta-button {
display: inline-block;
padding: 18px 44px;
background-color: #6B705C; /* Оливковый для безопасности/спокойствия */
color: #ffffff !important;
text-decoration: none;
border-radius: 30px;
font-weight: 600;
font-size: 16px;
}
.divider {
margin: 40px auto;
width: 100%;
height: 1px;
background-color: #EBEBE6;
}
.link-box {
background-color: #F9F9F7;
border: 1px dashed #6B705C;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.link-label {
font-size: 12px;
color: #6B705C;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
}
.link-text {
color: #D27D56;
word-break: break-all;
font-size: 13px;
font-family: monospace;
}
.warning-box {
background-color: #FEF9F6; /* Едва заметный терракотовый фон */
border-left: 3px solid #D27D56;
padding: 16px 20px;
margin: 25px 0;
font-size: 14px;
color: #555;
}
.security-notice {
background-color: #F9F9F7;
border-radius: 12px;
padding: 20px;
margin-top: 30px;
font-size: 14px;
border: 1px solid #EBEBE6;
}
.security-notice-title {
font-family: 'Lora', serif;
font-weight: 600;
color: #6B705C;
margin-bottom: 8px;
font-size: 15px;
}
.footer {
padding: 40px 50px;
text-align: center;
background-color: #F9F9F7;
color: #8B8B8B;
font-size: 13px;
}
.footer-brand {
font-family: 'Lora', serif;
color: #6B705C;
font-weight: 600;
font-size: 18px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="logo-section">
<div class="logo-text">{{company_name}}</div>
</div>
<div class="email-body">
<h1>Сброс пароля</h1>
<p>Здравствуйте, <span class="username">{{username}}</span>.</p>
<p>Мы получили запрос на восстановление доступа к вашему аккаунту в {{company_name}}. Если это сделали вы, нажмите на кнопку ниже.</p>
<div class="button-container">
<a href="{{url}}" class="cta-button">Установить новый пароль</a>
</div>
<div class="warning-box">
<b>Внимание:</b> Эта ссылка будет активна в течение {{expire_minutes}} минут.
</div>
<div class="divider"></div>
<div class="link-box">
<div class="link-label">Запасная ссылка:</div>
<div class="link-text">{{url}}</div>
</div>
<div class="security-notice">
<div class="security-notice-title">🛡 Забота о безопасности</div>
<p style="margin: 0; font-size: 14px;">Если вы не запрашивали сброс пароля, просто проигнорируйте это сообщение. Ваш текущий пароль остается в безопасности.</p>
</div>
</div>
<div class="footer">
<div class="footer-brand">{{company_name}}</div>
<div>Ваша безопасность — часть нашего эфира</div>
</div>
</div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
Здравствуйте, {{username}}!
Мы получили запрос на сброс пароля для вашей учетной записи.
Для создания нового пароля перейдите по ссылке:
{{url}}
ВНИМАНИЕ: Эта ссылка действительна в течение {{expire_minutes}} минут.
Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
С уважением,
Команда {{company_name}}
+7
View File
@@ -0,0 +1,7 @@
from app.dao import BaseDAO
from app.users.models import UserModel
from app.users.schemas import UserCreateDB, UserUpdateDB
class UserDAO(BaseDAO[UserModel, UserCreateDB, UserUpdateDB]):
model = UserModel
+56
View File
@@ -0,0 +1,56 @@
import logging
from typing import Optional
import uuid
from fastapi import Depends, HTTPException, status
from jose import jwt, JWTError
from app.utils.OAuth2WithCookie import OAuth2PasswordBearerWithCookie
from app.config import settings
from app.users.models import UserModel
from app.users.service import UserService
from app.exceptions import InvalidTokenException
log = logging.getLogger(__name__)
oauth2_scheme = OAuth2PasswordBearerWithCookie(tokenUrl="/api/v1/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> Optional[UserModel]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=settings.ALGORITHM)
user_id = int(payload.get("sub"))
log.debug("Successfully get current_user id", extra={"user_id": user_id})
if user_id is None:
log.warning("User id is None")
raise InvalidTokenException
except (Exception, JWTError) as ex:
if isinstance(ex, InvalidTokenException):
raise ex
if isinstance(ex, JWTError):
log.error("JWT error")
raise ex
log.error("Unknown exception")
raise ex
current_user = await UserService.get_user(user_id)
if not current_user.is_active:
log.debug("User is not active", extra={"user_id": current_user.id})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="User is not active")
return current_user
async def get_current_superuser(current_user: UserModel = Depends(get_current_user)) -> Optional[UserModel]:
if not current_user.is_superuser:
log.debug("User not enough privileges", extra={"user_id": current_user.id})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Not enough privileges")
return current_user
async def get_current_verified_user(current_user: UserModel = Depends(get_current_user)):
if not current_user.is_verified:
log.debug("User has not confirmed the email.", extra={"user_id": current_user.id})
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="verify email")
+30
View File
@@ -0,0 +1,30 @@
from datetime import date
import uuid
from sqlalchemy.orm import mapped_column, Mapped
from sqlalchemy import DATE, UUID, ForeignKey
from app.database import Base
class UserModel(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
display_name: Mapped[str] = mapped_column()
username: Mapped[str] = mapped_column(index=True, unique=True)
email: Mapped[str] = mapped_column(index=True, unique=True)
birth_day: Mapped[date] = mapped_column(DATE, nullable=True)
description: Mapped[str] = mapped_column(nullable=True)
avatar_url: Mapped[str] = mapped_column(nullable=True)
is_active: Mapped[bool] = mapped_column(default=True)
is_verified: Mapped[bool] = mapped_column(default=False)
is_superuser: Mapped[bool] = mapped_column(default=False)
hashed_password: Mapped[str] = mapped_column()
# class RefreshSessionModel(Base):
# __tablename__ = "refresh_session"
#
# id: Mapped[int] = mapped_column(primary_key=True, index=True)
# refresh_token: Mapped[uuid.UUID] = mapped_column(UUID, index=True)
# expires_in: Mapped[int] = mapped_column()
# user_ud: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))
+80
View File
@@ -0,0 +1,80 @@
import logging
import uuid
from fastapi import APIRouter, status, Response, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from app.users.schemas import UserCreate, User, Token
from app.users.service import AuthService, UserService
from app.users.models import UserModel
from app.exceptions import InvalidCredentialsException
from app.users.dependencies import get_current_user, get_current_verified_user
from app.config import settings
user_router = APIRouter(prefix="/users", tags=["User"])
auth_router = APIRouter(prefix="/auth", tags=["Auth"])
log = logging.getLogger(__name__)
@auth_router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user: UserCreate) -> User:
return await UserService.register_new_user(user)
@auth_router.get("/verify/{token}")
async def verify_email(token: uuid.UUID):
await UserService.verify_email(token)
return {"status": True}
@auth_router.post("/send/verify_email")
async def resend_verify_email(user: UserModel = Depends(get_current_user)):
await UserService.send_verify_email(user)
return {"status": True, "message": "Successfully send email letter"}
@auth_router.post("/login")
async def login(response: Response, credentials: OAuth2PasswordRequestForm = Depends()) -> Token:
user = await AuthService.authenticate_user(credentials.username, credentials.password)
if not user:
log.warning("Failed login attempt", extra={"email or username": credentials.username})
raise InvalidCredentialsException
token = await AuthService.create_token(user.id)
response.set_cookie(
'access_token',
token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
return token
@auth_router.post("/refresh")
async def refresh_token(request: Request, response: Response) -> Token:
new_token = await AuthService.refresh_token(uuid.UUID(request.cookies.get("refresh_token")))
response.set_cookie(
'access_token',
new_token.access_token,
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
httponly=True
)
response.set_cookie(
'refresh_token',
str(new_token.refresh_token),
max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 30 * 24 * 60,
httponly=True
)
log.debug("Token refreshed via endpoint")
return new_token
@auth_router.post("/logout")
async def logout(request: Request, response: Response, user: UserModel = Depends(get_current_user)):
response.delete_cookie("access_token")
response.delete_cookie("refresh_token")
await AuthService.logout(uuid.UUID(request.cookies.get("refresh_token")))
return {"status": True, "message": "Logged out successfully"}
+64
View File
@@ -0,0 +1,64 @@
from typing import Optional
from datetime import date
import uuid
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
display_name: Optional[str] = None
username: Optional[str] = None
# email: Optional[str] = None
birth_day: Optional[date] = None
# description: str
# avatar_url: str
class UserCreate(UserBase):
display_name: str
username: str
email: EmailStr
password: str
class Config:
from_attributes = True
class UserUpdate(UserBase):
description: Optional[str] = None
birth_day: Optional[date] = None
class User(UserBase):
id: int
display_name: str
username: str
email: EmailStr
birth_day: Optional[date] = None
description: Optional[str] = None
avatar_url: Optional[str] = None
is_active: bool
is_verified: bool
is_superuser: bool
class UserCreateDB(UserBase):
email: Optional[str] = None
hashed_password: Optional[str] = None
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class UserUpdateDB(UserBase):
email: Optional[str] = None
hashed_password: str
description: Optional[str] = None
birth_day: Optional[date] = None
is_active: Optional[bool] = None
is_verified: Optional[bool] = None
is_superuser: Optional[bool] = None
class Token(BaseModel):
access_token: str
refresh_token: uuid.UUID
token_type: str
+193
View File
@@ -0,0 +1,193 @@
import logging
import uuid
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, status
from jose import jwt
from sqlalchemy import or_
from app.utils.hash_password import hash_password, verify_password
from app.utils.redis import get_redis
from app.exceptions import InvalidTokenException, TokenExpiredException
from app.users.models import UserModel
from app.users.dao import UserDAO
from app.database import async_session_maker
from app.users.schemas import Token, UserCreate, UserCreateDB, User
from app.tasks.email_tasks import EmailTasks
from app.config import settings
log = logging.getLogger(__name__)
class AuthService:
@classmethod
async def create_token(cls, user_id: int) -> Token:
redis_client = await get_redis()
access_token = cls._create_access_token(user_id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await redis_client.setex(f"refresh:{refresh_token}", int(refresh_token_expires.total_seconds()), user_id)
log.info("Token created has user", extra={"user_id": user_id})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def logout(cls, token: uuid.UUID) -> None:
redis_client = await get_redis()
user_id = await redis_client.getdel(f"refresh:{token}")
log.info("User logged out", extra={"user_id": user_id})
@classmethod
async def refresh_token(cls, token: uuid.UUID) -> Token:
redis_client = await get_redis()
async with async_session_maker() as session:
refresh_session = await redis_client.getdel(f"refresh:{token}")
if refresh_session is None:
log.warning("Refresh token not found")
raise InvalidTokenException
user = await UserDAO.find_one_or_none(session, id=int(refresh_session))
if user is None:
log.error("User not found during token refresh", extra={"user_id": str(refresh_session.user_id)})
raise InvalidTokenException
access_token = cls._create_access_token(user.id)
refresh_token_expires = timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_token = cls._create_refresh_token()
await redis_client.setex(
f"refresh:{refresh_token}",
int(refresh_token_expires.total_seconds()),
user.id
)
await session.commit()
log.info("Token refreshed for user", extra={"user_id": str(user.id)})
return Token(access_token=access_token, refresh_token=refresh_token, token_type="bearer")
@classmethod
async def authenticate_user(cls, email_or_username: str, password: str) -> Optional[UserModel]:
async with async_session_maker() as session:
db_user = await UserDAO.find_one_or_none(
session,
or_(
UserModel.email==email_or_username,
UserModel.username==email_or_username
)
)
if db_user and verify_password(password, db_user.hashed_password):
log.info("User authenticated successfully", extra={"username": db_user.username})
return db_user
log.warning("Authentication failed", extra={"email": email_or_username})
return None
# @classmethod
# async def abort_all_sessions(cls, user_id: uuid.UUID):
# async with async_session_maker() as session:
# await RefreshSessionDAO.delete(session, RefreshSessionModel.user_id == user_id)
# await session.commit()
@classmethod
def _create_access_token(cls, user_id: int) -> str:
to_encode = {
"sub": str(user_id),
"exp": datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return f'Bearer {encoded_jwt}'
@classmethod
def _create_refresh_token(cls) -> str:
return uuid.uuid4()
class UserService:
@classmethod
async def get_user(cls, user_id: int) -> User:
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, id=user_id)
if user_exist is None:
log.warning("User not found", extra={"user_id": user_id})
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
log.debug("User fetched", extra={"user_id": user_id})
return user_exist
@classmethod
async def register_new_user(cls, user: UserCreate) -> User:
redis_client = await get_redis()
async with async_session_maker() as session:
user_exist = await UserDAO.find_one_or_none(session, or_(
UserModel.email==user.email,
UserModel.username==user.username
))
if user_exist:
log.warning("User already registered", extra={"email": user.email})
raise HTTPException(status_code=400, detail="User already exists")
print(user.email)
user_db = await UserDAO.add(
session,
UserCreateDB(
**user.model_dump(),
hashed_password=hash_password(user.password),
is_active=True,
is_verified=False,
is_superuser=False
)
)
await session.commit()
await cls.send_verify_email(user_db)
return user_db
@classmethod
async def send_verify_email(cls, user: UserModel):
redis_client = await get_redis()
token = cls._create_email_verification_token()
url = f"{settings.URL}/api/v1/auth/verify/{token}"
await redis_client.setex(f"email:{token}", timedelta(minutes=60), user.id)
EmailTasks.send_verify_email_task.delay(email=user.email, username=user.username, url=url)
@classmethod
def _create_email_verification_token(cls) -> uuid.UUID:
return uuid.uuid4()
@classmethod
async def verify_email(cls, token: uuid.UUID):
redis_client = await get_redis()
async with async_session_maker() as session:
user_id = await redis_client.getdel(f"email:{token}")
if user_id is None:
raise TokenExpiredException
user_exist = await UserDAO.find_one_or_none(session, id=int(user_id))
if user_exist is None:
raise InvalidTokenException
if user_exist.is_verified:
raise HTTPException(status_code=400, detail="Email already verified")
await UserDAO.update(
session,
UserModel.id==int(user_id),
obj_in={"is_verified": True}
)
await session.commit()
+36
View File
@@ -0,0 +1,36 @@
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
class OAuth2PasswordBearerWithCookie(OAuth2):
def __init__(
self,
tokenUrl: str,
scheme_name: Optional[str] = None,
scopes: Optional[Dict[str, str]] = None,
auto_error: bool = True,
):
if not scopes:
scopes = {}
flows = OAuthFlowsModel(
password={"tokenUrl": tokenUrl, "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")
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
+27
View File
@@ -0,0 +1,27 @@
import json
from functools import wraps
from fastapi import Request
from app.utils.redis import get_redis
def cache(ttl: int = 10):
if ttl <= 0:
raise ValueError("TTL must be greater than zero.")
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
redis = await get_redis()
request: Request = kwargs.get("request")
response_cache = await redis.get(str(request.url))
if response_cache is not None:
return json.loads(response_cache)
response_cache = await func(*args, **kwargs)
await redis.setex(str(request.url), ttl, json.dumps(response_cache))
return response_cache
return wrapper
return decorator
+11
View File
@@ -0,0 +1,11 @@
from celery import Celery
from app.config import settings
celery_app = Celery(
"app.utils.celery_app",
broker=settings.RABBITMQ_URL,
backend="rpc://"
)
celery_app.autodiscover_tasks(["app.tasks"])
+46
View File
@@ -0,0 +1,46 @@
import smtplib
import logging
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, FileSystemLoader
from app.config import settings
log = logging.getLogger(__name__)
class EmailClient:
env = Environment(
loader=FileSystemLoader(os.path.join(os.path.dirname(__file__), "..", "templates"))
)
@classmethod
def render(cls, template_path, **kwargs):
log.debug(f"Rendering {template_path}", extra={"kwargs": kwargs, "template_path": template_path})
template = cls.env.get_template(template_path)
return template.render(**kwargs)
@classmethod
def send_email(cls, to: str, subject: str, html: str, body: str):
log.info("Sending email", extra={"subject": subject, "to": to})
try:
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = settings.SMTP_EMAIL
msg["To"] = to
msg.attach(MIMEText(html, "html", "utf-8"))
msg.attach(MIMEText(body, "plain", "utf-8"))
with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
if not settings.MODE == "DEV":
smtp.starttls()
smtp.login(settings.SMTP_EMAIL, settings.SMTP_PASS)
smtp.send_message(msg)
log.info("Email sent successfully", extra={"to": to, "subject": subject})
except Exception as e:
log.error(f"Failed to send email: {str(e)}", extra={"to": to, "subject": subject})
raise e
+10
View File
@@ -0,0 +1,10 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password):
return pwd_context.hash(password)
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
+22
View File
@@ -0,0 +1,22 @@
from redis.asyncio import Redis, from_url
from app.config import settings
redis_client: Redis = None
async def init_redis() -> None:
global redis_client
redis_client = await from_url(
settings.REDIS_URL,
encoding="utf-8",
decode_responses=True
)
async def close_redis() -> None:
if redis_client:
await redis_client.close()
async def get_redis() -> Redis:
return redis_client