feat: first commit, auth

This commit is contained in:
2026-07-24 11:33:56 +07:00
commit 0d7944fdab
43 changed files with 1303 additions and 0 deletions

181
.gitignore vendored Normal file
View File

@@ -0,0 +1,181 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
*.env
dev.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
plans
dev.sh
test_dummy.py

149
alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

94
alembic/env.py Normal file
View File

@@ -0,0 +1,94 @@
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 config import cfg as project_config
from db.base import Base
from db.models import *
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# 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 = project_config.db_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.
"""
url = project_config.db_url
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
url=url,
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
alembic/script.py.mako Normal file
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"}

View File

@@ -0,0 +1,36 @@
"""revoked_at -> nullable, NULL by default
Revision ID: 086c8d206446
Revises: 40c196465541
Create Date: 2026-07-24 11:04:35.693439
"""
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 = '086c8d206446'
down_revision: Union[str, Sequence[str], None] = '40c196465541'
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('sessions', 'revoked_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('sessions', 'revoked_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,41 @@
"""added sessions table
Revision ID: 271e258d06be
Revises: a13612c28fcd
Create Date: 2026-07-23 22:12:08.027226
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '271e258d06be'
down_revision: Union[str, Sequence[str], None] = 'a13612c28fcd'
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('sessions',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.BIGINT(), nullable=False),
sa.Column('refresh_token_hash', sa.TEXT(), nullable=False),
sa.Column('source', sa.TEXT(), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sessions')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""revoking sessions
Revision ID: 40c196465541
Revises: 85e0c74c04b6
Create Date: 2026-07-24 10:59:52.997084
"""
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 = '40c196465541'
down_revision: Union[str, Sequence[str], None] = '85e0c74c04b6'
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.add_column('sessions', sa.Column('is_revoked', sa.BOOLEAN(), nullable=False))
op.add_column('sessions', sa.Column('revoked_at', postgresql.TIMESTAMP(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('sessions', 'revoked_at')
op.drop_column('sessions', 'is_revoked')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""fanthom changes
Revision ID: 85e0c74c04b6
Revises: 271e258d06be
Create Date: 2026-07-23 22:15:07.808811
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '85e0c74c04b6'
down_revision: Union[str, Sequence[str], None] = '271e258d06be'
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_unique_constraint(None, 'sessions', ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'sessions', type_='unique')
# ### end Alembic commands ###

View File

@@ -0,0 +1,44 @@
"""users.username and users.password -> nullable
Revision ID: a13612c28fcd
Revises: b1a3f3cd587b
Create Date: 2026-07-22 21:51:19.926393
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a13612c28fcd'
down_revision: Union[str, Sequence[str], None] = 'b1a3f3cd587b'
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('users', 'username',
existing_type=sa.TEXT(),
nullable=True)
op.alter_column('users', 'hashed_password',
existing_type=sa.VARCHAR(length=255),
nullable=True)
op.create_unique_constraint(None, 'users', ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='unique')
op.alter_column('users', 'hashed_password',
existing_type=sa.VARCHAR(length=255),
nullable=False)
op.alter_column('users', 'username',
existing_type=sa.TEXT(),
nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,41 @@
"""created users table
Revision ID: b1a3f3cd587b
Revises:
Create Date: 2026-07-22 21:12:08.095864
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b1a3f3cd587b'
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('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('username', sa.TEXT(), nullable=False),
sa.Column('hashed_password', sa.VARCHAR(length=255), nullable=False),
sa.Column('telegram_id', sa.BIGINT(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('telegram_id'),
sa.UniqueConstraint('username')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###

41
config.py Normal file
View File

@@ -0,0 +1,41 @@
import os
from pydantic import Field, ValidationError, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
postgres_user: str = Field()
postgres_password: str = Field()
postgres_host: str = Field()
postgres_port: str = Field()
postgres_db: str = Field()
private_key_fp: str = Field()
public_key_fp: str = Field()
@computed_field
@property
def db_url(self) -> str:
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
@computed_field
@property
def private_key(self) -> bytes:
if not os.path.isfile(self.private_key_fp):
raise ValidationError("Private key is not found")
with open(self.private_key_fp, "rb") as f:
return f.read()
@computed_field
@property
def public_key(self) -> bytes:
if not os.path.isfile(self.public_key_fp):
raise ValidationError("Public key is not found")
with open(self.public_key_fp, "rb") as f:
return f.read()
cfg = Settings() # type: ignore

0
core/__init__.py Normal file
View File

38
core/deps.py Normal file
View File

@@ -0,0 +1,38 @@
from datetime import UTC, datetime
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2AuthorizationCodeBearer
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from core.secrets import decode_jwt
from db.models import User
from db.session import get_db
from repositories.users import UserRepository
from schemas.jwt import JWTPayload
oauth_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="/auth/login", tokenUrl="/auth/refresh"
)
async def get_current_user(
session: AsyncSession = Depends(get_db), token: str = Depends(oauth_scheme)
) -> User | None:
content = decode_jwt(token)
try:
payload = JWTPayload.model_validate(content)
except ValidationError:
raise HTTPException(status_code=401, detail="Invalid credentials") from None
if payload.exp < datetime.now(UTC).timestamp():
raise HTTPException(status_code=401, detail="Access token expired")
repo = UserRepository(session)
user = await repo.get_user_by_id(int(payload.sub))
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user

51
core/secrets.py Normal file
View File

@@ -0,0 +1,51 @@
import hashlib
import logging
import secrets
from typing import Any
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from config import cfg
from schemas.dto import KeyPair
from schemas.jwt import JWTPayload
from schemas.providers import ProvidersType
ctx = PasswordHasher()
logger = logging.getLogger(__name__)
def hash_password(plain_password: str) -> ...:
return ctx.hash(plain_password)
def verify_password(hashed_password: str, plain_password: str) -> bool:
try:
ctx.verify(hashed_password, plain_password)
return True
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
except Exception:
logger.exception("unexpected error while comparing password and hash")
return False
def generate_jwt(payload: dict[str, Any]) -> str:
return jwt.encode(payload, cfg.private_key, "RS256")
def decode_jwt(token: str) -> dict[str, Any]:
return jwt.decode(token, cfg.private_key, "RS256")
def generate_pair(user_id: int, iss: ProvidersType) -> KeyPair:
payload = JWTPayload(sub=user_id, iss=iss)
access_token = generate_jwt(payload.model_dump())
refresh_token = secrets.token_urlsafe(32)
return KeyPair(access_token=access_token, refresh_token=refresh_token)
def hash_refresh_token(token: str):
return hashlib.sha256(token.encode()).hexdigest()

0
db/__init__.py Normal file
View File

4
db/base.py Normal file
View File

@@ -0,0 +1,4 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): ...

4
db/models/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .sessions import Session
from .users import User
__all__ = ["Session", "User"]

29
db/models/sessions.py Normal file
View File

@@ -0,0 +1,29 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import BOOLEAN, INTEGER, TEXT, ForeignKey, func
from sqlalchemy.dialects.postgresql import TIMESTAMP
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
if TYPE_CHECKING:
from db.models.users import User
class Session(Base):
__tablename__ = "sessions"
id: Mapped[int] = mapped_column(
INTEGER, unique=True, autoincrement=True, nullable=False, primary_key=True
)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
refresh_token_hash: Mapped[str] = mapped_column(TEXT, nullable=False)
source: Mapped[str] = mapped_column(TEXT, nullable=False)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, default=func.now())
is_revoked: Mapped[bool] = mapped_column(BOOLEAN, default=False)
revoked_at: Mapped[datetime] = mapped_column(TIMESTAMP, nullable=True)
user: Mapped["User"] = relationship(back_populates="sessions", lazy="selectin")

22
db/models/users.py Normal file
View File

@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, TEXT, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
if TYPE_CHECKING:
from db.models.sessions import Session
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(
BIGINT, unique=True, autoincrement=True, nullable=False, primary_key=True
)
username: Mapped[str] = mapped_column(TEXT, unique=True, nullable=True)
hashed_password: Mapped[str] = mapped_column(VARCHAR(255), nullable=True)
telegram_id: Mapped[int] = mapped_column(BIGINT, unique=True, nullable=True)
sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="selectin")

11
db/session.py Normal file
View File

@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from config import cfg
engine = create_async_engine(cfg.db_url, echo=True)
async_session = async_sessionmaker(bind=engine, expire_on_commit=False)
async def get_db():
async with async_session() as session:
yield session

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
postgres:
env_file: .env
image: postgres:16-alpine
container_name: malenia-backend-postgres
environment:
TZ: UTC
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}",
]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
volumes:
postgres_data:

0
entrypoints/startup.sh Executable file
View File

8
main.py Normal file
View File

@@ -0,0 +1,8 @@
from fastapi import FastAPI
from routes import routers
app = FastAPI(debug=True)
for r in routers:
app.include_router(r)

56
pyproject.toml Normal file
View File

@@ -0,0 +1,56 @@
[tool.black]
line-length = 100
target-version = ['py313']
include = '\.pyi?$'
extend-exclude = '''
/(
\.git
| venv
| build
| dist
| alembic
)/
'''
[tool.ruff]
line-length = 100
target-version = "py313"
exclude = [
".git",
"venv",
"build",
"dist",
"alembic",
]
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"N",
"UP",
"B",
"SIM",
"PL",
"RUF",
"TID",
"PT",
]
ignore = [
"E501",
"D100",
"D104",
"G004",
"PLR0913",
"RUF001",
"RUF002",
"RUF003",
"B008"
]
[tool.ruff.lint.isort]
combine-as-imports = true

0
repositories/__init__.py Normal file
View File

38
repositories/sessions.py Normal file
View File

@@ -0,0 +1,38 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Session
from schemas.providers import ProvidersType
class SessionsRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_session_by_id(self, id: int) -> Session | None:
stmt = select(Session).where(Session.id == id)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def get_session_by_user_id(self, user_id: int) -> Session | None:
stmt = select(Session).where(Session.user_id == user_id)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def get_session_by_hash(self, token_hash: str) -> Session | None:
stmt = select(Session).where(Session.refresh_token_hash == token_hash)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def create(self, user_id: int, refresh_token_hash: str, iss: ProvidersType) -> Session:
obj = Session(user_id=user_id, refresh_token_hash=refresh_token_hash, source=iss)
self.session.add(obj)
await self.session.commit()
return obj
async def revoke(self, token_id: int):
session = await self.get_session_by_id(token_id)
session.is_revoked = True
session.revoked_at = func.now()
await self.session.commit()
return session

41
repositories/users.py Normal file
View File

@@ -0,0 +1,41 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_user_by_id(self, id: int) -> User | None:
stmt = select(User).where(User.id == id)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def get_user_by_telegram_id(self, telegram_id: int) -> User | None:
stmt = select(User).where(User.telegram_id == telegram_id)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def get_user_by_username(self, username: str) -> User | None:
stmt = select(User).where(User.username == username)
res = await self.session.execute(stmt)
return res.scalar_one_or_none()
async def create(
self,
*,
username: str | None = None,
hashed_password: str | None = None,
telegram_id: int | None = None,
) -> User:
obj = User(
username=username,
hashed_password=hashed_password,
telegram_id=telegram_id,
)
self.session.add(obj)
await self.session.commit()
return obj

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
fastapi>=0.139.0

5
routes/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from fastapi import APIRouter
from .auth import router as auth_router
routers: list[APIRouter] = [auth_router]

75
routes/auth.py Normal file
View File

@@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from core.secrets import hash_password, hash_refresh_token, verify_password
from db.session import get_db
from repositories.sessions import SessionsRepository
from repositories.users import UserRepository
from schemas.login import UserLogin, UserLoginData, UserTokens
from schemas.providers import ProvidersType
from schemas.registration import UserRegistration
from schemas.user import UserInfo
from services.sessions import refresh_token_rotation
from services.users import authorize_user
router = APIRouter(prefix="/auth")
@router.post("/signup")
async def signup(req: UserRegistration, session: AsyncSession = Depends(get_db)):
users_repo = UserRepository(session)
if req.provider == "credentials":
if not req.username or not req.password:
raise HTTPException(status_code=400, detail="Username or password is not provided")
user = await users_repo.get_user_by_username(req.username)
if user:
raise HTTPException(status_code=409, detail="User already exists")
password_hash = hash_password(req.password)
user = await users_repo.create(username=req.username, hashed_password=password_hash)
return JSONResponse(
UserInfo(username=user.username, telegram_id=user.telegram_id).model_dump(),
status_code=201,
)
raise HTTPException(status_code=400, detail="Unsupported provider")
@router.post("/login", response_model=UserLogin)
async def login(req: UserLoginData, session: AsyncSession = Depends(get_db)):
users_repo = UserRepository(session)
sessions_repo = SessionsRepository(session)
if req.provider == "credentials":
if not req.username or not req.password:
raise HTTPException(status_code=400, detail="Username or password is not provided.")
user = await users_repo.get_user_by_username(req.username)
if not user:
raise HTTPException(status_code=401, detail="User doesn't exist.")
if not verify_password(user.hashed_password, req.password):
raise HTTPException(status_code=401, detail="Invalid password")
data = await authorize_user(sessions_repo, user, req.provider)
return data
if req.provider == "telegram":
raise HTTPException(status_code=503, detail="Under development :)")
else:
raise HTTPException(status_code=400, detail="Unknown provider.")
@router.post("/refresh", response_model=UserTokens)
async def refresh(refresh_token: str, iss: ProvidersType, session: AsyncSession = Depends(get_db)):
sessions_repo = SessionsRepository(session)
token_hash = hash_refresh_token(refresh_token)
token_entry = await sessions_repo.get_session_by_hash(token_hash)
if not token_entry:
raise HTTPException(status_code=401, detail="Refresh token is invalid.")
key_pair = await refresh_token_rotation(sessions_repo, token_entry, iss)
return UserTokens(access_token=key_pair.access_token, refresh_token=key_pair.refresh_token)

0
schemas/__init__.py Normal file
View File

7
schemas/dto.py Normal file
View File

@@ -0,0 +1,7 @@
from dataclasses import dataclass
@dataclass
class KeyPair:
access_token: str
refresh_token: str

11
schemas/jwt.py Normal file
View File

@@ -0,0 +1,11 @@
from datetime import UTC, datetime
from pydantic import BaseModel, Field
from schemas.providers import ProvidersType
class JWTPayload(BaseModel):
sub: int = Field(description="User ID")
iss: ProvidersType = Field(description="Issuer")
exp: float = Field(default_factory=lambda: datetime.now(UTC).timestamp())

27
schemas/login.py Normal file
View File

@@ -0,0 +1,27 @@
from pydantic import BaseModel
from schemas.providers import ProvidersType
from schemas.user import UserInfo
class TelegramData(BaseModel): ...
class UserLoginData(BaseModel):
provider: ProvidersType
username: str | None = None
password: str | None = None
telegram: TelegramData | None = None
class UserLogin(BaseModel):
access_token: str
refresh_token: str
user: UserInfo
class UserTokens(BaseModel):
access_token: str
refresh_token: str

3
schemas/providers.py Normal file
View File

@@ -0,0 +1,3 @@
from typing import Literal
ProvidersType = Literal["credentials", "telegram", "api"]

12
schemas/registration.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel, Field
from schemas.providers import ProvidersType
class UserRegistration(BaseModel):
telegram_id: str | None = Field()
username: str | None = Field()
password: str | None = Field()
provider: ProvidersType

6
schemas/user.py Normal file
View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel, Field
class UserInfo(BaseModel):
username: str | None = Field()
telegram_id: str | None = Field()

0
services/__init__.py Normal file
View File

18
services/sessions.py Normal file
View File

@@ -0,0 +1,18 @@
from core.secrets import generate_pair, hash_refresh_token
from db.models import Session
from repositories.sessions import SessionsRepository
from schemas.dto import KeyPair
from schemas.providers import ProvidersType
async def refresh_token_rotation(
sessions_repo: SessionsRepository, old_token: Session, iss: ProvidersType
) -> KeyPair:
user = old_token.user
await sessions_repo.revoke(old_token.id)
key_pair = generate_pair(user.id, iss=iss)
refresh_token_hash = hash_refresh_token(key_pair.refresh_token)
await sessions_repo.create(user.id, refresh_token_hash, iss=iss)
return key_pair

22
services/users.py Normal file
View File

@@ -0,0 +1,22 @@
from core.secrets import generate_pair, hash_refresh_token
from db.models.users import User
from repositories.sessions import SessionsRepository
from schemas.login import UserLogin
from schemas.providers import ProvidersType
from schemas.user import UserInfo
async def authorize_user(
sessions_repo: SessionsRepository, user: User, iss: ProvidersType
) -> UserLogin:
key_pair = generate_pair(user.id, iss)
refresh_token_hash = hash_refresh_token(key_pair.refresh_token)
await sessions_repo.create(user_id=user.id, refresh_token_hash=refresh_token_hash, iss=iss)
return UserLogin(
access_token=key_pair.access_token,
refresh_token=key_pair.refresh_token,
user=UserInfo(username=user.username, telegram_id=user.telegram_id),
)

52
static/private_key.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
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
d2eEvRoZYPiAUFamH1WPm3BvSwOzNbU=
-----END PRIVATE KEY-----

14
static/public_key.pem Normal file
View File

@@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2Rrcy7UCbPFM9C9Bv59b
t5TTn+l4nFbJ5a3lfipWhM70hyDGpkq1voNz3ASSS78NGlJ4MEll2tIiPgbHGK7M
B+jHD+x1TqRqCE9ZeSgo6/ClQiUt7yY7XeFVugZtFU7vtFo5qbbIbEIxoAkV9+/f
VVdSX33+76Zu2uuVmFnYbq5WYeZNrMMxbmsgxM2EiOTN3GfD1a5PxdXVfXqbTR6N
TY7qagA5Ix1KVvxzXrrzWUIUDGKOg+i8c6V6yv87cF1Q0AGG6eh9zZ7TLhP/xXpu
/QcDHEGKlks39DZPfnIC7jbyZ1aCr6JR3VyLJXOCujMlx08aesGQK6CPYooV8Yf1
7FslRSsx8i0g9hKvOR6cSX74swGF8uUCTUqzbUE0qWGuJoVz6JhuT0U/9qJ4+4d3
C59506HQNn0GcckKCt8ZRdT1VrP0X3A51jP7MyaqjgOEcHcnFyjXFN51ul4841yf
gIpMuakTer2ratLOGrkuAMggirSCGV0MtM0ZqGz2+hLuuShCIXkWPUMestDGcGAw
7nvG20HNQTliXpsWEkXqjeFK29lS4dNYbQIrynRwiLR8w0/fc+k6pyfO2UJhMEah
QL2boUXBPK8OVMaQFpxh9YuRk5/ZSvYAgBLNxyO39EbrK13URUy+Q1n+XC220Xjv
/mKbmVzr3LQepZbDYBYZHaMCAwEAAQ==
-----END PUBLIC KEY-----