From 0d7944fdab4a2e53d4501aff300dce27532a2966 Mon Sep 17 00:00:00 2001 From: hexdev Date: Fri, 24 Jul 2026 11:33:56 +0700 Subject: [PATCH] feat: first commit, auth --- .gitignore | 181 ++++++++++++++++++ alembic.ini | 149 ++++++++++++++ alembic/README | 1 + alembic/env.py | 94 +++++++++ alembic/script.py.mako | 28 +++ ...446_revoked_at_nullable_null_by_default.py | 36 ++++ .../271e258d06be_added_sessions_table.py | 41 ++++ .../40c196465541_revoking_sessions.py | 34 ++++ .../versions/85e0c74c04b6_fanthom_changes.py | 32 ++++ ...8fcd_users_username_and_users_password_.py | 44 +++++ .../b1a3f3cd587b_created_users_table.py | 41 ++++ config.py | 41 ++++ core/__init__.py | 0 core/deps.py | 38 ++++ core/secrets.py | 51 +++++ db/__init__.py | 0 db/base.py | 4 + db/models/__init__.py | 4 + db/models/sessions.py | 29 +++ db/models/users.py | 22 +++ db/session.py | 11 ++ docker-compose.yml | 26 +++ entrypoints/startup.sh | 0 main.py | 8 + pyproject.toml | 56 ++++++ repositories/__init__.py | 0 repositories/sessions.py | 38 ++++ repositories/users.py | 41 ++++ requirements.txt | 1 + routes/__init__.py | 5 + routes/auth.py | 75 ++++++++ schemas/__init__.py | 0 schemas/dto.py | 7 + schemas/jwt.py | 11 ++ schemas/login.py | 27 +++ schemas/providers.py | 3 + schemas/registration.py | 12 ++ schemas/user.py | 6 + services/__init__.py | 0 services/sessions.py | 18 ++ services/users.py | 22 +++ static/private_key.pem | 52 +++++ static/public_key.pem | 14 ++ 43 files changed, 1303 insertions(+) create mode 100644 .gitignore create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/086c8d206446_revoked_at_nullable_null_by_default.py create mode 100644 alembic/versions/271e258d06be_added_sessions_table.py create mode 100644 alembic/versions/40c196465541_revoking_sessions.py create mode 100644 alembic/versions/85e0c74c04b6_fanthom_changes.py create mode 100644 alembic/versions/a13612c28fcd_users_username_and_users_password_.py create mode 100644 alembic/versions/b1a3f3cd587b_created_users_table.py create mode 100644 config.py create mode 100644 core/__init__.py create mode 100644 core/deps.py create mode 100644 core/secrets.py create mode 100644 db/__init__.py create mode 100644 db/base.py create mode 100644 db/models/__init__.py create mode 100644 db/models/sessions.py create mode 100644 db/models/users.py create mode 100644 db/session.py create mode 100644 docker-compose.yml create mode 100755 entrypoints/startup.sh create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 repositories/__init__.py create mode 100644 repositories/sessions.py create mode 100644 repositories/users.py create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/auth.py create mode 100644 schemas/__init__.py create mode 100644 schemas/dto.py create mode 100644 schemas/jwt.py create mode 100644 schemas/login.py create mode 100644 schemas/providers.py create mode 100644 schemas/registration.py create mode 100644 schemas/user.py create mode 100644 services/__init__.py create mode 100644 services/sessions.py create mode 100644 services/users.py create mode 100644 static/private_key.pem create mode 100644 static/public_key.pem diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5770d7 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..df80d65 --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6ed6813 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/086c8d206446_revoked_at_nullable_null_by_default.py b/alembic/versions/086c8d206446_revoked_at_nullable_null_by_default.py new file mode 100644 index 0000000..d333413 --- /dev/null +++ b/alembic/versions/086c8d206446_revoked_at_nullable_null_by_default.py @@ -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 ### diff --git a/alembic/versions/271e258d06be_added_sessions_table.py b/alembic/versions/271e258d06be_added_sessions_table.py new file mode 100644 index 0000000..5595509 --- /dev/null +++ b/alembic/versions/271e258d06be_added_sessions_table.py @@ -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 ### diff --git a/alembic/versions/40c196465541_revoking_sessions.py b/alembic/versions/40c196465541_revoking_sessions.py new file mode 100644 index 0000000..f0cc53f --- /dev/null +++ b/alembic/versions/40c196465541_revoking_sessions.py @@ -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 ### diff --git a/alembic/versions/85e0c74c04b6_fanthom_changes.py b/alembic/versions/85e0c74c04b6_fanthom_changes.py new file mode 100644 index 0000000..1c1950c --- /dev/null +++ b/alembic/versions/85e0c74c04b6_fanthom_changes.py @@ -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 ### diff --git a/alembic/versions/a13612c28fcd_users_username_and_users_password_.py b/alembic/versions/a13612c28fcd_users_username_and_users_password_.py new file mode 100644 index 0000000..817ce47 --- /dev/null +++ b/alembic/versions/a13612c28fcd_users_username_and_users_password_.py @@ -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 ### diff --git a/alembic/versions/b1a3f3cd587b_created_users_table.py b/alembic/versions/b1a3f3cd587b_created_users_table.py new file mode 100644 index 0000000..046477b --- /dev/null +++ b/alembic/versions/b1a3f3cd587b_created_users_table.py @@ -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 ### diff --git a/config.py b/config.py new file mode 100644 index 0000000..db6adcb --- /dev/null +++ b/config.py @@ -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 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/deps.py b/core/deps.py new file mode 100644 index 0000000..788ea88 --- /dev/null +++ b/core/deps.py @@ -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 diff --git a/core/secrets.py b/core/secrets.py new file mode 100644 index 0000000..e651939 --- /dev/null +++ b/core/secrets.py @@ -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() diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/db/base.py b/db/base.py new file mode 100644 index 0000000..6196db8 --- /dev/null +++ b/db/base.py @@ -0,0 +1,4 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): ... diff --git a/db/models/__init__.py b/db/models/__init__.py new file mode 100644 index 0000000..dfdd56f --- /dev/null +++ b/db/models/__init__.py @@ -0,0 +1,4 @@ +from .sessions import Session +from .users import User + +__all__ = ["Session", "User"] diff --git a/db/models/sessions.py b/db/models/sessions.py new file mode 100644 index 0000000..1eb5257 --- /dev/null +++ b/db/models/sessions.py @@ -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") diff --git a/db/models/users.py b/db/models/users.py new file mode 100644 index 0000000..8120ff9 --- /dev/null +++ b/db/models/users.py @@ -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") diff --git a/db/session.py b/db/session.py new file mode 100644 index 0000000..cf36752 --- /dev/null +++ b/db/session.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9f34f81 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh new file mode 100755 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..53e9612 --- /dev/null +++ b/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +from routes import routers + +app = FastAPI(debug=True) + +for r in routers: + app.include_router(r) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28d418f --- /dev/null +++ b/pyproject.toml @@ -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 \ No newline at end of file diff --git a/repositories/__init__.py b/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repositories/sessions.py b/repositories/sessions.py new file mode 100644 index 0000000..f54182e --- /dev/null +++ b/repositories/sessions.py @@ -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 diff --git a/repositories/users.py b/repositories/users.py new file mode 100644 index 0000000..dfc3612 --- /dev/null +++ b/repositories/users.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..229ab19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +fastapi>=0.139.0 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..32ea286 --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter + +from .auth import router as auth_router + +routers: list[APIRouter] = [auth_router] diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..01ce7eb --- /dev/null +++ b/routes/auth.py @@ -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) diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schemas/dto.py b/schemas/dto.py new file mode 100644 index 0000000..2984c22 --- /dev/null +++ b/schemas/dto.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class KeyPair: + access_token: str + refresh_token: str diff --git a/schemas/jwt.py b/schemas/jwt.py new file mode 100644 index 0000000..d3c8ce7 --- /dev/null +++ b/schemas/jwt.py @@ -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()) diff --git a/schemas/login.py b/schemas/login.py new file mode 100644 index 0000000..f742f9e --- /dev/null +++ b/schemas/login.py @@ -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 diff --git a/schemas/providers.py b/schemas/providers.py new file mode 100644 index 0000000..651bc4a --- /dev/null +++ b/schemas/providers.py @@ -0,0 +1,3 @@ +from typing import Literal + +ProvidersType = Literal["credentials", "telegram", "api"] diff --git a/schemas/registration.py b/schemas/registration.py new file mode 100644 index 0000000..dc7ca45 --- /dev/null +++ b/schemas/registration.py @@ -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 diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..7f4c815 --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field + + +class UserInfo(BaseModel): + username: str | None = Field() + telegram_id: str | None = Field() diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/sessions.py b/services/sessions.py new file mode 100644 index 0000000..0c150fe --- /dev/null +++ b/services/sessions.py @@ -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 diff --git a/services/users.py b/services/users.py new file mode 100644 index 0000000..fee58bc --- /dev/null +++ b/services/users.py @@ -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), + ) diff --git a/static/private_key.pem b/static/private_key.pem new file mode 100644 index 0000000..8272ba5 --- /dev/null +++ b/static/private_key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +d2eEvRoZYPiAUFamH1WPm3BvSwOzNbU= +-----END PRIVATE KEY----- diff --git a/static/public_key.pem b/static/public_key.pem new file mode 100644 index 0000000..f0bc676 --- /dev/null +++ b/static/public_key.pem @@ -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-----