mirror of
https://github.com/lorsanstand/Aether.git
synced 2026-06-19 12:05:16 +03:00
Create authorization system
This commit is contained in:
Executable
+62
@@ -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()
|
||||
Executable
+9
@@ -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",
|
||||
}
|
||||
Executable
+133
@@ -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()
|
||||
Executable
+27
@@ -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)
|
||||
@@ -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")
|
||||
Executable
+38
@@ -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
@@ -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)
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
Executable
+93
@@ -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()
|
||||
Executable
+28
@@ -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 ###
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .email_tasks import *
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
Здравствуйте, {{username}}!
|
||||
|
||||
Спасибо за регистрацию!
|
||||
|
||||
Для подтверждения вашего email адреса перейдите по ссылке:
|
||||
{{url}}
|
||||
|
||||
Если вы не создавали аккаунт на нашем сайте, просто проигнорируйте это письмо.
|
||||
Внимание: Эта ссылка действительна в течение {{expire_minutes}} минут
|
||||
|
||||
С уважением,
|
||||
Команда {{company_name}}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,13 @@
|
||||
Здравствуйте, {{username}}!
|
||||
|
||||
Мы получили запрос на сброс пароля для вашей учетной записи.
|
||||
|
||||
Для создания нового пароля перейдите по ссылке:
|
||||
{{url}}
|
||||
|
||||
ВНИМАНИЕ: Эта ссылка действительна в течение {{expire_minutes}} минут.
|
||||
|
||||
Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
|
||||
|
||||
С уважением,
|
||||
Команда {{company_name}}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Executable
+30
@@ -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"))
|
||||
Executable
+80
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Executable
+27
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Executable
+22
@@ -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
|
||||
Reference in New Issue
Block a user