Complete admin package restructuring
This commit is contained in:
@ -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 os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
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.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
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 .auth_models import init_db, PasswordDB, AuthenticationFailedError, AuthenticationNeededError
|
||||
from .db import setup_database
|
||||
from .master_password import setup_master_password
|
||||
from .dependencies import BaseDependencies
|
||||
from .settings import AdminServerSettings
|
||||
from .frontend import create_frontend
|
||||
from .types import DBSessionDep
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -30,15 +28,14 @@ LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_frontend(
|
||||
app: FastAPI, settings: AdminServerSettings, get_db_session: DBSessionDep
|
||||
app: FastAPI, dependencies: BaseDependencies
|
||||
) -> None:
|
||||
"""Setup frontend."""
|
||||
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")
|
||||
frontend = create_frontend(settings, get_db_session)
|
||||
app.include_router(frontend)
|
||||
app.include_router(frontend.create_frontend_router(dependencies))
|
||||
|
||||
|
||||
def create_admin_app(
|
||||
@ -88,19 +85,15 @@ def create_admin_app(
|
||||
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||
)
|
||||
|
||||
@app.exception_handler(AuthenticationNeededError)
|
||||
async def authentication_needed_handler(
|
||||
request: Request, exc: AuthenticationNeededError,
|
||||
):
|
||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
||||
return RedirectResponse(f"/?{qs}")
|
||||
@app.exception_handler(RedirectException)
|
||||
async def redirect_handler(request: Request, exc: RedirectException) -> Response:
|
||||
"""Handle redirect exceptions."""
|
||||
if "hx-request" in request.headers:
|
||||
response = Response()
|
||||
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")
|
||||
async def get_health() -> JSONResponse:
|
||||
@ -109,10 +102,11 @@ def create_admin_app(
|
||||
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:
|
||||
setup_frontend(app, settings, get_db_session)
|
||||
setup_frontend(app, dependencies)
|
||||
|
||||
return app
|
||||
@ -7,29 +7,30 @@ import logging
|
||||
from typing import Any, cast
|
||||
import bcrypt
|
||||
import click
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
import uvicorn
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session, create_engine, select
|
||||
from .auth_models import init_db, User, PasswordDB
|
||||
from .settings import AdminServerSettings
|
||||
from sshecret_admin.auth.models import init_db, User, PasswordDB
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
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)
|
||||
LOG = logging.getLogger()
|
||||
LOG.addHandler(handler)
|
||||
LOG.setLevel(logging.INFO)
|
||||
|
||||
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password."""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
||||
return hashed_password.decode()
|
||||
|
||||
|
||||
def create_user(session: Session, username: str, password: str) -> None:
|
||||
"""Create a user."""
|
||||
hashed_password = hash_password(password)
|
||||
@ -48,7 +49,9 @@ def cli(ctx: click.Context, debug: bool) -> None:
|
||||
try:
|
||||
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
||||
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
|
||||
|
||||
|
||||
@ -66,6 +69,7 @@ def cli_create_user(ctx: click.Context, username: str, password: str) -> None:
|
||||
|
||||
click.echo("User created.")
|
||||
|
||||
|
||||
@cli.command("passwd")
|
||||
@click.argument("username")
|
||||
@click.password_option()
|
||||
@ -85,6 +89,7 @@ def cli_change_user_passwd(ctx: click.Context, username: str, password: str) ->
|
||||
session.commit()
|
||||
click.echo("Password updated.")
|
||||
|
||||
|
||||
@cli.command("deluser")
|
||||
@click.argument("username")
|
||||
@click.confirmation_option()
|
||||
@ -112,7 +117,9 @@ def cli_delete_user(ctx: click.Context, username: str) -> None:
|
||||
@click.option("--workers", type=click.INT)
|
||||
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
|
||||
"""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")
|
||||
@ -126,7 +133,9 @@ def cli_repl(ctx: click.Context) -> None:
|
||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||
|
||||
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:
|
||||
"""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."""
|
||||
import sys
|
||||
import uvicorn
|
||||
import click
|
||||
from pydantic import ValidationError
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
|
||||
from pydantic import AnyHttpUrl, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import URL
|
||||
|
||||
|
||||
DEFAULT_LISTEN_PORT = 8822
|
||||
|
||||
DEFAULT_DATABASE = "sqlite:///ssh_admin.db"
|
||||
DEFAULT_DATABASE = "ssh_admin.db"
|
||||
|
||||
|
||||
class AdminServerSettings(BaseSettings):
|
||||
@ -21,5 +22,12 @@ class AdminServerSettings(BaseSettings):
|
||||
listen_address: str = Field(default="")
|
||||
secret_key: str
|
||||
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
|
||||
|
||||
@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."""
|
||||
# pyright: reportUnusedFunction=false
|
||||
"""Audit view factory."""
|
||||
|
||||
import math
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
import math
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
||||
from sshecret_admin.auth_models import User
|
||||
from sshecret_admin.auth import User
|
||||
from sshecret_admin.services import AdminBackend
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PagingInfo(BaseModel):
|
||||
|
||||
page: int
|
||||
@ -36,20 +37,15 @@ class PagingInfo(BaseModel):
|
||||
"""Return total pages."""
|
||||
return math.ceil(self.total / self.limit)
|
||||
|
||||
def create_audit_view(
|
||||
templates: Jinja2Blocks,
|
||||
get_current_user_from_token: UserTokenDep,
|
||||
get_admin_backend: AdminDep,
|
||||
) -> APIRouter:
|
||||
"""Create client view."""
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create clients router."""
|
||||
|
||||
app = APIRouter()
|
||||
templates = dependencies.templates
|
||||
|
||||
async def resolve_audit_entries(
|
||||
request: Request,
|
||||
current_user: User,
|
||||
admin: AdminBackend,
|
||||
page: int
|
||||
request: Request, current_user: User, admin: AdminBackend, page: int
|
||||
) -> Response:
|
||||
"""Resolve audit entries."""
|
||||
LOG.info("Page: %r", page)
|
||||
@ -61,7 +57,9 @@ def create_audit_view(
|
||||
|
||||
entries = await admin.get_audit_log(offset=offset, limit=per_page)
|
||||
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"):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@ -69,8 +67,7 @@ def create_audit_view(
|
||||
{
|
||||
"entries": entries,
|
||||
"page_info": page_info,
|
||||
}
|
||||
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@ -80,34 +77,27 @@ def create_audit_view(
|
||||
"entries": entries,
|
||||
"user": current_user.username,
|
||||
"page_info": page_info,
|
||||
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/audit/")
|
||||
async def get_audit_entries(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Response:
|
||||
"""Get audit entries."""
|
||||
return await resolve_audit_entries(request, current_user, admin, 1)
|
||||
|
||||
@app.get("/audit/page/{page}")
|
||||
async def get_audit_entries_page(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
page: int,
|
||||
):
|
||||
) -> Response:
|
||||
"""Get audit entries."""
|
||||
LOG.info("Get audit entries page: %r", page)
|
||||
return await resolve_audit_entries(request, current_user, admin, page)
|
||||
|
||||
|
||||
|
||||
# --------------#
|
||||
# END OF ROUTES #
|
||||
# --------------#
|
||||
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
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request, Form
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, Response
|
||||
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
|
||||
from sshecret.backend import ClientFilter
|
||||
from sshecret.backend.models import FilterType
|
||||
from sshecret.crypto import validate_public_key
|
||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
||||
from sshecret_admin.auth_models import User
|
||||
from sshecret_admin.auth import User
|
||||
from sshecret_admin.services import AdminBackend
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -37,21 +36,18 @@ class ClientCreate(BaseModel):
|
||||
sources: str | None
|
||||
|
||||
|
||||
def create_client_view(
|
||||
templates: Jinja2Blocks,
|
||||
get_current_user_from_token: UserTokenDep,
|
||||
get_admin_backend: AdminDep,
|
||||
) -> APIRouter:
|
||||
"""Create client view."""
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create clients router."""
|
||||
|
||||
app = APIRouter()
|
||||
templates = dependencies.templates
|
||||
|
||||
@app.get("/clients")
|
||||
async def get_clients(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Response:
|
||||
"""Get clients."""
|
||||
clients = await admin.get_clients()
|
||||
LOG.info("Clients %r", clients)
|
||||
@ -68,10 +64,12 @@ def create_client_view(
|
||||
@app.post("/clients/query")
|
||||
async def query_clients(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
query: Annotated[str, Form()],
|
||||
):
|
||||
) -> Response:
|
||||
"""Query for a client."""
|
||||
query_filter: ClientFilter | None = None
|
||||
if query:
|
||||
@ -90,8 +88,10 @@ def create_client_view(
|
||||
async def update_client(
|
||||
request: Request,
|
||||
id: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
client: Annotated[ClientUpdate, Form()],
|
||||
):
|
||||
"""Update a client."""
|
||||
@ -135,9 +135,11 @@ def create_client_view(
|
||||
async def delete_client(
|
||||
request: Request,
|
||||
id: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Response:
|
||||
"""Delete a client."""
|
||||
await admin.delete_client(id)
|
||||
clients = await admin.get_clients()
|
||||
@ -154,10 +156,12 @@ def create_client_view(
|
||||
@app.post("/clients/")
|
||||
async def create_client(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
client: Annotated[ClientCreate, Form()],
|
||||
):
|
||||
) -> Response:
|
||||
"""Create client."""
|
||||
sources: list[str] | None = None
|
||||
if client.sources:
|
||||
@ -179,9 +183,11 @@ def create_client_view(
|
||||
@app.post("/clients/validate/source")
|
||||
async def validate_client_source(
|
||||
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()],
|
||||
):
|
||||
) -> Response:
|
||||
"""Validate source."""
|
||||
source_str = sources.split(",")
|
||||
for source in source_str:
|
||||
@ -211,9 +217,11 @@ def create_client_view(
|
||||
@app.post("/clients/validate/public_key")
|
||||
async def validate_client_public_key(
|
||||
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()],
|
||||
):
|
||||
) -> Response:
|
||||
"""Validate source."""
|
||||
if validate_public_key(public_key.rstrip()):
|
||||
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
|
||||
import logging
|
||||
import secrets as pysecrets
|
||||
from typing import Annotated, Any
|
||||
from fastapi import APIRouter, Depends, Request, Form
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
||||
from sshecret_admin.auth_models import User
|
||||
|
||||
from sshecret_admin.auth import User
|
||||
from sshecret_admin.services import AdminBackend
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def split_clients(clients: Any) -> Any:
|
||||
def split_clients(clients: Any) -> Any: # pyright: ignore[reportAny]
|
||||
"""Split clients."""
|
||||
if isinstance(clients, list):
|
||||
return clients
|
||||
return clients # pyright: ignore[reportUnknownVariableType]
|
||||
if not isinstance(clients, str):
|
||||
raise ValueError("Invalid type for clients.")
|
||||
if not clients:
|
||||
@ -26,7 +27,7 @@ def split_clients(clients: Any) -> Any:
|
||||
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."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
@ -47,20 +48,17 @@ class CreateSecret(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
def create_secrets_view(
|
||||
templates: Jinja2Blocks,
|
||||
get_current_user_from_token: UserTokenDep,
|
||||
get_admin_backend: AdminDep,
|
||||
) -> APIRouter:
|
||||
"""Create secrets view."""
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create secrets router."""
|
||||
|
||||
app = APIRouter()
|
||||
templates = dependencies.templates
|
||||
|
||||
@app.get("/secrets/")
|
||||
async def get_secrets(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Get secrets index page."""
|
||||
secrets = await admin.get_detailed_secrets()
|
||||
@ -79,8 +77,10 @@ def create_secrets_view(
|
||||
@app.post("/secrets/")
|
||||
async def add_secret(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
secret: Annotated[CreateSecret, Form()],
|
||||
):
|
||||
"""Add secret."""
|
||||
@ -108,8 +108,10 @@ def create_secrets_view(
|
||||
request: Request,
|
||||
name: str,
|
||||
id: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Remove a client's access to a secret."""
|
||||
await admin.delete_client_secret(id, name)
|
||||
@ -130,8 +132,10 @@ def create_secrets_view(
|
||||
request: Request,
|
||||
name: str,
|
||||
client: Annotated[str, Form()],
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Add a secret to a client."""
|
||||
await admin.create_client_secret(client, name)
|
||||
@ -153,8 +157,10 @@ def create_secrets_view(
|
||||
async def delete_secret(
|
||||
request: Request,
|
||||
name: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
_current_user: Annotated[
|
||||
User, Depends(dependencies.get_user_from_access_token)
|
||||
],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Delete a secret."""
|
||||
await admin.delete_secret(name)
|
||||
@ -172,7 +178,4 @@ def create_secrets_view(
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# --------------#
|
||||
# END OF ROUTES #
|
||||
# --------------#
|
||||
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 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.api import AuditAPI
|
||||
from sshecret.crypto import encrypt_string, load_public_key
|
||||
|
||||
from .keepass import PasswordContext, load_password_manager
|
||||
from .settings import AdminServerSettings
|
||||
from .view_models import SecretView
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from .models import SecretView
|
||||
|
||||
|
||||
class ClientManagementError(Exception):
|
||||
@ -381,6 +382,11 @@ class AdminBackend:
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
@property
|
||||
def audit(self) -> AuditAPI:
|
||||
"""Resolve audit API."""
|
||||
return self.backend.audit(SubSystem.ADMIN)
|
||||
|
||||
async def get_audit_log(
|
||||
self,
|
||||
offset: int = 0,
|
||||
@ -389,14 +395,36 @@ class AdminBackend:
|
||||
subsystem: str | None = None,
|
||||
) -> list[AuditLog]:
|
||||
"""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:
|
||||
"""Write to the audit log."""
|
||||
if not entry.subsystem:
|
||||
entry.subsystem = "admin"
|
||||
await self.backend.add_audit_log(entry)
|
||||
entry.subsystem = SubSystem.ADMIN
|
||||
|
||||
await self.audit.write_model_async(entry)
|
||||
#await self.backend.add_audit_log(entry)
|
||||
|
||||
async def get_audit_log_count(self) -> int:
|
||||
"""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
|
||||
from .master_password import decrypt_master_password
|
||||
from .settings import AdminServerSettings
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -8,7 +8,7 @@ from sshecret.crypto import (
|
||||
encrypt_string,
|
||||
decode_string,
|
||||
)
|
||||
from .settings import AdminServerSettings
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
KEY_FILENAME = "sshecret-admin-key"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Models for the API."""
|
||||
|
||||
import secrets
|
||||
from typing import Annotated, Literal, Self, Union
|
||||
from typing import Annotated, Literal
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BaseModel,
|
||||
@ -9,7 +9,6 @@ from pydantic import (
|
||||
Field,
|
||||
IPvAnyAddress,
|
||||
IPvAnyNetwork,
|
||||
model_validator,
|
||||
)
|
||||
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
|
||||
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session
|
||||
from .auth_models import User
|
||||
|
||||
|
||||
|
||||
@ -6,16 +6,10 @@ from fastapi import Request
|
||||
from sqlmodel import Session
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.auth_models import User
|
||||
from sshecret.backend import SshecretBackend
|
||||
from . import keepass
|
||||
|
||||
|
||||
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||
|
||||
BackendDep = Callable[[], AsyncGenerator[SshecretBackend, None]]
|
||||
|
||||
PasswdCtxDep = Callable[[DBSessionDep], AsyncGenerator[keepass.PasswordContext, None]]
|
||||
|
||||
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
||||
|
||||
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"]
|
||||
Reference in New Issue
Block a user