Compare commits

..

No commits in common. "5eb9d4d11380ec1f891b08c86584460fef8fb5e2" and "f56c2fb19badd773dc87541dffd6a1a01525c351" have entirely different histories.

6 changed files with 75 additions and 147 deletions

View file

@ -4,12 +4,11 @@ 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 secrets import token_hex
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, Field
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
@ -71,14 +70,6 @@ class JWTConfig(BaseModel):
hash_algorithm: str = ALGORITHMS.HS256 hash_algorithm: str = ALGORITHMS.HS256
expiry_minutes: int = 30 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( async def create_token(
self, self,
username: str, username: str,

View file

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

View file

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

View file

@ -8,33 +8,6 @@ from ..db import Connection, schemas
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="user/auth") 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( async def get_current_user(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: Session | None = Depends(Connection.get), db: Session | None = Depends(Connection.get),

View file

@ -1,4 +1,7 @@
from secrets import token_hex
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..config import Config from ..config import Config
from ..db import Connection, schemas from ..db import Connection, schemas
@ -7,41 +10,16 @@ from . import _deps
router = APIRouter(prefix="/admin") 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( @router.put(
"/config", "/config",
responses={ responses={
status.HTTP_200_OK: _deps.Responses.ok, status.HTTP_200_OK: {
status.HTTP_400_BAD_REQUEST: _deps.Responses.not_installed, "content": None,
status.HTTP_401_UNAUTHORIZED: _deps.Responses.needs_user, },
status.HTTP_403_FORBIDDEN: _deps.Responses.needs_admin, status.HTTP_403_FORBIDDEN: {
"description": "Must be admin",
"content": None,
},
}, },
) )
async def set_config( async def set_config(
@ -49,12 +27,44 @@ async def set_config(
current_config: Config | None = Depends(Config.load), current_config: Config | None = Depends(Config.load),
current_user: schemas.User | None = Depends(_deps.get_current_user), current_user: schemas.User | None = Depends(_deps.get_current_user),
): ):
if current_config is None: print(current_config, current_user)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
if (current_user is None if current_config is not None:
or schemas.UserCapability.admin not in current_user.capabilities): # server is configured, needs authorization
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 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() await new_config.save()
Connection.connect(await new_config.db.db_engine) 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")
schemas.User.create(
db=db,
user=user,
crypt_context=await current_config.crypto.crypt_context,
)

View file

@ -47,39 +47,3 @@ async def get_current_user(
current_user: schemas.User | None = Depends(_deps.get_current_user), current_user: schemas.User | None = Depends(_deps.get_current_user),
): ):
return 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