Compare commits

...

5 commits

6 changed files with 147 additions and 75 deletions

View file

@ -4,11 +4,12 @@ import functools
import json
from datetime import datetime, timedelta
from enum import Enum
from secrets import token_hex
from jose import JWTError, jwt
from jose.constants import ALGORITHMS
from passlib.context import CryptContext
from pydantic import BaseModel, BaseSettings, Field
from pydantic import BaseModel, BaseSettings, Field, validator
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
@ -70,6 +71,14 @@ class JWTConfig(BaseModel):
hash_algorithm: str = ALGORITHMS.HS256
expiry_minutes: int = 30
@validator("secret")
@classmethod
def ensure_secret(cls, value: str | None) -> str:
if value is None:
return token_hex(32)
return value
async def create_token(
self,
username: str,

View file

@ -29,6 +29,9 @@ class UserCapability(ORMBaseModel):
)
capability = Column(String, primary_key=True)
def __str__(self) -> str:
return self.capability
class DistinguishedName(ORMBaseModel):
__tablename__ = "distinguished_names"

View file

@ -1,9 +1,11 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from passlib.context import CryptContext
from pydantic import BaseModel, validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from . import models
@ -25,22 +27,12 @@ class Certificate(CertificateBase):
orm_mode = True
class UserCapability(Enum):
admin = "admin"
class UserBase(BaseModel):
name: str
capabilities: list[str]
@validator("capabilities", pre=True)
@classmethod
def unify_capabilities(
cls,
value: list[models.UserCapability | str]
) -> list[str]:
return [
capability.capability
if isinstance(capability, models.UserCapability)
else str(capability)
for capability in value
]
class UserCreate(UserBase):
@ -49,10 +41,22 @@ class UserCreate(UserBase):
class User(UserBase):
certificates: list[Certificate]
capabilities: list[UserCapability]
class Config:
orm_mode = True
@validator("capabilities", pre=True)
@classmethod
def unify_capabilities(
cls,
value: list[models.UserCapability | str]
) -> list[UserCapability]:
return [
UserCapability(str(capability))
for capability in value
]
@classmethod
def from_db(
cls,
@ -83,10 +87,12 @@ class User(UserBase):
.first())
if user is None:
# inexistent user, fake doing password verification
crypt_context.dummy_verify()
return None
if not crypt_context.verify(password, user.password):
# password hash mismatch
return None
return cls.from_orm(user)
@ -97,21 +103,22 @@ class User(UserBase):
db: Session,
user: UserCreate,
crypt_context: CryptContext,
) -> User:
user = models.User(
name=user.name,
password=crypt_context.hash(user.password),
capabilities=[
models.UserCapability(capability=capability)
for capability in user.capabilities
]
)
) -> User | None:
try:
user = models.User(
name=user.name,
password=crypt_context.hash(user.password),
capabilities=[models.UserCapability(capability="admin")],
)
db.add(user)
db.commit()
db.refresh(user)
db.add(user)
db.commit()
db.refresh(user)
return cls.from_orm(user)
return cls.from_orm(user)
except IntegrityError:
pass
class DistinguishedNameBase(BaseModel):

View file

@ -8,6 +8,33 @@ from ..db import Connection, schemas
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="user/auth")
# just a namespace
class Responses:
ok = {
"content": None,
}
installed = {
"description": "kiwi-vpn already installed",
"content": None,
}
not_installed = {
"description": "kiwi-vpn not installed",
"content": None,
}
needs_user = {
"description": "Must be logged in",
"content": None,
}
needs_admin = {
"description": "Must be admin",
"content": None,
}
entry_exists = {
"description": "Entry exists in database",
"content": None,
}
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session | None = Depends(Connection.get),

View file

@ -1,7 +1,4 @@
from secrets import token_hex
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..config import Config
from ..db import Connection, schemas
@ -10,61 +7,54 @@ from . import _deps
router = APIRouter(prefix="/admin")
@router.put(
"/install",
responses={
status.HTTP_200_OK: _deps.Responses.ok,
status.HTTP_400_BAD_REQUEST: _deps.Responses.installed,
},
)
async def install(
config: Config,
user: schemas.UserCreate,
current_config: Config | None = Depends(Config.load),
):
if current_config is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
await config.save()
Connection.connect(await config.db.db_engine)
async for db in Connection.get():
# user.capabilities.append("admin")
schemas.User.create(
db=db,
user=user,
crypt_context=await config.crypto.crypt_context,
)
@router.put(
"/config",
responses={
status.HTTP_200_OK: {
"content": None,
},
status.HTTP_403_FORBIDDEN: {
"description": "Must be admin",
"content": None,
},
status.HTTP_200_OK: _deps.Responses.ok,
status.HTTP_400_BAD_REQUEST: _deps.Responses.not_installed,
status.HTTP_401_UNAUTHORIZED: _deps.Responses.needs_user,
status.HTTP_403_FORBIDDEN: _deps.Responses.needs_admin,
},
)
async def set_config(
new_config: Config,
current_config: Config | None = Depends(Config.load),
current_user: schemas.User | None = Depends(_deps.get_current_user),
):
print(current_config, current_user)
if current_config is not None:
# server is configured, needs authorization
if current_user is None or "admin" not in current_user.capabilities:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if new_config.jwt.secret is None:
new_config.jwt.secret = token_hex(32)
await new_config.save()
Connection.connect(await new_config.db.db_engine)
@router.post(
"/user",
responses={
status.HTTP_200_OK: {
"content": None,
},
status.HTTP_400_BAD_REQUEST: {
"description": "Server is not configured",
"content": None,
},
},
)
async def add_user(
user: schemas.UserCreate,
current_config: Config | None = Depends(Config.load),
db: Session | None = Depends(Connection.get),
):
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
user.capabilities.append("admin")
if (current_user is None
or schemas.UserCapability.admin not in current_user.capabilities):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
schemas.User.create(
db=db,
user=user,
crypt_context=await current_config.crypto.crypt_context,
)
await new_config.save()
Connection.connect(await new_config.db.db_engine)

View file

@ -47,3 +47,39 @@ async def get_current_user(
current_user: schemas.User | None = Depends(_deps.get_current_user),
):
return current_user
@router.post(
"/new",
responses={
status.HTTP_200_OK: _deps.Responses.ok,
status.HTTP_400_BAD_REQUEST: _deps.Responses.not_installed,
status.HTTP_401_UNAUTHORIZED: _deps.Responses.needs_user,
status.HTTP_403_FORBIDDEN: _deps.Responses.needs_admin,
status.HTTP_409_CONFLICT: _deps.Responses.entry_exists,
},
response_model=schemas.User,
)
async def add_user(
user: schemas.UserCreate,
current_config: Config | None = Depends(Config.load),
current_user: schemas.User | None = Depends(_deps.get_current_user),
db: Session | None = Depends(Connection.get),
):
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
if (current_user is None
or schemas.UserCapability.admin not in current_user.capabilities):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
new_user = schemas.User.create(
db=db,
user=user,
crypt_context=await current_config.crypto.crypt_context,
)
if new_user is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
return new_user