Complete admin package restructuring
This commit is contained in:
@ -19,10 +19,14 @@ dependencies = [
|
|||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"pykeepass>=4.1.1.post1",
|
"pykeepass>=4.1.1.post1",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
|
"sshecret",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
sshecret = { workspace = true }
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
sshecret-admin = "sshecret_admin.cli:cli"
|
sshecret-admin = "sshecret_admin.core.cli:cli"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@ -31,4 +35,5 @@ build-backend = "hatchling.build"
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytailwindcss>=0.2.0",
|
"pytailwindcss>=0.2.0",
|
||||||
|
"types-pyjwt>=1.7.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
"""Admin API."""
|
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from sshecret.backend import Client, SshecretBackend
|
|
||||||
from sshecret.backend.models import Secret
|
|
||||||
|
|
||||||
from .admin_backend import AdminBackend
|
|
||||||
from .auth_models import (
|
|
||||||
PasswordDB,
|
|
||||||
Token,
|
|
||||||
TokenData,
|
|
||||||
User,
|
|
||||||
create_access_token,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
from .settings import AdminServerSettings
|
|
||||||
from .types import DBSessionDep
|
|
||||||
from .view_models import (
|
|
||||||
ClientCreate,
|
|
||||||
SecretCreate,
|
|
||||||
SecretUpdate,
|
|
||||||
SecretView,
|
|
||||||
UpdateKeyModel,
|
|
||||||
UpdateKeyResponse,
|
|
||||||
UpdatePoliciesRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
API_VERSION = "v1"
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
|
||||||
"""Authenticate user."""
|
|
||||||
user = session.exec(select(User).where(User.username == username)).first()
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
if not verify_password(password, user.hashed_password):
|
|
||||||
return None
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def map_secrets_to_clients(
|
|
||||||
backend: SshecretBackend,
|
|
||||||
) -> defaultdict[str, list[str]]:
|
|
||||||
"""Map secrets to clients."""
|
|
||||||
clients = await backend.get_clients()
|
|
||||||
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
|
|
||||||
for client in clients:
|
|
||||||
for secret in client.secrets:
|
|
||||||
client_secret_map[secret].append(client.name)
|
|
||||||
return client_secret_map
|
|
||||||
|
|
||||||
|
|
||||||
def get_admin_api(
|
|
||||||
get_db_session: DBSessionDep, settings: AdminServerSettings
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Get Admin API."""
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
|
||||||
|
|
||||||
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
|
|
||||||
"""Get admin backend API."""
|
|
||||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
|
||||||
if not password_db:
|
|
||||||
raise HTTPException(
|
|
||||||
500, detail="Error: The password manager has not yet been set up."
|
|
||||||
)
|
|
||||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
|
||||||
yield admin
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
token: Annotated[str, Depends(oauth2_scheme)],
|
|
||||||
session: Annotated[Session, Depends(get_db_session)],
|
|
||||||
) -> User:
|
|
||||||
"""Get current user from token."""
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
|
||||||
username = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
raise credentials_exception
|
|
||||||
token_data = TokenData(username=username)
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.username == token_data.username)
|
|
||||||
).first()
|
|
||||||
if not user:
|
|
||||||
raise credentials_exception
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def get_current_active_user(
|
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
|
||||||
) -> User:
|
|
||||||
"""Get current active user."""
|
|
||||||
if current_user.disabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Inactive or disabled user")
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
app = APIRouter(
|
|
||||||
prefix=f"/api/{API_VERSION}", dependencies=[Depends(get_current_active_user)]
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/token")
|
|
||||||
async def login_for_access_token(
|
|
||||||
session: Annotated[Session, Depends(get_db_session)],
|
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
|
||||||
) -> Token:
|
|
||||||
"""Login user and generate token."""
|
|
||||||
user = authenticate_user(session, form_data.username, form_data.password)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect username or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
access_token = create_access_token(
|
|
||||||
settings,
|
|
||||||
data={"sub": user.username},
|
|
||||||
expires_delta=access_token_expires,
|
|
||||||
)
|
|
||||||
return Token(access_token=access_token, token_type="bearer")
|
|
||||||
|
|
||||||
@app.get("/clients/")
|
|
||||||
async def get_clients(
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> list[Client]:
|
|
||||||
"""Get clients."""
|
|
||||||
clients = await admin.get_clients()
|
|
||||||
return clients
|
|
||||||
|
|
||||||
@app.post("/clients/")
|
|
||||||
async def create_client(
|
|
||||||
new_client: ClientCreate,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> Client:
|
|
||||||
"""Create a new client."""
|
|
||||||
sources: list[str] | None = None
|
|
||||||
if new_client.sources:
|
|
||||||
sources = [str(source) for source in new_client.sources]
|
|
||||||
client = await admin.create_client(
|
|
||||||
new_client.name, new_client.public_key, sources
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
|
|
||||||
@app.delete("/clients/{name}")
|
|
||||||
async def delete_client(
|
|
||||||
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> None:
|
|
||||||
"""Delete a client."""
|
|
||||||
await admin.delete_client(name)
|
|
||||||
|
|
||||||
@app.delete("/clients/{name}/secrets/{secret_name}")
|
|
||||||
async def delete_secret_from_client(
|
|
||||||
name: str,
|
|
||||||
secret_name: str,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
"""Delete a secret from a client."""
|
|
||||||
client = await admin.get_client(name)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if secret_name not in client.secrets:
|
|
||||||
LOG.debug("Client does not have requested secret. No action to perform.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
await admin.delete_client_secret(name, secret_name)
|
|
||||||
|
|
||||||
@app.put("/clients/{name}/policies")
|
|
||||||
async def update_client_policies(
|
|
||||||
name: str,
|
|
||||||
updated: UpdatePoliciesRequest,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> Client:
|
|
||||||
"""Update the client access policies."""
|
|
||||||
client = await admin.get_client(name)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
|
|
||||||
|
|
||||||
addresses: list[str] = [str(source) for source in updated.sources]
|
|
||||||
await admin.update_client_sources(name, addresses)
|
|
||||||
client = await admin.get_client(name)
|
|
||||||
|
|
||||||
assert client is not None, "Critical: The client disappeared after update!"
|
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
@app.get("/secrets/")
|
|
||||||
async def get_secret_names(
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> list[Secret]:
|
|
||||||
"""Get Secret Names."""
|
|
||||||
return await admin.get_secrets()
|
|
||||||
|
|
||||||
@app.post("/secrets/")
|
|
||||||
async def add_secret(
|
|
||||||
secret: SecretCreate, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> None:
|
|
||||||
"""Create a secret."""
|
|
||||||
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
|
|
||||||
|
|
||||||
@app.get("/secrets/{name}")
|
|
||||||
async def get_secret(
|
|
||||||
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> SecretView:
|
|
||||||
"""Get a secret."""
|
|
||||||
secret_view = await admin.get_secret(name)
|
|
||||||
|
|
||||||
if not secret_view:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
|
||||||
)
|
|
||||||
return secret_view
|
|
||||||
|
|
||||||
@app.put("/secrets/{name}")
|
|
||||||
async def update_secret(
|
|
||||||
name: str,
|
|
||||||
value: SecretUpdate,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
new_value = value.get_secret()
|
|
||||||
await admin.update_secret(name, new_value)
|
|
||||||
|
|
||||||
@app.delete("/secrets/{name}")
|
|
||||||
async def delete_secret(
|
|
||||||
name: str,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
"""Delete secret."""
|
|
||||||
await admin.delete_secret(name)
|
|
||||||
|
|
||||||
@app.put("/clients/{name}/public-key")
|
|
||||||
async def update_client_public_key(
|
|
||||||
name: str,
|
|
||||||
updated: UpdateKeyModel,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> UpdateKeyResponse:
|
|
||||||
"""Update client public key.
|
|
||||||
|
|
||||||
Updating the public key will invalidate the current secrets, so these well
|
|
||||||
be resolved first, and re-encrypted using the new key.
|
|
||||||
"""
|
|
||||||
# Let's first ensure that the key is actually updated.
|
|
||||||
updated_secrets = await admin.update_client_public_key(name, updated.public_key)
|
|
||||||
return UpdateKeyResponse(
|
|
||||||
public_key=updated.public_key, updated_secrets=updated_secrets
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.put("/clients/{name}/secrets/{secret_name}")
|
|
||||||
async def add_secret_to_client(
|
|
||||||
name: str,
|
|
||||||
secret_name: str,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
"""Add secret to a client."""
|
|
||||||
await admin.create_client_secret(name, secret_name)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
"""Admin REST API."""
|
||||||
|
|
||||||
|
from .router import create_router as create_api_router
|
||||||
|
|
||||||
|
__all__ = ["create_api_router"]
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""API Endpoints."""
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
"""Authentication related endpoints factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from sshecret_admin.auth import Token, authenticate_user, create_access_token
|
||||||
|
from sshecret_admin.core.dependencies import AdminDependencies
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||||
|
"""Create auth router."""
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
@app.post("/token")
|
||||||
|
async def login_for_access_token(
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
) -> Token:
|
||||||
|
"""Login user and generate token."""
|
||||||
|
user = authenticate_user(session, form_data.username, form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token = create_access_token(
|
||||||
|
dependencies.settings,
|
||||||
|
data={"sub": user.username},
|
||||||
|
)
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
|
return app
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
"""Client-related endpoints factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from sshecret.backend import Client
|
||||||
|
from sshecret_admin.core.dependencies import AdminDependencies
|
||||||
|
from sshecret_admin.services import AdminBackend
|
||||||
|
from sshecret_admin.services.models import (
|
||||||
|
ClientCreate,
|
||||||
|
UpdateKeyModel,
|
||||||
|
UpdateKeyResponse,
|
||||||
|
UpdatePoliciesRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||||
|
"""Create clients router."""
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
@app.get("/clients/")
|
||||||
|
async def get_clients(
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)]
|
||||||
|
) -> list[Client]:
|
||||||
|
"""Get clients."""
|
||||||
|
clients = await admin.get_clients()
|
||||||
|
return clients
|
||||||
|
|
||||||
|
@app.post("/clients/")
|
||||||
|
async def create_client(
|
||||||
|
new_client: ClientCreate,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> Client:
|
||||||
|
"""Create a new client."""
|
||||||
|
sources: list[str] | None = None
|
||||||
|
if new_client.sources:
|
||||||
|
sources = [str(source) for source in new_client.sources]
|
||||||
|
client = await admin.create_client(
|
||||||
|
new_client.name, new_client.public_key, sources=sources
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
@app.delete("/clients/{name}")
|
||||||
|
async def delete_client(
|
||||||
|
name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Delete a client."""
|
||||||
|
await admin.delete_client(name)
|
||||||
|
|
||||||
|
@app.delete("/clients/{name}/secrets/{secret_name}")
|
||||||
|
async def delete_secret_from_client(
|
||||||
|
name: str,
|
||||||
|
secret_name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Delete a secret from a client."""
|
||||||
|
client = await admin.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if secret_name not in client.secrets:
|
||||||
|
LOG.debug("Client does not have requested secret. No action to perform.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
await admin.delete_client_secret(name, secret_name)
|
||||||
|
|
||||||
|
@app.put("/clients/{name}/policies")
|
||||||
|
async def update_client_policies(
|
||||||
|
name: str,
|
||||||
|
updated: UpdatePoliciesRequest,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> Client:
|
||||||
|
"""Update the client access policies."""
|
||||||
|
client = await admin.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
|
||||||
|
|
||||||
|
addresses: list[str] = [str(source) for source in updated.sources]
|
||||||
|
await admin.update_client_sources(name, addresses)
|
||||||
|
client = await admin.get_client(name)
|
||||||
|
|
||||||
|
assert client is not None, "Critical: The client disappeared after update!"
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
@app.put("/clients/{name}/public-key")
|
||||||
|
async def update_client_public_key(
|
||||||
|
name: str,
|
||||||
|
updated: UpdateKeyModel,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> UpdateKeyResponse:
|
||||||
|
"""Update client public key.
|
||||||
|
|
||||||
|
Updating the public key will invalidate the current secrets, so these well
|
||||||
|
be resolved first, and re-encrypted using the new key.
|
||||||
|
"""
|
||||||
|
# Let's first ensure that the key is actually updated.
|
||||||
|
updated_secrets = await admin.update_client_public_key(name, updated.public_key)
|
||||||
|
return UpdateKeyResponse(
|
||||||
|
public_key=updated.public_key, updated_secrets=updated_secrets
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.put("/clients/{name}/secrets/{secret_name}")
|
||||||
|
async def add_secret_to_client(
|
||||||
|
name: str,
|
||||||
|
secret_name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Add secret to a client."""
|
||||||
|
await admin.create_client_secret(name, secret_name)
|
||||||
|
|
||||||
|
return app
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
"""Secrets related endpoints factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from sshecret.backend.models import Secret
|
||||||
|
from sshecret_admin.core.dependencies import AdminDependencies
|
||||||
|
from sshecret_admin.services import AdminBackend
|
||||||
|
from sshecret_admin.services.models import (
|
||||||
|
SecretCreate,
|
||||||
|
SecretUpdate,
|
||||||
|
SecretView,
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||||
|
"""Create secrets router."""
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
@app.get("/secrets/")
|
||||||
|
async def get_secret_names(
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)]
|
||||||
|
) -> list[Secret]:
|
||||||
|
"""Get Secret Names."""
|
||||||
|
return await admin.get_secrets()
|
||||||
|
|
||||||
|
@app.post("/secrets/")
|
||||||
|
async def add_secret(
|
||||||
|
secret: SecretCreate,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Create a secret."""
|
||||||
|
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
|
||||||
|
|
||||||
|
@app.get("/secrets/{name}")
|
||||||
|
async def get_secret(
|
||||||
|
name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> SecretView:
|
||||||
|
"""Get a secret."""
|
||||||
|
secret_view = await admin.get_secret(name)
|
||||||
|
|
||||||
|
if not secret_view:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||||
|
)
|
||||||
|
return secret_view
|
||||||
|
|
||||||
|
@app.put("/secrets/{name}")
|
||||||
|
async def update_secret(
|
||||||
|
name: str,
|
||||||
|
value: SecretUpdate,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
new_value = value.get_secret()
|
||||||
|
await admin.update_secret(name, new_value)
|
||||||
|
|
||||||
|
@app.delete("/secrets/{name}")
|
||||||
|
async def delete_secret(
|
||||||
|
name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Delete secret."""
|
||||||
|
await admin.delete_secret(name)
|
||||||
|
|
||||||
|
return app
|
||||||
78
packages/sshecret-admin/src/sshecret_admin/api/router.py
Normal file
78
packages/sshecret-admin/src/sshecret_admin/api/router.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Main API Router."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from sshecret_admin.services.admin_backend import AdminBackend
|
||||||
|
from sshecret_admin.core.dependencies import BaseDependencies, AdminDependencies
|
||||||
|
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||||
|
|
||||||
|
from .endpoints import auth, clients, secrets
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_VERSION = "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||||
|
"""Create clients router."""
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
) -> User:
|
||||||
|
"""Get current user from token."""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
token_data = decode_token(dependencies.settings, token)
|
||||||
|
if not token_data:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.username == token_data.username)
|
||||||
|
).first()
|
||||||
|
if not user:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_current_active_user(
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
) -> User:
|
||||||
|
"""Get current active user."""
|
||||||
|
if current_user.disabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive or disabled user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
async def get_admin_backend(session: Annotated[Session, Depends(dependencies.get_db_session)]):
|
||||||
|
"""Get admin backend API."""
|
||||||
|
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||||
|
if not password_db:
|
||||||
|
raise HTTPException(
|
||||||
|
500, detail="Error: The password manager has not yet been set up."
|
||||||
|
)
|
||||||
|
admin = AdminBackend(dependencies.settings, password_db.encrypted_password)
|
||||||
|
yield admin
|
||||||
|
|
||||||
|
app = APIRouter(
|
||||||
|
prefix=f"/api/{API_VERSION}", dependencies=[Depends(get_current_active_user)]
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint_deps = AdminDependencies.create(dependencies, get_admin_backend)
|
||||||
|
|
||||||
|
app.include_router(auth.create_router(endpoint_deps))
|
||||||
|
app.include_router(clients.create_router(endpoint_deps))
|
||||||
|
app.include_router(secrets.create_router(endpoint_deps))
|
||||||
|
|
||||||
|
return app
|
||||||
24
packages/sshecret-admin/src/sshecret_admin/auth/__init__.py
Normal file
24
packages/sshecret-admin/src/sshecret_admin/auth/__init__.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Authentication related module."""
|
||||||
|
|
||||||
|
from .authentication import (
|
||||||
|
authenticate_user,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
check_password,
|
||||||
|
decode_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from .models import User, Token, PasswordDB
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PasswordDB",
|
||||||
|
"Token",
|
||||||
|
"User",
|
||||||
|
"authenticate_user",
|
||||||
|
"check_password",
|
||||||
|
"create_access_token",
|
||||||
|
"create_refresh_token",
|
||||||
|
"decode_token",
|
||||||
|
"verify_password",
|
||||||
|
]
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
"""Authentication utilities."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import cast, Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import jwt
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
from .models import User, TokenData
|
||||||
|
from .exceptions import AuthenticationFailedError
|
||||||
|
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
# I know refresh tokens are supposed to be long-lived, but 6 hours for a
|
||||||
|
# sensitive application, seems reasonable.
|
||||||
|
REFRESH_TOKEN_EXPIRE_HOURS = 6
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(
|
||||||
|
settings: AdminServerSettings,
|
||||||
|
data: dict[str, Any],
|
||||||
|
expires_delta: timedelta,
|
||||||
|
) -> str:
|
||||||
|
"""Create access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
|
||||||
|
return str(encoded_jwt)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
settings: AdminServerSettings,
|
||||||
|
data: dict[str, Any],
|
||||||
|
expires_delta: timedelta | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create access token."""
|
||||||
|
if not expires_delta:
|
||||||
|
expires_delta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
return create_token(settings, data, expires_delta)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(
|
||||||
|
settings: AdminServerSettings,
|
||||||
|
data: dict[str, Any],
|
||||||
|
expires_delta: timedelta | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Create access token."""
|
||||||
|
if not expires_delta:
|
||||||
|
expires_delta = timedelta(hours=REFRESH_TOKEN_EXPIRE_HOURS)
|
||||||
|
return create_token(settings, data, expires_delta)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify password against stored hash."""
|
||||||
|
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(plain_password: str, hashed_password: str) -> None:
|
||||||
|
"""Check password.
|
||||||
|
|
||||||
|
If password doesn't match, throw AuthenticationFailedError.
|
||||||
|
"""
|
||||||
|
if not verify_password(plain_password, hashed_password):
|
||||||
|
raise AuthenticationFailedError()
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||||
|
"""Authenticate user."""
|
||||||
|
user = session.exec(select(User).where(User.username == username)).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(settings: AdminServerSettings, token: str) -> TokenData | None:
|
||||||
|
"""Decode token."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
||||||
|
username = cast("str | None", payload.get("sub"))
|
||||||
|
if not username:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_data = TokenData(username=username)
|
||||||
|
return token_data
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
LOG.debug("Could not decode token: %s", e, exc_info=True)
|
||||||
|
return None
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
"""Authentication related exceptions."""
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from .models import LoginError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationFailedError(Exception):
|
||||||
|
"""Authentication failed."""
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __init__(self, message: str | None = None) -> None:
|
||||||
|
"""Initialize exception class."""
|
||||||
|
if not message:
|
||||||
|
message = "Invalid user or password."
|
||||||
|
super().__init__(message)
|
||||||
|
self.login_error: LoginError = LoginError(
|
||||||
|
title="Authentication Failed", message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationNeededError(Exception):
|
||||||
|
"""Authentication needed error."""
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __init__(self, message: str | None = None) -> None:
|
||||||
|
"""Initialize exception class."""
|
||||||
|
if not message:
|
||||||
|
message = "You need to be logged in to continue."
|
||||||
|
super().__init__(message)
|
||||||
|
self.login_error: LoginError = LoginError(title="Unauthorized", message=message)
|
||||||
71
packages/sshecret-admin/src/sshecret_admin/auth/models.py
Normal file
71
packages/sshecret-admin/src/sshecret_admin/auth/models.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""Models for authentication."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
JWT_ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
# I know refresh tokens are supposed to be long-lived, but 6 hours for a
|
||||||
|
# sensitive application, seems reasonable.
|
||||||
|
REFRESH_TOKEN_EXPIRE_HOURS = 6
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
"""Users."""
|
||||||
|
|
||||||
|
username: str = Field(unique=True, primary_key=True)
|
||||||
|
hashed_password: str
|
||||||
|
disabled: bool = Field(default=False)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=sa.DateTime(timezone=True),
|
||||||
|
sa_column_kwargs={"server_default": sa.func.now()},
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordDB(SQLModel, table=True):
|
||||||
|
"""Password database."""
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
encrypted_password: str
|
||||||
|
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=sa.DateTime(timezone=True),
|
||||||
|
sa_column_kwargs={"server_default": sa.func.now()},
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
sa_type=sa.DateTime(timezone=True),
|
||||||
|
sa_column_kwargs={"onupdate": sa.func.now(), "server_default": sa.func.now()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(engine: sa.Engine) -> None:
|
||||||
|
"""Create database."""
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(SQLModel):
|
||||||
|
"""Token data."""
|
||||||
|
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Token(SQLModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginError(SQLModel):
|
||||||
|
"""Login Error model."""
|
||||||
|
# TODO: Remove this.
|
||||||
|
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
"""Models for authentication."""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import bcrypt
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from typing import Any, override
|
|
||||||
import jwt
|
|
||||||
from sqlmodel import SQLModel, Field
|
|
||||||
from sshecret_admin.settings import AdminServerSettings
|
|
||||||
|
|
||||||
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
|
||||||
"""Users."""
|
|
||||||
|
|
||||||
username: str = Field(unique=True, primary_key=True)
|
|
||||||
hashed_password: str
|
|
||||||
disabled: bool = Field(default=False)
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_type=sa.DateTime(timezone=True),
|
|
||||||
sa_column_kwargs={"server_default": sa.func.now()},
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordDB(SQLModel, table=True):
|
|
||||||
"""Password database."""
|
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
|
||||||
encrypted_password: str
|
|
||||||
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_type=sa.DateTime(timezone=True),
|
|
||||||
sa_column_kwargs={"server_default": sa.func.now()},
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_type=sa.DateTime(timezone=True),
|
|
||||||
sa_column_kwargs={"onupdate": sa.func.now(), "server_default": sa.func.now()},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db(engine: sa.Engine) -> None:
|
|
||||||
"""Create database."""
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
|
|
||||||
|
|
||||||
class TokenData(SQLModel):
|
|
||||||
"""Token data."""
|
|
||||||
|
|
||||||
username: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Token(SQLModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
|
||||||
settings: AdminServerSettings,
|
|
||||||
data: dict[str, Any],
|
|
||||||
expires_delta: timedelta | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Create access token."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
|
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verify password against stored hash."""
|
|
||||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
|
||||||
|
|
||||||
|
|
||||||
def check_password(plain_password: str, hashed_password: str) -> None:
|
|
||||||
"""Check password.
|
|
||||||
|
|
||||||
If password doesn't match, throw AuthenticationFailedError.
|
|
||||||
"""
|
|
||||||
if not verify_password(plain_password, hashed_password):
|
|
||||||
raise AuthenticationFailedError()
|
|
||||||
|
|
||||||
|
|
||||||
class LoginError(SQLModel):
|
|
||||||
"""Login Error model."""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationFailedError(Exception):
|
|
||||||
"""Authentication failed."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
def __init__(self, message: str | None = None) -> None:
|
|
||||||
"""Initialize exception class."""
|
|
||||||
if not message:
|
|
||||||
message = "Invalid user or password."
|
|
||||||
super().__init__(message)
|
|
||||||
self.login_error: LoginError = LoginError(
|
|
||||||
title="Authentication Failed", message=message
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationNeededError(Exception):
|
|
||||||
"""Authentication needed error."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
def __init__(self, message: str | None = None) -> None:
|
|
||||||
"""Initialize exception class."""
|
|
||||||
if not message:
|
|
||||||
message = "You need to be logged in to continue."
|
|
||||||
super().__init__(message)
|
|
||||||
self.login_error: LoginError = LoginError(title="Unauthorized", message=message)
|
|
||||||
@ -5,24 +5,22 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, status
|
from fastapi import FastAPI, Request, Response, status
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
from sshecret_admin import api, frontend
|
||||||
|
from sshecret_admin.auth.models import PasswordDB, init_db
|
||||||
|
from sshecret_admin.core.db import setup_database
|
||||||
|
from sshecret_admin.frontend.exceptions import RedirectException
|
||||||
|
from sshecret_admin.services.master_password import setup_master_password
|
||||||
|
|
||||||
from .admin_api import get_admin_api
|
from .dependencies import BaseDependencies
|
||||||
from .auth_models import init_db, PasswordDB, AuthenticationFailedError, AuthenticationNeededError
|
|
||||||
from .db import setup_database
|
|
||||||
from .master_password import setup_master_password
|
|
||||||
from .settings import AdminServerSettings
|
from .settings import AdminServerSettings
|
||||||
from .frontend import create_frontend
|
|
||||||
from .types import DBSessionDep
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -30,15 +28,14 @@ LOG = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def setup_frontend(
|
def setup_frontend(
|
||||||
app: FastAPI, settings: AdminServerSettings, get_db_session: DBSessionDep
|
app: FastAPI, dependencies: BaseDependencies
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Setup frontend."""
|
"""Setup frontend."""
|
||||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
||||||
static_path = script_path / "static"
|
static_path = script_path.parent / "static"
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||||
frontend = create_frontend(settings, get_db_session)
|
app.include_router(frontend.create_frontend_router(dependencies))
|
||||||
app.include_router(frontend)
|
|
||||||
|
|
||||||
|
|
||||||
def create_admin_app(
|
def create_admin_app(
|
||||||
@ -88,19 +85,15 @@ def create_admin_app(
|
|||||||
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(AuthenticationNeededError)
|
@app.exception_handler(RedirectException)
|
||||||
async def authentication_needed_handler(
|
async def redirect_handler(request: Request, exc: RedirectException) -> Response:
|
||||||
request: Request, exc: AuthenticationNeededError,
|
"""Handle redirect exceptions."""
|
||||||
):
|
if "hx-request" in request.headers:
|
||||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
response = Response()
|
||||||
return RedirectResponse(f"/?{qs}")
|
response.headers["HX-Redirect"] = str(exc.to)
|
||||||
|
return response
|
||||||
|
return RedirectResponse(url=str(exc.to))
|
||||||
|
|
||||||
@app.exception_handler(AuthenticationFailedError)
|
|
||||||
async def authentication_failed_handler(
|
|
||||||
request: Request, exc: AuthenticationNeededError,
|
|
||||||
):
|
|
||||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
|
||||||
return RedirectResponse(f"/?{qs}")
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def get_health() -> JSONResponse:
|
async def get_health() -> JSONResponse:
|
||||||
@ -109,10 +102,11 @@ def create_admin_app(
|
|||||||
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
|
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_api = get_admin_api(get_db_session, settings)
|
dependencies = BaseDependencies(settings, get_db_session)
|
||||||
|
|
||||||
app.include_router(admin_api)
|
|
||||||
|
app.include_router(api.create_api_router(dependencies))
|
||||||
if with_frontend:
|
if with_frontend:
|
||||||
setup_frontend(app, settings, get_db_session)
|
setup_frontend(app, dependencies)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@ -7,29 +7,30 @@ import logging
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import click
|
import click
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
from sshecret_admin.services.admin_backend import AdminBackend
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, create_engine, select
|
from sqlmodel import Session, create_engine, select
|
||||||
from .auth_models import init_db, User, PasswordDB
|
from sshecret_admin.auth.models import init_db, User, PasswordDB
|
||||||
from .settings import AdminServerSettings
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
formatter = logging.Formatter("%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s")
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
LOG = logging.getLogger()
|
LOG = logging.getLogger()
|
||||||
LOG.addHandler(handler)
|
LOG.addHandler(handler)
|
||||||
LOG.setLevel(logging.INFO)
|
LOG.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash password."""
|
"""Hash password."""
|
||||||
salt = bcrypt.gensalt()
|
salt = bcrypt.gensalt()
|
||||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
||||||
return hashed_password.decode()
|
return hashed_password.decode()
|
||||||
|
|
||||||
|
|
||||||
def create_user(session: Session, username: str, password: str) -> None:
|
def create_user(session: Session, username: str, password: str) -> None:
|
||||||
"""Create a user."""
|
"""Create a user."""
|
||||||
hashed_password = hash_password(password)
|
hashed_password = hash_password(password)
|
||||||
@ -48,7 +49,9 @@ def cli(ctx: click.Context, debug: bool) -> None:
|
|||||||
try:
|
try:
|
||||||
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise click.ClickException("Error: One or more required environment options are missing.") from e
|
raise click.ClickException(
|
||||||
|
"Error: One or more required environment options are missing."
|
||||||
|
) from e
|
||||||
ctx.obj = settings
|
ctx.obj = settings
|
||||||
|
|
||||||
|
|
||||||
@ -66,6 +69,7 @@ def cli_create_user(ctx: click.Context, username: str, password: str) -> None:
|
|||||||
|
|
||||||
click.echo("User created.")
|
click.echo("User created.")
|
||||||
|
|
||||||
|
|
||||||
@cli.command("passwd")
|
@cli.command("passwd")
|
||||||
@click.argument("username")
|
@click.argument("username")
|
||||||
@click.password_option()
|
@click.password_option()
|
||||||
@ -85,6 +89,7 @@ def cli_change_user_passwd(ctx: click.Context, username: str, password: str) ->
|
|||||||
session.commit()
|
session.commit()
|
||||||
click.echo("Password updated.")
|
click.echo("Password updated.")
|
||||||
|
|
||||||
|
|
||||||
@cli.command("deluser")
|
@cli.command("deluser")
|
||||||
@click.argument("username")
|
@click.argument("username")
|
||||||
@click.confirmation_option()
|
@click.confirmation_option()
|
||||||
@ -112,7 +117,9 @@ def cli_delete_user(ctx: click.Context, username: str) -> None:
|
|||||||
@click.option("--workers", type=click.INT)
|
@click.option("--workers", type=click.INT)
|
||||||
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
|
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
|
||||||
"""Run the server."""
|
"""Run the server."""
|
||||||
uvicorn.run("sshecret_admin.main:app", host=host, port=port, reload=dev, workers=workers)
|
uvicorn.run(
|
||||||
|
"sshecret_admin.core.main:app", host=host, port=port, reload=dev, workers=workers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@cli.command("repl")
|
@cli.command("repl")
|
||||||
@ -126,7 +133,9 @@ def cli_repl(ctx: click.Context) -> None:
|
|||||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||||
|
|
||||||
if not password_db:
|
if not password_db:
|
||||||
raise click.ClickException("Error: Password database has not yet been setup. Start the server to finish setup.")
|
raise click.ClickException(
|
||||||
|
"Error: Password database has not yet been setup. Start the server to finish setup."
|
||||||
|
)
|
||||||
|
|
||||||
def run(func: Awaitable[Any]) -> Any:
|
def run(func: Awaitable[Any]) -> Any:
|
||||||
"""Run an async function."""
|
"""Run an async function."""
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
"""Common type definitions."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Callable, Generator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
from sshecret_admin.services import AdminBackend
|
||||||
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
|
|
||||||
|
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||||
|
|
||||||
|
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BaseDependencies:
|
||||||
|
"""Base level dependencies."""
|
||||||
|
|
||||||
|
settings: AdminServerSettings
|
||||||
|
get_db_session: DBSessionDep
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminDependencies(BaseDependencies):
|
||||||
|
"""Dependency class with admin."""
|
||||||
|
|
||||||
|
get_admin_backend: AdminDep
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, deps: BaseDependencies, get_admin_backend: AdminDep) -> Self:
|
||||||
|
"""Create from base dependencies."""
|
||||||
|
return cls(
|
||||||
|
settings=deps.settings,
|
||||||
|
get_db_session=deps.get_db_session,
|
||||||
|
get_admin_backend=get_admin_backend,
|
||||||
|
)
|
||||||
@ -1,6 +1,5 @@
|
|||||||
"""Main server app."""
|
"""Main server app."""
|
||||||
import sys
|
import sys
|
||||||
import uvicorn
|
|
||||||
import click
|
import click
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
from pydantic import AnyHttpUrl, Field
|
from pydantic import AnyHttpUrl, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from sqlalchemy import URL
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LISTEN_PORT = 8822
|
DEFAULT_LISTEN_PORT = 8822
|
||||||
|
|
||||||
DEFAULT_DATABASE = "sqlite:///ssh_admin.db"
|
DEFAULT_DATABASE = "ssh_admin.db"
|
||||||
|
|
||||||
|
|
||||||
class AdminServerSettings(BaseSettings):
|
class AdminServerSettings(BaseSettings):
|
||||||
@ -21,5 +22,12 @@ class AdminServerSettings(BaseSettings):
|
|||||||
listen_address: str = Field(default="")
|
listen_address: str = Field(default="")
|
||||||
secret_key: str
|
secret_key: str
|
||||||
port: int = DEFAULT_LISTEN_PORT
|
port: int = DEFAULT_LISTEN_PORT
|
||||||
admin_db: str = Field(default=DEFAULT_DATABASE)
|
|
||||||
|
database: str = Field(default=DEFAULT_DATABASE)
|
||||||
|
#admin_db: str = Field(default=DEFAULT_DATABASE)
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_db(self) -> URL:
|
||||||
|
"""Construct database url."""
|
||||||
|
return URL.create(drivername="sqlite", database=self.database)
|
||||||
@ -1,240 +0,0 @@
|
|||||||
"""Frontend methods."""
|
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from sshecret_admin.settings import AdminServerSettings
|
|
||||||
from sshecret.backend import SshecretBackend
|
|
||||||
from .admin_backend import AdminBackend
|
|
||||||
from .auth_models import (
|
|
||||||
JWT_ALGORITHM,
|
|
||||||
AuthenticationFailedError,
|
|
||||||
AuthenticationNeededError,
|
|
||||||
LoginError,
|
|
||||||
PasswordDB,
|
|
||||||
User,
|
|
||||||
TokenData,
|
|
||||||
create_access_token,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
from .types import DBSessionDep
|
|
||||||
from .views import create_audit_view, create_client_view, create_secrets_view
|
|
||||||
|
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 45
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def login_error(templates: Jinja2Blocks, request: Request):
|
|
||||||
"""Return a login error."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"page_title": "Login",
|
|
||||||
"page_description": "Login Page",
|
|
||||||
"error": "Invalid Login.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_frontend(
|
|
||||||
settings: AdminServerSettings, get_db_session: DBSessionDep
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Create frontend."""
|
|
||||||
app = APIRouter(include_in_schema=False)
|
|
||||||
|
|
||||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
|
||||||
|
|
||||||
template_path = script_path / "templates"
|
|
||||||
|
|
||||||
templates = Jinja2Blocks(directory=template_path)
|
|
||||||
|
|
||||||
# @app.exception_handler(AuthenticationFailedError)
|
|
||||||
# async def handle_authentication_failed(request: Request, exc: AuthenticationFailedError):
|
|
||||||
# """Handle authentication failed error."""
|
|
||||||
# return templates.TemplateResponse(request, "login.html")
|
|
||||||
|
|
||||||
async def get_backend():
|
|
||||||
"""Get backend client."""
|
|
||||||
backend_client = SshecretBackend(
|
|
||||||
str(settings.backend_url), settings.backend_token
|
|
||||||
)
|
|
||||||
yield backend_client
|
|
||||||
|
|
||||||
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
|
|
||||||
"""Get admin backend API."""
|
|
||||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
|
||||||
if not password_db:
|
|
||||||
raise HTTPException(
|
|
||||||
500, detail="Error: The password manager has not yet been set up."
|
|
||||||
)
|
|
||||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
|
||||||
yield admin
|
|
||||||
|
|
||||||
async def get_login_status(
|
|
||||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
|
||||||
) -> bool:
|
|
||||||
"""Get login status."""
|
|
||||||
token = request.cookies.get("access_token")
|
|
||||||
if not token:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
|
||||||
username = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
return False
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return False
|
|
||||||
token_data = TokenData(username=username)
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.username == token_data.username)
|
|
||||||
).first()
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def get_current_user_from_token(
|
|
||||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
|
||||||
) -> User:
|
|
||||||
credentials_exception = AuthenticationNeededError()
|
|
||||||
"""Get current user from token."""
|
|
||||||
token = request.cookies.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise credentials_exception
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
|
||||||
username = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
raise credentials_exception
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
raise credentials_exception
|
|
||||||
token_data = TokenData(username=username)
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.username == token_data.username)
|
|
||||||
).first()
|
|
||||||
if not user:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def get_index(
|
|
||||||
request: Request,
|
|
||||||
login_status: Annotated[bool, Depends(get_login_status)],
|
|
||||||
error_title: str | None = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
):
|
|
||||||
"""Get index."""
|
|
||||||
if login_status:
|
|
||||||
return RedirectResponse("/dashboard")
|
|
||||||
login_error: LoginError | None = None
|
|
||||||
if error_title and error_message:
|
|
||||||
login_error = LoginError(title=error_title, message=error_message)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"page_title": "Login",
|
|
||||||
"page_description": "Login page.",
|
|
||||||
"login_error": login_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/")
|
|
||||||
async def post_index(
|
|
||||||
request: Request,
|
|
||||||
error_title: str | None = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
):
|
|
||||||
"""Get index."""
|
|
||||||
login_error: LoginError | None = None
|
|
||||||
if error_title and error_message:
|
|
||||||
login_error = LoginError(title=error_title, message=error_message)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"page_title": "Login",
|
|
||||||
"page_description": "Login page.",
|
|
||||||
"login_error": login_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login_user(
|
|
||||||
response: Response,
|
|
||||||
request: Request,
|
|
||||||
session: Annotated[Session, Depends(get_db_session)],
|
|
||||||
username: Annotated[str, Form()],
|
|
||||||
password: Annotated[str, Form()],
|
|
||||||
):
|
|
||||||
"""Log in user."""
|
|
||||||
user = session.exec(select(User).where(User.username == username)).first()
|
|
||||||
auth_error = AuthenticationFailedError()
|
|
||||||
if not user:
|
|
||||||
raise auth_error
|
|
||||||
|
|
||||||
if not verify_password(password, user.hashed_password):
|
|
||||||
raise auth_error
|
|
||||||
|
|
||||||
token_data = {"sub": user.username}
|
|
||||||
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
token = create_access_token(settings, token_data, expires_delta=expires)
|
|
||||||
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
|
||||||
response.set_cookie(
|
|
||||||
key="access_token", value=token, httponly=True, secure=False, samesite="lax"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.get("/success")
|
|
||||||
async def success_page(
|
|
||||||
request: Request,
|
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
|
||||||
):
|
|
||||||
"""Display a success page."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request, "success.html", {"page_title": "Success!", "user": current_user}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/dashboard")
|
|
||||||
async def get_dashboard(
|
|
||||||
request: Request,
|
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
):
|
|
||||||
"""Dashboard for mocking up the dashboard."""
|
|
||||||
# secrets = await admin.get_secrets()
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"dashboard.html",
|
|
||||||
{
|
|
||||||
"page_title": "sshecret",
|
|
||||||
"user": current_user.username,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stop adding routes here.
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
create_client_view(templates, get_current_user_from_token, get_admin_backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
create_secrets_view(templates, get_current_user_from_token, get_admin_backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
create_audit_view(templates, get_current_user_from_token, get_admin_backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
"""Frontend app."""
|
||||||
|
|
||||||
|
from .router import create_router as create_frontend_router
|
||||||
|
|
||||||
|
__all__ = ["create_frontend_router"]
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
"""Custom oauth2 class."""
|
||||||
|
|
||||||
|
from fastapi.security import OAuth2
|
||||||
|
|
||||||
|
|
||||||
|
class Oauth2TokenInCookies(OAuth2):
|
||||||
|
"""TODO: Create this."""
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"""Frontend dependencies."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from collections.abc import Callable, Awaitable
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from sshecret_admin.core.dependencies import AdminDep, BaseDependencies
|
||||||
|
|
||||||
|
from sshecret_admin.auth.models import User
|
||||||
|
|
||||||
|
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
||||||
|
UserLoginDep = Callable[[Request, Session], Awaitable[bool]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrontendDependencies(BaseDependencies):
|
||||||
|
"""Frontend dependencies."""
|
||||||
|
|
||||||
|
get_admin_backend: AdminDep
|
||||||
|
templates: Jinja2Blocks
|
||||||
|
get_user_from_access_token: UserTokenDep
|
||||||
|
get_user_from_refresh_token: UserTokenDep
|
||||||
|
get_login_status: UserLoginDep
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
deps: BaseDependencies,
|
||||||
|
get_admin_backend: AdminDep,
|
||||||
|
templates: Jinja2Blocks,
|
||||||
|
get_user_from_access_token: UserTokenDep,
|
||||||
|
get_user_from_refresh_token: UserTokenDep,
|
||||||
|
get_login_status: UserLoginDep,
|
||||||
|
) -> Self:
|
||||||
|
"""Create from base dependencies."""
|
||||||
|
return cls(
|
||||||
|
settings=deps.settings,
|
||||||
|
get_db_session=deps.get_db_session,
|
||||||
|
get_admin_backend=get_admin_backend,
|
||||||
|
templates=templates,
|
||||||
|
get_user_from_access_token=get_user_from_access_token,
|
||||||
|
get_user_from_refresh_token=get_user_from_refresh_token,
|
||||||
|
get_login_status=get_login_status,
|
||||||
|
)
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
"""Frontend exceptions."""
|
||||||
|
from starlette.datastructures import URL
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectException(Exception):
|
||||||
|
"""Exception that initiates a redirect flow."""
|
||||||
|
|
||||||
|
def __init__(self, to: str | URL) -> None: # pyright: ignore[reportMissingSuperCall]
|
||||||
|
"""Raise exception that redirects."""
|
||||||
|
if isinstance(to, str):
|
||||||
|
to = URL(to)
|
||||||
|
|
||||||
|
self.to: URL = to
|
||||||
133
packages/sshecret-admin/src/sshecret_admin/frontend/router.py
Normal file
133
packages/sshecret-admin/src/sshecret_admin/frontend/router.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""Frontend router."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from starlette.datastructures import URL
|
||||||
|
|
||||||
|
|
||||||
|
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||||
|
from sshecret_admin.core.dependencies import BaseDependencies
|
||||||
|
from sshecret_admin.services.admin_backend import AdminBackend
|
||||||
|
|
||||||
|
from .dependencies import FrontendDependencies
|
||||||
|
from .exceptions import RedirectException
|
||||||
|
from .views import audit, auth, clients, index, secrets
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
access_token = "access_token"
|
||||||
|
refresh_token = "refresh_token"
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||||
|
"""Create frontend router."""
|
||||||
|
|
||||||
|
app = APIRouter(include_in_schema=False)
|
||||||
|
|
||||||
|
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
||||||
|
|
||||||
|
template_path = script_path / "templates"
|
||||||
|
|
||||||
|
templates = Jinja2Blocks(directory=template_path)
|
||||||
|
|
||||||
|
async def get_admin_backend(
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)]
|
||||||
|
):
|
||||||
|
"""Get admin backend API."""
|
||||||
|
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||||
|
if not password_db:
|
||||||
|
raise HTTPException(
|
||||||
|
500, detail="Error: The password manager has not yet been set up."
|
||||||
|
)
|
||||||
|
admin = AdminBackend(dependencies.settings, password_db.encrypted_password)
|
||||||
|
yield admin
|
||||||
|
|
||||||
|
async def get_user_from_token(
|
||||||
|
token: str,
|
||||||
|
session: Session,
|
||||||
|
) -> User | None:
|
||||||
|
"""Get user from a token."""
|
||||||
|
token_data = decode_token(dependencies.settings, token)
|
||||||
|
if not token_data:
|
||||||
|
return None
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.username == token_data.username)
|
||||||
|
).first()
|
||||||
|
if not user or user.disabled:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_user_from_refresh_token(
|
||||||
|
request: Request,
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
) -> User:
|
||||||
|
"""Get user from refresh token."""
|
||||||
|
next = URL("/login").include_query_params(next=request.url.path)
|
||||||
|
credentials_error = RedirectException(to=next)
|
||||||
|
token = request.cookies.get("refresh_token")
|
||||||
|
if not token:
|
||||||
|
raise credentials_error
|
||||||
|
|
||||||
|
user = await get_user_from_token(token, session)
|
||||||
|
if not user:
|
||||||
|
raise credentials_error
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_user_from_access_token(
|
||||||
|
request: Request,
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
) -> User:
|
||||||
|
"""Get user from access token."""
|
||||||
|
token = request.cookies.get("access_token")
|
||||||
|
next = URL("/refresh").include_query_params(next=request.url.path)
|
||||||
|
credentials_error = RedirectException(to=next)
|
||||||
|
if not token:
|
||||||
|
raise credentials_error
|
||||||
|
|
||||||
|
user = await get_user_from_token(token, session)
|
||||||
|
if not user:
|
||||||
|
raise credentials_error
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_login_status(
|
||||||
|
request: Request,
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
) -> bool:
|
||||||
|
"""Get login status."""
|
||||||
|
token = request.cookies.get("access_token")
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user = await get_user_from_token(token, session)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
view_dependencies = FrontendDependencies.create(
|
||||||
|
dependencies,
|
||||||
|
get_admin_backend,
|
||||||
|
templates,
|
||||||
|
get_user_from_access_token,
|
||||||
|
get_user_from_refresh_token,
|
||||||
|
get_login_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(audit.create_router(view_dependencies))
|
||||||
|
app.include_router(auth.create_router(view_dependencies))
|
||||||
|
app.include_router(clients.create_router(view_dependencies))
|
||||||
|
app.include_router(index.create_router(view_dependencies))
|
||||||
|
app.include_router(secrets.create_router(view_dependencies))
|
||||||
|
|
||||||
|
return app
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
{% extends "/dashboard/_base.html" %} {% block content %}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">Welcome to Sshecret</h1>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||||
|
<div class="w-full">
|
||||||
|
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Clients</h3>
|
||||||
|
<span class="text-2xl font-bold leading-none text-gray-900 sm:text-3xl dark:text-white">{{ stats.clients }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- reference -->
|
||||||
|
|
||||||
|
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||||
|
<div class="w-full">
|
||||||
|
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">New products</h3>
|
||||||
|
<span class="text-2xl font-bold leading-none text-gray-900 sm:text-3xl dark:text-white">2,340</span>
|
||||||
|
<p class="flex items-center text-base font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="flex items-center mr-1.5 text-sm text-green-500 dark:text-green-400">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path clip-rule="evenodd" fill-rule="evenodd" d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"></path>
|
||||||
|
</svg>
|
||||||
|
12.5%
|
||||||
|
</span>
|
||||||
|
Since last month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full" id="new-products-chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||||
|
<div class="w-full">
|
||||||
|
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Users</h3>
|
||||||
|
<span class="text-2xl font-bold leading-none text-gray-900 sm:text-3xl dark:text-white">2,340</span>
|
||||||
|
<p class="flex items-center text-base font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="flex items-center mr-1.5 text-sm text-green-500 dark:text-green-400">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path clip-rule="evenodd" fill-rule="evenodd" d="M10 17a.75.75 0 01-.75-.75V5.612L5.29 9.77a.75.75 0 01-1.08-1.04l5.25-5.5a.75.75 0 011.08 0l5.25 5.5a.75.75 0 11-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0110 17z"></path>
|
||||||
|
</svg>
|
||||||
|
3,4%
|
||||||
|
</span>
|
||||||
|
Since last month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-full" id="week-signups-chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||||
|
<div class="w-full">
|
||||||
|
<h3 class="mb-2 text-base font-normal text-gray-500 dark:text-gray-400">Audience by age</h3>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div class="w-16 text-sm font-medium dark:text-white">50+</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||||
|
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 18%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div class="w-16 text-sm font-medium dark:text-white">40+</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||||
|
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 15%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div class="w-16 text-sm font-medium dark:text-white">30+</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||||
|
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 60%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div class="w-16 text-sm font-medium dark:text-white">20+</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
||||||
|
<div class="bg-primary-600 h-2.5 rounded-full dark:bg-primary-500" style="width: 30%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="traffic-channels-chart" class="w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""Frontend views."""
|
||||||
@ -1,19 +1,20 @@
|
|||||||
"""Audit view."""
|
"""Audit view factory."""
|
||||||
# pyright: reportUnusedFunction=false
|
|
||||||
|
|
||||||
import math
|
# pyright: reportUnusedFunction=false
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
from sshecret_admin.auth import User
|
||||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
from sshecret_admin.services import AdminBackend
|
||||||
from sshecret_admin.auth_models import User
|
|
||||||
|
from ..dependencies import FrontendDependencies
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PagingInfo(BaseModel):
|
class PagingInfo(BaseModel):
|
||||||
|
|
||||||
page: int
|
page: int
|
||||||
@ -36,20 +37,15 @@ class PagingInfo(BaseModel):
|
|||||||
"""Return total pages."""
|
"""Return total pages."""
|
||||||
return math.ceil(self.total / self.limit)
|
return math.ceil(self.total / self.limit)
|
||||||
|
|
||||||
def create_audit_view(
|
|
||||||
templates: Jinja2Blocks,
|
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||||
get_current_user_from_token: UserTokenDep,
|
"""Create clients router."""
|
||||||
get_admin_backend: AdminDep,
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Create client view."""
|
|
||||||
|
|
||||||
app = APIRouter()
|
app = APIRouter()
|
||||||
|
templates = dependencies.templates
|
||||||
|
|
||||||
async def resolve_audit_entries(
|
async def resolve_audit_entries(
|
||||||
request: Request,
|
request: Request, current_user: User, admin: AdminBackend, page: int
|
||||||
current_user: User,
|
|
||||||
admin: AdminBackend,
|
|
||||||
page: int
|
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Resolve audit entries."""
|
"""Resolve audit entries."""
|
||||||
LOG.info("Page: %r", page)
|
LOG.info("Page: %r", page)
|
||||||
@ -61,7 +57,9 @@ def create_audit_view(
|
|||||||
|
|
||||||
entries = await admin.get_audit_log(offset=offset, limit=per_page)
|
entries = await admin.get_audit_log(offset=offset, limit=per_page)
|
||||||
LOG.info("Entries: %r", entries)
|
LOG.info("Entries: %r", entries)
|
||||||
page_info = PagingInfo(page=page, limit=per_page, total=total_messages, offset=offset)
|
page_info = PagingInfo(
|
||||||
|
page=page, limit=per_page, total=total_messages, offset=offset
|
||||||
|
)
|
||||||
if request.headers.get("HX-Request"):
|
if request.headers.get("HX-Request"):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@ -69,8 +67,7 @@ def create_audit_view(
|
|||||||
{
|
{
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
"page_info": page_info,
|
"page_info": page_info,
|
||||||
}
|
},
|
||||||
|
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@ -80,34 +77,27 @@ def create_audit_view(
|
|||||||
"entries": entries,
|
"entries": entries,
|
||||||
"user": current_user.username,
|
"user": current_user.username,
|
||||||
"page_info": page_info,
|
"page_info": page_info,
|
||||||
|
},
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/audit/")
|
@app.get("/audit/")
|
||||||
async def get_audit_entries(
|
async def get_audit_entries(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
) -> Response:
|
||||||
"""Get audit entries."""
|
"""Get audit entries."""
|
||||||
return await resolve_audit_entries(request, current_user, admin, 1)
|
return await resolve_audit_entries(request, current_user, admin, 1)
|
||||||
|
|
||||||
@app.get("/audit/page/{page}")
|
@app.get("/audit/page/{page}")
|
||||||
async def get_audit_entries_page(
|
async def get_audit_entries_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
page: int,
|
page: int,
|
||||||
):
|
) -> Response:
|
||||||
"""Get audit entries."""
|
"""Get audit entries."""
|
||||||
LOG.info("Get audit entries page: %r", page)
|
LOG.info("Get audit entries page: %r", page)
|
||||||
return await resolve_audit_entries(request, current_user, admin, page)
|
return await resolve_audit_entries(request, current_user, admin, page)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --------------#
|
|
||||||
# END OF ROUTES #
|
|
||||||
# --------------#
|
|
||||||
return app
|
return app
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
"""Authentication related views factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
import logging
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlmodel import Session
|
||||||
|
from starlette.datastructures import URL
|
||||||
|
|
||||||
|
from sshecret_admin.auth import (
|
||||||
|
User,
|
||||||
|
authenticate_user,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..dependencies import FrontendDependencies
|
||||||
|
from ..exceptions import RedirectException
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginError(BaseModel):
|
||||||
|
"""Login error."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||||
|
"""Create auth router."""
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
templates = dependencies.templates
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def get_login(
|
||||||
|
request: Request,
|
||||||
|
login_status: Annotated[bool, Depends(dependencies.get_login_status)],
|
||||||
|
error_title: str | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
):
|
||||||
|
"""Get index."""
|
||||||
|
if login_status:
|
||||||
|
return RedirectResponse("/dashboard")
|
||||||
|
login_error: LoginError | None = None
|
||||||
|
if error_title and error_message:
|
||||||
|
login_error = LoginError(title=error_title, message=error_message)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"login.html",
|
||||||
|
{
|
||||||
|
"page_title": "Login",
|
||||||
|
"page_description": "Login page.",
|
||||||
|
"login_error": login_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
async def login_user(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
next: Annotated[str, Query()] = "/dashboard",
|
||||||
|
error_title: str | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
):
|
||||||
|
"""Log in user."""
|
||||||
|
if error_title and error_message:
|
||||||
|
login_error = LoginError(title=error_title, message=error_message)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"login.html",
|
||||||
|
{
|
||||||
|
"page_title": "Login",
|
||||||
|
"page_description": "Login page.",
|
||||||
|
"login_error": login_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = authenticate_user(session, form_data.username, form_data.password)
|
||||||
|
login_failed = RedirectException(
|
||||||
|
to=URL("/login").include_query_params(
|
||||||
|
error_title="Login Error", error_message="Invalid username or password"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise login_failed
|
||||||
|
token_data: dict[str, str] = {"sub": user.username}
|
||||||
|
access_token = create_access_token(dependencies.settings, data=token_data)
|
||||||
|
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
|
||||||
|
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
"access_token",
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
value=refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.get("/refresh")
|
||||||
|
async def get_refresh_token(
|
||||||
|
response: Response,
|
||||||
|
user: Annotated[User, Depends(dependencies.get_user_from_refresh_token)],
|
||||||
|
next: Annotated[str, Query()],
|
||||||
|
):
|
||||||
|
"""Refresh tokens.
|
||||||
|
|
||||||
|
We might as well refresh the long-lived one here.
|
||||||
|
"""
|
||||||
|
token_data: dict[str, str] = {"sub": user.username}
|
||||||
|
access_token = create_access_token(dependencies.settings, data=token_data)
|
||||||
|
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
|
||||||
|
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
"access_token",
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
value=refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app
|
||||||
@ -1,21 +1,20 @@
|
|||||||
"""Client views."""
|
"""clients view factory."""
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, Request, Form
|
from fastapi import APIRouter, Depends, Form, Request, Response
|
||||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
|
||||||
|
|
||||||
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
|
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
|
||||||
from sshecret.backend import ClientFilter
|
from sshecret.backend import ClientFilter
|
||||||
from sshecret.backend.models import FilterType
|
from sshecret.backend.models import FilterType
|
||||||
from sshecret.crypto import validate_public_key
|
from sshecret.crypto import validate_public_key
|
||||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
from sshecret_admin.auth import User
|
||||||
from sshecret_admin.auth_models import User
|
from sshecret_admin.services import AdminBackend
|
||||||
|
|
||||||
|
from ..dependencies import FrontendDependencies
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -37,21 +36,18 @@ class ClientCreate(BaseModel):
|
|||||||
sources: str | None
|
sources: str | None
|
||||||
|
|
||||||
|
|
||||||
def create_client_view(
|
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||||
templates: Jinja2Blocks,
|
"""Create clients router."""
|
||||||
get_current_user_from_token: UserTokenDep,
|
|
||||||
get_admin_backend: AdminDep,
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Create client view."""
|
|
||||||
|
|
||||||
app = APIRouter()
|
app = APIRouter()
|
||||||
|
templates = dependencies.templates
|
||||||
|
|
||||||
@app.get("/clients")
|
@app.get("/clients")
|
||||||
async def get_clients(
|
async def get_clients(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
) -> Response:
|
||||||
"""Get clients."""
|
"""Get clients."""
|
||||||
clients = await admin.get_clients()
|
clients = await admin.get_clients()
|
||||||
LOG.info("Clients %r", clients)
|
LOG.info("Clients %r", clients)
|
||||||
@ -68,10 +64,12 @@ def create_client_view(
|
|||||||
@app.post("/clients/query")
|
@app.post("/clients/query")
|
||||||
async def query_clients(
|
async def query_clients(
|
||||||
request: Request,
|
request: Request,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
query: Annotated[str, Form()],
|
query: Annotated[str, Form()],
|
||||||
):
|
) -> Response:
|
||||||
"""Query for a client."""
|
"""Query for a client."""
|
||||||
query_filter: ClientFilter | None = None
|
query_filter: ClientFilter | None = None
|
||||||
if query:
|
if query:
|
||||||
@ -90,8 +88,10 @@ def create_client_view(
|
|||||||
async def update_client(
|
async def update_client(
|
||||||
request: Request,
|
request: Request,
|
||||||
id: str,
|
id: str,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
client: Annotated[ClientUpdate, Form()],
|
client: Annotated[ClientUpdate, Form()],
|
||||||
):
|
):
|
||||||
"""Update a client."""
|
"""Update a client."""
|
||||||
@ -135,9 +135,11 @@ def create_client_view(
|
|||||||
async def delete_client(
|
async def delete_client(
|
||||||
request: Request,
|
request: Request,
|
||||||
id: str,
|
id: str,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
):
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> Response:
|
||||||
"""Delete a client."""
|
"""Delete a client."""
|
||||||
await admin.delete_client(id)
|
await admin.delete_client(id)
|
||||||
clients = await admin.get_clients()
|
clients = await admin.get_clients()
|
||||||
@ -154,10 +156,12 @@ def create_client_view(
|
|||||||
@app.post("/clients/")
|
@app.post("/clients/")
|
||||||
async def create_client(
|
async def create_client(
|
||||||
request: Request,
|
request: Request,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
client: Annotated[ClientCreate, Form()],
|
client: Annotated[ClientCreate, Form()],
|
||||||
):
|
) -> Response:
|
||||||
"""Create client."""
|
"""Create client."""
|
||||||
sources: list[str] | None = None
|
sources: list[str] | None = None
|
||||||
if client.sources:
|
if client.sources:
|
||||||
@ -179,9 +183,11 @@ def create_client_view(
|
|||||||
@app.post("/clients/validate/source")
|
@app.post("/clients/validate/source")
|
||||||
async def validate_client_source(
|
async def validate_client_source(
|
||||||
request: Request,
|
request: Request,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
sources: Annotated[str, Form()],
|
sources: Annotated[str, Form()],
|
||||||
):
|
) -> Response:
|
||||||
"""Validate source."""
|
"""Validate source."""
|
||||||
source_str = sources.split(",")
|
source_str = sources.split(",")
|
||||||
for source in source_str:
|
for source in source_str:
|
||||||
@ -211,9 +217,11 @@ def create_client_view(
|
|||||||
@app.post("/clients/validate/public_key")
|
@app.post("/clients/validate/public_key")
|
||||||
async def validate_client_public_key(
|
async def validate_client_public_key(
|
||||||
request: Request,
|
request: Request,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
public_key: Annotated[str, Form()],
|
public_key: Annotated[str, Form()],
|
||||||
):
|
) -> Response:
|
||||||
"""Validate source."""
|
"""Validate source."""
|
||||||
if validate_public_key(public_key.rstrip()):
|
if validate_public_key(public_key.rstrip()):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
"""Front page view factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sshecret_admin.auth import User
|
||||||
|
from sshecret_admin.services import AdminBackend
|
||||||
|
|
||||||
|
from ..dependencies import FrontendDependencies
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
START_PAGE = "/dashboard"
|
||||||
|
LOGIN_PAGE = "/login"
|
||||||
|
|
||||||
|
|
||||||
|
class StatsView(BaseModel):
|
||||||
|
"""Stats for the frontend."""
|
||||||
|
|
||||||
|
clients: int = 0
|
||||||
|
secrets: int = 0
|
||||||
|
audit_events: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_stats(admin: AdminBackend) -> StatsView:
|
||||||
|
"""Get stats for the frontpage."""
|
||||||
|
clients = await admin.get_clients()
|
||||||
|
secrets = await admin.get_secrets()
|
||||||
|
audit = await admin.get_audit_log_count()
|
||||||
|
return StatsView(clients=len(clients), secrets=len(secrets), audit_events=audit)
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||||
|
"""Create auth router."""
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
templates = dependencies.templates
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def get_index(logged_in: Annotated[bool, Depends(dependencies.get_login_status)]):
|
||||||
|
"""Get the index."""
|
||||||
|
next = LOGIN_PAGE
|
||||||
|
if logged_in:
|
||||||
|
next = START_PAGE
|
||||||
|
|
||||||
|
return RedirectResponse(url=next)
|
||||||
|
|
||||||
|
@app.get("/dashboard")
|
||||||
|
async def get_dashboard(
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||||
|
):
|
||||||
|
"""Dashboard for mocking up the dashboard."""
|
||||||
|
stats = await get_stats(admin)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"dashboard.html",
|
||||||
|
{
|
||||||
|
"page_title": "sshecret",
|
||||||
|
"user": current_user.username,
|
||||||
|
"stats": stats,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
@ -1,24 +1,25 @@
|
|||||||
"""Secrets view."""
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
# pyright: reportUnusedFunction=false
|
||||||
import logging
|
import logging
|
||||||
import secrets as pysecrets
|
import secrets as pysecrets
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
from fastapi import APIRouter, Depends, Request, Form
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
|
||||||
|
|
||||||
from pydantic import BaseModel, BeforeValidator, Field
|
from pydantic import BaseModel, BeforeValidator, Field
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
|
||||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
from sshecret_admin.auth import User
|
||||||
from sshecret_admin.auth_models import User
|
from sshecret_admin.services import AdminBackend
|
||||||
|
|
||||||
|
from ..dependencies import FrontendDependencies
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def split_clients(clients: Any) -> Any:
|
def split_clients(clients: Any) -> Any: # pyright: ignore[reportAny]
|
||||||
"""Split clients."""
|
"""Split clients."""
|
||||||
if isinstance(clients, list):
|
if isinstance(clients, list):
|
||||||
return clients
|
return clients # pyright: ignore[reportUnknownVariableType]
|
||||||
if not isinstance(clients, str):
|
if not isinstance(clients, str):
|
||||||
raise ValueError("Invalid type for clients.")
|
raise ValueError("Invalid type for clients.")
|
||||||
if not clients:
|
if not clients:
|
||||||
@ -26,7 +27,7 @@ def split_clients(clients: Any) -> Any:
|
|||||||
return [client.rstrip() for client in clients.split(",")]
|
return [client.rstrip() for client in clients.split(",")]
|
||||||
|
|
||||||
|
|
||||||
def handle_select_bool(value: Any) -> Any:
|
def handle_select_bool(value: Any) -> Any: # pyright: ignore[reportAny]
|
||||||
"""Handle boolean from select."""
|
"""Handle boolean from select."""
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
@ -47,20 +48,17 @@ class CreateSecret(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_secrets_view(
|
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||||
templates: Jinja2Blocks,
|
"""Create secrets router."""
|
||||||
get_current_user_from_token: UserTokenDep,
|
|
||||||
get_admin_backend: AdminDep,
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Create secrets view."""
|
|
||||||
|
|
||||||
app = APIRouter()
|
app = APIRouter()
|
||||||
|
templates = dependencies.templates
|
||||||
|
|
||||||
@app.get("/secrets/")
|
@app.get("/secrets/")
|
||||||
async def get_secrets(
|
async def get_secrets(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
):
|
||||||
"""Get secrets index page."""
|
"""Get secrets index page."""
|
||||||
secrets = await admin.get_detailed_secrets()
|
secrets = await admin.get_detailed_secrets()
|
||||||
@ -79,8 +77,10 @@ def create_secrets_view(
|
|||||||
@app.post("/secrets/")
|
@app.post("/secrets/")
|
||||||
async def add_secret(
|
async def add_secret(
|
||||||
request: Request,
|
request: Request,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
secret: Annotated[CreateSecret, Form()],
|
secret: Annotated[CreateSecret, Form()],
|
||||||
):
|
):
|
||||||
"""Add secret."""
|
"""Add secret."""
|
||||||
@ -108,8 +108,10 @@ def create_secrets_view(
|
|||||||
request: Request,
|
request: Request,
|
||||||
name: str,
|
name: str,
|
||||||
id: str,
|
id: str,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
):
|
||||||
"""Remove a client's access to a secret."""
|
"""Remove a client's access to a secret."""
|
||||||
await admin.delete_client_secret(id, name)
|
await admin.delete_client_secret(id, name)
|
||||||
@ -130,8 +132,10 @@ def create_secrets_view(
|
|||||||
request: Request,
|
request: Request,
|
||||||
name: str,
|
name: str,
|
||||||
client: Annotated[str, Form()],
|
client: Annotated[str, Form()],
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
):
|
||||||
"""Add a secret to a client."""
|
"""Add a secret to a client."""
|
||||||
await admin.create_client_secret(client, name)
|
await admin.create_client_secret(client, name)
|
||||||
@ -153,8 +157,10 @@ def create_secrets_view(
|
|||||||
async def delete_secret(
|
async def delete_secret(
|
||||||
request: Request,
|
request: Request,
|
||||||
name: str,
|
name: str,
|
||||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
_current_user: Annotated[
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
User, Depends(dependencies.get_user_from_access_token)
|
||||||
|
],
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
):
|
):
|
||||||
"""Delete a secret."""
|
"""Delete a secret."""
|
||||||
await admin.delete_secret(name)
|
await admin.delete_secret(name)
|
||||||
@ -172,7 +178,4 @@ def create_secrets_view(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --------------#
|
|
||||||
# END OF ROUTES #
|
|
||||||
# --------------#
|
|
||||||
return app
|
return app
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
"""Services module.
|
||||||
|
|
||||||
|
This module contains business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .admin_backend import AdminBackend
|
||||||
|
|
||||||
|
__all__ = ["AdminBackend"]
|
||||||
@ -7,13 +7,14 @@ import logging
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend
|
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend, Operation, SubSystem
|
||||||
from sshecret.backend.models import DetailedSecrets
|
from sshecret.backend.models import DetailedSecrets
|
||||||
|
from sshecret.backend.api import AuditAPI
|
||||||
from sshecret.crypto import encrypt_string, load_public_key
|
from sshecret.crypto import encrypt_string, load_public_key
|
||||||
|
|
||||||
from .keepass import PasswordContext, load_password_manager
|
from .keepass import PasswordContext, load_password_manager
|
||||||
from .settings import AdminServerSettings
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
from .view_models import SecretView
|
from .models import SecretView
|
||||||
|
|
||||||
|
|
||||||
class ClientManagementError(Exception):
|
class ClientManagementError(Exception):
|
||||||
@ -381,6 +382,11 @@ class AdminBackend:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BackendUnavailableError() from e
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audit(self) -> AuditAPI:
|
||||||
|
"""Resolve audit API."""
|
||||||
|
return self.backend.audit(SubSystem.ADMIN)
|
||||||
|
|
||||||
async def get_audit_log(
|
async def get_audit_log(
|
||||||
self,
|
self,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@ -389,14 +395,36 @@ class AdminBackend:
|
|||||||
subsystem: str | None = None,
|
subsystem: str | None = None,
|
||||||
) -> list[AuditLog]:
|
) -> list[AuditLog]:
|
||||||
"""Get audit log from backend."""
|
"""Get audit log from backend."""
|
||||||
return await self.backend.get_audit_log(offset, limit, client_name, subsystem)
|
return await self.audit.get(offset, limit, client_name, subsystem)
|
||||||
|
|
||||||
|
async def write_audit_message(
|
||||||
|
self,
|
||||||
|
operation: Operation,
|
||||||
|
message: str,
|
||||||
|
origin: str,
|
||||||
|
client: Client | None = None,
|
||||||
|
secret_name: str | None = None,
|
||||||
|
**data: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write an audit message."""
|
||||||
|
await self.audit.write_async(
|
||||||
|
operation=operation,
|
||||||
|
message=message,
|
||||||
|
origin=origin,
|
||||||
|
client=client,
|
||||||
|
secret=None,
|
||||||
|
secret_name=secret_name,
|
||||||
|
**data,
|
||||||
|
)
|
||||||
|
|
||||||
async def write_audit_log(self, entry: AuditLog) -> None:
|
async def write_audit_log(self, entry: AuditLog) -> None:
|
||||||
"""Write to the audit log."""
|
"""Write to the audit log."""
|
||||||
if not entry.subsystem:
|
if not entry.subsystem:
|
||||||
entry.subsystem = "admin"
|
entry.subsystem = SubSystem.ADMIN
|
||||||
await self.backend.add_audit_log(entry)
|
|
||||||
|
await self.audit.write_model_async(entry)
|
||||||
|
#await self.backend.add_audit_log(entry)
|
||||||
|
|
||||||
async def get_audit_log_count(self) -> int:
|
async def get_audit_log_count(self) -> int:
|
||||||
"""Get audit log count."""
|
"""Get audit log count."""
|
||||||
return await self.backend.get_audit_log_count()
|
return await self.audit.count()
|
||||||
@ -8,7 +8,7 @@ from typing import cast
|
|||||||
|
|
||||||
import pykeepass
|
import pykeepass
|
||||||
from .master_password import decrypt_master_password
|
from .master_password import decrypt_master_password
|
||||||
from .settings import AdminServerSettings
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
@ -8,7 +8,7 @@ from sshecret.crypto import (
|
|||||||
encrypt_string,
|
encrypt_string,
|
||||||
decode_string,
|
decode_string,
|
||||||
)
|
)
|
||||||
from .settings import AdminServerSettings
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
KEY_FILENAME = "sshecret-admin-key"
|
KEY_FILENAME = "sshecret-admin-key"
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""Models for the API."""
|
"""Models for the API."""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Annotated, Literal, Self, Union
|
from typing import Annotated, Literal
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AfterValidator,
|
AfterValidator,
|
||||||
BaseModel,
|
BaseModel,
|
||||||
@ -9,7 +9,6 @@ from pydantic import (
|
|||||||
Field,
|
Field,
|
||||||
IPvAnyAddress,
|
IPvAnyAddress,
|
||||||
IPvAnyNetwork,
|
IPvAnyNetwork,
|
||||||
model_validator,
|
|
||||||
)
|
)
|
||||||
from sshecret.crypto import validate_public_key
|
from sshecret.crypto import validate_public_key
|
||||||
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">Welcome to Sshecret</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -4,8 +4,7 @@ import os
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from sqlalchemy import Engine
|
from sqlmodel import Session
|
||||||
from sqlmodel import Session, select
|
|
||||||
from .auth_models import User
|
from .auth_models import User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,16 +6,10 @@ from fastapi import Request
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
from sshecret_admin.admin_backend import AdminBackend
|
||||||
from sshecret_admin.auth_models import User
|
from sshecret_admin.auth_models import User
|
||||||
from sshecret.backend import SshecretBackend
|
|
||||||
from . import keepass
|
|
||||||
|
|
||||||
|
|
||||||
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||||
|
|
||||||
BackendDep = Callable[[], AsyncGenerator[SshecretBackend, None]]
|
|
||||||
|
|
||||||
PasswdCtxDep = Callable[[DBSessionDep], AsyncGenerator[keepass.PasswordContext, None]]
|
|
||||||
|
|
||||||
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
||||||
|
|
||||||
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
from .audit import create_audit_view
|
|
||||||
from .clients import create_client_view
|
|
||||||
from .secrets import create_secrets_view
|
|
||||||
|
|
||||||
__all__ = ["create_audit_view", "create_client_view", "create_secrets_view"]
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html class="no-js" lang="">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
|
||||||
<title>Untitled</title>
|
|
||||||
<meta name="description" content="" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
<!-- Place favicon.ico in the root directory -->
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!--[if lt IE 8]>
|
|
||||||
<p class="browserupgrade">
|
|
||||||
You are using an <strong>outdated</strong> browser. Please
|
|
||||||
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
|
|
||||||
your experience.
|
|
||||||
</p>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
<p>I am outside of the package</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user