From 793941e8b260f1ae6095a9669c9d08b49414d134 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 10 Nov 2021 22:37:32 -0800 Subject: [PATCH 01/20] Vagrant/docker-compose: basic app deploy lay the groundwork for deploying a development environment with debian 11 via vagrant and running the database + application stack via docker-compose for cross platform development. --- Dockerfile | 15 +++++++++++++ Vagrantfile | 45 +++++++++++++++++++++++++++++++++++++ docker-compose.override.yml | 9 ++++++++ docker-compose.yml | 36 +++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 Dockerfile create mode 100644 Vagrantfile create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ebb0d87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.10-alpine3.14 + +COPY . /app +WORKDIR /app +RUN \ + apk add --no-cache postgresql-libs && \ + apk add --no-cache --virtual .build-deps gcc musl-dev openssl-dev libffi-dev postgresql-dev && \ + python3 -m pip install --no-cache-dir poetry==1.1.11 && \ + poetry install && \ + apk --purge del .build-deps +CMD ["poetry", "run", \ + "uvicorn", \ + "mycodeplug.api:app", "--reload", \ + "--host", "0.0.0.0", "--port", "8009" \ +] diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..e813876 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,45 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# All Vagrant configuration is done below. The "2" in Vagrant.configure +# configures the configuration version (we support older styles for +# backwards compatibility). Please don't change it unless you know what +# you're doing. +Vagrant.configure("2") do |config| + config.vm.box = "generic/debian11" + + # require plugin https://github.com/leighmcculloch/vagrant-docker-compose + config.vagrant.plugins = "vagrant-docker-compose" + + # install docker and docker-compose + config.vm.provision :docker + config.vm.provision :docker_compose + + config.vm.provider "virtualbox" do |vb| + vb.customize ["modifyvm", :id, "--ioapic", "on"] + vb.customize ["modifyvm", :id, "--memory", "2048"] + vb.customize ["modifyvm", :id, "--cpus", "2"] + end + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine. In the example below, + # accessing "localhost:8080" will access port 80 on the guest machine. + # NOTE: This will enable public access to the opened port + config.vm.network "forwarded_port", guest: 5432, host: 5432 + config.vm.network "forwarded_port", guest: 54321, host: 54321 + config.vm.network "forwarded_port", guest: 8009, host: 8009 + + # Share an additional folder to the guest VM. The first argument is + # the path on the host to the actual folder. The second argument is + # the path on the guest to mount the folder. And the optional third + # argument is a set of non-required options. + config.vm.synced_folder "/Users/masen/code", "/git" + + # Enable provisioning with a shell script. Additional provisioners such as + # Ansible, Chef, Docker, Puppet and Salt are also available. Please see the + # documentation for more information about their specific syntax and use. + # config.vm.provision "shell", inline: <<-SHELL + # apt-get update + # apt-get install -y apache2 + # SHELL +end diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..944affa --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,9 @@ +version: '3.1' + +services: + app: + environment: + PGPASSWORD: DEVsKNOWwHATtHEYREdOING + SECRET_KEY: aa408ae0266f2530913410f1a53c0f285d3659a482576001e1055335a33a1d2b + volumes: + - ./src:/app/src diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0b75d45 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# Use postgres/example user/password credentials +version: '3.1' + +services: + + db: + image: postgres:14-alpine3.14 + restart: always + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: example + volumes: + - pg_data:/var/lib/postgresql + - ./src/schema/10_init.sql:/docker-entrypoint-initdb.d/10_init.sql + - ./src/schema/20_schema.sql:/docker-entrypoint-initdb.d/20_schema.sql + - ./src/schema/sample_data.sql:/docker-entrypoint-initdb.d/30_sample_data.sql + + adminer: + image: adminer:4-standalone + restart: always + ports: + - 54321:8080 + + app: + build: . + restart: always + ports: + - 8009:8009 + environment: + PGHOST: db + PGUSER: mycodeplug + PGDATABASE: mycodeplug + +volumes: + pg_data: From 860947a43b99be079eb6909c30db799b9bff779a Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 10 Nov 2021 22:38:25 -0800 Subject: [PATCH 02/20] src/schema: sql files for the mycodeplug database --- src/schema/10_init.sql | 19 +++++++ src/schema/20_schema.sql | 104 +++++++++++++++++++++++++++++++++++++ src/schema/sample_data.sql | 29 +++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/schema/10_init.sql create mode 100644 src/schema/20_schema.sql create mode 100644 src/schema/sample_data.sql diff --git a/src/schema/10_init.sql b/src/schema/10_init.sql new file mode 100644 index 0000000..d99ce96 --- /dev/null +++ b/src/schema/10_init.sql @@ -0,0 +1,19 @@ +CREATE DATABASE mycodeplug; + +CREATE USER mycodeplug WITH ENCRYPTED PASSWORD 'DEVsKNOWwHATtHEYREdOING'; +CREATE USER mycodeplug_ro WITH ENCRYPTED PASSWORD 'We-re Pr0s'; + +\connect mycodeplug +ALTER DEFAULT PRIVILEGES + IN SCHEMA public + GRANT ALL PRIVILEGES ON SEQUENCES TO mycodeplug; +ALTER DEFAULT PRIVILEGES + IN SCHEMA public + GRANT ALL PRIVILEGES ON TABLES TO mycodeplug; + +ALTER DEFAULT PRIVILEGES + IN SCHEMA public + GRANT SELECT ON SEQUENCES TO mycodeplug_ro; +ALTER DEFAULT PRIVILEGES + IN SCHEMA public + GRANT SELECT ON TABLES TO mycodeplug_ro; diff --git a/src/schema/20_schema.sql b/src/schema/20_schema.sql new file mode 100644 index 0000000..762f6e4 --- /dev/null +++ b/src/schema/20_schema.sql @@ -0,0 +1,104 @@ +\connect mycodeplug +CREATE TABLE IF NOT EXISTS users ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created timestamp with time zone NOT NULL DEFAULT now(), + created_ip inet NOT NULL, + email text UNIQUE NOT NULL, + enabled boolean NOT NULL DEFAULT true, + name text, + data jsonb +); + +CREATE INDEX users_created_ip ON users(created_ip); +CREATE INDEX users_email ON users(email); +CREATE INDEX users_name ON users(name); + +CREATE TABLE IF NOT EXISTS groups ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created timestamp with time zone NOT NULL DEFAULT now(), + created_ip inet NOT NULL, + owner int references users(id) NOT NULL, + members int[] +); + +CREATE TABLE IF NOT EXISTS otp ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id int references users(id) NOT NULL, + ts timestamp with time zone NOT NULL DEFAULT now(), + ip inet NOT NULL, + expires timestamp with time zone NOT NULL DEFAULT (now() + '30 minutes'::interval), + otp text DEFAULT gen_random_uuid() +); +-- XXX: should readonly user have access to otp? + +CREATE INDEX otp_user_id_ip_otp ON otp(user_id, ip, otp); + +CREATE TABLE IF NOT EXISTS channel ( + channel_uuid uuid DEFAULT gen_random_uuid() PRIMARY KEY, + owner int references users(id) NOT NULL, + group_id int references groups(id) NOT NULL, + source text NOT NULL, + source_id text, + parent_channel uuid references channel(channel_uuid) +); + +CREATE INDEX channel_owner ON channel(owner); +CREATE INDEX channel_uuid ON channel(channel_uuid); + +CREATE TABLE IF NOT EXISTS channel_name ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text, + alt_name_16 varchar(16), + alt_name_6 varchar(6), + alt_name_5 varchar(5) +); + +CREATE TYPE power AS ENUM ('low', 'mid', 'high', 'turbo'); + +CREATE TABLE IF NOT EXISTS channel_revision ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id int references users(id) NOT NULL, + channel_uuid uuid references channel(channel_uuid) NOT NULL, + ts timestamp with time zone NOT NULL DEFAULT now(), + name_id int references channel_name(id), + description jsonb, + frequency numeric(8, 4), + f_offset numeric(8, 4), + power power, + rx_only boolean, + mode text, + mode_settings jsonb, + vendor_settings jsonb +); + +/* channel revision aggregation query +SELECT + channel.channel_uuid, + owner, + group_id, + source, + source_id, + (ARRAY_AGG(name) FILTER (WHERE name IS NOT NULL))[1] as name, + (ARRAY_AGG(alt_name_16) FILTER (WHERE alt_name_16 IS NOT NULL))[1] as alt_name_16, + (ARRAY_AGG(alt_name_6) FILTER (WHERE alt_name_6 IS NOT NULL))[1] as alt_name_6, + (ARRAY_AGG(alt_name_5) FILTER (WHERE alt_name_5 IS NOT NULL))[1] as alt_name_5, + (ARRAY_AGG(description) FILTER (WHERE description IS NOT NULL))[1] as description, + (ARRAY_AGG(frequency) FILTER (WHERE frequency IS NOT NULL))[1] as frequency, + (ARRAY_AGG(f_offset) FILTER (WHERE f_offset IS NOT NULL))[1] as f_offset, + (ARRAY_AGG(power) FILTER (WHERE power IS NOT NULL))[1] as power, + (ARRAY_AGG(rx_only) FILTER (WHERE rx_only IS NOT NULL))[1] as rx_only, + (ARRAY_AGG(mode) FILTER (WHERE mode IS NOT NULL))[1] as mode, + (ARRAY_AGG(mode_settings) FILTER (WHERE mode_settings IS NOT NULL))[1] as mode_settings, + (ARRAY_AGG(vendor_settings) FILTER (WHERE vendor_settings IS NOT NULL))[1] as vendor_settings, + MAX(ts) as last_updated, + COUNT(ts) as n_revisions +FROM channel +JOIN + (SELECT channel_uuid, ts, name, alt_name_16, alt_name_6, alt_name_5, description, + frequency, f_offset, power, rx_only, mode, mode_settings, + vendor_settings + FROM channel_revision + LEFT JOIN channel_name ON channel_revision.name_id = channel_name.id + ORDER BY ts DESC) as revisions ON channel.channel_uuid = revisions.channel_uuid +GROUP BY channel.channel_uuid +*/ diff --git a/src/schema/sample_data.sql b/src/schema/sample_data.sql new file mode 100644 index 0000000..2a9f093 --- /dev/null +++ b/src/schema/sample_data.sql @@ -0,0 +1,29 @@ +\connect mycodeplug +INSERT INTO "users" ("created_ip", "email", "name") +VALUES ('127.0.0.1', 'admin@mycodeplug.com', 'Administrator'); + +INSERT INTO "groups" ("created_ip", "owner", "members") +VALUES ('127.0.0.1', 1, '{1}'); + +INSERT INTO "channel" + ("channel_uuid", "owner", "group_id", "source") +VALUES + ('95a0a797-64c3-458f-9f4f-b02332f84dc8', 1, 1, 'sample'); + +INSERT INTO "channel_name" ("name", "alt_name_16", "alt_name_6") +VALUES ( + 'National 2m FM Simplex Calling Frequency', + '146.52 Calling', + '2MCALL' +); + +INSERT INTO "channel_revision" + ("user_id", "channel_uuid", "ts", "name_id", "frequency", "f_offset", "power", + "rx_only", "mode") +VALUES + (1, '95a0a797-64c3-458f-9f4f-b02332f84dc8', now(), 1, 146.520, 0.0, 'high', false, 'FM'); + +INSERT INTO "channel_revision" + ("user_id", "channel_uuid", "ts", "power") +VALUES + (1, '95a0a797-64c3-458f-9f4f-b02332f84dc8', now(), 'low'); From 0f8ad41f09a727553d425bf3d1a65b4cbe9b0332 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 10 Nov 2021 22:39:41 -0800 Subject: [PATCH 03/20] src/mycodeplug: fastapi skeleton with user authentication skeleton for magic-token authentication and JWT bearer tokens + postgres access --- poetry.lock | 502 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 21 ++ src/mycodeplug/__init__.py | 0 src/mycodeplug/api.py | 117 +++++++++ src/mycodeplug/db.py | 25 ++ src/mycodeplug/user.py | 269 ++++++++++++++++++++ 6 files changed, 934 insertions(+) create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/mycodeplug/__init__.py create mode 100644 src/mycodeplug/api.py create mode 100644 src/mycodeplug/db.py create mode 100644 src/mycodeplug/user.py diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..c06983a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,502 @@ +[[package]] +name = "anyio" +version = "3.3.4" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16)"] + +[[package]] +name = "asgiref" +version = "3.4.1" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.2.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] + +[[package]] +name = "click" +version = "8.0.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "ecdsa" +version = "0.17.0" +description = "ECDSA cryptographic signature library (pure python)" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "fastapi" +version = "0.70.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.16.0" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] +test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] + +[[package]] +name = "h11" +version = "0.12.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.2" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3" + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.1" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pydantic" +version = "1.8.2" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pytest" +version = "6.2.5" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "python-jose" +version = "3.3.0" +description = "JOSE implementation in Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = "*" +rsa = "*" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pycrypto (>=2.6.0,<2.7.0)", "pyasn1"] +pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)", "pyasn1"] + +[[package]] +name = "python-multipart" +version = "0.0.5" +description = "A streaming multipart parser for Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.4.0" + +[[package]] +name = "rsa" +version = "4.7.2" +description = "Pure-Python RSA implementation" +category = "main" +optional = false +python-versions = ">=3.5, <4" + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sniffio" +version = "1.2.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "starlette" +version = "0.16.0" +description = "The little ASGI library that shines." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +anyio = ">=3.0.0,<4" + +[package.extras] +full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "graphene"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "typing-extensions" +version = "3.10.0.2" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "uvicorn" +version = "0.15.0" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +asgiref = ">=3.4.0" +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "5aa2d9763c1e613f1e3b747d17beb6410685ef6989588dc6b24169dc795e8b3b" + +[metadata.files] +anyio = [ + {file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"}, + {file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"}, +] +asgiref = [ + {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"}, + {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, +] +click = [ + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +ecdsa = [ + {file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"}, + {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"}, +] +fastapi = [ + {file = "fastapi-0.70.0-py3-none-any.whl", hash = "sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c"}, + {file = "fastapi-0.70.0.tar.gz", hash = "sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced"}, +] +h11 = [ + {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, + {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +packaging = [ + {file = "packaging-21.2-py3-none-any.whl", hash = "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0"}, + {file = "packaging-21.2.tar.gz", hash = "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +psycopg2-binary = [ + {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:542875f62bc56e91c6eac05a0deadeae20e1730be4c6334d8f04c944fcd99759"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-win32.whl", hash = "sha256:ebccf1123e7ef66efc615a68295bf6fdba875a75d5bba10a05073202598085fc"}, + {file = "psycopg2_binary-2.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:1f6ca4a9068f5c5c57e744b4baa79f40e83e3746875cac3c45467b16326bab45"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-win32.whl", hash = "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d"}, + {file = "psycopg2_binary-2.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-win32.whl", hash = "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e"}, + {file = "psycopg2_binary-2.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-win32.whl", hash = "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32"}, + {file = "psycopg2_binary-2.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"}, + {file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyasn1 = [ + {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, + {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, + {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, + {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, + {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, + {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, + {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, + {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, + {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, + {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] +pydantic = [ + {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, + {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, + {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, + {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, + {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, + {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, + {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, + {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, + {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, + {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pytest = [ + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, +] +python-jose = [ + {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, + {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, +] +python-multipart = [ + {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, +] +rsa = [ + {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"}, + {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sniffio = [ + {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, + {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, +] +starlette = [ + {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"}, + {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +typing-extensions = [ + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, +] +uvicorn = [ + {file = "uvicorn-0.15.0-py3-none-any.whl", hash = "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1"}, + {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..57f8ad9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.poetry] +name = "mycodeplug" +version = "0.1.0" +description = "mycodeplug.com API server" +authors = ["Masen Furer "] +license = "AGPL" + +[tool.poetry.dependencies] +python = "^3.9" +fastapi = "^0.70.0" +psycopg2-binary = "^2.9.1" +uvicorn = "^0.15.0" +python-jose = "^3.3.0" +python-multipart = "^0.0.5" + +[tool.poetry.dev-dependencies] +pytest = "^6.2.5" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/mycodeplug/__init__.py b/src/mycodeplug/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py new file mode 100644 index 0000000..f4dec28 --- /dev/null +++ b/src/mycodeplug/api.py @@ -0,0 +1,117 @@ +import importlib.metadata + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from pydantic import BaseModel + +from .user import get_current_active, Token, User + +APP_NAME = "mycodeplug" + +app = FastAPI() + + +class RootData(BaseModel): + """ + Information returned from the top level GET request. + """ + + application: str = APP_NAME + version: str = importlib.metadata.metadata(APP_NAME)["version"] + + +@app.get("/", response_model=RootData) +async def root() -> RootData: + """ + :return: Information about the application and version + """ + return RootData() + + +@app.post("/login") +async def login(email: str, request: Request): + """ + Trigger a login request for the given email address. + + Generate a one time password for login via magic link or standard + username/password oauth via /token endpoint. + + In production mode, the otp would be emailed to the given address. + + In development mode, the otp is printed to the console. + + Either way, the client POSTing /token must be the same client + that POSTed /login. + + :param email: the user to login + :param request: the request, used to fetch the login IP. + :return: None -- the token is emailed (or printed, in dev mode). + """ + try: + user = User(email=email).lookup() + except KeyError: + raise HTTPException(status_code=400, detail="Incorrect username or password") + # XXX: send s.otp via email! + print("{} magic token is: {}".format(user.name, user.login(request.client.host))) + return + + +def _token(email: str, otp: str, request: Request) -> Token: + """ + Authenticate an OTP. + + :param email: the user to login + :param otp: the one-time password from a /login request + :param request: the request, must match the IP that requested /login + :return: oauth Token + """ + try: + token_data = ( + User(email=email).lookup().authenticate(ip=request.client.host, otp=otp) + ) + return Token( + access_token=token_data.to_jwt(), + token_type="bearer", + ) + except (KeyError, ValueError): + pass + raise HTTPException(status_code=400, detail="Incorrect username or password") + + +@app.post("/token", response_model=Token) +async def token( + request: Request, form_data: OAuth2PasswordRequestForm = Depends() +) -> Token: + """ + Standard OAuth2 Password endpoint + + :param request: the request IP must match the IP that requested /login + :param form_data: username/password + :return: oauth Token + """ + return _token(form_data.username, form_data.password, request) + + +@app.get("/magic/{email}/{otp}", response_model=Token) +async def magic(email: str, otp: str, request: Request) -> Token: + """ + Magic link login: XXX: Needs to be a JS application to save + the token client side. + + :param email: + :param otp: + :param request: + :return: + """ + return _token(email, otp, request) + + +@app.get("/users/me", response_model=User) +async def get_users_me(current_user: User = Depends(get_current_active)) -> User: + """ + Get information about the authenticated user. + + :param current_user: active user from oAuth/database + :return: User information + """ + return current_user diff --git a/src/mycodeplug/db.py b/src/mycodeplug/db.py new file mode 100644 index 0000000..064b0f6 --- /dev/null +++ b/src/mycodeplug/db.py @@ -0,0 +1,25 @@ +from contextlib import contextmanager + +import psycopg2 +import psycopg2.extras +import psycopg2.extensions +import psycopg2.pool + + +psycopg2.extras.register_default_json(globally=True) +psycopg2.extras.register_default_jsonb(globally=True) + + +class DBModel: + pool = None + + @classmethod + @contextmanager + def conn(cls) -> psycopg2.extensions.connection: + if cls.pool is None: + cls.pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10) + try: + c = cls.pool.getconn() + yield c + finally: + cls.pool.putconn(c) diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py new file mode 100644 index 0000000..9b86689 --- /dev/null +++ b/src/mycodeplug/user.py @@ -0,0 +1,269 @@ +""" +User and Session management. +""" +import datetime +from ipaddress import IPv4Address, IPv6Address, ip_address +import json +import os +from typing import Any, Dict, Optional, Union +import uuid + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from jose import JWTError, jwt +import psycopg2.extensions +import psycopg2.extras +from pydantic import BaseModel + +from .db import DBModel + + +SECRET_KEY = os.environ["SECRET_KEY"] +ALGORITHM = "HS256" +DEFAULT_EXPIRY = datetime.timedelta(days=14) +OTP_VALIDITY_PERIOD = datetime.timedelta(minutes=30) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + user_id: int + session_id: int + + def to_jwt(self, expires: Optional[datetime.timedelta] = None) -> str: + """ + Encode data as JSON Web Token + + :param expires: expiration date for the token (DEFAULT_EXPIRY) + :return: Signed and encoded payload. + """ + to_encode = self.dict() + now = datetime.datetime.now(tz=datetime.timezone.utc) + to_encode.update( + dict( + exp=now + (expires or DEFAULT_EXPIRY), + sub="{}:{}".format(self.user_id, self.session_id), + ), + ) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + @classmethod + def from_jwt(cls, token: str) -> "TokenData": + """ + Decode payload and validate token signature. + + :param token: Signed and encoded payload + :return: TokenData instance + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + sub: str = payload["sub"] + return cls(user_id=payload["user_id"], session_id=payload["session_id"]) + except (JWTError, KeyError): + raise ValueError("Invalid Token") + + +class User(BaseModel, DBModel): + """ + id SERIAL PRIMARY KEY, + created timestamp with time zone NOT NULL DEFAULT now(), + created_ip inet NOT NULL, + email text UNIQUE NOT NULL, + enabled boolean NOT NULL DEFAULT true, + name text, + data jsonb + """ + + id: Optional[int] = None + created: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) + created_ip: Optional[Union[IPv4Address, IPv6Address]] = None + email: str + enabled: bool = True + name: Optional[str] = None + data: Dict[str, Any] = {} + + @classmethod + def from_token(cls, token: TokenData) -> "User": + return cls.by_id(id=token.user_id) + + @classmethod + def by_id(cls, id: int) -> "User": + return cls(id=id, email="").lookup() + + def lookup(self) -> "User": + """ + Refresh this instance from database. + + Prefer lookup by id, if specified. Otherwise lookup by email address. + + :return: User instance if email is found + :raise: KeyError if id or email is not found + """ + param = self.email + condition = "email = %s" + if self.id is not None: + param = self.id + condition = "id = %s" + query = """ + SELECT id, created, created_ip, email, enabled, name, data + FROM users + WHERE {} + LIMIT 1 + """.format( + condition + ) + with self.conn() as conn: + c: psycopg2.extensions.cursor = conn.cursor() + c.execute(query, (param,)) + if c.rowcount < 1: + raise KeyError("User not found") + row = c.fetchone() + ( + self.id, + self.created, + created_ip, + self.email, + self.enabled, + self.name, + data, + ) = row + self.created_ip = ip_address(created_ip) + self.data = data or {} + return self + + def save(self): + """ + Persist data from this instance to the database. + + :return: self + """ + params = [ + self.created, + str(self.created_ip), + self.email, + self.enabled, + self.name, + json.dumps(self.data) if self.data else None, + ] + if self.id is None: + query = """ + INSERT INTO users (created, created_ip, email, enabled, name, data) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """ + else: + query = """ + UPDATE users + SET created = %s, + created_ip = %s, + email = %s, + enabled = %s, + name = %s, + data = %s + WHERE id = %s + """ + params.append(self.id) + with self.conn() as conn: + c: psycopg2.extensions.cursor = conn.cursor() + c.execute(query, params) + if self.id is None: + self.id = c.fetchone()[0] + conn.commit() + return self + + def login(self, ip) -> str: + """ + Request login for the given user. + + A given user can only have one active OTP at any given time. Subsequent + logins will invalidate previously issued OTPs. + + :param ip: IP address of the request (auth must match!) + :return: otp used to authenticate the session + """ + invalidate_query = """ + UPDATE otp + SET expires = NOW() + WHERE user_id = %s AND + NOW() < expires; + """ + create_params = [ + self.id, + str(ip), + ] + create_query = """ + INSERT INTO otp (user_id, ip) + VALUES (%s, %s) + RETURNING otp; + """ + with self.conn() as conn: + c: psycopg2.extensions.cursor = conn.cursor() + c.execute(invalidate_query, (self.id,)) + c.execute(create_query, create_params) + otp = c.fetchone()[0] + conn.commit() + return otp + + def authenticate(self, ip, otp) -> TokenData: + """ + Validate OTP and generate a session token. + + :param ip: IP requesting authentication must match IP passed to login() + :param otp: One time password + :return: TokenData + :raise: ValueError if OTP doesn't match or is expired + """ + query = """ + UPDATE otp + SET expires = NOW() + WHERE + user_id = %s AND + ip = %s AND + otp = %s AND + NOW() < expires + RETURNING id + """ + with self.conn() as conn: + c: psycopg2.extensions.cursor = conn.cursor() + c.execute(query, (self.id, ip, otp)) + if c.rowcount < 1: + raise ValueError("Invalid OTP") + valid_id = c.fetchone()[0] + conn.commit() + return TokenData(user_id=self.id, session_id=valid_id) + + +async def get_token(jwt_raw: str = Depends(oauth2_scheme)) -> TokenData: + """ + Depends returns a decoded TokenData (or raises Exception). + + :param jwt_raw: auth token from oauth2 bearer + :return: TokenData instance + """ + return TokenData.from_jwt(jwt_raw) + + +async def get_current(token_data: TokenData = Depends(get_token)) -> User: + """ + Fetch the User from the oauth session token. + + :param token_data: TokenData from `get_token` + :return: User + """ + return User.from_token(token_data) + + +async def get_current_active(current: User = Depends(get_current)) -> User: + """ + Provide an enabled User from the oauth session token (or raise HTTP 400). + + :param current: User from `get_current` + :return: User + """ + if not current.enabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current From 947e4171a2d51654922878da00be373f119de21e Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 08:49:26 -0800 Subject: [PATCH 04/20] src/mycodeplug: OTP is hashed via passlib/bcrypt Instead of storing the bare OTP in the database, store the hash, like a reasonable person, and do all comparisons against the salted hash. --- poetry.lock | 187 +++++++++++++++++++++++++++++++-------- pyproject.toml | 1 + src/mycodeplug/user.py | 67 ++++++++------ src/schema/20_schema.sql | 7 +- 4 files changed, 195 insertions(+), 67 deletions(-) diff --git a/poetry.lock b/poetry.lock index c06983a..e36c821 100644 --- a/poetry.lock +++ b/poetry.lock @@ -48,6 +48,33 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +[[package]] +name = "bcrypt" +version = "3.2.0" +description = "Modern password hashing for your software and your servers" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + +[[package]] +name = "cffi" +version = "1.15.0" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.0.3" @@ -135,6 +162,23 @@ python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2,<3" +[[package]] +name = "passlib" +version = "1.7.4" +description = "comprehensive password hashing framework supporting over 30 schemes" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +bcrypt = {version = ">=3.1.0", optional = true, markers = "extra == \"bcrypt\""} + +[package.extras] +argon2 = ["argon2-cffi (>=18.2.0)"] +bcrypt = ["bcrypt (>=3.1.0)"] +build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"] +totp = ["cryptography"] + [[package]] name = "pluggy" version = "1.0.0" @@ -149,7 +193,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "psycopg2-binary" -version = "2.9.1" +version = "2.9.2" description = "psycopg2 - Python-PostgreSQL Database Adapter" category = "main" optional = false @@ -171,6 +215,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pydantic" version = "1.8.2" @@ -320,7 +372,7 @@ standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6 [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "5aa2d9763c1e613f1e3b747d17beb6410685ef6989588dc6b24169dc795e8b3b" +content-hash = "7ce81a700934c94b337992dfd2e6956c744faab1acce6f233ec20c8f20c8258f" [metadata.files] anyio = [ @@ -339,6 +391,67 @@ attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] +bcrypt = [ + {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"}, + {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"}, + {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"}, + {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"}, + {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, +] +cffi = [ + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, +] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, @@ -371,47 +484,41 @@ packaging = [ {file = "packaging-21.2-py3-none-any.whl", hash = "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0"}, {file = "packaging-21.2.tar.gz", hash = "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966"}, ] +passlib = [ + {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"}, + {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"}, +] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] psycopg2-binary = [ - {file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:24b0b6688b9f31a911f2361fe818492650795c9e5d3a1bc647acbd7440142a4f"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:542875f62bc56e91c6eac05a0deadeae20e1730be4c6334d8f04c944fcd99759"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661509f51531ec125e52357a489ea3806640d0ca37d9dada461ffc69ee1e7b6e"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:d92272c7c16e105788efe2cfa5d680f07e34e0c29b03c1908f8636f55d5f915a"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:736b8797b58febabb85494142c627bd182b50d2a7ec65322983e71065ad3034c"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-win32.whl", hash = "sha256:ebccf1123e7ef66efc615a68295bf6fdba875a75d5bba10a05073202598085fc"}, - {file = "psycopg2_binary-2.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:1f6ca4a9068f5c5c57e744b4baa79f40e83e3746875cac3c45467b16326bab45"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aef9aee84ec78af51107181d02fe8773b100b01c5dfde351184ad9223eab3698"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123c3fb684e9abfc47218d3784c7b4c47c8587951ea4dd5bc38b6636ac57f616"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:995fc41ebda5a7a663a254a1dcac52638c3e847f48307b5416ee373da15075d7"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:fbb42a541b1093385a2d8c7eec94d26d30437d0e77c1d25dae1dcc46741a385e"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-win32.whl", hash = "sha256:20f1ab44d8c352074e2d7ca67dc00843067788791be373e67a0911998787ce7d"}, - {file = "psycopg2_binary-2.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f6fac64a38f6768e7bc7b035b9e10d8a538a9fadce06b983fb3e6fa55ac5f5ce"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:1e3a362790edc0a365385b1ac4cc0acc429a0c0d662d829a50b6ce743ae61b5a"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8559617b1fcf59a9aedba2c9838b5b6aa211ffedecabca412b92a1ff75aac1a"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a36c7eb6152ba5467fb264d73844877be8b0847874d4822b7cf2d3c0cb8cdcb0"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:2f62c207d1740b0bde5c4e949f857b044818f734a3d57f1d0d0edc65050532ed"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cfc523edecddaef56f6740d7de1ce24a2fdf94fd5e704091856a201872e37f9f"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-win32.whl", hash = "sha256:1e85b74cbbb3056e3656f1cc4781294df03383127a8114cbc6531e8b8367bf1e"}, - {file = "psycopg2_binary-2.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1473c0215b0613dd938db54a653f68251a45a78b05f6fc21af4326f40e8360a2"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:35c4310f8febe41f442d3c65066ca93cccefd75013df3d8c736c5b93ec288140"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c13d72ed6af7fd2c8acbd95661cf9477f94e381fce0792c04981a8283b52917"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14db1752acdd2187d99cb2ca0a1a6dfe57fc65c3281e0f20e597aac8d2a5bd90"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:aed4a9a7e3221b3e252c39d0bf794c438dc5453bc2963e8befe9d4cd324dff72"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:da113b70f6ec40e7d81b43d1b139b9db6a05727ab8be1ee559f3a69854a69d34"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-win32.whl", hash = "sha256:4235f9d5ddcab0b8dbd723dca56ea2922b485ea00e1dafacf33b0c7e840b3d32"}, - {file = "psycopg2_binary-2.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:988b47ac70d204aed01589ed342303da7c4d84b56c2f4c4b8b00deda123372bf"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:7360647ea04db2e7dff1648d1da825c8cf68dc5fbd80b8fb5b3ee9f068dcd21a"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca86db5b561b894f9e5f115d6a159fff2a2570a652e07889d8a383b5fae66eb4"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ced67f1e34e1a450cdb48eb53ca73b60aa0af21c46b9b35ac3e581cf9f00e31"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:0f2e04bd2a2ab54fa44ee67fe2d002bb90cee1c0f1cc0ebc3148af7b02034cbd"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:3242b9619de955ab44581a03a64bdd7d5e470cc4183e8fcadd85ab9d3756ce7a"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-win32.whl", hash = "sha256:0b7dae87f0b729922e06f85f667de7bf16455d411971b2043bbd9577af9d1975"}, - {file = "psycopg2_binary-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4d7679a08fea64573c969f6994a2631908bb2c0e69a7235648642f3d2e39a68"}, + {file = "psycopg2-binary-2.9.2.tar.gz", hash = "sha256:234b1f48488b2f86aac04fb00cb04e5e9bcb960f34fa8a8e41b73149d581a93b"}, + {file = "psycopg2_binary-2.9.2-cp310-cp310-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c0e1fb7097ded2cc44d9037cfc68ad86a30341261492e7de95d180e534969fb2"}, + {file = "psycopg2_binary-2.9.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:717525cdc97b23182ff6f470fb5bf6f0bc796b5a7000c6f6699d6679991e4a5e"}, + {file = "psycopg2_binary-2.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3865d0cd919349c45603bd7e80249a382c5ecf8106304cfd153282adf9684b6a"}, + {file = "psycopg2_binary-2.9.2-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:daf6b5c62eb738872d61a1fa740d7768904911ba5a7e055ed72169d379b58beb"}, + {file = "psycopg2_binary-2.9.2-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:3ac83656ff4fbe7f2a956ab085e3eb1d678df54759965d509bdd6a06ce520d49"}, + {file = "psycopg2_binary-2.9.2-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:53912199abb626a7249c662e72b70b4f57bf37f840599cec68625171435790dd"}, + {file = "psycopg2_binary-2.9.2-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:029e09a892b9ebc3c77851f69ce0720e1b72a9c6850460cee49b14dfbf9ccdd2"}, + {file = "psycopg2_binary-2.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db1b03c189f85b8df29030ad32d521dd7dcb862fd5f8892035314f5b886e70ce"}, + {file = "psycopg2_binary-2.9.2-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:2eecbdc5fa5886f2dd6cc673ce4291cc0fb8900965315268960ad9c2477f8276"}, + {file = "psycopg2_binary-2.9.2-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:a77e98c68b0e6c51d4d6a994d22b30e77276cbd33e4aabdde03b9ad3a2c148aa"}, + {file = "psycopg2_binary-2.9.2-cp37-cp37m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:a507db7758953b1b170c4310691a1a89877029b1e11b08ba5fc8ae3ddb35596b"}, + {file = "psycopg2_binary-2.9.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e4bbcfb403221ea1953f3e0a85cef00ed15c1683a66cf35c956a7e37c33a4c4"}, + {file = "psycopg2_binary-2.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4dff0f15af6936c6fe6da7067b4216edbbe076ad8625da819cc066591b1133c"}, + {file = "psycopg2_binary-2.9.2-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:8d2aafe46eb87742425ece38130510fbb035787ee89a329af299029c4d9ae318"}, + {file = "psycopg2_binary-2.9.2-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:37c8f00f7a2860bac9f7a54f03c243fc1dd9b367e5b2b52f5a02e5f4e9d8c49b"}, + {file = "psycopg2_binary-2.9.2-cp38-cp38-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:eeee7b18c51d02e49bf1984d7af26e8843fe68e31fa1cbab5366ebdfa1c89ade"}, + {file = "psycopg2_binary-2.9.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:497372cc76e6cbce2f51b37be141f360a321423c03eb9be45524b1d123f4cd11"}, + {file = "psycopg2_binary-2.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5671699aff57d22a245b7f4bba89e3de97dc841c5e98bd7f685429b2b20eca47"}, + {file = "psycopg2_binary-2.9.2-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:b9d45374ba98c1184df9cce93a0b766097544f8bdfcd5de83ff10f939c193125"}, + {file = "psycopg2_binary-2.9.2-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:a1852c5bef7e5f52bd43fde5eda610d4df0fb2efc31028150933e84b4140d47a"}, + {file = "psycopg2_binary-2.9.2-cp39-cp39-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:578c279cd1ce04f05ae0912530ece00bab92854911808e5aec27588aba87e361"}, + {file = "psycopg2_binary-2.9.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2dea4deac3dd3687e32daeb0712ee96c535970dfdded37a11de6a21145ab0e"}, + {file = "psycopg2_binary-2.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b592f09ff18cfcc9037b9a976fcd62db48cae9dbd5385f2471d4c2ba40c52b4d"}, + {file = "psycopg2_binary-2.9.2-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:3a320e7a804f3886a599fea507364aaafbb8387027fffcdfbd34d96316c806c7"}, + {file = "psycopg2_binary-2.9.2-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:7585ca73dcfe326f31fafa8f96e6bb98ea9e9e46c7a1924ec8101d797914ae27"}, ] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, @@ -432,6 +539,10 @@ pyasn1 = [ {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, ] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] pydantic = [ {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, diff --git a/pyproject.toml b/pyproject.toml index 57f8ad9..87b2c88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ psycopg2-binary = "^2.9.1" uvicorn = "^0.15.0" python-jose = "^3.3.0" python-multipart = "^0.0.5" +passlib = {extras = ["bcrypt"], version = "^1.7.4"} [tool.poetry.dev-dependencies] pytest = "^6.2.5" diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 9b86689..5fcc6d9 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -5,12 +5,14 @@ from ipaddress import IPv4Address, IPv6Address, ip_address import json import os +import random from typing import Any, Dict, Optional, Union import uuid from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import JWTError, jwt +from passlib.context import CryptContext import psycopg2.extensions import psycopg2.extras from pydantic import BaseModel @@ -21,8 +23,9 @@ SECRET_KEY = os.environ["SECRET_KEY"] ALGORITHM = "HS256" DEFAULT_EXPIRY = datetime.timedelta(days=14) -OTP_VALIDITY_PERIOD = datetime.timedelta(minutes=30) +OTP_VALIDITY_PERIOD = datetime.timedelta(minutes=5) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") class Token(BaseModel): @@ -175,6 +178,22 @@ def save(self): conn.commit() return self + def _find_token(self, ip) -> (id, str): + get_query = """ + SELECT id, otp + FROM otp + WHERE + user_id = %s AND + ip = %s AND + NOW() < expires + """ + with self.conn() as conn: + c: psycopg2.extensions.cursor = conn.cursor() + c.execute(get_query, (self.id, ip)) + if c.rowcount != 1: + raise ValueError("Expired token") + return c.fetchone() + def login(self, ip) -> str: """ Request login for the given user. @@ -185,28 +204,28 @@ def login(self, ip) -> str: :param ip: IP address of the request (auth must match!) :return: otp used to authenticate the session """ - invalidate_query = """ - UPDATE otp - SET expires = NOW() - WHERE user_id = %s AND - NOW() < expires; - """ + try: + old_token_data = self._find_token(ip) + except ValueError: + old_token_data = None + if old_token_data: + raise ValueError("Previous token still active") + new_otp = "{:06}".format(random.randint(0,999999)) + hashed_otp = pwd_context.hash(new_otp) create_params = [ self.id, str(ip), + hashed_otp, ] create_query = """ - INSERT INTO otp (user_id, ip) - VALUES (%s, %s) - RETURNING otp; + INSERT INTO otp (user_id, ip, otp) + VALUES (%s, %s, %s); """ with self.conn() as conn: c: psycopg2.extensions.cursor = conn.cursor() - c.execute(invalidate_query, (self.id,)) c.execute(create_query, create_params) - otp = c.fetchone()[0] conn.commit() - return otp + return new_otp def authenticate(self, ip, otp) -> TokenData: """ @@ -217,24 +236,22 @@ def authenticate(self, ip, otp) -> TokenData: :return: TokenData :raise: ValueError if OTP doesn't match or is expired """ - query = """ + token_id, hashed_otp = self._find_token(ip=ip) + + if not pwd_context.verify(otp, hashed_otp): + raise ValueError("Bad token") + + update_query = """ UPDATE otp SET expires = NOW() - WHERE - user_id = %s AND - ip = %s AND - otp = %s AND - NOW() < expires - RETURNING id + WHERE + id = %s """ with self.conn() as conn: c: psycopg2.extensions.cursor = conn.cursor() - c.execute(query, (self.id, ip, otp)) - if c.rowcount < 1: - raise ValueError("Invalid OTP") - valid_id = c.fetchone()[0] + c.execute(update_query, (token_id,)) conn.commit() - return TokenData(user_id=self.id, session_id=valid_id) + return TokenData(user_id=self.id, session_id=token_id) async def get_token(jwt_raw: str = Depends(oauth2_scheme)) -> TokenData: diff --git a/src/schema/20_schema.sql b/src/schema/20_schema.sql index 762f6e4..4609f66 100644 --- a/src/schema/20_schema.sql +++ b/src/schema/20_schema.sql @@ -26,12 +26,11 @@ CREATE TABLE IF NOT EXISTS otp ( user_id int references users(id) NOT NULL, ts timestamp with time zone NOT NULL DEFAULT now(), ip inet NOT NULL, - expires timestamp with time zone NOT NULL DEFAULT (now() + '30 minutes'::interval), - otp text DEFAULT gen_random_uuid() + expires timestamp with time zone NOT NULL DEFAULT (now() + '5 minutes'::interval), + otp text NOT NULL ); -- XXX: should readonly user have access to otp? - -CREATE INDEX otp_user_id_ip_otp ON otp(user_id, ip, otp); +CREATE INDEX otp_user_id_ip_otp ON otp(user_id, ip); CREATE TABLE IF NOT EXISTS channel ( channel_uuid uuid DEFAULT gen_random_uuid() PRIMARY KEY, From 55e69620d96f3c0ab94596126747097205a7b923 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 10:23:00 -0800 Subject: [PATCH 05/20] mycodeplug/user.py: raise custom exceptions Raise HTTPException derived custom exceptions to help avoid information leakage from the authentication part of the app. Allow exceptions raised deeply to be bubbled to FastAPI directly to avoid tedious mapping layers. --- src/mycodeplug/user.py | 74 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 5fcc6d9..32dc50f 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -1,5 +1,5 @@ """ -User and Session management. +User, Authentication and Session management. """ import datetime from ipaddress import IPv4Address, IPv6Address, ip_address @@ -28,6 +28,49 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +class AuthenticationError(HTTPException): + DEFAULT_STATUS_CODE = 401 # Unauthorized + DEFAULT_DETAIL = "Incorrect email or password" + """The response seen by the user, NO SENSITIVE DATA!""" + + def __init__( + self, + ctx: str = "", + **kwargs + ): + self.ctx = ctx + super().__init__( + status_code=kwargs.pop("status_code", self.DEFAULT_STATUS_CODE), + detail=kwargs.pop("detail", self.DEFAULT_DETAIL), + **kwargs, + ) + + +class InactiveUser(AuthenticationError): + DEFAULT_STATUS_CODE = 400 + DEFAULT_DETAIL = "User is disabled or inactive. Contact admin." + + +class UnknownUser(AuthenticationError, KeyError): + """The requested user was not found.""" + + +class ExpiredToken(AuthenticationError): + """The JWT presented is expired or the signature cannot be verified.""" + + +class InvalidToken(AuthenticationError): + """The token is otherwise valid but malformed -- maybe from a previous version.""" + + +class ExpiredOTP(AuthenticationError): + """The OTP for this user/ip combination is not valid or nonexistant.""" + + +class IncorrectOTP(AuthenticationError): + """Valid OTP for this user/ip was found, but the provided value does not match.""" + + class Token(BaseModel): access_token: str token_type: str @@ -66,8 +109,10 @@ def from_jwt(cls, token: str) -> "TokenData": payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) sub: str = payload["sub"] return cls(user_id=payload["user_id"], session_id=payload["session_id"]) - except (JWTError, KeyError): - raise ValueError("Invalid Token") + except JWTError: + raise ExpiredToken("Expired Token or signature mismatch") + except KeyError: + raise InvalidToken("Invalid Token format: {}".format(payload)) class User(BaseModel, DBModel): @@ -104,7 +149,7 @@ def lookup(self) -> "User": Prefer lookup by id, if specified. Otherwise lookup by email address. :return: User instance if email is found - :raise: KeyError if id or email is not found + :raise: UnknownUser if id or email is not found """ param = self.email condition = "email = %s" @@ -123,7 +168,7 @@ def lookup(self) -> "User": c: psycopg2.extensions.cursor = conn.cursor() c.execute(query, (param,)) if c.rowcount < 1: - raise KeyError("User not found") + raise UnknownUser(condition % param) row = c.fetchone() ( self.id, @@ -178,7 +223,7 @@ def save(self): conn.commit() return self - def _find_token(self, ip) -> (id, str): + def _find_token(self, ip) -> (int, str): get_query = """ SELECT id, otp FROM otp @@ -191,7 +236,7 @@ def _find_token(self, ip) -> (id, str): c: psycopg2.extensions.cursor = conn.cursor() c.execute(get_query, (self.id, ip)) if c.rowcount != 1: - raise ValueError("Expired token") + raise ExpiredOTP("Expired token for {}".format(ip)) return c.fetchone() def login(self, ip) -> str: @@ -203,13 +248,15 @@ def login(self, ip) -> str: :param ip: IP address of the request (auth must match!) :return: otp used to authenticate the session + :raise: IncorrectOTP if the given (user, ip) pair already has + an active token (must wait before granting another). """ try: old_token_data = self._find_token(ip) - except ValueError: + if old_token_data: + raise IncorrectOTP(detail="Previous token still active") + except ExpiredOTP: old_token_data = None - if old_token_data: - raise ValueError("Previous token still active") new_otp = "{:06}".format(random.randint(0,999999)) hashed_otp = pwd_context.hash(new_otp) create_params = [ @@ -234,12 +281,13 @@ def authenticate(self, ip, otp) -> TokenData: :param ip: IP requesting authentication must match IP passed to login() :param otp: One time password :return: TokenData - :raise: ValueError if OTP doesn't match or is expired + :raise: ExpiredOTP if the OTP doesn't exist or is expired + :raise: IncorrectOTP if OTP is found, but doesn't match """ token_id, hashed_otp = self._find_token(ip=ip) if not pwd_context.verify(otp, hashed_otp): - raise ValueError("Bad token") + raise IncorrectOTP("Bad token") update_query = """ UPDATE otp @@ -282,5 +330,5 @@ async def get_current_active(current: User = Depends(get_current)) -> User: :return: User """ if not current.enabled: - raise HTTPException(status_code=400, detail="Inactive user") + raise InactiveUser() return current From 59fde487d1d76c6b45ebc658d6b1a2197f251605 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 10:23:40 -0800 Subject: [PATCH 06/20] mycodeplug/api.py: enable python logging leverage existing uvicorn stream handlers to inject custom log messages --- src/mycodeplug/api.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index f4dec28..50ec35a 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -1,4 +1,6 @@ import importlib.metadata +import logging +import os from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm @@ -10,6 +12,13 @@ app = FastAPI() +# set logging based on MYCODEPLUG_LOGLEVEL +app_loglevel = getattr(logging, os.environ.get("MYCODEPLUG_LOGLEVEL", "INFO").upper()) +uvicorn_logger = logging.getLogger("uvicorn") +logger = logging.getLogger(APP_NAME) +logger.setLevel(app_loglevel) +logger.handlers = uvicorn_logger.handlers + class RootData(BaseModel): """ From 75f18186c7803afbe68d1e7b8b6b70c7e707bb51 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 10:24:17 -0800 Subject: [PATCH 07/20] mycodeplug/api.py: don't map exceptions Allow the exceptions from mycodeplug.user to bubble up and be converted into HTTP responses --- src/mycodeplug/api.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index 50ec35a..d273028 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -6,7 +6,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel -from .user import get_current_active, Token, User +from .user import AuthenticationError, get_current_active, Token, UnknownUser, User APP_NAME = "mycodeplug" @@ -58,10 +58,11 @@ async def login(email: str, request: Request): """ try: user = User(email=email).lookup() - except KeyError: - raise HTTPException(status_code=400, detail="Incorrect username or password") + except UnknownUser: + user = User(email=email, created_ip=request.client.host).save() + logger.info("Created a new user for {}".format(email)) # XXX: send s.otp via email! - print("{} magic token is: {}".format(user.name, user.login(request.client.host))) + logger.warning("{} magic token is: {}".format(user.name, user.login(request.client.host))) return @@ -74,17 +75,13 @@ def _token(email: str, otp: str, request: Request) -> Token: :param request: the request, must match the IP that requested /login :return: oauth Token """ - try: - token_data = ( - User(email=email).lookup().authenticate(ip=request.client.host, otp=otp) - ) - return Token( - access_token=token_data.to_jwt(), - token_type="bearer", - ) - except (KeyError, ValueError): - pass - raise HTTPException(status_code=400, detail="Incorrect username or password") + token_data = ( + User(email=email).lookup().authenticate(ip=request.client.host, otp=otp) + ) + return Token( + access_token=token_data.to_jwt(), + token_type="bearer", + ) @app.post("/token", response_model=Token) From a2523195e8f2f94adbc95fe33658dc03f8da01a2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 11:13:06 -0800 Subject: [PATCH 08/20] mycodeplug/user.py: create "admin" users admin users can modify other users and edit objects that don't belong to them --- src/mycodeplug/user.py | 14 +++++++++----- src/schema/20_schema.sql | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 32dc50f..3fbf61d 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -122,6 +122,7 @@ class User(BaseModel, DBModel): created_ip inet NOT NULL, email text UNIQUE NOT NULL, enabled boolean NOT NULL DEFAULT true, + admin boolean NOT NULL DEFAULT false, name text, data jsonb """ @@ -131,6 +132,7 @@ class User(BaseModel, DBModel): created_ip: Optional[Union[IPv4Address, IPv6Address]] = None email: str enabled: bool = True + admin: bool = False name: Optional[str] = None data: Dict[str, Any] = {} @@ -157,7 +159,7 @@ def lookup(self) -> "User": param = self.id condition = "id = %s" query = """ - SELECT id, created, created_ip, email, enabled, name, data + SELECT id, created, created_ip, email, enabled, admin, name, data FROM users WHERE {} LIMIT 1 @@ -176,6 +178,7 @@ def lookup(self) -> "User": created_ip, self.email, self.enabled, + self.admin, self.name, data, ) = row @@ -194,13 +197,14 @@ def save(self): str(self.created_ip), self.email, self.enabled, + self.admin, self.name, json.dumps(self.data) if self.data else None, ] if self.id is None: query = """ - INSERT INTO users (created, created_ip, email, enabled, name, data) - VALUES (%s, %s, %s, %s, %s, %s) + INSERT INTO users (created, created_ip, email, enabled, admin, name, data) + VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """ else: @@ -210,6 +214,7 @@ def save(self): created_ip = %s, email = %s, enabled = %s, + admin = %s, name = %s, data = %s WHERE id = %s @@ -243,8 +248,7 @@ def login(self, ip) -> str: """ Request login for the given user. - A given user can only have one active OTP at any given time. Subsequent - logins will invalidate previously issued OTPs. + A given user can only have one active OTP at any given time. :param ip: IP address of the request (auth must match!) :return: otp used to authenticate the session diff --git a/src/schema/20_schema.sql b/src/schema/20_schema.sql index 4609f66..ccac2bd 100644 --- a/src/schema/20_schema.sql +++ b/src/schema/20_schema.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users ( created_ip inet NOT NULL, email text UNIQUE NOT NULL, enabled boolean NOT NULL DEFAULT true, + admin boolean NOT NULL DEFAULT false, name text, data jsonb ); From ad79e547a8d5956661595979e5ffa2c970f5c429 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 11:15:08 -0800 Subject: [PATCH 09/20] mycodeplug/user.py: otp_delivery dependendable Move OTP delivery (via email or otherwise) to a separate dependable that can be overridden for testing or to provide other means of delivery. --- src/mycodeplug/api.py | 15 ++++++++++----- src/mycodeplug/user.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index d273028..e8e23e3 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -6,7 +6,14 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel -from .user import AuthenticationError, get_current_active, Token, UnknownUser, User +from .user import ( + AuthenticationError, + get_current_active, + otp_delivery, + Token, + UnknownUser, + User, +) APP_NAME = "mycodeplug" @@ -38,7 +45,7 @@ async def root() -> RootData: @app.post("/login") -async def login(email: str, request: Request): +async def login(email: str, request: Request, deliver = Depends(otp_delivery)): """ Trigger a login request for the given email address. @@ -61,9 +68,7 @@ async def login(email: str, request: Request): except UnknownUser: user = User(email=email, created_ip=request.client.host).save() logger.info("Created a new user for {}".format(email)) - # XXX: send s.otp via email! - logger.warning("{} magic token is: {}".format(user.name, user.login(request.client.host))) - return + return deliver(user, user.login(ip=request.client.host)) def _token(email: str, otp: str, request: Request) -> Token: diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 3fbf61d..31d6ddc 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -336,3 +336,20 @@ async def get_current_active(current: User = Depends(get_current)) -> User: if not current.enabled: raise InactiveUser() return current + + +def otp_delivery(): + """ + :return: callable accepting a User and otp string, arranging for it to be sent to the user + """ + def deliver(user: User, otp: str): + # XXX: send s.otp via email! + print( + "{} magic token is: {}".format( + user.name or user.email, + otp, + ), + ) + return {"detail": "new OTP sent"} + + return deliver \ No newline at end of file From 288c52cdc791c3711fec44828fb0349dc0d57a88 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 11:15:58 -0800 Subject: [PATCH 10/20] mycodeplug/user.py: EditableUser models describe what user fields are user and admin editable --- src/mycodeplug/user.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 31d6ddc..7401328 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -306,6 +306,18 @@ def authenticate(self, ip, otp) -> TokenData: return TokenData(user_id=self.id, session_id=token_id) +class EditableUser(BaseModel): + """Components of the User that the User can edit""" + email: Optional[str] = None + name: Optional[str] = None + data: Dict[str, Any] = None + + +class AdminEditableUser(EditableUser): + """Components of the User that an admin can edit""" + enabled: Optional[bool] = True + + async def get_token(jwt_raw: str = Depends(oauth2_scheme)) -> TokenData: """ Depends returns a decoded TokenData (or raises Exception). From 14b79185515501334dcd71315dfaf297d34eeb70 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 11:17:26 -0800 Subject: [PATCH 11/20] mycodeplug/api.py: expose POST /users/me allow the user to edit fields of their own profile allow the email field to be edited only after reauthenticating with the new email address --- src/mycodeplug/api.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index e8e23e3..b223d05 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -1,6 +1,7 @@ import importlib.metadata import logging import os +from typing import Optional from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm @@ -8,6 +9,7 @@ from .user import ( AuthenticationError, + EditableUser, get_current_active, otp_delivery, Token, @@ -126,3 +128,20 @@ async def get_users_me(current_user: User = Depends(get_current_active)) -> User :return: User information """ return current_user + + +@app.post("/users/me") +async def post_users_me(data: EditableUser, request: Request, otp: Optional[str] = None, current_user: User = Depends(get_current_active), deliver = Depends(otp_delivery)): + updated_settings = data.dict(exclude_none=True, exclude_unset=True, exclude_defaults=True) + if "email" in updated_settings: + if otp is not None: + current_user.authenticate(ip=request.client.host, otp=otp) + else: + # handle email updates specially, to validate the new address + current_user.email = data.email + otp = current_user.login(ip=request.client.host) + deliver(current_user, otp) + return {"detail": "Resubmit request with updated OTP"} + for k, v in updated_settings.items(): + setattr(current_user, k, v) + current_user.save() \ No newline at end of file From 5a8e6e2fbcce5d2496c37a68dabe95f94fe298ff Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 12:43:43 -0800 Subject: [PATCH 12/20] mycodeplug: add httpx as a dependency will use for submitting external HTTP requests to other services --- poetry.lock | 91 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index e36c821..d83d990 100644 --- a/poetry.lock +++ b/poetry.lock @@ -64,6 +64,14 @@ six = ">=1.4.1" tests = ["pytest (>=3.2.1,!=3.3.0)"] typecheck = ["mypy"] +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "cffi" version = "1.15.0" @@ -75,6 +83,17 @@ python-versions = "*" [package.dependencies] pycparser = "*" +[[package]] +name = "charset-normalizer" +version = "2.0.7" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "click" version = "8.0.3" @@ -135,6 +154,42 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "httpcore" +version = "0.13.7" +description = "A minimal low-level HTTP client." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +anyio = ">=3.0.0,<4.0.0" +h11 = ">=0.11,<0.13" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] + +[[package]] +name = "httpx" +version = "0.20.0" +description = "The next generation HTTP client." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +certifi = "*" +charset-normalizer = "*" +httpcore = ">=0.13.3,<0.14.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotlicffi", "brotli"] +cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"] +http2 = ["h2 (>=3,<5)"] + [[package]] name = "idna" version = "3.3" @@ -296,6 +351,20 @@ python-versions = "*" [package.dependencies] six = ">=1.4.0" +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + [[package]] name = "rsa" version = "4.7.2" @@ -372,7 +441,7 @@ standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6 [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "7ce81a700934c94b337992dfd2e6956c744faab1acce6f233ec20c8f20c8258f" +content-hash = "7e190d8b6757963053bcfc17e747fa4faacc41b0b44fe0dfab1cb80c0bb85551" [metadata.files] anyio = [ @@ -400,6 +469,10 @@ bcrypt = [ {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"}, {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"}, ] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] cffi = [ {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, @@ -452,6 +525,10 @@ cffi = [ {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, ] +charset-normalizer = [ + {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, + {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"}, +] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, @@ -472,6 +549,14 @@ h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, ] +httpcore = [ + {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"}, + {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"}, +] +httpx = [ + {file = "httpx-0.20.0-py3-none-any.whl", hash = "sha256:33af5aad9bdc82ef1fc89219c1e36f5693bf9cd0ebe330884df563445682c0f8"}, + {file = "httpx-0.20.0.tar.gz", hash = "sha256:09606d630f070d07f9ff28104fbcea429ea0014c1e89ac90b4d8de8286c40e7b"}, +] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, @@ -582,6 +667,10 @@ python-jose = [ python-multipart = [ {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, ] +rfc3986 = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, +] rsa = [ {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"}, {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"}, diff --git a/pyproject.toml b/pyproject.toml index 87b2c88..f0fb663 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ uvicorn = "^0.15.0" python-jose = "^3.3.0" python-multipart = "^0.0.5" passlib = {extras = ["bcrypt"], version = "^1.7.4"} +httpx = "^0.20.0" [tool.poetry.dev-dependencies] pytest = "^6.2.5" From 8c96fd254574a67503996c6b79c34d19bfbd8ca7 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 12:45:05 -0800 Subject: [PATCH 13/20] mycodeplug/mail.py: send OTP token via mailgun if MG_TOKEN and MG_DOMAIN are not in the environ then fall back to the "print to console" method of delivering the OTP from the user module (now called "local_otp_delivery"). --- src/mycodeplug/api.py | 26 ++++++++--------- src/mycodeplug/logging.py | 13 +++++++++ src/mycodeplug/mail.py | 60 +++++++++++++++++++++++++++++++++++++++ src/mycodeplug/user.py | 6 ++-- 4 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 src/mycodeplug/logging.py create mode 100644 src/mycodeplug/mail.py diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index b223d05..4121b09 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -1,5 +1,4 @@ import importlib.metadata -import logging import os from typing import Optional @@ -7,11 +6,12 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel +from .logging import getLogger +from .mail import otp_delivery from .user import ( AuthenticationError, EditableUser, get_current_active, - otp_delivery, Token, UnknownUser, User, @@ -20,13 +20,7 @@ APP_NAME = "mycodeplug" app = FastAPI() - -# set logging based on MYCODEPLUG_LOGLEVEL -app_loglevel = getattr(logging, os.environ.get("MYCODEPLUG_LOGLEVEL", "INFO").upper()) -uvicorn_logger = logging.getLogger("uvicorn") -logger = logging.getLogger(APP_NAME) -logger.setLevel(app_loglevel) -logger.handlers = uvicorn_logger.handlers +logger = getLogger(APP_NAME) class RootData(BaseModel): @@ -47,7 +41,7 @@ async def root() -> RootData: @app.post("/login") -async def login(email: str, request: Request, deliver = Depends(otp_delivery)): +def login(email: str, request: Request, deliver = Depends(otp_delivery)): """ Trigger a login request for the given email address. @@ -92,7 +86,7 @@ def _token(email: str, otp: str, request: Request) -> Token: @app.post("/token", response_model=Token) -async def token( +def token( request: Request, form_data: OAuth2PasswordRequestForm = Depends() ) -> Token: """ @@ -106,7 +100,7 @@ async def token( @app.get("/magic/{email}/{otp}", response_model=Token) -async def magic(email: str, otp: str, request: Request) -> Token: +def magic(email: str, otp: str, request: Request) -> Token: """ Magic link login: XXX: Needs to be a JS application to save the token client side. @@ -131,7 +125,13 @@ async def get_users_me(current_user: User = Depends(get_current_active)) -> User @app.post("/users/me") -async def post_users_me(data: EditableUser, request: Request, otp: Optional[str] = None, current_user: User = Depends(get_current_active), deliver = Depends(otp_delivery)): +def post_users_me( + data: EditableUser, + request: Request, + otp: Optional[str] = None, + current_user: User = Depends(get_current_active), + deliver = Depends(otp_delivery), +): updated_settings = data.dict(exclude_none=True, exclude_unset=True, exclude_defaults=True) if "email" in updated_settings: if otp is not None: diff --git a/src/mycodeplug/logging.py b/src/mycodeplug/logging.py new file mode 100644 index 0000000..ecbfcea --- /dev/null +++ b/src/mycodeplug/logging.py @@ -0,0 +1,13 @@ +import os +import logging + +# set logging based on MYCODEPLUG_LOGLEVEL +app_loglevel = getattr(logging, os.environ.get("MYCODEPLUG_LOGLEVEL", "INFO").upper()) +uvicorn_logger = logging.getLogger("uvicorn") + + +def getLogger(*args, **kwargs) -> logging.Logger: + logger = logging.getLogger(*args, **kwargs) + logger.setLevel(app_loglevel) + logger.handlers = uvicorn_logger.handlers + return logger diff --git a/src/mycodeplug/mail.py b/src/mycodeplug/mail.py new file mode 100644 index 0000000..a641747 --- /dev/null +++ b/src/mycodeplug/mail.py @@ -0,0 +1,60 @@ +""" +Handle email in and out of the application +""" +import os +from urllib.parse import urljoin + +import httpx + +from .logging import getLogger +from .user import User + + +BASE_URL = os.environ.get("BASE_URL") +DOMAIN = os.environ.get("MG_DOMAIN") +TOKEN = os.environ.get("MG_TOKEN") +MG_API = f"https://api.mailgun.net/v3/{DOMAIN}/messages" +FROM = "MyCodeplug.com " +OTP_MESSAGE = { + "from": FROM, + "subject": "Click this link to login", + "text": """{user}, +Your one-time password is {otp}. This password expires in 5 minutes. + +Use the following link to login: {link} + +-MyCodeplug +""" +} + +logger = getLogger(__name__) + + +def otp_delivery(): + """ + :return: callable accepting a User and otp string, arranging for it to be sent to the user + """ + def deliver(user: User, otp: str): + data = OTP_MESSAGE.copy() + data["to"] = user.email + data["text"] = data["text"].format( + user=user.name or user.email, + otp=otp, + link=urljoin(BASE_URL, "/magic/{}/{}".format(user.email, otp)), + ) + httpx.post( + MG_API, + data=data, + auth=("api", TOKEN) + ).raise_for_status() + return {"detail": "new OTP sent"} + + return deliver + + +if None in (TOKEN, DOMAIN): + logger.warning( + "Mailgun token and/or domain not available in environment. " + "Falling back to local/weak OTP delivery", + ) + from .user import local_otp_delivery as otp_delivery \ No newline at end of file diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 7401328..ee0b4a3 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -328,7 +328,7 @@ async def get_token(jwt_raw: str = Depends(oauth2_scheme)) -> TokenData: return TokenData.from_jwt(jwt_raw) -async def get_current(token_data: TokenData = Depends(get_token)) -> User: +def get_current(token_data: TokenData = Depends(get_token)) -> User: """ Fetch the User from the oauth session token. @@ -338,7 +338,7 @@ async def get_current(token_data: TokenData = Depends(get_token)) -> User: return User.from_token(token_data) -async def get_current_active(current: User = Depends(get_current)) -> User: +def get_current_active(current: User = Depends(get_current)) -> User: """ Provide an enabled User from the oauth session token (or raise HTTP 400). @@ -350,7 +350,7 @@ async def get_current_active(current: User = Depends(get_current)) -> User: return current -def otp_delivery(): +def local_otp_delivery(): """ :return: callable accepting a User and otp string, arranging for it to be sent to the user """ From 78cf67714312016c4ea85c6455e062085a3dd838 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 12:46:30 -0800 Subject: [PATCH 14/20] mycodeplug: blacken apply automatic formatting --- src/mycodeplug/api.py | 14 +++++++------- src/mycodeplug/mail.py | 11 ++++------- src/mycodeplug/user.py | 13 ++++++------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index 4121b09..3ab8684 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -41,7 +41,7 @@ async def root() -> RootData: @app.post("/login") -def login(email: str, request: Request, deliver = Depends(otp_delivery)): +def login(email: str, request: Request, deliver=Depends(otp_delivery)): """ Trigger a login request for the given email address. @@ -86,9 +86,7 @@ def _token(email: str, otp: str, request: Request) -> Token: @app.post("/token", response_model=Token) -def token( - request: Request, form_data: OAuth2PasswordRequestForm = Depends() -) -> Token: +def token(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> Token: """ Standard OAuth2 Password endpoint @@ -130,9 +128,11 @@ def post_users_me( request: Request, otp: Optional[str] = None, current_user: User = Depends(get_current_active), - deliver = Depends(otp_delivery), + deliver=Depends(otp_delivery), ): - updated_settings = data.dict(exclude_none=True, exclude_unset=True, exclude_defaults=True) + updated_settings = data.dict( + exclude_none=True, exclude_unset=True, exclude_defaults=True + ) if "email" in updated_settings: if otp is not None: current_user.authenticate(ip=request.client.host, otp=otp) @@ -144,4 +144,4 @@ def post_users_me( return {"detail": "Resubmit request with updated OTP"} for k, v in updated_settings.items(): setattr(current_user, k, v) - current_user.save() \ No newline at end of file + current_user.save() diff --git a/src/mycodeplug/mail.py b/src/mycodeplug/mail.py index a641747..bbecc42 100644 --- a/src/mycodeplug/mail.py +++ b/src/mycodeplug/mail.py @@ -24,7 +24,7 @@ Use the following link to login: {link} -MyCodeplug -""" +""", } logger = getLogger(__name__) @@ -34,6 +34,7 @@ def otp_delivery(): """ :return: callable accepting a User and otp string, arranging for it to be sent to the user """ + def deliver(user: User, otp: str): data = OTP_MESSAGE.copy() data["to"] = user.email @@ -42,11 +43,7 @@ def deliver(user: User, otp: str): otp=otp, link=urljoin(BASE_URL, "/magic/{}/{}".format(user.email, otp)), ) - httpx.post( - MG_API, - data=data, - auth=("api", TOKEN) - ).raise_for_status() + httpx.post(MG_API, data=data, auth=("api", TOKEN)).raise_for_status() return {"detail": "new OTP sent"} return deliver @@ -57,4 +54,4 @@ def deliver(user: User, otp: str): "Mailgun token and/or domain not available in environment. " "Falling back to local/weak OTP delivery", ) - from .user import local_otp_delivery as otp_delivery \ No newline at end of file + from .user import local_otp_delivery as otp_delivery diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index ee0b4a3..7d3b552 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -33,11 +33,7 @@ class AuthenticationError(HTTPException): DEFAULT_DETAIL = "Incorrect email or password" """The response seen by the user, NO SENSITIVE DATA!""" - def __init__( - self, - ctx: str = "", - **kwargs - ): + def __init__(self, ctx: str = "", **kwargs): self.ctx = ctx super().__init__( status_code=kwargs.pop("status_code", self.DEFAULT_STATUS_CODE), @@ -261,7 +257,7 @@ def login(self, ip) -> str: raise IncorrectOTP(detail="Previous token still active") except ExpiredOTP: old_token_data = None - new_otp = "{:06}".format(random.randint(0,999999)) + new_otp = "{:06}".format(random.randint(0, 999999)) hashed_otp = pwd_context.hash(new_otp) create_params = [ self.id, @@ -308,6 +304,7 @@ def authenticate(self, ip, otp) -> TokenData: class EditableUser(BaseModel): """Components of the User that the User can edit""" + email: Optional[str] = None name: Optional[str] = None data: Dict[str, Any] = None @@ -315,6 +312,7 @@ class EditableUser(BaseModel): class AdminEditableUser(EditableUser): """Components of the User that an admin can edit""" + enabled: Optional[bool] = True @@ -354,6 +352,7 @@ def local_otp_delivery(): """ :return: callable accepting a User and otp string, arranging for it to be sent to the user """ + def deliver(user: User, otp: str): # XXX: send s.otp via email! print( @@ -364,4 +363,4 @@ def deliver(user: User, otp: str): ) return {"detail": "new OTP sent"} - return deliver \ No newline at end of file + return deliver From fb5b0bc6a2c029eca5dc6b9513b7f704040bb1a3 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 16:48:34 -0800 Subject: [PATCH 15/20] mycodeplug: include sqlmodel Use sqlmodel to bring sqlalchemy and pydantic together, or, I'm sick of repeating myself and hand-writing queryies =-[ --- Dockerfile | 3 +- poetry.lock | 167 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ebb0d87..9112347 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY . /app WORKDIR /app RUN \ apk add --no-cache postgresql-libs && \ - apk add --no-cache --virtual .build-deps gcc musl-dev openssl-dev libffi-dev postgresql-dev && \ + apk add --no-cache --virtual .build-deps gcc g++ musl-dev openssl-dev libffi-dev postgresql-dev && \ python3 -m pip install --no-cache-dir poetry==1.1.11 && \ poetry install && \ apk --purge del .build-deps @@ -12,4 +12,5 @@ CMD ["poetry", "run", \ "uvicorn", \ "mycodeplug.api:app", "--reload", \ "--host", "0.0.0.0", "--port", "8009" \ +# "--proxy-headers" \ ] diff --git a/poetry.lock b/poetry.lock index d83d990..4220b59 100644 --- a/poetry.lock +++ b/poetry.lock @@ -146,6 +146,17 @@ dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,< doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] +[[package]] +name = "greenlet" +version = "1.1.2" +description = "Lightweight in-process concurrent programming" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.extras] +docs = ["sphinx"] + [[package]] name = "h11" version = "0.12.0" @@ -392,6 +403,62 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "sqlalchemy" +version = "1.4.27" +description = "Database Abstraction Library" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] +aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"] +mariadb_connector = ["mariadb (>=1.0.1)"] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"] +mysql_connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] +postgresql_pg8000 = ["pg8000 (>=1.16.6)"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql (<1)", "pymysql"] +sqlcipher = ["sqlcipher3-binary"] + +[[package]] +name = "sqlalchemy2-stubs" +version = "0.0.2a19" +description = "Typing Stubs for SQLAlchemy 1.4" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = ">=3.7.4" + +[[package]] +name = "sqlmodel" +version = "0.0.4" +description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." +category = "main" +optional = false +python-versions = ">=3.6.1,<4.0.0" + +[package.dependencies] +pydantic = ">=1.8.2,<2.0.0" +SQLAlchemy = ">=1.4.17,<1.5.0" +sqlalchemy2-stubs = "*" + [[package]] name = "starlette" version = "0.16.0" @@ -441,7 +508,7 @@ standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6 [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "7e190d8b6757963053bcfc17e747fa4faacc41b0b44fe0dfab1cb80c0bb85551" +content-hash = "6629355918bdc86743c26d265cd02bce008253c345b550745923555aef9306f6" [metadata.files] anyio = [ @@ -545,6 +612,58 @@ fastapi = [ {file = "fastapi-0.70.0-py3-none-any.whl", hash = "sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c"}, {file = "fastapi-0.70.0.tar.gz", hash = "sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced"}, ] +greenlet = [ + {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"}, + {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"}, + {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"}, + {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"}, + {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"}, + {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"}, + {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"}, + {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"}, + {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"}, + {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"}, + {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"}, + {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"}, + {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"}, + {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"}, + {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"}, + {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"}, + {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"}, + {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"}, + {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"}, + {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, + {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, +] h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, @@ -683,6 +802,52 @@ sniffio = [ {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] +sqlalchemy = [ + {file = "SQLAlchemy-1.4.27-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:6afa9e4e63f066e0fd90a21db7e95e988d96127f52bfb298a0e9bec6999357a9"}, + {file = "SQLAlchemy-1.4.27-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ec1c908fa721f2c5684900cc8ff75555b1a5a2ae4f5a5694eb0e37a5263cea44"}, + {file = "SQLAlchemy-1.4.27-cp27-cp27m-win32.whl", hash = "sha256:0438bccc16349db2d5203598be6073175ce16d4e53b592d6e6cef880c197333e"}, + {file = "SQLAlchemy-1.4.27-cp27-cp27m-win_amd64.whl", hash = "sha256:435b1980c1333ffe3ab386ad28d7b209590b0fa83ea8544d853e7a22f957331b"}, + {file = "SQLAlchemy-1.4.27-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:486f7916ef77213103467924ef25f5ea1055ae901f385fe4d707604095fdf6a9"}, + {file = "SQLAlchemy-1.4.27-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:d81c84c9d2523b3ea20f8e3aceea68615768a7464c0f9a9899600ce6592ec570"}, + {file = "SQLAlchemy-1.4.27-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5881644fc51af7b232ab8d64f75c0f32295dfe88c2ee188023795cdbd4cf99b"}, + {file = "SQLAlchemy-1.4.27-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:24828c5e74882cf41516740c0b150702bee4c6817d87d5c3d3bafef2e6896f80"}, + {file = "SQLAlchemy-1.4.27-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7d0a1b1258efff7d7f2e6cfa56df580d09ba29d35a1e3f604f867e1f685feb2"}, + {file = "SQLAlchemy-1.4.27-cp310-cp310-win32.whl", hash = "sha256:aadc6d1e58e14010ae4764d1ba1fd0928dbb9423b27a382ea3a1444f903f4084"}, + {file = "SQLAlchemy-1.4.27-cp310-cp310-win_amd64.whl", hash = "sha256:9134e5810262203388b203c2022bbcbf1a22e89861eef9340e772a73dd9076fa"}, + {file = "SQLAlchemy-1.4.27-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:fa52534076394af7315306a8701b726a6521b591d95e8f4e5121c82f94790e8d"}, + {file = "SQLAlchemy-1.4.27-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2717ceae35e71de1f58b0d1ee7e773d3aab5c403c6e79e8d262277c7f7f95269"}, + {file = "SQLAlchemy-1.4.27-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e93624d186ea7a738ada47314701c8830e0e4b021a6bce7fbe6f39b87ee1516"}, + {file = "SQLAlchemy-1.4.27-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:987fe2f84ceaf744fa0e48805152abe485a9d7002c9923b18a4b2529c7bff218"}, + {file = "SQLAlchemy-1.4.27-cp36-cp36m-win32.whl", hash = "sha256:2146ef996181e3d4dd20eaf1d7325eb62d6c8aa4dc1677c1872ddfa8561a47d9"}, + {file = "SQLAlchemy-1.4.27-cp36-cp36m-win_amd64.whl", hash = "sha256:ad8ec6b69d03e395db48df8991aa15fce3cd23e378b73e01d46a26a6efd5c26d"}, + {file = "SQLAlchemy-1.4.27-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:52f23a76544ed29573c0f3ee41f0ca1aedbab3a453102b60b540cc6fa55448ad"}, + {file = "SQLAlchemy-1.4.27-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd421a14edf73cfe01e8f51ed8966294ee3b3db8da921cacc88e497fd6e977af"}, + {file = "SQLAlchemy-1.4.27-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:10230364479429437f1b819a8839f1edc5744c018bfeb8d01320930f97695bc9"}, + {file = "SQLAlchemy-1.4.27-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78943451ab3ffd0e27876f9cea2b883317518b418f06b90dadf19394534637e9"}, + {file = "SQLAlchemy-1.4.27-cp37-cp37m-win32.whl", hash = "sha256:a81e40dfa50ed3c472494adadba097640bfcf43db160ed783132045eb2093cb1"}, + {file = "SQLAlchemy-1.4.27-cp37-cp37m-win_amd64.whl", hash = "sha256:015511c52c650eebf1059ed8a21674d9d4ae567ebfd80fc73f8252faccd71864"}, + {file = "SQLAlchemy-1.4.27-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:cc49fb8ff103900c20e4a9c53766c82a7ebbc183377fb357a8298bad216e9cdd"}, + {file = "SQLAlchemy-1.4.27-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9369f927f4d19b58322cfea8a51710a3f7c47a0e7f3398d94a4632760ecd74f6"}, + {file = "SQLAlchemy-1.4.27-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6510f4a5029643301bdfe56b61e806093af2101d347d485c42a5535847d2c699"}, + {file = "SQLAlchemy-1.4.27-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:771eca9872b47a629010665ff92de1c248a6979b8d1603daced37773d6f6e365"}, + {file = "SQLAlchemy-1.4.27-cp38-cp38-win32.whl", hash = "sha256:4d1d707b752137e6bf45720648e1b828d5e4881d690df79cca07f7217ea06365"}, + {file = "SQLAlchemy-1.4.27-cp38-cp38-win_amd64.whl", hash = "sha256:c035184af4e58e154b0977eea52131edd096e0754a88f7d5a847e7ccb3510772"}, + {file = "SQLAlchemy-1.4.27-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:bac949be7579fed824887eed6672f44b7c4318abbfb2004b2c6968818b535a2f"}, + {file = "SQLAlchemy-1.4.27-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ac8306e04275d382d6393e557047b0a9d7ddf9f7ca5da9b3edbd9323ea75bd9"}, + {file = "SQLAlchemy-1.4.27-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8327e468b1775c0dfabc3d01f39f440585bf4d398508fcbbe2f0d931c502337d"}, + {file = "SQLAlchemy-1.4.27-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b02eee1577976acb4053f83d32b7826424f8b9f70809fa756529a52c6537eda4"}, + {file = "SQLAlchemy-1.4.27-cp39-cp39-win32.whl", hash = "sha256:5beeff18b4e894f6cb73c8daf2c0d8768844ef40d97032bb187d75b1ec8de24b"}, + {file = "SQLAlchemy-1.4.27-cp39-cp39-win_amd64.whl", hash = "sha256:8dbe5f639e6d035778ebf700be6d573f82a13662c3c2c3aa0f1dba303b942806"}, + {file = "SQLAlchemy-1.4.27.tar.gz", hash = "sha256:d768359daeb3a86644f3854c6659e4496a3e6bba2b4651ecc87ce7ad415b320c"}, +] +sqlalchemy2-stubs = [ + {file = "sqlalchemy2-stubs-0.0.2a19.tar.gz", hash = "sha256:2117c48ce5acfe33bf9c9bfce2a981632d931949e68fa313aa5c2a3bc980ca7a"}, + {file = "sqlalchemy2_stubs-0.0.2a19-py3-none-any.whl", hash = "sha256:aac7dca77a2c49e5f0934976421d5e25ae4dc5e27db48c01e055f81caa1e3ead"}, +] +sqlmodel = [ + {file = "sqlmodel-0.0.4-py3-none-any.whl", hash = "sha256:441224eea75ea3799661dad0f9599bbd70322ce7d083f30f6741ebc807bc3ee6"}, + {file = "sqlmodel-0.0.4.tar.gz", hash = "sha256:9503befd4374a8e84e9432b344dc9488e9ca2cc4bd1c944ab6837c1bae0b9837"}, +] starlette = [ {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"}, {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"}, diff --git a/pyproject.toml b/pyproject.toml index f0fb663..b6a528b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ python-jose = "^3.3.0" python-multipart = "^0.0.5" passlib = {extras = ["bcrypt"], version = "^1.7.4"} httpx = "^0.20.0" +sqlmodel = "^0.0.4" [tool.poetry.dev-dependencies] pytest = "^6.2.5" From f669cab6b69924e8660729530c6bbc0eedd25dc9 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 16:49:39 -0800 Subject: [PATCH 16/20] docker-compose: don't initialize database tables we'll do that in python code now --- docker-compose.override.yml | 1 + docker-compose.yml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 944affa..4f7bbd6 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -3,6 +3,7 @@ version: '3.1' services: app: environment: + BASE_URL: http://127.0.0.1:8009 PGPASSWORD: DEVsKNOWwHATtHEYREdOING SECRET_KEY: aa408ae0266f2530913410f1a53c0f285d3659a482576001e1055335a33a1d2b volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 0b75d45..1c12b80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,8 @@ services: volumes: - pg_data:/var/lib/postgresql - ./src/schema/10_init.sql:/docker-entrypoint-initdb.d/10_init.sql - - ./src/schema/20_schema.sql:/docker-entrypoint-initdb.d/20_schema.sql - - ./src/schema/sample_data.sql:/docker-entrypoint-initdb.d/30_sample_data.sql + #- ./src/schema/20_schema.sql:/docker-entrypoint-initdb.d/20_schema.sql + #- ./src/schema/sample_data.sql:/docker-entrypoint-initdb.d/30_sample_data.sql adminer: image: adminer:4-standalone From b2e190daec83a0dabffa04fe3fc400bb4ef308c5 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 16:50:23 -0800 Subject: [PATCH 17/20] mycodeplug/db: remove custom ORM code --- src/mycodeplug/db.py | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/mycodeplug/db.py b/src/mycodeplug/db.py index 064b0f6..c2f441b 100644 --- a/src/mycodeplug/db.py +++ b/src/mycodeplug/db.py @@ -1,25 +1,31 @@ from contextlib import contextmanager +from typing import cast, Iterator -import psycopg2 -import psycopg2.extras -import psycopg2.extensions -import psycopg2.pool +from sqlmodel import create_engine, Session, SQLModel psycopg2.extras.register_default_json(globally=True) psycopg2.extras.register_default_jsonb(globally=True) -class DBModel: - pool = None +global_engine = create_engine("postgresql:///", echo=True) - @classmethod - @contextmanager - def conn(cls) -> psycopg2.extensions.connection: - if cls.pool is None: - cls.pool = psycopg2.pool.SimpleConnectionPool(minconn=1, maxconn=10) - try: - c = cls.pool.getconn() - yield c - finally: - cls.pool.putconn(c) + +def initialize_metadata(): + from . import channel + from . import user + + SQLModel.metadata.create_all(global_engine) + + +def session(session=None, engine=None): + if session is None: + if engine is None: + engine = global_engine + with Session(engine) as session: + yield session + else: + yield session + + +get_session = contextmanager(session) From f4b831a5dc8037ad74ccce7f2d6738823044f387 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 16:51:18 -0800 Subject: [PATCH 18/20] mycodeplug/user: rewrite with SQLModel --- src/mycodeplug/api.py | 29 +++-- src/mycodeplug/user.py | 255 +++++++++++++++++------------------------ 2 files changed, 123 insertions(+), 161 deletions(-) diff --git a/src/mycodeplug/api.py b/src/mycodeplug/api.py index 3ab8684..c626182 100644 --- a/src/mycodeplug/api.py +++ b/src/mycodeplug/api.py @@ -6,6 +6,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel +from .db import session from .logging import getLogger from .mail import otp_delivery from .user import ( @@ -41,7 +42,12 @@ async def root() -> RootData: @app.post("/login") -def login(email: str, request: Request, deliver=Depends(otp_delivery)): +def login( + email: str, + request: Request, + deliver=Depends(otp_delivery), + session=Depends(session), +): """ Trigger a login request for the given email address. @@ -60,11 +66,14 @@ def login(email: str, request: Request, deliver=Depends(otp_delivery)): :return: None -- the token is emailed (or printed, in dev mode). """ try: - user = User(email=email).lookup() + user = User.from_email(email, session=session) except UnknownUser: - user = User(email=email, created_ip=request.client.host).save() + user = User(email=email, created_ip=request.client.host) + session.add(user) + session.commit() + session.refresh(user) logger.info("Created a new user for {}".format(email)) - return deliver(user, user.login(ip=request.client.host)) + return deliver(user, user.login(ip=request.client.host, session=session)) def _token(email: str, otp: str, request: Request) -> Token: @@ -76,9 +85,7 @@ def _token(email: str, otp: str, request: Request) -> Token: :param request: the request, must match the IP that requested /login :return: oauth Token """ - token_data = ( - User(email=email).lookup().authenticate(ip=request.client.host, otp=otp) - ) + token_data = User.from_email(email).authenticate(ip=request.client.host, otp=otp) return Token( access_token=token_data.to_jwt(), token_type="bearer", @@ -129,19 +136,21 @@ def post_users_me( otp: Optional[str] = None, current_user: User = Depends(get_current_active), deliver=Depends(otp_delivery), + session=Depends(session), ): updated_settings = data.dict( exclude_none=True, exclude_unset=True, exclude_defaults=True ) if "email" in updated_settings: if otp is not None: - current_user.authenticate(ip=request.client.host, otp=otp) + current_user.authenticate(ip=request.client.host, otp=otp, session=session) else: # handle email updates specially, to validate the new address current_user.email = data.email - otp = current_user.login(ip=request.client.host) + otp = current_user.login(ip=request.client.host, session=session) deliver(current_user, otp) return {"detail": "Resubmit request with updated OTP"} for k, v in updated_settings.items(): setattr(current_user, k, v) - current_user.save() + session.add(current_user) + session.commit() diff --git a/src/mycodeplug/user.py b/src/mycodeplug/user.py index 7d3b552..7051131 100644 --- a/src/mycodeplug/user.py +++ b/src/mycodeplug/user.py @@ -16,8 +16,13 @@ import psycopg2.extensions import psycopg2.extras from pydantic import BaseModel +from sqlalchemy import Column, DateTime, JSON +from sqlalchemy.exc import NoResultFound +from sqlalchemy.sql import func, text +from sqlmodel import SQLModel, Field, select, Session +from sqlmodel.sql.expression import Select -from .db import DBModel +from . import db SECRET_KEY = os.environ["SECRET_KEY"] @@ -111,136 +116,71 @@ def from_jwt(cls, token: str) -> "TokenData": raise InvalidToken("Invalid Token format: {}".format(payload)) -class User(BaseModel, DBModel): +class User(SQLModel, table=True): """ - id SERIAL PRIMARY KEY, - created timestamp with time zone NOT NULL DEFAULT now(), - created_ip inet NOT NULL, - email text UNIQUE NOT NULL, - enabled boolean NOT NULL DEFAULT true, - admin boolean NOT NULL DEFAULT false, - name text, - data jsonb + A database-backed user account """ - id: Optional[int] = None - created: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc) - created_ip: Optional[Union[IPv4Address, IPv6Address]] = None + id: Optional[int] = Field(default=None, primary_key=True) + created: datetime.datetime = Field( + default=None, + sa_column=Column( + "created", + DateTime(timezone=True), + server_default=func.now(), + ), + ) + created_ip: str email: str enabled: bool = True admin: bool = False name: Optional[str] = None - data: Dict[str, Any] = {} + data: dict = Field( + default_factory=dict, + sa_column=Column("data", JSON), + ) @classmethod - def from_token(cls, token: TokenData) -> "User": - return cls.by_id(id=token.user_id) + def from_query(cls, query: Select, session: Optional[Session] = None) -> "User": + with db.get_session(session) as s: + result = s.exec(query) + return result.one() @classmethod - def by_id(cls, id: int) -> "User": - return cls(id=id, email="").lookup() - - def lookup(self) -> "User": - """ - Refresh this instance from database. + def from_email(cls, email: str, session: Optional[Session] = None) -> "User": + try: + return cls.from_query( + select(cls).where(cls.email == email), session=session + ) + except NoResultFound: + raise UnknownUser(email) - Prefer lookup by id, if specified. Otherwise lookup by email address. + @classmethod + def from_id(cls, id: int, session: Optional[Session] = None) -> "User": + try: + return cls.from_query(select(cls).where(cls.id == id), session=session) + except NoResultFound: + raise UnknownUser(id) - :return: User instance if email is found - :raise: UnknownUser if id or email is not found - """ - param = self.email - condition = "email = %s" - if self.id is not None: - param = self.id - condition = "id = %s" - query = """ - SELECT id, created, created_ip, email, enabled, admin, name, data - FROM users - WHERE {} - LIMIT 1 - """.format( - condition + @classmethod + def from_token(cls, token: TokenData, session: Optional[Session] = None) -> "User": + return cls.from_id(token.user_id, session=session) + + def _find_token(self, ip: str, session: Optional[Session] = None) -> "Otp": + query = ( + select(Otp) + .where(Otp.user_id == self.id) + .where(Otp.ip == ip) + .where(Otp.expires > func.now()) ) - with self.conn() as conn: - c: psycopg2.extensions.cursor = conn.cursor() - c.execute(query, (param,)) - if c.rowcount < 1: - raise UnknownUser(condition % param) - row = c.fetchone() - ( - self.id, - self.created, - created_ip, - self.email, - self.enabled, - self.admin, - self.name, - data, - ) = row - self.created_ip = ip_address(created_ip) - self.data = data or {} - return self - - def save(self): - """ - Persist data from this instance to the database. - - :return: self - """ - params = [ - self.created, - str(self.created_ip), - self.email, - self.enabled, - self.admin, - self.name, - json.dumps(self.data) if self.data else None, - ] - if self.id is None: - query = """ - INSERT INTO users (created, created_ip, email, enabled, admin, name, data) - VALUES (%s, %s, %s, %s, %s, %s, %s) - RETURNING id - """ - else: - query = """ - UPDATE users - SET created = %s, - created_ip = %s, - email = %s, - enabled = %s, - admin = %s, - name = %s, - data = %s - WHERE id = %s - """ - params.append(self.id) - with self.conn() as conn: - c: psycopg2.extensions.cursor = conn.cursor() - c.execute(query, params) - if self.id is None: - self.id = c.fetchone()[0] - conn.commit() - return self - - def _find_token(self, ip) -> (int, str): - get_query = """ - SELECT id, otp - FROM otp - WHERE - user_id = %s AND - ip = %s AND - NOW() < expires - """ - with self.conn() as conn: - c: psycopg2.extensions.cursor = conn.cursor() - c.execute(get_query, (self.id, ip)) - if c.rowcount != 1: + with db.get_session(session) as s: + result = s.exec(query) + try: + return result.one() + except NoResultFound: raise ExpiredOTP("Expired token for {}".format(ip)) - return c.fetchone() - def login(self, ip) -> str: + def login(self, ip: str, session: Optional[Session] = None) -> str: """ Request login for the given user. @@ -251,30 +191,22 @@ def login(self, ip) -> str: :raise: IncorrectOTP if the given (user, ip) pair already has an active token (must wait before granting another). """ - try: - old_token_data = self._find_token(ip) - if old_token_data: - raise IncorrectOTP(detail="Previous token still active") - except ExpiredOTP: - old_token_data = None - new_otp = "{:06}".format(random.randint(0, 999999)) - hashed_otp = pwd_context.hash(new_otp) - create_params = [ - self.id, - str(ip), - hashed_otp, - ] - create_query = """ - INSERT INTO otp (user_id, ip, otp) - VALUES (%s, %s, %s); - """ - with self.conn() as conn: - c: psycopg2.extensions.cursor = conn.cursor() - c.execute(create_query, create_params) - conn.commit() + with db.get_session(session) as s: + try: + old_token_data = self._find_token(ip, session=s) + if old_token_data: + raise IncorrectOTP(detail="Previous token still active") + except ExpiredOTP: + old_token_data = None + new_otp = "{:06}".format(random.randint(0, 999999)) + hashed_otp = pwd_context.hash(new_otp) + s.add(Otp(user_id=self.id, ip=ip, otp=hashed_otp)) + s.commit() return new_otp - def authenticate(self, ip, otp) -> TokenData: + def authenticate( + self, ip: str, otp: str, session: Optional[Session] = None + ) -> TokenData: """ Validate OTP and generate a session token. @@ -284,22 +216,43 @@ def authenticate(self, ip, otp) -> TokenData: :raise: ExpiredOTP if the OTP doesn't exist or is expired :raise: IncorrectOTP if OTP is found, but doesn't match """ - token_id, hashed_otp = self._find_token(ip=ip) + with db.get_session(session) as s: + token = self._find_token(ip=ip, session=s) - if not pwd_context.verify(otp, hashed_otp): - raise IncorrectOTP("Bad token") + if not pwd_context.verify(otp, token.otp): + raise IncorrectOTP("Bad token") - update_query = """ - UPDATE otp - SET expires = NOW() - WHERE - id = %s - """ - with self.conn() as conn: - c: psycopg2.extensions.cursor = conn.cursor() - c.execute(update_query, (token_id,)) - conn.commit() - return TokenData(user_id=self.id, session_id=token_id) + token.expires = func.now() + s.add(token) + s.commit() + return TokenData(user_id=self.id, session_id=token.id) + + +class Otp(SQLModel, table=True): + """ + One-time password for email / magic login. + """ + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + ts: datetime.datetime = Field( + default=None, + sa_column=Column( + "ts", + DateTime(timezone=True), + server_default=func.now(), + ), + ) + ip: str + expires: datetime.datetime = Field( + default=None, + sa_column=Column( + "expires", + DateTime(timezone=True), + server_default=text("(now() + '5 minutes'::interval)"), + ), + ) + otp: str class EditableUser(BaseModel): From fea97d6cc81a2eb57c11af804bc51772704913e0 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 19:29:14 -0800 Subject: [PATCH 19/20] mycodeplug/db: comment out psycopg2 extras call --- src/mycodeplug/db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mycodeplug/db.py b/src/mycodeplug/db.py index c2f441b..7b5ca48 100644 --- a/src/mycodeplug/db.py +++ b/src/mycodeplug/db.py @@ -4,8 +4,8 @@ from sqlmodel import create_engine, Session, SQLModel -psycopg2.extras.register_default_json(globally=True) -psycopg2.extras.register_default_jsonb(globally=True) +# psycopg2.extras.register_default_json(globally=True) +# psycopg2.extras.register_default_jsonb(globally=True) global_engine = create_engine("postgresql:///", echo=True) From 4ac293f4d0952e2360d5d18ff55b6c85b3a5bb9e Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Sun, 14 Nov 2021 19:30:11 -0800 Subject: [PATCH 20/20] mycodeplug/channel: data model for channels Linked SQLModel classes for channel, name, and channel revision and a sample function for creating a channel --- src/mycodeplug/channel.py | 119 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/mycodeplug/channel.py diff --git a/src/mycodeplug/channel.py b/src/mycodeplug/channel.py new file mode 100644 index 0000000..2d0160d --- /dev/null +++ b/src/mycodeplug/channel.py @@ -0,0 +1,119 @@ +import datetime +import enum +from typing import Any, Dict, List, Optional +import uuid + +import psycopg2.extras +from pydantic import BaseModel +from sqlalchemy import Column, DateTime, Enum, JSON +from sqlalchemy.sql import func, text +from sqlmodel import Field, Relationship, Session, SQLModel, create_engine + +from . import db +from .user import User + + +class Channel(SQLModel, table=True): + """ + A channel entry. Actual channel data is versioned in ChannelRevision + """ + + channel_uuid: Optional[uuid.UUID] = Field( + default_factory=uuid.uuid4, + primary_key=True, + ) + owner_id: Optional[int] = Field(nullable=False, foreign_key="user.id") + group_id: Optional[int] + source: Optional[str] + source_id: Optional[str] + parent_channel: Optional[uuid.UUID] = Field(foreign_key="channel.channel_uuid") + + revisions: List["ChannelRevision"] = Relationship(back_populates="channel") + owner: User = Relationship() + + +class Name(SQLModel, table=True): + """ + The name of a channel, zone, scanlist, contact, etc. + """ + + id: Optional[int] = Field(default=None, primary_key=True) + name: str + alt_name_16: Optional[str] = Field(default=None, max_length=16) + alt_name_6: Optional[str] = Field(default=None, max_length=6) + alt_name_5: Optional[str] = Field(default=None, max_length=5) + + +class Power(enum.Enum): + LOW = "low" + MID = "mid" + HIGH = "high" + TURBO = "turbo" + + +class ChannelRevision(SQLModel, table=True): + """ + Channel settings. + + Typicaly would be the result of aggregating revisions for a given + channel up to a certain point. + """ + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: Optional[int] = Field(foreign_key="user.id") + channel_uuid: Optional[uuid.UUID] = Field(foreign_key="channel.channel_uuid") + ts: datetime.datetime = Field( + default=None, + sa_column=Column( + "ts", + DateTime(timezone=True), + server_default=func.now(), + ), + ) + parent_revision: Optional[int] = Field(foreign_key="channelrevision.id") + name_id: Optional[int] = Field(foreign_key="name.id") + description: Optional[dict] = Field( + default_factory=dict, + sa_column=Column("description", JSON), + ) + frequency: Optional[float] + f_offset: Optional[float] + power: Optional[Power] = Field(sa_column=Column(Enum(Power))) + rx_only: Optional[bool] + mode: Optional[str] + mode_settings: Optional[dict] = Field( + default_factory=dict, + sa_column=Column("mode_settings", JSON), + ) + vendor_settings: Optional[dict] = Field( + default_factory=dict, + sa_column=Column("vendor_settings", JSON), + ) + + channel: Channel = Relationship(back_populates="revisions") + name: Name = Relationship() + user: User = Relationship() + + +def make_sample_channels(email, session=None): + with db.get_session(session) as s: + u = User.from_email(email) + ch = Channel( + owner=u, + revisions=[ + ChannelRevision( + user=u, + name=Name(name="Foo Channel"), + frequency=146.520, + f_offset=0, + power=Power.LOW, + rx_only=False, + mode="FM", + mode_settings=dict( + bandwidth="12.5", + ), + ) + ], + ) + s.add(ch) + s.commit()