Compare commits
131 commits
master
...
permission
Author | SHA1 | Date | |
---|---|---|---|
48d8eb077d | |||
64f8c416ab | |||
aa7becf057 | |||
96a3aed24e | |||
dcce31da0b | |||
047b565331 | |||
3487c2e0f1 | |||
423cc009f8 | |||
e6fe35d14e | |||
c0388d58c1 | |||
bca5b2b55c | |||
2d755b8e3d | |||
d89409f973 | |||
f2948a7b64 | |||
143e9a9fa9 | |||
c94e07fbac | |||
d8bdb46a5c | |||
2d39c4aaa3 | |||
054b351435 | |||
8079036c75 | |||
b421d6f79b | |||
e6c270a0fa | |||
5b68f5ef7e | |||
762af5dd48 | |||
821d72a773 | |||
78e0515042 | |||
72fc209349 | |||
b291c20ed6 | |||
3b79efaa80 | |||
ae16c884d6 | |||
26d171e6d3 | |||
eb2301d193 | |||
583d1de06a | |||
a88168b8d4 | |||
008f0b2cf6 | |||
5d0d996288 | |||
69b0a619e0 | |||
968e9491cf | |||
2566702d9e | |||
23a806e325 | |||
a524c02138 | |||
366b4dc6a0 | |||
1f4a9994a6 | |||
d98d234cc1 | |||
c1e7f31501 | |||
d9552cbf42 | |||
24721dd342 | |||
e1ae186382 | |||
e078c7b094 | |||
53cb7c9c1e | |||
d6702165b8 | |||
f6032829cd | |||
667fcba559 | |||
b202f85d3b | |||
f899e0c0df | |||
d02239816a | |||
cb3a3fca69 | |||
598b0ca2cb | |||
3b66565481 | |||
9b5a98e0c0 | |||
03d3a86668 | |||
0d02c24b64 | |||
bb53bab0c0 | |||
e11f96b0af | |||
fdce81c5a3 | |||
5990577699 | |||
617ae92d72 | |||
0c8298871f | |||
ec0f7890ef | |||
fdc85bf529 | |||
f058f29d9a | |||
4120a9b71f | |||
4ca92a11b2 | |||
8a0058f7f0 | |||
d3ed11fce4 | |||
186ac0eab3 | |||
865e712ea5 | |||
cd3cccb540 | |||
e4548aab3a | |||
a87a1848c9 | |||
d42ef089ff | |||
567b863742 | |||
dbbe7a8c35 | |||
6254daa51d | |||
a465dba92e | |||
7dbd25b894 | |||
21b85d7cfa | |||
499c97a28a | |||
5b623e885c | |||
270de7f87c | |||
1b24861e48 | |||
3d2abbc39b | |||
77b40cb836 | |||
a5783a0c40 | |||
aa8563995e | |||
799b2f7585 | |||
d9a9ad98f7 | |||
71ac02e5d7 | |||
d406f15382 | |||
a20699d6ca | |||
6b6da69bb4 | |||
ca955d1104 | |||
3d83ddb6cc | |||
b7179e7cfc | |||
ce9ea61da5 | |||
19dd5aaee7 | |||
ae8894f5cc | |||
12432286bf | |||
b4a74aca5f | |||
0619f00f6a | |||
6012998ecf | |||
d41cd9b15b | |||
22e1ef7bf4 | |||
396359ceff | |||
89069c9d0f | |||
bc3f7984f5 | |||
c7f93d468e | |||
24ade65bb0 | |||
04a5798258 | |||
730c7ab966 | |||
e2f916debc | |||
9625336df9 | |||
ae3627cbe0 | |||
e7030eb521 | |||
8daeb946f8 | |||
b5e9323026 | |||
94fbab278c | |||
557bceed1f | |||
c47fa5a89b | |||
02225cdf09 | |||
1ed3b587c7 |
22 changed files with 1647 additions and 872 deletions
9
api/.vscode/launch.json
vendored
9
api/.vscode/launch.json
vendored
|
@ -5,11 +5,18 @@
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python: Modul",
|
"name": "Main App",
|
||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
3
api/.vscode/settings.json
vendored
3
api/.vscode/settings.json
vendored
|
@ -11,5 +11,6 @@
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": true
|
"source.organizeImports": true
|
||||||
}
|
},
|
||||||
|
"git.closeDiffOnOperation": true
|
||||||
}
|
}
|
|
@ -9,19 +9,17 @@ 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, Field, validator
|
from pydantic import BaseModel, BaseSettings, constr, validator
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.engine import Engine
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
@ -31,18 +29,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("config.json")
|
return self.data_dir.joinpath(self.config_file_name)
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS = Settings()
|
||||||
|
|
||||||
|
|
||||||
class DBType(Enum):
|
class DBType(Enum):
|
||||||
|
@ -63,23 +61,20 @@ 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 | None = Settings.get().data_dir.joinpath("vpn.db")
|
database: str | Path | None = SETTINGS.data_dir.joinpath("kiwi-vpn.db")
|
||||||
|
|
||||||
mysql_driver: str = "pymysql"
|
mysql_driver: str = "pymysql"
|
||||||
mysql_args: list[str] = ["charset=utf8mb4"]
|
mysql_args: list[str] = ["charset=utf8mb4"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
async def db_engine(self) -> Engine:
|
def uri(self) -> str:
|
||||||
"""
|
"""
|
||||||
Construct an SQLAlchemy engine
|
Construct a database connection string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.type is DBType.sqlite:
|
if self.type is DBType.sqlite:
|
||||||
# SQLite backend
|
# SQLite backend
|
||||||
return create_engine(
|
return f"sqlite:///{self.database}"
|
||||||
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
|
||||||
|
@ -88,12 +83,11 @@ class DBConfig(BaseModel):
|
||||||
else:
|
else:
|
||||||
args_str = ""
|
args_str = ""
|
||||||
|
|
||||||
return create_engine(
|
return (f"mysql+{self.mysql_driver}://"
|
||||||
f"mysql+{self.mysql_driver}://"
|
f"{self.user}:{self.password}@{self.host}"
|
||||||
f"{self.user}:{self.password}@{self.host}"
|
f"/{self.database}{args_str}")
|
||||||
f"/{self.database}{args_str}",
|
|
||||||
pool_recycle=3600,
|
return ""
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JWTConfig(BaseModel):
|
class JWTConfig(BaseModel):
|
||||||
|
@ -166,22 +160,68 @@ class JWTConfig(BaseModel):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# get username
|
# get username
|
||||||
username = payload.get("sub")
|
return 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 hash algorithms
|
Configuration for cryptography
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 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
|
||||||
async def crypt_context(self) -> CryptContext:
|
def context(self) -> CryptContext:
|
||||||
return CryptContext(
|
return CryptContext(
|
||||||
schemes=self.schemes,
|
schemes=self.schemes,
|
||||||
deprecated="auto",
|
deprecated="auto",
|
||||||
|
@ -193,27 +233,48 @@ class Config(BaseModel):
|
||||||
Configuration for `kiwi-vpn-api`
|
Configuration for `kiwi-vpn-api`
|
||||||
"""
|
"""
|
||||||
|
|
||||||
db: DBConfig = Field(default_factory=DBConfig)
|
# may include client-to-client, cipher etc.
|
||||||
jwt: JWTConfig = Field(default_factory=JWTConfig)
|
openvpn_extra_options: dict[str, Any] | None
|
||||||
crypto: CryptoConfig = Field(default_factory=CryptoConfig)
|
|
||||||
|
|
||||||
@staticmethod
|
db: DBConfig
|
||||||
async def load() -> Config | None:
|
jwt: JWTConfig
|
||||||
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
if cls.__instance is None:
|
||||||
with open(Settings.get().config_file, "r") as config_file:
|
try:
|
||||||
return Config.parse_obj(json.load(config_file))
|
with open(SETTINGS.config_file, "r") as config_file:
|
||||||
|
cls.__instance = cls.parse_obj(json.load(config_file))
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def save(self) -> None:
|
return cls.__instance
|
||||||
|
|
||||||
|
@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.get().config_file, "w") as config_file:
|
with open(SETTINGS.config_file, "w") as config_file:
|
||||||
config_file.write(self.json(indent=2))
|
config_file.write(self.json(indent=2))
|
||||||
|
|
|
@ -1,4 +1,21 @@
|
||||||
from . import models, schemas
|
"""
|
||||||
from .connection import Connection
|
Package `db`: ORM and schemas for database content.
|
||||||
|
"""
|
||||||
|
|
||||||
__all__ = ["Connection", "models", "schemas"]
|
from .connection import Connection
|
||||||
|
from .device import Device, DeviceBase, DeviceCreate, DeviceRead
|
||||||
|
from .tag import TagValue
|
||||||
|
from .user import User, UserBase, UserCreate, UserRead
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Connection",
|
||||||
|
"Device",
|
||||||
|
"DeviceBase",
|
||||||
|
"DeviceCreate",
|
||||||
|
"DeviceRead",
|
||||||
|
"User",
|
||||||
|
"UserBase",
|
||||||
|
"UserCreate",
|
||||||
|
"UserRead",
|
||||||
|
"TagValue",
|
||||||
|
]
|
||||||
|
|
|
@ -1,75 +1,34 @@
|
||||||
"""
|
"""
|
||||||
Utilities for handling SQLAlchemy database connections.
|
Database connection management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Generator
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
|
||||||
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: Engine | None = None
|
engine = None
|
||||||
session_local: sessionmaker | None = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def connect(cls, engine: Engine) -> None:
|
def connect(cls, connection_url: str) -> None:
|
||||||
"""
|
"""
|
||||||
Connect ORM to a database engine.
|
Connect ORM to a database engine.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cls.engine = engine
|
cls.engine = create_engine(connection_url)
|
||||||
cls.session_local = sessionmaker(
|
SQLModel.metadata.create_all(cls.engine)
|
||||||
autocommit=False, autoflush=False, bind=engine,
|
|
||||||
)
|
|
||||||
ORMBaseModel.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def use(cls) -> SessionManager | None:
|
@property
|
||||||
|
def session(cls) -> Session:
|
||||||
"""
|
"""
|
||||||
Create an ORM session using a context manager.
|
Create an ORM session using a context manager.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if cls.session_local is None:
|
if cls.engine is None:
|
||||||
return None
|
raise ValueError("Not connected to database, can't create session")
|
||||||
|
|
||||||
return SessionManager(cls.session_local())
|
return Session(cls.engine)
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
133
api/kiwi_vpn_api/db/device.py
Normal file
133
api/kiwi_vpn_api/db/device.py
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
"""
|
||||||
|
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()
|
|
@ -1,106 +0,0 @@
|
||||||
"""
|
|
||||||
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"
|
|
||||||
)
|
|
|
@ -1,275 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
68
api/kiwi_vpn_api/db/tag.py
Normal file
68
api/kiwi_vpn_api/db/tag.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
)
|
309
api/kiwi_vpn_api/db/user.py
Normal file
309
api/kiwi_vpn_api/db/user.py
Normal file
|
@ -0,0 +1,309 @@
|
||||||
|
"""
|
||||||
|
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 check_edit(
|
||||||
|
self,
|
||||||
|
target: User | Device,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Check if this user can edit another user or a device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# admin can "edit" everything
|
||||||
|
if self.is_admin:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# user can only "edit" itself
|
||||||
|
if isinstance(target, User) and target == self:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# user can edit its owned devices
|
||||||
|
if isinstance(target, Device) and target.owner == self:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# deny by default
|
||||||
|
raise PermissionError()
|
||||||
|
|
||||||
|
def check_admin(
|
||||||
|
self,
|
||||||
|
target: User | Device,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Check if this user can administer another user or a device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# only admin can "admin" anything
|
||||||
|
if not self.is_admin:
|
||||||
|
raise PermissionError("Must be admin")
|
||||||
|
|
||||||
|
# admin cannot "admin" itself!
|
||||||
|
if isinstance(target, User) and target == self:
|
||||||
|
raise PermissionError("Can't administer self")
|
||||||
|
|
||||||
|
# admin can "admin" everything else
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_create(
|
||||||
|
self,
|
||||||
|
target: type,
|
||||||
|
owner: User | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
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)):
|
||||||
|
raise PermissionError(f"Cannot create target type {target}")
|
||||||
|
|
||||||
|
# admin can "create" everything
|
||||||
|
if self.is_admin:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# user can only create devices for itself
|
||||||
|
if target is Device and owner == self:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# deny by default
|
||||||
|
raise PermissionError()
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
|
@ -1,35 +1,198 @@
|
||||||
|
"""
|
||||||
|
Python interface to EasyRSA CA.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from OpenSSL import crypto
|
from cryptography import x509
|
||||||
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
ca = auto()
|
||||||
|
client = auto()
|
||||||
|
server = auto()
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self._name_
|
||||||
|
|
||||||
|
|
||||||
class EasyRSA:
|
class EasyRSA:
|
||||||
__directory: Path | None
|
"""
|
||||||
__ca_password: str | None
|
Represents an EasyRSA PKI.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, directory: Path) -> None:
|
__mapKeyAlgorithm = {
|
||||||
self.__directory = directory
|
KeyAlgorithm.rsa2048: {
|
||||||
|
"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: {},
|
||||||
|
}
|
||||||
|
|
||||||
def set_ca_password(self, password: str | None = None) -> None:
|
@property
|
||||||
if password is None:
|
def output_directory(self) -> Path:
|
||||||
password = pwd.genword(length=32, charset="ascii_62")
|
"""
|
||||||
|
Where certificates are stored
|
||||||
|
"""
|
||||||
|
|
||||||
self.__ca_password = password
|
return SETTINGS.data_dir.joinpath("pki")
|
||||||
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_args: str,
|
*easyrsa_cmd: str,
|
||||||
|
**easyrsa_env: str,
|
||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""
|
||||||
|
Call the `easyrsa` executable
|
||||||
|
"""
|
||||||
|
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[
|
[
|
||||||
"easyrsa", "--batch",
|
"/usr/local/bin/easyrsa",
|
||||||
f"--pki-dir={self.__directory}",
|
*easyrsa_cmd,
|
||||||
*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,
|
||||||
)
|
)
|
||||||
|
@ -37,82 +200,171 @@ class EasyRSA:
|
||||||
def __build_cert(
|
def __build_cert(
|
||||||
self,
|
self,
|
||||||
cert_filename: Path,
|
cert_filename: Path,
|
||||||
*easyrsa_args: str,
|
*easyrsa_cmd: str,
|
||||||
) -> crypto.X509:
|
**easyrsa_env: str,
|
||||||
self.__easyrsa(*easyrsa_args)
|
) -> x509.Certificate | None:
|
||||||
|
"""
|
||||||
|
Create an X.509 certificate
|
||||||
|
"""
|
||||||
|
|
||||||
with open(
|
config = Config._
|
||||||
self.__directory.joinpath(cert_filename), "r"
|
|
||||||
) as cert_file:
|
if ((algorithm := config.crypto.key_algorithm)
|
||||||
return crypto.load_certificate(
|
not in EasyRSA.__mapKeyAlgorithm):
|
||||||
crypto.FILETYPE_PEM, cert_file.read()
|
raise ValueError(f"Unexpected algorithm: {algorithm}")
|
||||||
|
|
||||||
|
# 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
def init_pki(self) -> bool:
|
# parse the new certificate
|
||||||
|
with open(
|
||||||
|
self.output_directory.joinpath(cert_filename), "rb"
|
||||||
|
) as cert_file:
|
||||||
|
return x509.load_pem_x509_certificate(
|
||||||
|
cert_file.read()
|
||||||
|
)
|
||||||
|
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
# certificate couldn't be built
|
||||||
|
return None
|
||||||
|
|
||||||
|
def init_pki(self) -> None:
|
||||||
|
"""
|
||||||
|
Clean working directory
|
||||||
|
"""
|
||||||
|
|
||||||
self.__easyrsa("init-pki")
|
self.__easyrsa("init-pki")
|
||||||
|
|
||||||
def build_ca(
|
def build_ca(self) -> x509.Certificate:
|
||||||
self,
|
"""
|
||||||
days: int = 365 * 50,
|
Build the CA certificate
|
||||||
cn: str = "kiwi-vpn-ca"
|
"""
|
||||||
) -> crypto.X509:
|
|
||||||
cert = self.__build_cert(
|
cert = self.__build_cert(
|
||||||
Path("ca.crt"),
|
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",
|
|
||||||
|
|
||||||
"build-ca",
|
"build-ca",
|
||||||
|
|
||||||
|
EASYRSA_DN="cn_only",
|
||||||
|
EASYRSA_REQ_CN="kiwi-vpn-ca",
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
||||||
days: int = 365 * 50,
|
cert_type: CertificateType = CertificateType.client,
|
||||||
cn: str = "kiwi-vpn-client",
|
dn: DistinguishedName | None = None,
|
||||||
cert_type: str = "client"
|
) -> x509.Certificate | None:
|
||||||
) -> crypto.X509:
|
"""
|
||||||
return self.__build_cert(
|
Issue a client or server certificate
|
||||||
Path(f"issued/{cn}.crt"),
|
"""
|
||||||
|
|
||||||
f"--passin=pass:{self.__ca_password}",
|
if dn is None:
|
||||||
f"--days={days}",
|
dn = DistinguishedName.build()
|
||||||
|
|
||||||
|
if not (cert_type is CertificateType.client
|
||||||
|
or cert_type is CertificateType.server):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.__build_cert(
|
||||||
|
Path("issued").joinpath(f"{dn.common_name}.crt"),
|
||||||
|
|
||||||
f"build-{cert_type}-full",
|
f"build-{cert_type}-full",
|
||||||
cn,
|
dn.common_name,
|
||||||
"nopass",
|
"nopass",
|
||||||
|
|
||||||
|
**dn.easyrsa_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def renew(
|
||||||
|
self,
|
||||||
|
dn: DistinguishedName | None = None,
|
||||||
|
) -> x509.Certificate | None:
|
||||||
|
"""
|
||||||
|
Renew a client or server certificate
|
||||||
|
"""
|
||||||
|
|
||||||
|
if dn is None:
|
||||||
|
dn = DistinguishedName.build()
|
||||||
|
|
||||||
|
return self.__build_cert(
|
||||||
|
Path("issued").joinpath(f"{dn.common_name}.crt"),
|
||||||
|
|
||||||
|
"renew",
|
||||||
|
dn.common_name,
|
||||||
|
"nopass",
|
||||||
|
|
||||||
|
# allow renewal 14 days before cert expiry
|
||||||
|
EASYRSA_CERT_RENEW="14",
|
||||||
|
|
||||||
|
**dn.easyrsa_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
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__":
|
||||||
easy_rsa = EasyRSA(Path("tmp/easyrsa"))
|
ca = EASYRSA.build_ca()
|
||||||
easy_rsa.init_pki()
|
server = EASYRSA.issue(CertificateType.server)
|
||||||
easy_rsa.set_ca_password()
|
client = None
|
||||||
|
|
||||||
ca = easy_rsa.build_ca(cn="kiwi-vpn-ca")
|
# check if configured
|
||||||
server = easy_rsa.issue(cert_type="server", cn="kiwi-vpn-server")
|
if (current_config := Config.load()) is not None:
|
||||||
client = easy_rsa.issue(cert_type="client", cn="kiwi-vpn-client")
|
# connect to database
|
||||||
|
Connection.connect(current_config.db.uri)
|
||||||
|
|
||||||
date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
|
if (device := Device.get(1)) is not None:
|
||||||
|
client = EASYRSA.issue(
|
||||||
|
dn=DistinguishedName.build(device)
|
||||||
|
)
|
||||||
|
|
||||||
for cert in [ca, server, client]:
|
for cert in (ca, server, client):
|
||||||
print(cert.get_subject().CN)
|
if cert is not None:
|
||||||
print(cert.get_signature_algorithm().decode(encoding))
|
print(cert.subject)
|
||||||
print(datetime.strptime(
|
print(cert.signature_hash_algorithm)
|
||||||
cert.get_notAfter().decode(encoding), date_format))
|
print(cert.not_valid_after)
|
||||||
|
|
|
@ -12,14 +12,10 @@ If run directly, uses `uvicorn` to run the app.
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .config import Config, Settings
|
from .config import SETTINGS, Config
|
||||||
from .db import Connection
|
from .db import Connection, User
|
||||||
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.",
|
||||||
|
@ -31,9 +27,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)
|
||||||
|
@ -42,19 +38,18 @@ 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 := await Config.load()) is not None:
|
if (current_config := Config.load()) is not None:
|
||||||
# connect to database
|
# connect to database
|
||||||
Connection.connect(await current_config.db.db_engine)
|
Connection.connect(current_config.db.uri)
|
||||||
|
|
||||||
# some testing
|
# some testing
|
||||||
with Connection.use() as db:
|
print(User.get("admin"))
|
||||||
print(User.from_db(db, "admin"))
|
print(User.get("nonexistent"))
|
||||||
print(User.from_db(db, "nonexistent"))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"kiwi_vpn_api.main:app",
|
app="kiwi_vpn_api.main:app",
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8000,
|
port=8000,
|
||||||
reload=True,
|
reload=True,
|
||||||
|
|
|
@ -1,10 +1,21 @@
|
||||||
|
"""
|
||||||
|
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 . import admin, user
|
from ..config import SETTINGS
|
||||||
|
from . import admin, device, service, user
|
||||||
|
|
||||||
main_router = APIRouter(prefix="/api/v1")
|
main_router = APIRouter(prefix=f"/{SETTINGS.api_v1_prefix}")
|
||||||
|
|
||||||
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__ = ["main_router"]
|
__all__ = [
|
||||||
|
"main_router",
|
||||||
|
]
|
||||||
|
|
|
@ -2,16 +2,15 @@
|
||||||
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 Config
|
from ..config import SETTINGS, Config
|
||||||
from ..db import Connection
|
from ..db import Device, User
|
||||||
from ..db.schemas import User
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="user/authenticate")
|
oauth2_scheme = OAuth2PasswordBearer(
|
||||||
|
tokenUrl=f"{SETTINGS.api_v1_prefix}/user/authenticate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Responses:
|
class Responses:
|
||||||
|
@ -24,24 +23,20 @@ class Responses:
|
||||||
OK = {
|
OK = {
|
||||||
"content": None,
|
"content": None,
|
||||||
}
|
}
|
||||||
INSTALLED = {
|
|
||||||
"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": "Must be logged in",
|
"description": "Not logged in",
|
||||||
"content": None,
|
"content": None,
|
||||||
}
|
}
|
||||||
NEEDS_ADMIN = {
|
NEEDS_PERMISSION = {
|
||||||
"description": "Must be admin",
|
"description": "Operation not permitted",
|
||||||
"content": None,
|
"content": None,
|
||||||
}
|
}
|
||||||
NEEDS_ADMIN_OR_SELF = {
|
ENTRY_ADDED = {
|
||||||
"description": "Must be the requested user",
|
"description": "Entry added to database",
|
||||||
"content": None,
|
"content": None,
|
||||||
}
|
}
|
||||||
ENTRY_EXISTS = {
|
ENTRY_EXISTS = {
|
||||||
|
@ -54,69 +49,83 @@ class Responses:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_config(
|
||||||
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),
|
||||||
) -> User | None:
|
) -> Config:
|
||||||
"""
|
"""
|
||||||
Get the currently logged-in user from the database.
|
Get the current configuration if it exists.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 400: `kiwi-vpn` not installed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# can't connect to an unconfigured database
|
# fail if not configured
|
||||||
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)
|
||||||
|
|
||||||
username = await current_config.jwt.decode_token(token)
|
return current_config
|
||||||
user = User.from_db(db, username)
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
current_config: Config = Depends(get_current_config),
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
if (username := await current_config.jwt.decode_token(token)) is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
if (user := User.get(username)) is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_if_exists(
|
async def get_user_by_name(
|
||||||
current_config: Config | None = Depends(Config.load),
|
|
||||||
current_user: User | None = Depends(get_current_user),
|
|
||||||
) -> User:
|
|
||||||
"""
|
|
||||||
Get the currently logged-in user if it exists.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# fail if not requested by a user
|
|
||||||
if current_user is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_if_admin(
|
|
||||||
current_config: Config | None = Depends(Config.load),
|
|
||||||
current_user: User = Depends(get_current_user_if_exists),
|
|
||||||
) -> User:
|
|
||||||
"""
|
|
||||||
Get the currently logged-in user if it is an admin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# fail if not requested by an admin
|
|
||||||
if not current_user.is_admin():
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
|
||||||
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_if_admin_or_self(
|
|
||||||
user_name: str,
|
user_name: str,
|
||||||
current_config: Config | None = Depends(Config.load),
|
|
||||||
current_user: User = Depends(get_current_user_if_exists),
|
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Get the currently logged-in user.
|
Get a user by name.
|
||||||
|
|
||||||
Fails a) if the currently logged-in user is not the requested user,
|
Status:
|
||||||
and b) if it is not an admin.
|
|
||||||
|
- 403: user not found
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# fail if not requested by an admin or self
|
# don't use error 404 here - possible user enumeration
|
||||||
if not (current_user.is_admin() or current_user.name == user_name):
|
|
||||||
|
# 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 current_user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_device_by_id(
|
||||||
|
device_id: int,
|
||||||
|
) -> Device:
|
||||||
|
"""
|
||||||
|
Get a device by ID.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 404: device not found
|
||||||
|
"""
|
||||||
|
|
||||||
|
# fail if device doesn't exist
|
||||||
|
if (device := Device.get(device_id)) is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
|
@ -2,51 +2,76 @@
|
||||||
/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
|
from ..db import Connection, TagValue, User, UserCreate
|
||||||
from ..db.schemas import User, UserCapability, UserCreate
|
from ._common import Responses, get_current_config, get_current_user
|
||||||
from ._common import Responses, get_current_user
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/install",
|
"/install/config",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: Responses.OK,
|
status.HTTP_200_OK: Responses.OK,
|
||||||
status.HTTP_400_BAD_REQUEST: Responses.INSTALLED,
|
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def install(
|
async def initial_configure(
|
||||||
config: Config,
|
config: Config,
|
||||||
admin_user: UserCreate,
|
|
||||||
current_config: Config | None = Depends(Config.load),
|
current_config: Config | None = Depends(Config.load),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
PUT ./install: Install `kiwi-vpn`.
|
PUT ./install/config: Configure `kiwi-vpn`.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 409: `kiwi-vpn` already installed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# fail if already installed
|
# fail if already configured
|
||||||
if current_config is not None:
|
if current_config is not None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||||
|
|
||||||
# create config file, connect to database
|
# create config file, connect to database
|
||||||
await config.save()
|
config.save()
|
||||||
Connection.connect(await config.db.db_engine)
|
Connection.connect(config.db.uri)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/install/admin",
|
||||||
|
responses={
|
||||||
|
status.HTTP_201_CREATED: Responses.OK,
|
||||||
|
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
|
||||||
with Connection.use() as db:
|
if (new_user := User.create(user=admin_user)) is None:
|
||||||
new_user = User.create(
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||||
db=db,
|
|
||||||
user=admin_user,
|
|
||||||
crypt_context=await config.crypto.crypt_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
new_user.capabilities.append(UserCapability.admin)
|
new_user.add_tags([TagValue.admin])
|
||||||
new_user.update(db)
|
new_user.update()
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
|
@ -55,27 +80,21 @@ async def install(
|
||||||
status.HTTP_200_OK: Responses.OK,
|
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_ADMIN,
|
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def set_config(
|
async def set_config(
|
||||||
new_config: Config,
|
config: Config,
|
||||||
current_config: Config | None = Depends(Config.load),
|
current_user: User = Depends(get_current_user),
|
||||||
current_user: User | None = Depends(get_current_user),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
PUT ./config: Edit `kiwi-vpn` main config.
|
PUT ./config: Edit `kiwi-vpn` main config.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# fail if not installed
|
# check permissions
|
||||||
if current_config is None:
|
if not current_user.is_admin:
|
||||||
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
|
||||||
await new_config.save()
|
config.save()
|
||||||
Connection.connect(await new_config.db.db_engine)
|
Connection.connect(config.db.uri)
|
||||||
|
|
244
api/kiwi_vpn_api/routers/device.py
Normal file
244
api/kiwi_vpn_api/routers/device.py
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
"""
|
||||||
|
/device endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from kiwi_vpn_api.db.device import DeviceStatus
|
||||||
|
|
||||||
|
from ..db import Device, DeviceCreate, DeviceRead, User
|
||||||
|
from ..easyrsa import EASYRSA, DistinguishedName
|
||||||
|
from ._common import (Responses, get_current_user, get_device_by_id,
|
||||||
|
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 permissions
|
||||||
|
try:
|
||||||
|
current_user.check_create(Device, owner)
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
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 permissions
|
||||||
|
try:
|
||||||
|
current_user.check_edit(device)
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# delete device
|
||||||
|
device.delete()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{device_id}/issue",
|
||||||
|
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_PERMISSION,
|
||||||
|
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
|
||||||
|
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
||||||
|
},
|
||||||
|
response_model=DeviceRead,
|
||||||
|
)
|
||||||
|
async def request_certificate_issuance(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
device: Device = Depends(get_device_by_id),
|
||||||
|
) -> Device:
|
||||||
|
"""
|
||||||
|
POST ./{device_id}/issue: Request certificate issuance for a device.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 403: no user permission to edit device
|
||||||
|
- 409: device certificate cannot be "issued"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# check permissions
|
||||||
|
try:
|
||||||
|
current_user.check_edit(device)
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# 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 := EASYRSA.issue(
|
||||||
|
dn=DistinguishedName.build(device)
|
||||||
|
)) is not None:
|
||||||
|
device.set_status(DeviceStatus.certified)
|
||||||
|
device.expiry = certificate.not_valid_after
|
||||||
|
|
||||||
|
# return updated device
|
||||||
|
device.update()
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{device_id}/renew",
|
||||||
|
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_PERMISSION,
|
||||||
|
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
|
||||||
|
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
||||||
|
},
|
||||||
|
response_model=DeviceRead,
|
||||||
|
)
|
||||||
|
async def request_certificate_renewal(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
device: Device = Depends(get_device_by_id),
|
||||||
|
) -> Device:
|
||||||
|
"""
|
||||||
|
POST ./{device_id}/renew: Request certificate renewal for a device.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 403: no user permission to edit device
|
||||||
|
- 409: device certificate cannot be "renewed"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# check permissions
|
||||||
|
try:
|
||||||
|
current_user.check_edit(device)
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# 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 := EASYRSA.renew(
|
||||||
|
dn=DistinguishedName.build(device)
|
||||||
|
)) is not None:
|
||||||
|
device.set_status(DeviceStatus.certified)
|
||||||
|
device.expiry = certificate.not_valid_after
|
||||||
|
|
||||||
|
# return updated device
|
||||||
|
device.update()
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{device_id}/revoke",
|
||||||
|
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_PERMISSION,
|
||||||
|
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
|
||||||
|
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
||||||
|
},
|
||||||
|
response_model=DeviceRead,
|
||||||
|
)
|
||||||
|
async def revoke_certificate(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
device: Device = Depends(get_device_by_id),
|
||||||
|
) -> Device:
|
||||||
|
"""
|
||||||
|
POST ./{device_id}/revoke: Revoke a device certificate.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 403: no user permission to edit device
|
||||||
|
- 409: device certificate cannot be "revoked"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# check permissions
|
||||||
|
try:
|
||||||
|
current_user.check_edit(device)
|
||||||
|
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# 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
|
||||||
|
EASYRSA.revoke(dn=DistinguishedName.build(device))
|
||||||
|
|
||||||
|
# reset the device
|
||||||
|
device.set_status(DeviceStatus.uncertified)
|
||||||
|
device.expiry = None
|
||||||
|
|
||||||
|
# return updated device
|
||||||
|
device.update()
|
||||||
|
return device
|
|
@ -1,58 +0,0 @@
|
||||||
"""
|
|
||||||
/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
|
|
34
api/kiwi_vpn_api/routers/service.py
Normal file
34
api/kiwi_vpn_api/routers/service.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
"""
|
||||||
|
/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,
|
||||||
|
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)
|
|
@ -5,12 +5,11 @@
|
||||||
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 Connection
|
from ..db import TagValue, User, UserCreate, UserRead
|
||||||
from ..db.schemas import User, UserCapability, UserCreate
|
from ._common import (Responses, get_current_config, get_current_user,
|
||||||
from ._common import Responses, get_current_user, get_current_user_if_admin
|
get_user_by_name)
|
||||||
|
|
||||||
router = APIRouter(prefix="/user", tags=["user"])
|
router = APIRouter(prefix="/user", tags=["user"])
|
||||||
|
|
||||||
|
@ -24,27 +23,32 @@ class Token(BaseModel):
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/authenticate", response_model=Token)
|
@router.post(
|
||||||
|
"/authenticate",
|
||||||
|
responses={
|
||||||
|
status.HTTP_200_OK: Responses.OK,
|
||||||
|
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 | None = Depends(Config.load),
|
current_config: Config = Depends(get_current_config),
|
||||||
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
|
||||||
user = User(name=form_data.username)
|
if (user := User.authenticate(
|
||||||
if not user.authenticate(
|
name=form_data.username,
|
||||||
db=db,
|
|
||||||
password=form_data.password,
|
password=form_data.password,
|
||||||
crypt_context=await current_config.crypto.crypt_context,
|
)) is None:
|
||||||
):
|
|
||||||
# authentication failed
|
# authentication failed
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
@ -57,9 +61,18 @@ async def login(
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current", response_model=User)
|
@router.get(
|
||||||
async def get_current_user(
|
"/current",
|
||||||
current_user: User | None = Depends(get_current_user),
|
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_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.
|
||||||
|
@ -71,35 +84,45 @@ async def get_current_user(
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: Responses.OK,
|
status.HTTP_201_CREATED: Responses.ENTRY_ADDED,
|
||||||
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_ADMIN,
|
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
||||||
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
|
||||||
},
|
},
|
||||||
response_model=User,
|
response_model=UserRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def add_user(
|
async def add_user(
|
||||||
user: UserCreate,
|
user: UserCreate,
|
||||||
current_config: Config | None = Depends(Config.load),
|
current_user: User = Depends(get_current_user),
|
||||||
_: User = Depends(get_current_user_if_admin),
|
) -> User:
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# actually create the new user
|
# check permissions
|
||||||
new_user = User.create(
|
try:
|
||||||
db=db,
|
current_user.check_create(User)
|
||||||
user=user,
|
|
||||||
crypt_context=await current_config.crypto.crypt_context,
|
except PermissionError as e:
|
||||||
)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# create the new user
|
||||||
|
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
|
||||||
|
|
||||||
|
@ -110,86 +133,97 @@ async def add_user(
|
||||||
status.HTTP_200_OK: Responses.OK,
|
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_ADMIN,
|
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
||||||
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
|
|
||||||
},
|
},
|
||||||
response_model=User,
|
response_model=User,
|
||||||
)
|
)
|
||||||
async def remove_user(
|
async def remove_user(
|
||||||
user_name: str,
|
current_user: User = Depends(get_current_user),
|
||||||
_: User = Depends(get_current_user_if_admin),
|
user: User = Depends(get_user_by_name),
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# get the user
|
# check permissions
|
||||||
user = User.from_db(
|
try:
|
||||||
db=db,
|
current_user.check_admin(user)
|
||||||
name=user_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# fail if deletion was unsuccessful
|
except PermissionError as e:
|
||||||
if user is None or not user.delete(db):
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
# delete user
|
||||||
|
user.delete()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{user_name}/capabilities",
|
"/{user_name}/tags",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: Responses.OK,
|
status.HTTP_201_CREATED: Responses.ENTRY_ADDED,
|
||||||
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_ADMIN,
|
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
||||||
},
|
},
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def extend_capabilities(
|
async def extend_tags(
|
||||||
user_name: str,
|
tags: list[TagValue],
|
||||||
capabilities: list[UserCapability],
|
current_user: User = Depends(get_current_user),
|
||||||
_: User = Depends(get_current_user_if_admin),
|
user: User = Depends(get_user_by_name),
|
||||||
db: Session | None = Depends(Connection.get),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
POST ./{user_name}/capabilities: Add capabilities to a user.
|
POST ./{user_name}/tags: Add tags to a user.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 403: no user permission to admin user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# get and change the user
|
# check permissions
|
||||||
user = User.from_db(
|
try:
|
||||||
db=db,
|
current_user.check_admin(user)
|
||||||
name=user_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
user.capabilities.extend(capabilities)
|
except PermissionError as e:
|
||||||
user.update(db)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
|
# change user
|
||||||
|
user.add_tags(tags)
|
||||||
|
user.update()
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{user_name}/capabilities",
|
"/{user_name}/tags",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_200_OK: Responses.OK,
|
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_ADMIN,
|
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def remove_capabilities(
|
async def remove_tags(
|
||||||
user_name: str,
|
tags: list[TagValue],
|
||||||
capabilities: list[UserCapability],
|
current_user: User = Depends(get_current_user),
|
||||||
_: User = Depends(get_current_user_if_admin),
|
user: User = Depends(get_user_by_name),
|
||||||
db: Session | None = Depends(Connection.get),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
DELETE ./{user_name}/capabilities: Remove capabilities from a user.
|
DELETE ./{user_name}/tags: Remove tags from a user.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- 403: no user permission to admin user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# get and change the user
|
# check permissions
|
||||||
user = User.from_db(
|
try:
|
||||||
db=db,
|
current_user.check_admin(user)
|
||||||
name=user_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
for capability in capabilities:
|
except PermissionError as e:
|
||||||
user.capabilities.remove(capability)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
|
||||||
|
|
||||||
user.update(db)
|
# change user
|
||||||
|
user.remove_tags(tags)
|
||||||
|
user.update()
|
||||||
|
|
46
api/plan.md
Normal file
46
api/plan.md
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
## 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
146
api/poetry.lock
generated
|
@ -108,11 +108,11 @@ pycparser = "*"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.0.4"
|
version = "8.1.2"
|
||||||
description = "Composable command line interface toolkit"
|
description = "Composable command line interface toolkit"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
[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.0"
|
version = "0.75.1"
|
||||||
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-cli (>=0.0.12,<0.0.13)", "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 (>=0.4.1,<0.5.0)", "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)"]
|
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)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
|
@ -292,21 +292,6 @@ 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"
|
||||||
|
@ -398,7 +383,7 @@ python-versions = ">=3.5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "1.4.32"
|
version = "1.4.35"
|
||||||
description = "Database Abstraction Library"
|
description = "Database Abstraction Library"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
|
@ -411,7 +396,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)"]
|
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
|
||||||
mariadb_connector = ["mariadb (>=1.0.1)"]
|
mariadb_connector = ["mariadb (>=1.0.1)"]
|
||||||
mssql = ["pyodbc"]
|
mssql = ["pyodbc"]
|
||||||
mssql_pymssql = ["pymssql"]
|
mssql_pymssql = ["pymssql"]
|
||||||
|
@ -428,6 +413,30 @@ 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"
|
||||||
|
@ -477,7 +486,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 = "432d2933102f8a0091cec1b5484944a0211ca74c5dc9b65877d99d7bd160e4bb"
|
content-hash = "36a56b6982734607590597302276605f8977119869934f35116e72377905b6b5"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
anyio = [
|
anyio = [
|
||||||
|
@ -588,8 +597,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.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
|
{file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"},
|
||||||
{file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
|
{file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"},
|
||||||
]
|
]
|
||||||
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"},
|
||||||
|
@ -622,8 +631,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.0-py3-none-any.whl", hash = "sha256:43d12891b78fc497a50623e9c7c24640c569489f060acd9ce2c4902080487a93"},
|
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"},
|
||||||
{file = "fastapi-0.75.0.tar.gz", hash = "sha256:124774ce4cb3322841965f559669b233a0b8d343ea24fdd8b293253c077220d7"},
|
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"},
|
||||||
]
|
]
|
||||||
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"},
|
||||||
|
@ -766,10 +775,6 @@ 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"},
|
||||||
|
@ -798,41 +803,50 @@ 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.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
|
{file = "SQLAlchemy-1.4.35-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6"},
|
||||||
{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-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
|
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win32.whl", hash = "sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70"},
|
||||||
{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-cp27m-win_amd64.whl", hash = "sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
|
{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-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
|
{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_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_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_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_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
|
{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-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
|
{file = "SQLAlchemy-1.4.35-cp310-cp310-win32.whl", hash = "sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
|
{file = "SQLAlchemy-1.4.35-cp310-cp310-win_amd64.whl", hash = "sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
|
{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_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_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_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_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
|
{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-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
|
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win32.whl", hash = "sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
|
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win_amd64.whl", hash = "sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
|
{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_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_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_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_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
|
{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-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
|
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win32.whl", hash = "sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
|
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win_amd64.whl", hash = "sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
|
{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_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_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_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_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
|
{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-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
|
{file = "SQLAlchemy-1.4.35-cp38-cp38-win32.whl", hash = "sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
|
{file = "SQLAlchemy-1.4.35-cp38-cp38-win_amd64.whl", hash = "sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
|
{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_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_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_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_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7"},
|
||||||
{file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
|
{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-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
|
{file = "SQLAlchemy-1.4.35-cp39-cp39-win32.whl", hash = "sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228"},
|
||||||
{file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
|
{file = "SQLAlchemy-1.4.35-cp39-cp39-win_amd64.whl", hash = "sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a"},
|
||||||
|
{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"},
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
[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"
|
||||||
python-multipart = "^0.0.5"
|
|
||||||
uvicorn = "^0.17.6"
|
|
||||||
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
|
||||||
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
|
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
|
||||||
SQLAlchemy = "^1.4.32"
|
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
||||||
pyOpenSSL = "^22.0.0"
|
python-multipart = "^0.0.5"
|
||||||
|
sqlmodel = "^0.0.6"
|
||||||
|
uvicorn = "^0.17.6"
|
||||||
|
cryptography = "^36.0.2"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.1.0"
|
pytest = "^7.1.0"
|
||||||
|
@ -21,5 +22,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]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|
Loading…
Reference in a new issue