Compare commits

...

131 commits

Author SHA1 Message Date
48d8eb077d exception-based permissions 2022-04-07 08:53:54 +00:00
64f8c416ab poetry update 2022-04-07 08:23:12 +00:00
aa7becf057 "approved: bool | None" -> "status: DeviceStatus" 2022-04-07 08:00:41 +00:00
96a3aed24e HTTP status codes and documentation 2022-04-07 06:23:09 +00:00
dcce31da0b certificate renewal time 2022-04-07 06:22:24 +00:00
047b565331 device revoke endpoint 2022-04-07 05:44:42 +00:00
3487c2e0f1 explicit "return None" 2022-04-06 00:34:53 +00:00
423cc009f8 certificate renewal 2022-04-06 00:34:37 +00:00
e6fe35d14e EASYRSA global object 2022-04-05 22:39:09 +00:00
c0388d58c1 few renames 2022-04-05 22:34:25 +00:00
bca5b2b55c global object SETTINGS 2022-04-05 21:33:48 +00:00
2d755b8e3d /{device_id}/issue mostly done 2022-04-05 01:55:35 +00:00
d89409f973 introduce EasyRSA._ 2022-04-05 01:53:13 +00:00
f2948a7b64 check device approval elsewhere 2022-04-05 01:52:58 +00:00
143e9a9fa9 lazy=joined for Device.owner 2022-04-05 01:26:48 +00:00
c94e07fbac EasyRSA: pyopenssl -> cryptography 2022-04-05 00:42:55 +00:00
d8bdb46a5c check issue permission 2022-04-02 21:24:44 +00:00
2d39c4aaa3 formatting 2022-04-02 00:44:35 +00:00
054b351435 wrong key size 2022-04-02 00:44:27 +00:00
8079036c75 easyrsa: use env variables 2022-04-02 00:08:27 +00:00
b421d6f79b device: request_certificate (no "approval" check) 2022-04-01 17:51:01 +00:00
e6c270a0fa missing 401 2022-04-01 17:15:56 +00:00
5b68f5ef7e docstrings: Added HTTP status 2022-04-01 17:05:57 +00:00
762af5dd48 issue: server DN default value evaluation time 2022-04-01 16:44:28 +00:00
821d72a773 init pki endpoint 2022-04-01 15:40:08 +00:00
78e0515042 CertificateType instead of str 2022-04-01 15:39:48 +00:00
72fc209349 HTTP 201 2022-04-01 06:35:28 +00:00
b291c20ed6 User.is_admin property 2022-04-01 06:20:20 +00:00
3b79efaa80 brevity 2022-04-01 00:14:12 +00:00
ae16c884d6 comments 2022-03-31 23:15:49 +00:00
26d171e6d3 refactoring 2022-03-31 16:59:14 +00:00
eb2301d193 launch config for EasyRSA script 2022-03-31 16:56:57 +00:00
583d1de06a error messages 2022-03-31 16:49:04 +00:00
a88168b8d4 fix: device creation 2022-03-31 16:48:52 +00:00
008f0b2cf6 require email field 2022-03-31 16:34:36 +00:00
5d0d996288 resolved all warnings 2022-03-31 16:32:07 +00:00
69b0a619e0 test: certify device with id 1 2022-03-30 23:59:25 +00:00
968e9491cf use ORG mode for client- and server certs 2022-03-30 23:41:34 +00:00
2566702d9e actually issuing certs 2022-03-30 22:27:17 +00:00
23a806e325 create /service router 2022-03-30 21:19:06 +00:00
a524c02138 easyrsa: use config 2022-03-30 21:18:54 +00:00
366b4dc6a0 rename launch config 2022-03-30 21:17:43 +00:00
1f4a9994a6 get_current_config dependable 2022-03-30 20:57:09 +00:00
d98d234cc1 formatting 2022-03-30 20:55:53 +00:00
c1e7f31501 main_router prefix 2022-03-30 20:43:31 +00:00
d9552cbf42 make some config parts optional 2022-03-30 11:24:47 +00:00
24721dd342 better type hinting 2022-03-30 11:15:38 +00:00
e1ae186382 typo/regression 2022-03-30 10:53:52 +00:00
e078c7b094 minor refactoring 2022-03-30 10:53:36 +00:00
53cb7c9c1e doc strings 2022-03-30 10:43:02 +00:00
d6702165b8 clarifications 2022-03-30 10:36:14 +00:00
f6032829cd Device.approved 2022-03-30 10:23:24 +00:00
667fcba559 plan: server props 2022-03-30 10:15:24 +00:00
b202f85d3b country max_length 2022-03-30 08:30:20 +00:00
f899e0c0df Merge branch 'feature/permission-rework' into develop 2022-03-30 02:17:43 +00:00
d02239816a admin can login 2022-03-30 02:16:06 +00:00
cb3a3fca69 typo 2022-03-30 02:11:04 +00:00
598b0ca2cb remove NEEDS_ADMIN 2022-03-30 02:07:22 +00:00
3b66565481 remove get_current_user_if_admin 2022-03-30 02:02:45 +00:00
9b5a98e0c0 rework User methods 2022-03-30 01:51:58 +00:00
03d3a86668 basic permissions system 2022-03-29 23:36:23 +00:00
0d02c24b64 start "permission" implementation 2022-03-29 20:47:25 +00:00
bb53bab0c0 rename "capability" -> "tag" 2022-03-29 20:22:03 +00:00
e11f96b0af dirty commit 2022-03-29 16:35:41 +00:00
fdce81c5a3 refactor get_user_by_name_if_editable 2022-03-29 16:12:55 +00:00
5990577699 possible security flaw 2022-03-29 16:12:29 +00:00
617ae92d72 deleting a device 2022-03-29 15:56:25 +00:00
0c8298871f several minor fixes 2022-03-29 15:56:25 +00:00
ec0f7890ef rename rollback 2022-03-29 15:56:25 +00:00
fdc85bf529 don't delete yourself 2022-03-29 15:56:25 +00:00
f058f29d9a use kwarg in User.create 2022-03-29 15:56:25 +00:00
4120a9b71f Merge branch 'develop' into develop 2022-03-29 00:20:14 +00:00
4ca92a11b2 remove ugly kwargs versions 2022-03-29 00:14:36 +00:00
8a0058f7f0 cleanup in _common 2022-03-29 00:13:38 +00:00
d3ed11fce4 use get_user_by_name 2022-03-29 00:10:24 +00:00
186ac0eab3 User.create() methods 2022-03-29 00:01:28 +00:00
865e712ea5 POST /device route 2022-03-29 00:01:12 +00:00
cd3cccb540 minor bugs 2022-03-28 23:22:32 +00:00
e4548aab3a remove SQLAlchemy direct dependency 2022-03-28 23:07:13 +00:00
a87a1848c9 pyproject reformat, poetry update 2022-03-28 23:06:11 +00:00
d42ef089ff Merge pull request 'develop1' (#1) from Yavook.de/kiwi-vpn:develop into develop
Reviewed-on: penner/kiwi-vpn#1
2022-03-28 22:27:11 +00:00
567b863742 comment 2022-03-28 22:25:37 +00:00
dbbe7a8c35 syntax error 2022-03-28 22:20:25 +00:00
6254daa51d check: user can login, "admin" can do everything 2022-03-28 22:17:31 +00:00
a465dba92e side effects 2022-03-28 22:07:12 +00:00
7dbd25b894 rollback tablename settings 2022-03-28 21:54:39 +00:00
21b85d7cfa formatting 2022-03-28 21:41:56 +00:00
499c97a28a Capability -> UserCapabilityType 2022-03-28 21:41:49 +00:00
5b623e885c table names 2022-03-28 21:26:47 +00:00
270de7f87c Settings.config_file_name 2022-03-28 21:00:16 +00:00
1b24861e48 Docstrings 2022-03-28 20:58:40 +00:00
3d2abbc39b fix user router 2022-03-28 20:18:19 +00:00
77b40cb836 User.delete return value 2022-03-28 20:18:00 +00:00
a5783a0c40 fix: typing 2022-03-28 20:04:49 +00:00
aa8563995e Settings.get() -> Settings._ 2022-03-28 20:04:19 +00:00
799b2f7585 simplify set_config 2022-03-28 02:38:52 +00:00
d9a9ad98f7 Merge branch 'develop' into feature/db-redesign 2022-03-28 02:34:44 +00:00
71ac02e5d7 actually, app must be a string! 2022-03-28 02:33:56 +00:00
d406f15382 app not a string 2022-03-28 02:29:14 +00:00
a20699d6ca Merge branch 'develop' into feature/db-redesign 2022-03-28 02:27:28 +00:00
6b6da69bb4 app not a string 2022-03-28 02:27:14 +00:00
ca955d1104 fix for crypt_context and load() 2022-03-28 02:23:00 +00:00
3d83ddb6cc Config.load_sync -> Config._ 2022-03-28 02:15:42 +00:00
b7179e7cfc limit query 2022-03-28 02:03:31 +00:00
ce9ea61da5 some testing stuff 2022-03-28 02:02:12 +00:00
19dd5aaee7 use correct database URI 2022-03-28 02:00:58 +00:00
ae8894f5cc repaired admin routes 2022-03-28 01:52:56 +00:00
12432286bf rename db_new -> db 2022-03-28 01:34:52 +00:00
b4a74aca5f remove old db 2022-03-28 01:34:15 +00:00
0619f00f6a rework common and admin router for new db 2022-03-28 01:31:37 +00:00
6012998ecf don't play with new db 2022-03-28 01:30:37 +00:00
d41cd9b15b check if user can do 2022-03-28 01:28:49 +00:00
22e1ef7bf4 don't require email 2022-03-28 01:00:07 +00:00
396359ceff message to future me 2022-03-28 00:59:53 +00:00
89069c9d0f typo 2022-03-28 00:55:18 +00:00
bc3f7984f5 some fun with db_new 2022-03-28 00:50:21 +00:00
c7f93d468e rename 2022-03-28 00:50:00 +00:00
24ade65bb0 db_new interface 2022-03-28 00:48:59 +00:00
04a5798258 capabilities rework 2022-03-28 00:48:44 +00:00
730c7ab966 add devices 2022-03-28 00:43:28 +00:00
e2f916debc Capabilities 2022-03-27 13:47:38 +00:00
9625336df9 User CRUD 2022-03-27 13:47:18 +00:00
ae3627cbe0 use "cls" 2022-03-27 01:22:28 +00:00
e7030eb521 "user" table with sqlmodel 2022-03-27 01:17:54 +00:00
8daeb946f8 Merge branch 'develop' into feature/db-redesign 2022-03-26 16:06:11 +00:00
b5e9323026 git close diff on operation 2022-03-26 16:05:36 +00:00
94fbab278c add models.User.from_db() 2022-03-26 01:49:47 +00:00
557bceed1f legacy 2022-03-25 23:56:57 +00:00
c47fa5a89b split db.schemas -> db.schemata package 2022-03-25 23:54:19 +00:00
02225cdf09 plan models + schemas 2022-03-25 23:03:56 +00:00
1ed3b587c7 quark 2022-03-25 15:50:45 +00:00
22 changed files with 1647 additions and 872 deletions

View file

@ -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
} }
] ]
} }

View file

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

View file

@ -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))

View file

@ -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",
]

View file

@ -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()

View 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()

View file

@ -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"
)

View file

@ -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

View 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
View 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)
)

View file

@ -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)

View file

@ -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,

View file

@ -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",
]

View file

@ -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

View file

@ -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)

View 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

View file

@ -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

View 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)

View file

@ -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
View 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
View file

@ -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"},

View file

@ -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"]