Compare commits

..

No commits in common. "develop" and "master" have entirely different histories.

25 changed files with 874 additions and 1679 deletions

View file

@ -23,7 +23,6 @@ RUN set -ex; \
apt-get update; apt-get -y install --no-install-recommends \ apt-get update; apt-get -y install --no-install-recommends \
easy-rsa \ easy-rsa \
git-flow \ git-flow \
openvpn \
; rm -rf /var/lib/apt/lists/*; \ ; rm -rf /var/lib/apt/lists/*; \
ln -s /usr/share/easy-rsa/easyrsa /usr/local/bin; ln -s /usr/share/easy-rsa/easyrsa /usr/local/bin;

View file

@ -14,6 +14,7 @@
"NODE_VERSION": "none" "NODE_VERSION": "none"
} }
}, },
// Set *default* container specific settings.json values on container create. // Set *default* container specific settings.json values on container create.
"settings": { "settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python", "python.defaultInterpreterPath": "/usr/local/bin/python",
@ -29,17 +30,22 @@
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"ms-python.python", "ms-python.python",
"ms-python.vscode-pylance", "ms-python.vscode-pylance",
"be5invis.toml" "be5invis.toml",
"qwtel.sqlite-viewer"
], ],
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [], // "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt", // "postCreateCommand": "pip3 install --user -r requirements.txt",
"postCreateCommand": "poetry install", "postCreateCommand": "poetry install",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode" "remoteUser": "vscode"
} }

View file

@ -1,5 +0,0 @@
{
"recommendations": [
"qwtel.sqlite-viewer"
]
}

View file

@ -5,18 +5,11 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Main App", "name": "Python: Modul",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"module": "kiwi_vpn_api.main", "module": "kiwi_vpn_api.main",
"justMyCode": true "justMyCode": true
},
{
"name": "EasyRSA script",
"type": "python",
"request": "launch",
"module": "kiwi_vpn_api.easyrsa",
"justMyCode": true
} }
] ]
} }

View file

@ -11,6 +11,5 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": true "source.organizeImports": true
}, }
"git.closeDiffOnOperation": true
} }

View file

@ -9,17 +9,19 @@ Pydantic models might have convenience methods attached.
from __future__ import annotations from __future__ import annotations
import functools
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Any
from jose import JWTError, jwt from jose import JWTError, jwt
from jose.constants import ALGORITHMS from jose.constants import ALGORITHMS
from passlib.context import CryptContext from passlib.context import CryptContext
from pydantic import BaseModel, BaseSettings, constr, validator from pydantic import BaseModel, BaseSettings, Field, validator
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
class Settings(BaseSettings): class Settings(BaseSettings):
@ -29,18 +31,18 @@ class Settings(BaseSettings):
production_mode: bool = False production_mode: bool = False
data_dir: Path = Path("./tmp") data_dir: Path = Path("./tmp")
config_file_name: Path = Path("config.json")
api_v1_prefix: str = "api/v1"
openapi_url: str = "/openapi.json" openapi_url: str = "/openapi.json"
docs_url: str | None = "/docs" docs_url: str | None = "/docs"
redoc_url: str | None = "/redoc" redoc_url: str | None = "/redoc"
@staticmethod
@functools.lru_cache
def get() -> Settings:
return Settings()
@property @property
def config_file(self) -> Path: def config_file(self) -> Path:
return self.data_dir.joinpath(self.config_file_name) return self.data_dir.joinpath("config.json")
SETTINGS = Settings()
class DBType(Enum): class DBType(Enum):
@ -61,20 +63,23 @@ class DBConfig(BaseModel):
user: str | None = None user: str | None = None
password: str | None = None password: str | None = None
host: str | None = None host: str | None = None
database: str | Path | None = SETTINGS.data_dir.joinpath("kiwi-vpn.db") database: str | None = Settings.get().data_dir.joinpath("vpn.db")
mysql_driver: str = "pymysql" mysql_driver: str = "pymysql"
mysql_args: list[str] = ["charset=utf8mb4"] mysql_args: list[str] = ["charset=utf8mb4"]
@property @property
def uri(self) -> str: async def db_engine(self) -> Engine:
""" """
Construct a database connection string Construct an SQLAlchemy engine
""" """
if self.type is DBType.sqlite: if self.type is DBType.sqlite:
# SQLite backend # SQLite backend
return f"sqlite:///{self.database}" return create_engine(
f"sqlite:///{self.database}",
connect_args={"check_same_thread": False},
)
elif self.type is DBType.mysql: elif self.type is DBType.mysql:
# MySQL backend # MySQL backend
@ -83,11 +88,12 @@ class DBConfig(BaseModel):
else: else:
args_str = "" args_str = ""
return (f"mysql+{self.mysql_driver}://" return create_engine(
f"{self.user}:{self.password}@{self.host}" f"mysql+{self.mysql_driver}://"
f"/{self.database}{args_str}") f"{self.user}:{self.password}@{self.host}"
f"/{self.database}{args_str}",
return "" pool_recycle=3600,
)
class JWTConfig(BaseModel): class JWTConfig(BaseModel):
@ -160,68 +166,22 @@ class JWTConfig(BaseModel):
return None return None
# get username # get username
return payload.get("sub") username = payload.get("sub")
if username is None:
return None
return username
class LockableString(BaseModel):
"""
A string that can be (logically) locked with an attached bool
"""
value: str
locked: bool
class LockableCountry(LockableString):
"""
Like `LockableString`, but with a `value` constrained two characters
"""
value: constr(max_length=2) # type: ignore
class ServerDN(BaseModel):
"""
This server's "distinguished name"
"""
country: LockableCountry
state: LockableString
city: LockableString
organization: LockableString
organizational_unit: LockableString
email: LockableString
common_name: str
class KeyAlgorithm(Enum):
"""
Supported certificate signing algorithms
"""
rsa2048 = "rsa2048"
rsa4096 = "rsa4096"
secp256r1 = "secp256r1"
secp384r1 = "secp384r1"
ed25519 = "ed25519"
class CryptoConfig(BaseModel): class CryptoConfig(BaseModel):
""" """
Configuration for cryptography Configuration for hash algorithms
""" """
# password hash algorithms
schemes: list[str] = ["bcrypt"] schemes: list[str] = ["bcrypt"]
# pki settings
key_algorithm: KeyAlgorithm | None
ca_password: str | None
ca_expiry_days: int | None
cert_expiry_days: int | None
@property @property
def context(self) -> CryptContext: async def crypt_context(self) -> CryptContext:
return CryptContext( return CryptContext(
schemes=self.schemes, schemes=self.schemes,
deprecated="auto", deprecated="auto",
@ -233,48 +193,27 @@ class Config(BaseModel):
Configuration for `kiwi-vpn-api` Configuration for `kiwi-vpn-api`
""" """
# may include client-to-client, cipher etc. db: DBConfig = Field(default_factory=DBConfig)
openvpn_extra_options: dict[str, Any] | None jwt: JWTConfig = Field(default_factory=JWTConfig)
crypto: CryptoConfig = Field(default_factory=CryptoConfig)
db: DBConfig @staticmethod
jwt: JWTConfig async def load() -> Config | None:
crypto: CryptoConfig
server_dn: ServerDN
__instance: Config | None = None
@classmethod
def load(cls) -> Config | None:
""" """
Load configuration from config file Load configuration from config file
""" """
if cls.__instance is None: try:
try: with open(Settings.get().config_file, "r") as config_file:
with open(SETTINGS.config_file, "r") as config_file: return Config.parse_obj(json.load(config_file))
cls.__instance = cls.parse_obj(json.load(config_file))
except FileNotFoundError: except FileNotFoundError:
return None return None
return cls.__instance async def save(self) -> None:
@classmethod
@property
def _(cls) -> Config:
"""
Shorthand for load(), but config file must exist
"""
if (config := cls.load()) is None:
raise FileNotFoundError(SETTINGS.config_file)
return config
def save(self) -> None:
""" """
Save configuration to config file Save configuration to config file
""" """
with open(SETTINGS.config_file, "w") as config_file: with open(Settings.get().config_file, "w") as config_file:
config_file.write(self.json(indent=2)) config_file.write(self.json(indent=2))

View file

@ -1,22 +1,4 @@
""" from . import models, schemas
Package `db`: ORM and schemas for database content.
"""
from .connection import Connection from .connection import Connection
from .device import Device, DeviceBase, DeviceCreate, DeviceRead, DeviceStatus
from .tag import TagValue
from .user import User, UserBase, UserCreate, UserRead
__all__ = [ __all__ = ["Connection", "models", "schemas"]
"Connection",
"Device",
"DeviceBase",
"DeviceCreate",
"DeviceRead",
"DeviceStatus",
"User",
"UserBase",
"UserCreate",
"UserRead",
"TagValue",
]

View file

@ -1,34 +1,75 @@
""" """
Database connection management. Utilities for handling SQLAlchemy database connections.
""" """
from sqlmodel import Session, SQLModel, create_engine from typing import Generator
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from .models import ORMBaseModel
class SessionManager:
"""
Simple context manager for an ORM session.
"""
__session: Session
def __init__(self, session: Session) -> None:
self.__session = session
def __enter__(self) -> Session:
return self.__session
def __exit__(self, *args) -> None:
self.__session.close()
class Connection: class Connection:
""" """
Namespace for the database connection Namespace for the database connection.
""" """
engine = None engine: Engine | None = None
session_local: sessionmaker | None = None
@classmethod @classmethod
def connect(cls, connection_url: str) -> None: def connect(cls, engine: Engine) -> None:
""" """
Connect ORM to a database engine. Connect ORM to a database engine.
""" """
cls.engine = create_engine(connection_url) cls.engine = engine
SQLModel.metadata.create_all(cls.engine) cls.session_local = sessionmaker(
autocommit=False, autoflush=False, bind=engine,
)
ORMBaseModel.metadata.create_all(bind=engine)
@classmethod @classmethod
@property def use(cls) -> SessionManager | None:
def session(cls) -> Session:
""" """
Create an ORM session using a context manager. Create an ORM session using a context manager.
""" """
if cls.engine is None: if cls.session_local is None:
raise ValueError("Not connected to database, can't create session") return None
return Session(cls.engine) return SessionManager(cls.session_local())
@classmethod
async def get(cls) -> Generator[Session | None, None, None]:
"""
Create an ORM session using a FastAPI compatible async generator.
"""
if cls.session_local is None:
yield None
else:
db = cls.session_local()
try:
yield db
finally:
db.close()

View file

@ -1,133 +0,0 @@
"""
Python representation of `device` table.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from .connection import Connection
if TYPE_CHECKING:
from .user import User
class DeviceStatus(Enum):
uncertified = "uncertified"
pending = "pending"
certified = "certified"
def __repr__(self) -> str:
return self.value
class DeviceBase(SQLModel):
"""
Common to all representations of devices
"""
name: str
type: str
class DeviceCreate(DeviceBase):
"""
Representation of a newly created device
"""
class DeviceRead(DeviceBase):
"""
Representation of a device read via the API
"""
id: int | None = Field(primary_key=True)
status_str: str = Field(default=repr(DeviceStatus.uncertified))
expiry: datetime | None = Field(default=None)
owner_name: str = Field(foreign_key="user.name")
@property
def status(self) -> DeviceStatus:
return DeviceStatus(self.status_str)
# property setters don't work with sqlmodel
def set_status(self, status: DeviceStatus) -> None:
self.status_str = repr(status)
class Device(DeviceRead, table=True):
"""
Representation of `device` table
"""
__table_args__ = (UniqueConstraint(
"owner_name",
"name",
),)
# no idea, but "User" (in quotes) doesn't work here
# might be a future problem?
owner: User = Relationship(
back_populates="devices",
sa_relationship_kwargs={
"lazy": "joined",
},
)
@classmethod
def create(
cls,
*,
owner: User,
device: DeviceCreate,
) -> Device | None:
"""
Create a new device in the database.
"""
try:
with Connection.session as db:
new_device = cls.from_orm(device, {"owner_name": owner.name})
db.add(new_device)
db.commit()
db.refresh(new_device)
return new_device
except IntegrityError:
# device already existed
return None
@classmethod
def get(cls, id: int) -> Device | None:
"""
Load device from database by id.
"""
with Connection.session as db:
return db.get(cls, id)
def update(self) -> None:
"""
Update this device in the database.
"""
with Connection.session as db:
db.add(self)
db.commit()
db.refresh(self)
def delete(self) -> None:
"""
Delete this device from the database.
"""
with Connection.session as db:
db.delete(self)
db.commit()

View file

@ -0,0 +1,106 @@
"""
SQLAlchemy representation of database contents.
"""
from __future__ import annotations
import datetime
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
UniqueConstraint)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, relationship
ORMBaseModel = declarative_base()
class User(ORMBaseModel):
__tablename__ = "users"
name = Column(String, primary_key=True, index=True)
password = Column(String, nullable=False)
capabilities: list[UserCapability] = relationship(
"UserCapability", lazy="joined", cascade="all, delete-orphan"
)
certificates: list[Certificate] = relationship(
"Certificate", lazy="select", back_populates="owner"
)
distinguished_names: list[DistinguishedName] = relationship(
"DistinguishedName", lazy="select", back_populates="owner"
)
@classmethod
def load(cls, db: Session, name: str) -> User | None:
"""
Load user from database by name.
"""
return (db
.query(User)
.filter(User.name == name)
.first())
class UserCapability(ORMBaseModel):
__tablename__ = "user_capabilities"
user_name = Column(
String,
ForeignKey("users.name"),
primary_key=True,
index=True,
)
capability = Column(String, primary_key=True)
class DistinguishedName(ORMBaseModel):
__tablename__ = "distinguished_names"
id = Column(Integer, primary_key=True, autoincrement=True)
owner_name = Column(String, ForeignKey("users.name"))
cn_only = Column(Boolean, default=True, nullable=False)
country = Column(String(2))
state = Column(String)
city = Column(String)
organization = Column(String)
organizational_unit = Column(String)
email = Column(String)
common_name = Column(String, nullable=False)
owner: User = relationship(
"User", lazy="joined", back_populates="distinguished_names"
)
UniqueConstraint(
country,
state,
city,
organization,
organizational_unit,
email,
common_name,
)
class Certificate(ORMBaseModel):
__tablename__ = "certificates"
id = Column(Integer, primary_key=True, autoincrement=True)
owner_name = Column(String, ForeignKey("users.name"))
dn_id = Column(
Integer,
ForeignKey("distinguished_names.id"),
nullable=False,
)
expiry = Column(DateTime, default=datetime.datetime.now)
distinguished_name: DistinguishedName = relationship(
"DistinguishedName", lazy="joined"
)
owner: User = relationship(
"User", lazy="joined", back_populates="certificates"
)

View file

@ -0,0 +1,275 @@
"""
Pydantic representation of database contents.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from passlib.context import CryptContext
from pydantic import BaseModel, Field, constr, validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from . import models
##########
# table: distinguished_names
##########
class DistinguishedNameBase(BaseModel):
cn_only: bool
country: constr(max_length=2) | None
state: str | None
city: str | None
organization: str | None
organizational_unit: str | None
email: str | None
common_name: str
class DistinguishedNameCreate(DistinguishedNameBase):
pass
class DistinguishedName(DistinguishedNameBase):
class Config:
orm_mode = True
@classmethod
def create(
cls,
db: Session,
dn: DistinguishedNameCreate,
owner: User,
) -> User | None:
"""
Create a new distinguished name in the database.
"""
try:
db_owner = models.User.load(
db=db,
name=owner.name,
)
dn = models.DistinguishedName(
cn_only=dn.cn_only,
country=dn.country,
state=dn.state,
city=dn.city,
organization=dn.organization,
organizational_unit=dn.organizational_unit,
email=dn.email,
common_name=dn.common_name,
owner=db_owner,
)
db.add(dn)
db.commit()
db.refresh(dn)
return cls.from_orm(dn)
except IntegrityError:
# distinguished name already existed
pass
##########
# table: certificates
##########
class CertificateBase(BaseModel):
expiry: datetime
class CertificateCreate(CertificateBase):
pass
class Certificate(CertificateBase):
distinguished_name: DistinguishedName
class Config:
orm_mode = True
##########
# table: user_capabilities
##########
class UserCapability(Enum):
admin = "admin"
def __repr__(self) -> str:
return self.value
@classmethod
def from_value(cls, value) -> UserCapability:
"""
Create UserCapability from various formats
"""
if isinstance(value, cls):
# value is already a UserCapability, use that
return value
elif isinstance(value, models.UserCapability):
# create from db format
return cls(value.capability)
else:
# create from string representation
return cls(str(value))
##########
# table: users
##########
class UserBase(BaseModel):
name: str
class UserCreate(UserBase):
password: str
class User(UserBase):
capabilities: list[UserCapability] = []
distinguished_names: list[DistinguishedName] = Field(
default=[], repr=False
)
certificates: list[Certificate] = Field(
default=[], repr=False
)
class Config:
orm_mode = True
@validator("capabilities", pre=True)
@classmethod
def unify_capabilities(cls, value: list[Any]) -> list[UserCapability]:
"""
Import the capabilities from various formats
"""
return [
UserCapability.from_value(capability)
for capability in value
]
@classmethod
def from_db(
cls,
db: Session,
name: str,
) -> User | None:
"""
Load user from database by name.
"""
if (db_user := models.User.load(db, name)) is None:
return None
return cls.from_orm(db_user)
@classmethod
def create(
cls,
db: Session,
user: UserCreate,
crypt_context: CryptContext,
) -> User | None:
"""
Create a new user in the database.
"""
try:
user = models.User(
name=user.name,
password=crypt_context.hash(user.password),
capabilities=[],
)
db.add(user)
db.commit()
db.refresh(user)
return cls.from_orm(user)
except IntegrityError:
# user already existed
pass
def is_admin(self) -> bool:
return UserCapability.admin in self.capabilities
def authenticate(
self,
db: Session,
password: str,
crypt_context: CryptContext,
) -> User | None:
"""
Authenticate with name/password against users in database.
"""
if (db_user := models.User.load(db, self.name)) is None:
# nonexistent user, fake doing password verification
crypt_context.dummy_verify()
return False
if not crypt_context.verify(password, db_user.password):
# password hash mismatch
return False
self.from_orm(db_user)
return True
def update(
self,
db: Session,
) -> None:
"""
Update this user in the database.
"""
old_dbuser = models.User.load(db, self.name)
old_user = self.from_orm(old_dbuser)
for capability in self.capabilities:
if capability not in old_user.capabilities:
old_dbuser.capabilities.append(
models.UserCapability(capability=capability.value)
)
for capability in old_dbuser.capabilities:
if UserCapability.from_value(capability) not in self.capabilities:
db.delete(capability)
db.commit()
def delete(
self,
db: Session,
) -> bool:
"""
Delete this user from the database.
"""
if (db_user := models.User.load(db, self.name)) is None:
# nonexistent user
return False
db.delete(db_user)
db.commit()
return True

View file

@ -1,68 +0,0 @@
"""
Python representation of `tag` table.
"""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from .user import User
class TagValue(Enum):
"""
Allowed values for tags
"""
admin = "admin"
login = "login"
issue = "issue"
renew = "renew"
def __repr__(self) -> str:
return self.value
def _(self, user: User) -> Tag:
"""
Transform into a `Tag`.
"""
return Tag(
user_name=user.name,
tag_value=self.value,
)
class TagBase(SQLModel):
"""
Common to all representations of tags
"""
tag_value: str = Field(primary_key=True)
@property
def _(self) -> TagValue:
"""
Transform into a `TagValue`.
"""
return TagValue(self.tag_value)
def __repr__(self) -> str:
return self.tag_value
class Tag(TagBase, table=True):
"""
Representation of `tag` table
"""
user_name: str = Field(primary_key=True, foreign_key="user.name")
user: User = Relationship(
back_populates="tags",
)

View file

@ -1,305 +0,0 @@
"""
Python representation of `user` table.
"""
from __future__ import annotations
from typing import Any, Iterable, Sequence
from pydantic import root_validator
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, Relationship, SQLModel
from ..config import Config
from .connection import Connection
from .device import Device
from .tag import Tag, TagValue
class UserBase(SQLModel):
"""
Common to all representations of users
"""
name: str = Field(primary_key=True)
email: str
country: str | None = Field(default=None, max_length=2)
state: str | None = Field(default=None)
city: str | None = Field(default=None)
organization: str | None = Field(default=None)
organizational_unit: str | None = Field(default=None)
class UserCreate(UserBase):
"""
Representation of a newly created user
"""
password: str | None = Field(default=None)
password_clear: str | None = Field(default=None)
@root_validator
@classmethod
def hash_password(cls, values: dict[str, Any]) -> dict[str, Any]:
"""
Ensure the `password` value of this user gets set.
"""
if (values.get("password")) is not None:
# password is set
return values
if (password_clear := values.get("password_clear")) is None:
raise ValueError("No password to hash")
if (current_config := Config.load()) is None:
raise ValueError("Not configured")
values["password"] = current_config.crypto.context.hash(
password_clear)
return values
class UserRead(UserBase):
"""
Representation of a user read via the API
"""
pass
class User(UserBase, table=True):
"""
Representation of `user` table
"""
password: str
tags: list[Tag] = Relationship(
back_populates="user",
sa_relationship_kwargs={
"lazy": "joined",
"cascade": "all, delete-orphan",
},
)
devices: list[Device] = Relationship(
back_populates="owner",
)
@classmethod
def create(
cls,
*,
user: UserCreate,
) -> User | None:
"""
Create a new user in the database.
"""
try:
with Connection.session as db:
new_user = cls.from_orm(user)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
except IntegrityError:
# user already existed
return None
@classmethod
def get(cls, name: str) -> User | None:
"""
Load user from database by name.
"""
with Connection.session as db:
return db.get(cls, name)
@classmethod
def authenticate(
cls,
name: str,
password: str,
) -> User | None:
"""
Authenticate with name/password against users in database.
"""
crypt_context = Config._.crypto.context
if (user := cls.get(name)) is None:
# nonexistent user, fake doing password verification
crypt_context.dummy_verify()
return None
if not crypt_context.verify(password, user.password):
# password hash mismatch
return None
if not (user.has_tag(TagValue.login) or user.is_admin):
# no login permission
return None
return user
def update(self) -> None:
"""
Update this user in the database.
"""
with Connection.session as db:
db.add(self)
db.commit()
db.refresh(self)
def delete(self) -> None:
"""
Delete this user from the database.
"""
with Connection.session as db:
db.delete(self)
db.commit()
@property
def __tags(self) -> Iterable[TagValue]:
"""
Return the tags of this user.
"""
return (
tag._
for tag in self.tags
)
def has_tag(self, tag: TagValue) -> bool:
"""
Check if this user has a tag.
"""
return tag in self.__tags
@property
def is_admin(self) -> bool:
"""
Shorthand for checking if this user has the `admin` tag.
"""
return TagValue.admin in self.__tags
def add_tags(
self,
tags: Sequence[TagValue],
) -> None:
"""
Add tags to this user.
"""
self.tags = [
tag._(self)
for tag in (set(self.__tags) | set(tags))
]
def remove_tags(
self,
tags: Sequence[TagValue],
) -> None:
"""
remove tags from this user.
"""
self.tags = [
tag._(self)
for tag in (set(self.__tags) - set(tags))
]
def can_edit(
self,
target: User | Device,
) -> bool:
"""
Check if this user can edit another user or a device.
"""
# admin can "edit" everything
if self.is_admin:
return True
# user can "edit" itself
if isinstance(target, User):
return target == self
# user can edit its owned devices
return target.owner == self
def can_admin(
self,
target: User | Device,
) -> bool:
"""
Check if this user can administer another user or a device.
"""
# only admin can "admin" anything
if not self.is_admin:
return False
# admin canot "admin itself"!
if isinstance(target, User) and target == self:
return False
# admin can "admin" everything else
return True
def can_create(
self,
target: type,
owner: User | None = None,
) -> bool:
"""
Check if this user can create another user or a device.
"""
# can never create anything but users or devices
if not issubclass(target, (User, Device)):
return False
# admin can "create" everything
if self.is_admin:
return True
# user can only create devices for itself
if target is Device and owner == self:
return True
# deny be default
return False
@property
def can_issue(self) -> bool:
"""
Check if this user can issue a certificate without approval.
"""
return (
self.is_admin
or self.has_tag(TagValue.issue)
)
@property
def can_renew(self) -> bool:
"""
Check if this user can renew a certificate without approval.
"""
return (
self.is_admin
or self.has_tag(TagValue.renew)
)

View file

@ -1,388 +1,118 @@
"""
Python interface to EasyRSA CA.
"""
from __future__ import annotations
import subprocess import subprocess
from enum import Enum, auto from datetime import datetime
from pathlib import Path from pathlib import Path
from cryptography import x509 from OpenSSL import crypto
from passlib import pwd from passlib import pwd
from pydantic import BaseModel
from .config import SETTINGS, Config, KeyAlgorithm
from .db import Connection, Device
class DistinguishedName(BaseModel):
"""
An `X.509 distinguished name` (DN) as specified in RFC 5280
"""
country: str
state: str
city: str
organization: str
organizational_unit: str
email: str
common_name: str
@classmethod
def build(cls, device: Device | None = None) -> DistinguishedName:
"""
Create a DN from the current config and an optional device
"""
# extract server DN config
server_dn = Config._.server_dn
result = cls(
country=server_dn.country.value,
state=server_dn.state.value,
city=server_dn.city.value,
organization=server_dn.organization.value,
organizational_unit=server_dn.organizational_unit.value,
email=server_dn.email.value,
common_name=server_dn.common_name,
)
# no device specified -> done
if device is None:
return result
# don't override locked or empty fields
if not (server_dn.country.locked
or device.owner.country is None):
result.country = device.owner.country
if not (server_dn.state.locked
or device.owner.state is None):
result.state = device.owner.state
if not (server_dn.city.locked
or device.owner.city is None):
result.city = device.owner.city
if not (server_dn.organization.locked
or device.owner.organization is None):
result.organization = device.owner.organization
if not (server_dn.organizational_unit.locked
or device.owner.organizational_unit is None):
result.organizational_unit = device.owner.organizational_unit
# definitely use derived email and common_name
result.email = device.owner.email
result.common_name = f"{device.owner.name}_{device.name}"
return result
@property
def easyrsa_env(self) -> dict[str, str]:
"""
Pass this DN as arguments to easyrsa
"""
return {
"EASYRSA_DN": "org",
"EASYRSA_REQ_COUNTRY": self.country,
"EASYRSA_REQ_PROVINCE": self.state,
"EASYRSA_REQ_CITY": self.city,
"EASYRSA_REQ_ORG": self.organization,
"EASYRSA_REQ_OU": self.organizational_unit,
"EASYRSA_REQ_EMAIL": self.email,
"EASYRSA_REQ_CN": self.common_name,
}
class CertificateType(Enum):
"""
Possible types of certificates
"""
client = auto()
server = auto()
def __str__(self) -> str:
return self._name_
class EasyRSA: class EasyRSA:
""" __directory: Path | None
Represents an EasyRSA PKI. __ca_password: str | None
"""
__mapKeyAlgorithm = { def __init__(self, directory: Path) -> None:
KeyAlgorithm.rsa2048: { self.__directory = directory
"EASYRSA_ALGO": "rsa",
"EASYRSA_KEY_SIZE": "2048",
},
KeyAlgorithm.rsa4096: {
"EASYRSA_ALGO": "rsa",
"EASYRSA_KEY_SIZE": "4096",
},
KeyAlgorithm.secp256r1: {
"EASYRSA_ALGO": "ec",
"EASYRSA_CURVE": "secp256r1",
},
KeyAlgorithm.secp384r1: {
"EASYRSA_ALGO": "ec",
"EASYRSA_CURVE": "secp384r1",
},
KeyAlgorithm.ed25519: {
"EASYRSA_ALGO": "ed",
"EASYRSA_CURVE": "ed25519",
},
None: {},
}
@property def set_ca_password(self, password: str | None = None) -> None:
def output_directory(self) -> Path: if password is None:
""" password = pwd.genword(length=32, charset="ascii_62")
Where certificates are stored
"""
return SETTINGS.data_dir.joinpath("pki") self.__ca_password = password
print(self.__ca_password)
@property
def ca_password(self) -> str:
"""
Get CA password from config, or generate a new one
"""
config = Config._
if (ca_password := config.crypto.ca_password) is None:
# generate and save new CA password
ca_password = pwd.genword(
length=32,
charset="ascii_62",
)
config.crypto.ca_password = ca_password
config.save()
return ca_password
def __easyrsa( def __easyrsa(
self, self,
*easyrsa_cmd: str, *easyrsa_args: str,
**easyrsa_env: str,
) -> subprocess.CompletedProcess: ) -> subprocess.CompletedProcess:
"""
Call the `easyrsa` executable
"""
return subprocess.run( return subprocess.run(
[ [
"/usr/local/bin/easyrsa", "easyrsa", "--batch",
*easyrsa_cmd, f"--pki-dir={self.__directory}",
*easyrsa_args,
], ],
env={
# base settings
"EASYRSA_BATCH": "1",
"EASYRSA_PKI": str(self.output_directory),
# always include CA password
"EASYRSA_PASSOUT": f"pass:{self.ca_password}",
"EASYRSA_PASSIN": f"pass:{self.ca_password}",
# include env from parameters
**easyrsa_env,
},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=True, check=True,
) )
def __build_cert( def __build_cert(
self, self,
*easyrsa_cmd: str, cert_filename: Path,
**easyrsa_env: str, *easyrsa_args: str,
) -> None: ) -> crypto.X509:
""" self.__easyrsa(*easyrsa_args)
Create an X.509 certificate
"""
config = Config._ with open(
self.__directory.joinpath(cert_filename), "r"
if ((algorithm := config.crypto.key_algorithm) ) as cert_file:
not in EasyRSA.__mapKeyAlgorithm): return crypto.load_certificate(
raise ValueError(f"Unexpected algorithm: {algorithm}") crypto.FILETYPE_PEM, cert_file.read()
# include expiry options
if (ca_expiry_days := config.crypto.ca_expiry_days) is not None:
easyrsa_env["EASYRSA_CA_EXPIRE"] = str(ca_expiry_days)
if (cert_expiry_days := config.crypto.cert_expiry_days) is not None:
easyrsa_env["EASYRSA_CERT_EXPIRE"] = str(cert_expiry_days)
try:
# call easyrsa
self.__easyrsa(
*easyrsa_cmd,
# include algorithm options
**EasyRSA.__mapKeyAlgorithm[algorithm],
**easyrsa_env,
) )
except (subprocess.CalledProcessError): def init_pki(self) -> bool:
# certificate couldn't be built
pass
return None
def get_certificate(
self,
*,
dn: DistinguishedName | None = None,
) -> x509.Certificate | None:
"""
Get a certificate from the PKI directory
"""
if dn is None:
cert_filename = self.output_directory.joinpath("ca.crt")
else:
cert_filename = (self.output_directory.joinpath("issued")
.joinpath(f"{dn.common_name}.crt"))
try:
# parse the certificate
with open(cert_filename, "rb") as cert_file:
return x509.load_pem_x509_certificate(
cert_file.read()
)
except FileNotFoundError:
return None
def init_pki(self) -> None:
"""
Clean working directory
"""
self.__easyrsa("init-pki") self.__easyrsa("init-pki")
def build_ca(self) -> x509.Certificate: def build_ca(
""" self,
Build the CA certificate days: int = 365 * 50,
""" cn: str = "kiwi-vpn-ca"
) -> crypto.X509:
cert = self.__build_cert(
Path("ca.crt"),
f"--passout=pass:{self.__ca_password}",
f"--passin=pass:{self.__ca_password}",
# "--dn-mode=org",
# "--req-c=EX",
# "--req-st=EXAMPLE",
# "--req-city=EXAMPLE",
# "--req-org=EXAMPLE",
# "--req-ou=EXAMPLE",
# "--req-email=EXAMPLE",
f"--req-cn={cn}",
f"--days={days}",
# "--use-algo=ed",
# "--curve=ed25519",
self.__build_cert(
"build-ca", "build-ca",
EASYRSA_DN="cn_only",
EASYRSA_REQ_CN="kiwi-vpn-ca",
) )
cert = self.get_certificate() self.__easyrsa("gen-dh")
assert cert is not None
# # this takes long!
# self.__easyrsa("gen-dh")
return cert return cert
def issue( def issue(
self, self,
cert_type: CertificateType = CertificateType.client, days: int = 365 * 50,
dn: DistinguishedName | None = None, cn: str = "kiwi-vpn-client",
) -> x509.Certificate | None: cert_type: str = "client"
""" ) -> crypto.X509:
Issue a client or server certificate return self.__build_cert(
""" Path(f"issued/{cn}.crt"),
if dn is None: f"--passin=pass:{self.__ca_password}",
dn = DistinguishedName.build() f"--days={days}",
if not (cert_type is CertificateType.client
or cert_type is CertificateType.server):
return None
self.__build_cert(
f"build-{cert_type}-full", f"build-{cert_type}-full",
dn.common_name, cn,
"nopass", "nopass",
**dn.easyrsa_env,
) )
return self.get_certificate(dn=dn)
def renew(
self,
dn: DistinguishedName | None = None,
) -> x509.Certificate | None:
"""
Renew a client or server certificate
"""
if dn is None:
dn = DistinguishedName.build()
self.__build_cert(
"renew",
dn.common_name,
"nopass",
# allow renewal 14 days before cert expiry
EASYRSA_CERT_RENEW="14",
**dn.easyrsa_env,
)
return self.get_certificate(dn=dn)
def revoke(
self,
dn: DistinguishedName | None = None,
) -> bool:
"""
Revoke a client or server certificate
"""
if dn is None:
dn = DistinguishedName.build()
try:
self.__easyrsa(
"revoke",
dn.common_name,
**dn.easyrsa_env,
)
except subprocess.CalledProcessError:
return False
return True
EASYRSA = EasyRSA()
# some basic test
if __name__ == "__main__": if __name__ == "__main__":
ca = EASYRSA.build_ca() easy_rsa = EasyRSA(Path("tmp/easyrsa"))
server = EASYRSA.issue(CertificateType.server) easy_rsa.init_pki()
client = None easy_rsa.set_ca_password()
# check if configured ca = easy_rsa.build_ca(cn="kiwi-vpn-ca")
if (current_config := Config.load()) is not None: server = easy_rsa.issue(cert_type="server", cn="kiwi-vpn-server")
# connect to database client = easy_rsa.issue(cert_type="client", cn="kiwi-vpn-client")
Connection.connect(current_config.db.uri)
if (device := Device.get(1)) is not None: date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
client = EASYRSA.issue(
dn=DistinguishedName.build(device)
)
for cert in (ca, server, client): for cert in [ca, server, client]:
if cert is not None: print(cert.get_subject().CN)
print(cert.subject) print(cert.get_signature_algorithm().decode(encoding))
print(cert.signature_hash_algorithm) print(datetime.strptime(
print(cert.not_valid_after) cert.get_notAfter().decode(encoding), date_format))

View file

@ -12,10 +12,14 @@ If run directly, uses `uvicorn` to run the app.
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from .config import SETTINGS, Config from .config import Config, Settings
from .db import Connection from .db import Connection
from .db.schemas import User
from .routers import main_router from .routers import main_router
settings = Settings.get()
app = FastAPI( app = FastAPI(
title="kiwi-vpn API", title="kiwi-vpn API",
description="This API enables the `kiwi-vpn` service.", description="This API enables the `kiwi-vpn` service.",
@ -27,9 +31,9 @@ app = FastAPI(
"name": "MIT License", "name": "MIT License",
"url": "https://opensource.org/licenses/mit-license.php", "url": "https://opensource.org/licenses/mit-license.php",
}, },
openapi_url=SETTINGS.openapi_url, openapi_url=settings.openapi_url,
docs_url=SETTINGS.docs_url if not SETTINGS.production_mode else None, docs_url=settings.docs_url if not settings.production_mode else None,
redoc_url=SETTINGS.redoc_url if not SETTINGS.production_mode else None, redoc_url=settings.redoc_url if not settings.production_mode else None,
) )
app.include_router(main_router) app.include_router(main_router)
@ -38,14 +42,19 @@ app.include_router(main_router)
@app.on_event("startup") @app.on_event("startup")
async def on_startup() -> None: async def on_startup() -> None:
# check if configured # check if configured
if (current_config := Config.load()) is not None: if (current_config := await Config.load()) is not None:
# connect to database # connect to database
Connection.connect(current_config.db.uri) Connection.connect(await current_config.db.db_engine)
# some testing
with Connection.use() as db:
print(User.from_db(db, "admin"))
print(User.from_db(db, "nonexistent"))
def main() -> None: def main() -> None:
uvicorn.run( uvicorn.run(
app="kiwi_vpn_api.main:app", "kiwi_vpn_api.main:app",
host="0.0.0.0", host="0.0.0.0",
port=8000, port=8000,
reload=True, reload=True,

View file

@ -1,21 +1,10 @@
"""
Package `routers`: Each module contains the path operations for their prefixes.
This file: Main API router definition.
"""
from fastapi import APIRouter from fastapi import APIRouter
from ..config import SETTINGS from . import admin, user
from . import admin, device, service, user
main_router = APIRouter(prefix=f"/{SETTINGS.api_v1_prefix}") main_router = APIRouter(prefix="/api/v1")
main_router.include_router(admin.router) main_router.include_router(admin.router)
main_router.include_router(service.router)
main_router.include_router(device.router)
main_router.include_router(user.router) main_router.include_router(user.router)
__all__ = [ __all__ = ["main_router"]
"main_router",
]

View file

@ -2,16 +2,16 @@
Common dependencies for routers. Common dependencies for routers.
""" """
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from ..config import SETTINGS, Config from ..config import Config
from ..db import Device, User from ..db import Connection
from ..easyrsa import EASYRSA, EasyRSA from ..db.schemas import User
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(tokenUrl="user/authenticate")
tokenUrl=f"{SETTINGS.api_v1_prefix}/user/authenticate"
)
class Responses: class Responses:
@ -22,33 +22,26 @@ class Responses:
""" """
OK = { OK = {
"description": "Operation successful",
}
OK_NONE = {
"description": "Operation successful",
"content": None, "content": None,
} }
OK_WAIT = { INSTALLED = {
"description": "Operation successful, waiting for approval", "description": "kiwi-vpn already installed",
"content": None,
} }
NOT_INSTALLED = { NOT_INSTALLED = {
"description": "kiwi-vpn not installed", "description": "kiwi-vpn not installed",
"content": None, "content": None,
} }
NEEDS_USER = { NEEDS_USER = {
"description": "Not logged in", "description": "Must be logged in",
"content": None, "content": None,
} }
NEEDS_PERMISSION = { NEEDS_ADMIN = {
"description": "Operation not permitted", "description": "Must be admin",
"content": None, "content": None,
} }
NEEDS_PKI = { NEEDS_ADMIN_OR_SELF = {
"description": "PKI hasn't been initialized", "description": "Must be the requested user",
"content": None,
}
ENTRY_ADDED = {
"description": "Entry added to database",
"content": None, "content": None,
} }
ENTRY_EXISTS = { ENTRY_EXISTS = {
@ -61,98 +54,69 @@ class Responses:
} }
async def get_current_config( async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session | None = Depends(Connection.get),
current_config: Config | None = Depends(Config.load), current_config: Config | None = Depends(Config.load),
) -> Config: ) -> User | None:
""" """
Get the current configuration if it exists. Get the currently logged-in user from the database.
Status:
- 400: `kiwi-vpn` not installed
""" """
# fail if not configured # can't connect to an unconfigured database
if current_config is None: if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
return current_config username = await current_config.jwt.decode_token(token)
user = User.from_db(db, username)
return user
async def get_current_user( async def get_current_user_if_exists(
token: str = Depends(oauth2_scheme), current_config: Config | None = Depends(Config.load),
current_config: Config = Depends(get_current_config), current_user: User | None = Depends(get_current_user),
) -> User: ) -> User:
""" """
Get the currently logged-in user if it exists. Get the currently logged-in user if it exists.
Status:
- (400: `kiwi-vpn` not installed)
- 401: No auth token provided/not logged in
- 403: invalid auth token, or user not found
""" """
# don't use error 404 here - possible user enumeration
# fail if not requested by a user # fail if not requested by a user
if (username := await current_config.jwt.decode_token(token)) is None: if current_user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if (user := User.get(username)) is None: return current_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return user
async def get_user_by_name( async def get_current_user_if_admin(
user_name: str, current_config: Config | None = Depends(Config.load),
current_user: User = Depends(get_current_user_if_exists),
) -> User: ) -> User:
""" """
Get a user by name. Get the currently logged-in user if it is an admin.
Status:
- 403: user not found
""" """
# don't use error 404 here - possible user enumeration # fail if not requested by an admin
if not current_user.is_admin():
# fail if user doesn't exist
if (user := User.get(user_name)) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return user return current_user
async def get_device_by_id( async def get_current_user_if_admin_or_self(
device_id: int, user_name: str,
) -> Device: current_config: Config | None = Depends(Config.load),
current_user: User = Depends(get_current_user_if_exists),
) -> User:
""" """
Get a device by ID. Get the currently logged-in user.
Status: Fails a) if the currently logged-in user is not the requested user,
and b) if it is not an admin.
- 404: device not found
""" """
# fail if device doesn't exist # fail if not requested by an admin or self
if (device := Device.get(device_id)) is None: if not (current_user.is_admin() or current_user.name == user_name):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return device return current_user
async def get_pki() -> EasyRSA:
"""
Get the EasyRSA object if the CA has been built.
Status:
- 425: EasyRSA not initialized
"""
if EASYRSA.get_certificate() is None:
raise HTTPException(status_code=status.HTTP_425_TOO_EARLY)
return EASYRSA

View file

@ -2,99 +2,80 @@
/admin endpoints. /admin endpoints.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select
from ..config import Config from ..config import Config
from ..db import Connection, TagValue, User, UserCreate from ..db import Connection
from ._common import Responses, get_current_config, get_current_user from ..db.schemas import User, UserCapability, UserCreate
from ._common import Responses, get_current_user
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@router.put( @router.put(
"/install/config", "/install",
responses={ responses={
status.HTTP_200_OK: Responses.OK_NONE, status.HTTP_200_OK: Responses.OK,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, status.HTTP_400_BAD_REQUEST: Responses.INSTALLED,
}, },
) )
async def initial_configure( async def install(
config: Config, config: Config,
admin_user: UserCreate,
current_config: Config | None = Depends(Config.load), current_config: Config | None = Depends(Config.load),
): ):
""" """
PUT ./install/config: Configure `kiwi-vpn`. PUT ./install: Install `kiwi-vpn`.
Status:
- 409: `kiwi-vpn` already installed
""" """
# fail if already configured # fail if already installed
if current_config is not None: if current_config is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# create config file, connect to database # create config file, connect to database
config.save() await config.save()
Connection.connect(config.db.uri) Connection.connect(await config.db.db_engine)
@router.put(
"/install/admin",
responses={
status.HTTP_201_CREATED: Responses.OK_NONE,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
status_code=status.HTTP_201_CREATED,
)
async def create_initial_admin(
admin_user: UserCreate,
_: Config = Depends(get_current_config),
):
"""
PUT ./install/admin: Create the first administrative user.
Status:
- 409: not the first user
"""
# fail if any user exists
with Connection.session as db:
if db.exec(select(User).limit(1)).first() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# create an administrative user # create an administrative user
if (new_user := User.create(user=admin_user)) is None: with Connection.use() as db:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) new_user = User.create(
db=db,
user=admin_user,
crypt_context=await config.crypto.crypt_context,
)
new_user.add_tags([TagValue.admin]) new_user.capabilities.append(UserCapability.admin)
new_user.update() new_user.update(db)
@router.put( @router.put(
"/config", "/config",
responses={ responses={
status.HTTP_200_OK: Responses.OK_NONE, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
}, },
) )
async def set_config( async def set_config(
config: Config, new_config: Config,
current_user: User = Depends(get_current_user), current_config: Config | None = Depends(Config.load),
current_user: User | None = Depends(get_current_user),
): ):
""" """
PUT ./config: Edit `kiwi-vpn` main config. PUT ./config: Edit `kiwi-vpn` main config.
""" """
# check permissions # fail if not installed
if not current_user.is_admin: if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# fail if not requested by an admin
if (current_user is None
or UserCapability.admin not in current_user.capabilities):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# update config file, reconnect to database # update config file, reconnect to database
config.save() await new_config.save()
Connection.connect(config.db.uri) Connection.connect(await new_config.db.db_engine)

View file

@ -1,248 +0,0 @@
"""
/device endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, Response, status
from ..db import Device, DeviceCreate, DeviceRead, DeviceStatus, User
from ..easyrsa import DistinguishedName, EasyRSA
from ._common import (Responses, get_current_user, get_device_by_id, get_pki,
get_user_by_name)
router = APIRouter(prefix="/device", tags=["device"])
@router.post(
"/{user_name}",
responses={
status.HTTP_201_CREATED: Responses.ENTRY_ADDED,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
response_model=DeviceRead,
status_code=status.HTTP_201_CREATED,
)
async def add_device(
device: DeviceCreate,
current_user: User = Depends(get_current_user),
owner: User = Depends(get_user_by_name),
) -> Device:
"""
POST ./: Create a new device in the database.
Status:
- 403: no user permission to create device
- 409: device creation unsuccessful
"""
# check permission
if not current_user.can_create(Device, owner):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# create the new device
new_device = Device.create(
owner=owner,
device=device,
)
# fail if creation was unsuccessful
if new_device is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# return the created device on success
return new_device
@router.delete(
"/{device_id}",
responses={
status.HTTP_200_OK: Responses.OK_NONE,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
},
response_model=User,
)
async def remove_device(
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
):
"""
DELETE ./{device_id}: Remove a device from the database.
Status:
- 403: no user permission to edit device
"""
# check permission
if not current_user.can_edit(device):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# delete device
device.delete()
@router.put(
"/{device_id}/certificate",
responses={
status.HTTP_200_OK: Responses.OK | {"model": DeviceRead},
status.HTTP_202_ACCEPTED: Responses.OK_WAIT | {"model": DeviceRead},
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
status.HTTP_425_TOO_EARLY: Responses.NEEDS_PKI,
},
response_model=DeviceRead,
status_code=status.HTTP_202_ACCEPTED,
)
async def request_certificate_issuance(
response: Response,
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
pki: EasyRSA = Depends(get_pki),
) -> Device:
"""
PUT ./{device_id}/certificate: Request certificate issuance for a device.
Status:
- 200: certificate issued
- 202: issuance requested
- 403: no user permission to edit device
- 409: device certificate cannot be "issued"
"""
# check permission
if not current_user.can_edit(device):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# can only "request" on an uncertified device
if device.status is not DeviceStatus.uncertified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
device.set_status(DeviceStatus.pending)
# check if we can issue the certificate immediately
if current_user.can_issue:
if (certificate := pki.issue(
dn=DistinguishedName.build(device)
)) is not None:
device.set_status(DeviceStatus.certified)
device.expiry = certificate.not_valid_after
response.status_code = status.HTTP_200_OK
# return updated device
device.update()
return device
@router.patch(
"/{device_id}/certificate",
responses={
status.HTTP_200_OK: Responses.OK | {"model": DeviceRead},
status.HTTP_202_ACCEPTED: Responses.OK_WAIT | {"model": DeviceRead},
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
status.HTTP_425_TOO_EARLY: Responses.NEEDS_PKI,
},
response_model=DeviceRead,
status_code=status.HTTP_202_ACCEPTED,
)
async def request_certificate_renewal(
response: Response,
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
pki: EasyRSA = Depends(get_pki),
) -> Device:
"""
PATCH ./{device_id}/certificate: Request certificate renewal for a device.
Status:
- 200: certificate renewed
- 202: renewal requested
- 403: no user permission to edit device
- 409: device certificate cannot be "renewed"
"""
# check permission
if not current_user.can_edit(device):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# can only "renew" on an already certified device
if device.status is not DeviceStatus.certified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
device.set_status(DeviceStatus.pending)
# check if we can renew the certificate immediately
if current_user.can_renew:
if (certificate := pki.renew(
dn=DistinguishedName.build(device)
)) is not None:
device.set_status(DeviceStatus.certified)
device.expiry = certificate.not_valid_after
response.status_code = status.HTTP_200_OK
# return updated device
device.update()
return device
@router.delete(
"/{device_id}/certificate",
responses={
status.HTTP_200_OK: Responses.OK_NONE,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
status.HTTP_425_TOO_EARLY: Responses.NEEDS_PKI,
},
response_model=DeviceRead,
)
async def revoke_certificate(
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
pki: EasyRSA = Depends(get_pki),
) -> Device:
"""
DELETE ./{device_id}/certificate: Revoke a device certificate.
Status:
- 403: no user permission to edit device
- 409: device certificate cannot be "revoked"
"""
# check permission
if not current_user.can_edit(device):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# can only "revoke" on a currently certified device
if device.status is not DeviceStatus.certified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# revoke the device certificate
pki.revoke(dn=DistinguishedName.build(device))
# reset the device
device.set_status(DeviceStatus.uncertified)
device.expiry = None
# return updated device
device.update()
return device

View file

@ -0,0 +1,58 @@
"""
/dn endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..db import Connection
from ..db.schemas import DistinguishedName, DistinguishedNameCreate, User
from ._common import Responses, get_current_user_if_admin_or_self
router = APIRouter(prefix="/dn")
@router.post(
"",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
)
async def add_distinguished_name(
user_name: str,
distinguished_name: DistinguishedNameCreate,
_: User = Depends(get_current_user_if_admin_or_self),
db: Session | None = Depends(Connection.get),
):
"""
POST ./: Create a new distinguished name in the database.
"""
owner = User.from_db(
db=db,
name=user_name,
)
# fail if owner doesn't exist
if owner is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# actually create the new user
new_dn = DistinguishedName.create(
db=db,
dn=distinguished_name,
owner=owner,
)
# fail if creation was unsuccessful
if new_dn is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# return the created user on success
return new_dn

View file

@ -1,34 +0,0 @@
"""
/service endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from ..db import User
from ..easyrsa import CertificateType, EasyRSA
from ._common import Responses, get_current_user
router = APIRouter(prefix="/service", tags=["service"])
@router.put(
"/pki/init",
responses={
status.HTTP_200_OK: Responses.OK_NONE,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
},
)
async def init_pki(
current_user: User = Depends(get_current_user),
) -> None:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
easy_rsa = EasyRSA()
easy_rsa.init_pki()
easy_rsa.build_ca()
easy_rsa.issue(CertificateType.server)

View file

@ -5,11 +5,12 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..config import Config from ..config import Config
from ..db import TagValue, User, UserCreate, UserRead from ..db import Connection
from ._common import (Responses, get_current_config, get_current_user, from ..db.schemas import User, UserCapability, UserCreate
get_user_by_name) from ._common import Responses, get_current_user, get_current_user_if_admin
router = APIRouter(prefix="/user", tags=["user"]) router = APIRouter(prefix="/user", tags=["user"])
@ -23,32 +24,27 @@ class Token(BaseModel):
token_type: str token_type: str
@router.post( @router.post("/authenticate", response_model=Token)
"/authenticate",
responses={
status.HTTP_200_OK: Responses.OK_NONE,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
},
response_model=Token,
)
async def login( async def login(
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
current_config: Config = Depends(get_current_config), current_config: Config | None = Depends(Config.load),
db: Session | None = Depends(Connection.get),
): ):
""" """
POST ./authenticate: Authenticate a user. Issues a bearer token. POST ./authenticate: Authenticate a user. Issues a bearer token.
Status:
- 401: username/password is incorrect
""" """
# fail if not installed
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# try logging in # try logging in
if (user := User.authenticate( user = User(name=form_data.username)
name=form_data.username, if not user.authenticate(
db=db,
password=form_data.password, password=form_data.password,
)) is None: crypt_context=await current_config.crypto.crypt_context,
):
# authentication failed # authentication failed
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@ -61,18 +57,9 @@ async def login(
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.get( @router.get("/current", response_model=User)
"/current", async def get_current_user(
responses={ current_user: User | None = Depends(get_current_user),
status.HTTP_200_OK: Responses.OK_NONE,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_USER,
},
response_model=UserRead,
)
async def get_current_user_route(
current_user: User = Depends(get_current_user),
): ):
""" """
GET ./current: Respond with the currently logged-in user. GET ./current: Respond with the currently logged-in user.
@ -84,42 +71,35 @@ async def get_current_user_route(
@router.post( @router.post(
"", "",
responses={ responses={
status.HTTP_201_CREATED: Responses.ENTRY_ADDED, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
}, },
response_model=UserRead, response_model=User,
status_code=status.HTTP_201_CREATED,
) )
async def add_user( async def add_user(
user: UserCreate, user: UserCreate,
current_user: User = Depends(get_current_user), current_config: Config | None = Depends(Config.load),
) -> User: _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
):
""" """
POST ./: Create a new user in the database. POST ./: Create a new user in the database.
Status:
- 403: no user permission to create user
- 409: user could not be created
""" """
# check permissions # actually create the new user
if not current_user.can_create(User): new_user = User.create(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) db=db,
user=user,
# create the new user crypt_context=await current_config.crypto.crypt_context,
new_user = User.create(user=user) )
# fail if creation was unsuccessful # fail if creation was unsuccessful
if new_user is None: if new_user is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
new_user.add_tags([TagValue.login])
new_user.update()
# return the created user on success # return the created user on success
return new_user return new_user
@ -127,91 +107,89 @@ async def add_user(
@router.delete( @router.delete(
"/{user_name}", "/{user_name}",
responses={ responses={
status.HTTP_200_OK: Responses.OK_NONE, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
}, },
response_model=User, response_model=User,
) )
async def remove_user( async def remove_user(
current_user: User = Depends(get_current_user), user_name: str,
user: User = Depends(get_user_by_name), _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
): ):
""" """
DELETE ./{user_name}: Remove a user from the database. DELETE ./{user_name}: Remove a user from the database.
Status:
- 403: no user permission to admin user
""" """
# check permissions # get the user
if not current_user.can_admin(user): user = User.from_db(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) db=db,
name=user_name,
)
# delete user # fail if deletion was unsuccessful
user.delete() if user is None or not user.delete(db):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@router.post( @router.post(
"/{user_name}/tags", "/{user_name}/capabilities",
responses={ responses={
status.HTTP_201_CREATED: Responses.ENTRY_ADDED, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
}, },
status_code=status.HTTP_201_CREATED,
) )
async def extend_tags( async def extend_capabilities(
tags: list[TagValue], user_name: str,
current_user: User = Depends(get_current_user), capabilities: list[UserCapability],
user: User = Depends(get_user_by_name), _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
): ):
""" """
POST ./{user_name}/tags: Add tags to a user. POST ./{user_name}/capabilities: Add capabilities to a user.
Status:
- 403: no user permission to admin user
""" """
# check permissions # get and change the user
if not current_user.can_admin(user): user = User.from_db(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) db=db,
name=user_name,
)
# change user user.capabilities.extend(capabilities)
user.add_tags(tags) user.update(db)
user.update()
@router.delete( @router.delete(
"/{user_name}/tags", "/{user_name}/capabilities",
responses={ responses={
status.HTTP_200_OK: Responses.OK_NONE, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
}, },
) )
async def remove_tags( async def remove_capabilities(
tags: list[TagValue], user_name: str,
current_user: User = Depends(get_current_user), capabilities: list[UserCapability],
user: User = Depends(get_user_by_name), _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
): ):
""" """
DELETE ./{user_name}/tags: Remove tags from a user. DELETE ./{user_name}/capabilities: Remove capabilities from a user.
Status:
- 403: no user permission to admin user
""" """
# check permissions # get and change the user
if not current_user.can_admin(user): user = User.from_db(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) db=db,
name=user_name,
)
# change user for capability in capabilities:
user.remove_tags(tags) user.capabilities.remove(capability)
user.update()
user.update(db)

View file

@ -1,46 +0,0 @@
## Server props
- default DN parts: country, state, city, org, OU
- "customizable" flags for DN parts
- flag: use client-to-client
- force cipher, tls-cipher, auth params
- server name
- default certification duration
- default certificate algo
## User props
- username (CN part)
- password
- custom DN parts: country, state, city, org, OU
- email (DN part)
- tags
## User tags
- admin: administrator
- login: can log into the web interface
- issue: can certify own devices (without approval)
- renew: can renew certificates for own devices (without approval)
## Device props
- name (CN part)
- type (icon)
- approved: bool
- expiry
## Device status
- created (approved = NULL): device has been newly created
- requested (approved = false): certificate has been requested (issue or renew)
- issued (approved = true): certificate has been granted (may be expired)
## Permissions
- admin cannot "admin" itself (to prevent self decapitation)
- admin can "edit", "admin" and "create" everything else
- user can "edit" itself and its devices
- user can "create" devices for itself
### User
- edit: change DN parts, password
- admin: add or remove tag, delete, generate password
### Device
- edit: change type, delete, request
- admin: approve

146
api/poetry.lock generated
View file

@ -108,11 +108,11 @@ pycparser = "*"
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.2" version = "8.0.4"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.6"
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""} colorama = {version = "*", markers = "platform_system == \"Windows\""}
@ -161,7 +161,7 @@ gmpy2 = ["gmpy2"]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.75.1" version = "0.75.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main" category = "main"
optional = false optional = false
@ -174,8 +174,8 @@ starlette = "0.17.1"
[package.extras] [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)"] 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)"] 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 (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<6.0.0)"] doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.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 (==22.3.0)", "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)"] 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]] [[package]]
name = "greenlet" name = "greenlet"
@ -292,6 +292,21 @@ typing-extensions = ">=3.7.4.3"
dotenv = ["python-dotenv (>=0.10.4)"] dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"] email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyopenssl"
version = "22.0.0"
description = "Python wrapper module around the OpenSSL library"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cryptography = ">=35.0"
[package.extras]
docs = ["sphinx", "sphinx-rtd-theme"]
test = ["flaky", "pretend", "pytest (>=3.0.1)"]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.0.7" version = "3.0.7"
@ -383,7 +398,7 @@ python-versions = ">=3.5"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.35" version = "1.4.32"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -396,7 +411,7 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"] asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"]
mariadb_connector = ["mariadb (>=1.0.1)"] mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"] mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"] mssql_pymssql = ["pymssql"]
@ -413,30 +428,6 @@ postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"] pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"] sqlcipher = ["sqlcipher3-binary"]
[[package]]
name = "sqlalchemy2-stubs"
version = "0.0.2a21"
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.6"
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]] [[package]]
name = "starlette" name = "starlette"
version = "0.17.1" version = "0.17.1"
@ -486,7 +477,7 @@ standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "p
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "36a56b6982734607590597302276605f8977119869934f35116e72377905b6b5" content-hash = "432d2933102f8a0091cec1b5484944a0211ca74c5dc9b65877d99d7bd160e4bb"
[metadata.files] [metadata.files]
anyio = [ anyio = [
@ -597,8 +588,8 @@ cffi = [
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
] ]
click = [ click = [
{file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
{file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
] ]
colorama = [ colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@ -631,8 +622,8 @@ ecdsa = [
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"}, {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
] ]
fastapi = [ fastapi = [
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"}, {file = "fastapi-0.75.0-py3-none-any.whl", hash = "sha256:43d12891b78fc497a50623e9c7c24640c569489f060acd9ce2c4902080487a93"},
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"}, {file = "fastapi-0.75.0.tar.gz", hash = "sha256:124774ce4cb3322841965f559669b233a0b8d343ea24fdd8b293253c077220d7"},
] ]
greenlet = [ greenlet = [
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
@ -775,6 +766,10 @@ pydantic = [
{file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
{file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
] ]
pyopenssl = [
{file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"},
{file = "pyOpenSSL-22.0.0.tar.gz", hash = "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf"},
]
pyparsing = [ pyparsing = [
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
@ -803,50 +798,41 @@ sniffio = [
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
] ]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.35-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win32.whl", hash = "sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win_amd64.whl", hash = "sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157"}, {file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
{file = "SQLAlchemy-1.4.35-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4efb70a62cbbbc052c67dc66b5448b0053b509732184af3e7859d05fdf6223c"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-win32.whl", hash = "sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-win_amd64.whl", hash = "sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfd8e4c64c30a5219032e64404d468c425bdbc13b397da906fc9bee6591fc0dd"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win32.whl", hash = "sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win_amd64.whl", hash = "sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2489e70bfa2356f2d421106794507daccf6cc8711753c442fc97272437fc606"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win32.whl", hash = "sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win_amd64.whl", hash = "sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95411abc0e36d18f54fa5e24d42960ea3f144fb16caaa5a8c2e492b5424cc82c"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-win32.whl", hash = "sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-win_amd64.whl", hash = "sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28aa2ef06c904729620cc735262192e622db9136c26d8587f71f29ec7715628a"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-win32.whl", hash = "sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-win_amd64.whl", hash = "sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a"}, {file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
{file = "SQLAlchemy-1.4.35.tar.gz", hash = "sha256:2ffc813b01dc6473990f5e575f210ca5ac2f5465ace3908b78ffd6d20058aab5"},
]
sqlalchemy2-stubs = [
{file = "sqlalchemy2-stubs-0.0.2a21.tar.gz", hash = "sha256:207e3d8a36fc032d325f4eec89e0c6760efe81d07e978513d8c9b14f108dcd0c"},
{file = "sqlalchemy2_stubs-0.0.2a21-py3-none-any.whl", hash = "sha256:bd4a3d5ca7ff9d01b2245e1b26304d6b2ec4daf43a01faf40db9e09245679433"},
]
sqlmodel = [
{file = "sqlmodel-0.0.6-py3-none-any.whl", hash = "sha256:c5fd8719e09da348cd32ce2a5b6a44f289d3029fa8f1c9818229b6f34f1201b4"},
{file = "sqlmodel-0.0.6.tar.gz", hash = "sha256:3b4f966b9671b24d85529d274e6c4dbc7753b468e35d2d6a40bd75cad1f66813"},
] ]
starlette = [ starlette = [
{file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"},

View file

@ -1,19 +1,18 @@
[tool.poetry] [tool.poetry]
authors = ["Jörn-Michael Miehe <40151420+ldericher@users.noreply.github.com>"]
description = ""
name = "kiwi-vpn-api" name = "kiwi-vpn-api"
version = "0.1.0" version = "0.1.0"
description = ""
authors = ["Jörn-Michael Miehe <40151420+ldericher@users.noreply.github.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
fastapi = "^0.75.0" fastapi = "^0.75.0"
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
python-multipart = "^0.0.5" python-multipart = "^0.0.5"
sqlmodel = "^0.0.6"
uvicorn = "^0.17.6" uvicorn = "^0.17.6"
cryptography = "^36.0.2" python-jose = {extras = ["cryptography"], version = "^3.3.0"}
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
SQLAlchemy = "^1.4.32"
pyOpenSSL = "^22.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.0" pytest = "^7.1.0"
@ -22,5 +21,5 @@ pytest = "^7.1.0"
kiwi-vpn-api = "kiwi_vpn_api.main:main" kiwi-vpn-api = "kiwi_vpn_api.main:main"
[build-system] [build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"