Compare commits

..

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

22 changed files with 870 additions and 1645 deletions

View file

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

View file

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

View file

@ -9,17 +9,19 @@ Pydantic models might have convenience methods attached.
from __future__ import annotations
import functools
import json
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from secrets import token_urlsafe
from typing import Any
from jose import JWTError, jwt
from jose.constants import ALGORITHMS
from passlib.context import CryptContext
from pydantic import BaseModel, BaseSettings, constr, validator
from pydantic import BaseModel, BaseSettings, Field, validator
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
class Settings(BaseSettings):
@ -29,18 +31,18 @@ class Settings(BaseSettings):
production_mode: bool = False
data_dir: Path = Path("./tmp")
config_file_name: Path = Path("config.json")
api_v1_prefix: str = "api/v1"
openapi_url: str = "/openapi.json"
docs_url: str | None = "/docs"
redoc_url: str | None = "/redoc"
@staticmethod
@functools.lru_cache
def get() -> Settings:
return Settings()
@property
def config_file(self) -> Path:
return self.data_dir.joinpath(self.config_file_name)
SETTINGS = Settings()
return self.data_dir.joinpath("config.json")
class DBType(Enum):
@ -61,20 +63,23 @@ class DBConfig(BaseModel):
user: str | None = None
password: str | None = None
host: str | None = None
database: str | Path | None = SETTINGS.data_dir.joinpath("kiwi-vpn.db")
database: str | None = Settings.get().data_dir.joinpath("vpn.db")
mysql_driver: str = "pymysql"
mysql_args: list[str] = ["charset=utf8mb4"]
@property
def uri(self) -> str:
async def db_engine(self) -> Engine:
"""
Construct a database connection string
Construct an SQLAlchemy engine
"""
if self.type is DBType.sqlite:
# SQLite backend
return f"sqlite:///{self.database}"
return create_engine(
f"sqlite:///{self.database}",
connect_args={"check_same_thread": False},
)
elif self.type is DBType.mysql:
# MySQL backend
@ -83,11 +88,12 @@ class DBConfig(BaseModel):
else:
args_str = ""
return (f"mysql+{self.mysql_driver}://"
return create_engine(
f"mysql+{self.mysql_driver}://"
f"{self.user}:{self.password}@{self.host}"
f"/{self.database}{args_str}")
return ""
f"/{self.database}{args_str}",
pool_recycle=3600,
)
class JWTConfig(BaseModel):
@ -160,68 +166,22 @@ class JWTConfig(BaseModel):
return None
# get username
return payload.get("sub")
username = payload.get("sub")
if username is None:
return None
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"
return username
class CryptoConfig(BaseModel):
"""
Configuration for cryptography
Configuration for hash algorithms
"""
# password hash algorithms
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
def context(self) -> CryptContext:
async def crypt_context(self) -> CryptContext:
return CryptContext(
schemes=self.schemes,
deprecated="auto",
@ -233,48 +193,27 @@ class Config(BaseModel):
Configuration for `kiwi-vpn-api`
"""
# may include client-to-client, cipher etc.
openvpn_extra_options: dict[str, Any] | None
db: DBConfig = Field(default_factory=DBConfig)
jwt: JWTConfig = Field(default_factory=JWTConfig)
crypto: CryptoConfig = Field(default_factory=CryptoConfig)
db: DBConfig
jwt: JWTConfig
crypto: CryptoConfig
server_dn: ServerDN
__instance: Config | None = None
@classmethod
def load(cls) -> Config | None:
@staticmethod
async def load() -> Config | None:
"""
Load configuration from config file
"""
if cls.__instance is None:
try:
with open(SETTINGS.config_file, "r") as config_file:
cls.__instance = cls.parse_obj(json.load(config_file))
with open(Settings.get().config_file, "r") as config_file:
return Config.parse_obj(json.load(config_file))
except FileNotFoundError:
return 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:
async def save(self) -> None:
"""
Save configuration to config file
"""
with open(SETTINGS.config_file, "w") as config_file:
with open(Settings.get().config_file, "w") as config_file:
config_file.write(self.json(indent=2))

View file

@ -1,21 +1,4 @@
"""
Package `db`: ORM and schemas for database content.
"""
from . import 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",
]
__all__ = ["Connection", "models", "schemas"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,198 +1,35 @@
"""
Python interface to EasyRSA CA.
"""
from __future__ import annotations
import subprocess
from enum import Enum, auto
from datetime import datetime
from pathlib import Path
from cryptography import x509
from OpenSSL import crypto
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:
"""
Represents an EasyRSA PKI.
"""
__directory: Path | None
__ca_password: str | None
__mapKeyAlgorithm = {
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 __init__(self, directory: Path) -> None:
self.__directory = directory
@property
def output_directory(self) -> Path:
"""
Where certificates are stored
"""
def set_ca_password(self, password: str | None = None) -> None:
if password is None:
password = pwd.genword(length=32, charset="ascii_62")
return SETTINGS.data_dir.joinpath("pki")
@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
self.__ca_password = password
print(self.__ca_password)
def __easyrsa(
self,
*easyrsa_cmd: str,
**easyrsa_env: str,
*easyrsa_args: str,
) -> subprocess.CompletedProcess:
"""
Call the `easyrsa` executable
"""
return subprocess.run(
[
"/usr/local/bin/easyrsa",
*easyrsa_cmd,
"easyrsa", "--batch",
f"--pki-dir={self.__directory}",
*easyrsa_args,
],
env={
# base settings
"EASYRSA_BATCH": "1",
"EASYRSA_PKI": str(self.output_directory),
# always include CA password
"EASYRSA_PASSOUT": f"pass:{self.ca_password}",
"EASYRSA_PASSIN": f"pass:{self.ca_password}",
# include env from parameters
**easyrsa_env,
},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=True,
)
@ -200,171 +37,82 @@ class EasyRSA:
def __build_cert(
self,
cert_filename: Path,
*easyrsa_cmd: str,
**easyrsa_env: str,
) -> x509.Certificate | None:
"""
Create an X.509 certificate
"""
*easyrsa_args: str,
) -> crypto.X509:
self.__easyrsa(*easyrsa_args)
config = Config._
if ((algorithm := config.crypto.key_algorithm)
not in EasyRSA.__mapKeyAlgorithm):
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,
)
# parse the new certificate
with open(
self.output_directory.joinpath(cert_filename), "rb"
self.__directory.joinpath(cert_filename), "r"
) as cert_file:
return x509.load_pem_x509_certificate(
cert_file.read()
return crypto.load_certificate(
crypto.FILETYPE_PEM, cert_file.read()
)
except (subprocess.CalledProcessError, FileNotFoundError):
# certificate couldn't be built
return None
def init_pki(self) -> None:
"""
Clean working directory
"""
def init_pki(self) -> bool:
self.__easyrsa("init-pki")
def build_ca(self) -> x509.Certificate:
"""
Build the CA certificate
"""
def build_ca(
self,
days: int = 365 * 50,
cn: str = "kiwi-vpn-ca"
) -> crypto.X509:
cert = self.__build_cert(
Path("ca.crt"),
"build-ca",
EASYRSA_DN="cn_only",
EASYRSA_REQ_CN="kiwi-vpn-ca",
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",
)
assert cert is not None
# # this takes long!
# self.__easyrsa("gen-dh")
self.__easyrsa("gen-dh")
return cert
def issue(
self,
cert_type: CertificateType = CertificateType.client,
dn: DistinguishedName | None = None,
) -> x509.Certificate | None:
"""
Issue a client or server certificate
"""
if dn is None:
dn = DistinguishedName.build()
if not (cert_type is CertificateType.client
or cert_type is CertificateType.server):
return None
days: int = 365 * 50,
cn: str = "kiwi-vpn-client",
cert_type: str = "client"
) -> crypto.X509:
return self.__build_cert(
Path("issued").joinpath(f"{dn.common_name}.crt"),
Path(f"issued/{cn}.crt"),
f"--passin=pass:{self.__ca_password}",
f"--days={days}",
f"build-{cert_type}-full",
dn.common_name,
cn,
"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__":
ca = EASYRSA.build_ca()
server = EASYRSA.issue(CertificateType.server)
client = None
easy_rsa = EasyRSA(Path("tmp/easyrsa"))
easy_rsa.init_pki()
easy_rsa.set_ca_password()
# check if configured
if (current_config := Config.load()) is not None:
# connect to database
Connection.connect(current_config.db.uri)
ca = easy_rsa.build_ca(cn="kiwi-vpn-ca")
server = easy_rsa.issue(cert_type="server", cn="kiwi-vpn-server")
client = easy_rsa.issue(cert_type="client", cn="kiwi-vpn-client")
if (device := Device.get(1)) is not None:
client = EASYRSA.issue(
dn=DistinguishedName.build(device)
)
date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
for cert in (ca, server, client):
if cert is not None:
print(cert.subject)
print(cert.signature_hash_algorithm)
print(cert.not_valid_after)
for cert in [ca, server, client]:
print(cert.get_subject().CN)
print(cert.get_signature_algorithm().decode(encoding))
print(datetime.strptime(
cert.get_notAfter().decode(encoding), date_format))

View file

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

View file

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

View file

@ -2,15 +2,16 @@
Common dependencies for routers.
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from ..config import SETTINGS, Config
from ..db import Device, User
from ..config import Config
from ..db import Connection
from ..db.schemas import User
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{SETTINGS.api_v1_prefix}/user/authenticate"
)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="user/authenticate")
class Responses:
@ -23,20 +24,24 @@ class Responses:
OK = {
"content": None,
}
INSTALLED = {
"description": "kiwi-vpn already installed",
"content": None,
}
NOT_INSTALLED = {
"description": "kiwi-vpn not installed",
"content": None,
}
NEEDS_USER = {
"description": "Not logged in",
"description": "Must be logged in",
"content": None,
}
NEEDS_PERMISSION = {
"description": "Operation not permitted",
NEEDS_ADMIN = {
"description": "Must be admin",
"content": None,
}
ENTRY_ADDED = {
"description": "Entry added to database",
NEEDS_ADMIN_OR_SELF = {
"description": "Must be the requested user",
"content": None,
}
ENTRY_EXISTS = {
@ -49,83 +54,69 @@ class Responses:
}
async def get_current_config(
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session | None = Depends(Connection.get),
current_config: Config | None = Depends(Config.load),
) -> Config:
) -> User | None:
"""
Get the current configuration if it exists.
Status:
- 400: `kiwi-vpn` not installed
Get the currently logged-in user from the database.
"""
# fail if not configured
# can't connect to an unconfigured database
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
return current_config
username = await current_config.jwt.decode_token(token)
user = User.from_db(db, username)
return user
async def get_current_user(
token: str = Depends(oauth2_scheme),
current_config: Config = Depends(get_current_config),
async def get_current_user_if_exists(
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.
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:
if current_user 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 current_user
async def get_user_by_name(
user_name: str,
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 a user by name.
Status:
- 403: user not found
Get the currently logged-in user if it is an admin.
"""
# don't use error 404 here - possible user enumeration
# fail if user doesn't exist
if (user := User.get(user_name)) is None:
# fail if not requested by an admin
if not current_user.is_admin():
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return user
return current_user
async def get_device_by_id(
device_id: int,
) -> Device:
async def get_current_user_if_admin_or_self(
user_name: str,
current_config: Config | None = Depends(Config.load),
current_user: User = Depends(get_current_user_if_exists),
) -> User:
"""
Get a device by ID.
Get the currently logged-in user.
Status:
- 404: device not found
Fails a) if the currently logged-in user is not the requested user,
and b) if it is not an admin.
"""
# fail if device doesn't exist
if (device := Device.get(device_id)) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# fail if not requested by an admin or self
if not (current_user.is_admin() or current_user.name == user_name):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return device
return current_user

View file

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

View file

@ -1,244 +0,0 @@
"""
/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

View file

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

View file

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

View file

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

View file

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

146
api/poetry.lock generated
View file

@ -108,11 +108,11 @@ pycparser = "*"
[[package]]
name = "click"
version = "8.1.2"
version = "8.0.4"
description = "Composable command line interface toolkit"
category = "main"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.6"
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
@ -161,7 +161,7 @@ gmpy2 = ["gmpy2"]
[[package]]
name = "fastapi"
version = "0.75.1"
version = "0.75.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main"
optional = false
@ -174,8 +174,8 @@ starlette = "0.17.1"
[package.extras]
all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=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 (==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)"]
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"]
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
[[package]]
name = "greenlet"
@ -292,6 +292,21 @@ typing-extensions = ">=3.7.4.3"
dotenv = ["python-dotenv (>=0.10.4)"]
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]]
name = "pyparsing"
version = "3.0.7"
@ -383,7 +398,7 @@ python-versions = ">=3.5"
[[package]]
name = "sqlalchemy"
version = "1.4.35"
version = "1.4.32"
description = "Database Abstraction Library"
category = "main"
optional = false
@ -396,7 +411,7 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"]
mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"]
@ -413,30 +428,6 @@ postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"]
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]]
name = "starlette"
version = "0.17.1"
@ -486,7 +477,7 @@ standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "p
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "36a56b6982734607590597302276605f8977119869934f35116e72377905b6b5"
content-hash = "432d2933102f8a0091cec1b5484944a0211ca74c5dc9b65877d99d7bd160e4bb"
[metadata.files]
anyio = [
@ -597,8 +588,8 @@ cffi = [
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
]
click = [
{file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"},
{file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"},
{file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
{file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
]
colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@ -631,8 +622,8 @@ ecdsa = [
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
]
fastapi = [
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"},
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"},
{file = "fastapi-0.75.0-py3-none-any.whl", hash = "sha256:43d12891b78fc497a50623e9c7c24640c569489f060acd9ce2c4902080487a93"},
{file = "fastapi-0.75.0.tar.gz", hash = "sha256:124774ce4cb3322841965f559669b233a0b8d343ea24fdd8b293253c077220d7"},
]
greenlet = [
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
@ -775,6 +766,10 @@ pydantic = [
{file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
{file = "pydantic-1.9.0.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 = [
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
@ -803,50 +798,41 @@ sniffio = [
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
]
sqlalchemy = [
{file = "SQLAlchemy-1.4.35-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win32.whl", hash = "sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win_amd64.whl", hash = "sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157"},
{file = "SQLAlchemy-1.4.35-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc"},
{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.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.35-cp310-cp310-win32.whl", hash = "sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-win_amd64.whl", hash = "sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6"},
{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.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.35-cp36-cp36m-win32.whl", hash = "sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win_amd64.whl", hash = "sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182"},
{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.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.35-cp37-cp37m-win32.whl", hash = "sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win_amd64.whl", hash = "sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03"},
{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.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.35-cp38-cp38-win32.whl", hash = "sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-win_amd64.whl", hash = "sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62"},
{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.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.35-cp39-cp39-win32.whl", hash = "sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228"},
{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"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
{file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
{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.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.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
{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.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.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
{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.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.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
{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.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.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
{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.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.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
{file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
]
starlette = [
{file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"},

View file

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