Implement oidc login

This commit is contained in:
2025-05-30 10:57:59 +02:00
parent b491dff4b1
commit 391e310b91
39 changed files with 938 additions and 308 deletions

View File

@ -9,7 +9,7 @@ from pydantic import BaseModel
from sshecret.backend import AuditFilter, Operation
from sshecret_admin.auth import User
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
@ -18,7 +18,6 @@ LOG = logging.getLogger(__name__)
class PagingInfo(BaseModel):
page: int
limit: int
total: int
@ -48,7 +47,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
async def resolve_audit_entries(
request: Request,
current_user: User,
current_user: LocalUserInfo,
admin: AdminBackend,
page: int,
filters: AuditFilter,
@ -82,7 +81,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
{
"page_title": "Audit",
"entries": audit_log.results,
"user": current_user.username,
"user": current_user.display_name,
"page_info": page_info,
"operations": operations,
},
@ -91,7 +90,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.get("/audit/")
async def get_audit_entries(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
filters: Annotated[AuditFilter, Depends()],
) -> Response:
@ -101,7 +100,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.get("/audit/page/{page}")
async def get_audit_entries_page(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
filters: Annotated[AuditFilter, Depends()],
page: int,

View File

@ -13,7 +13,7 @@ from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret_admin.auth import (
User,
IdentityClaims,
authenticate_user_async,
create_access_token,
create_refresh_token,
@ -34,7 +34,16 @@ class LoginError(BaseModel):
message: str
async def audit_login_failure(admin: AdminBackend, username: str, request: Request) -> None:
class OidcLogin(BaseModel):
"""Small container to hold OIDC info for the login box."""
enabled: bool = False
provider_name: str | None = None
async def audit_login_failure(
admin: AdminBackend, username: str, request: Request
) -> None:
"""Write login failure to audit log."""
origin: str | None = None
if request.client:
@ -65,7 +74,16 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
return RedirectResponse("/dashboard")
login_error: LoginError | None = None
if error_title and error_message:
LOG.info("Got an error here: %s %s", error_title, error_message)
login_error = LoginError(title=error_title, message=error_message)
else:
LOG.info("Got no errors")
oidc_login = OidcLogin()
if dependencies.settings.oidc:
oidc_login.enabled = True
oidc_login.provider_name = dependencies.settings.oidc.name
return templates.TemplateResponse(
request,
"login.html",
@ -73,6 +91,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
"page_title": "Login",
"page_description": "Login page.",
"login_error": login_error,
"oidc": oidc_login,
},
)
@ -100,7 +119,9 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
},
)
user = await authenticate_user_async(session, form_data.username, form_data.password)
user = await authenticate_user_async(
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"
@ -143,16 +164,22 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.get("/refresh")
async def get_refresh_token(
response: Response,
user: Annotated[User, Depends(dependencies.get_user_from_refresh_token)],
refresh_claims: Annotated[
IdentityClaims, Depends(dependencies.get_refresh_claims)
],
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)
token_data: dict[str, str] = {"sub": refresh_claims.sub}
access_token = create_access_token(
dependencies.settings, data=token_data, provider=refresh_claims.provider
)
refresh_token = create_refresh_token(
dependencies.settings, data=token_data, provider=refresh_claims.provider
)
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
response.set_cookie(
"access_token",
@ -176,8 +203,12 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
):
"""Log out user."""
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
response.delete_cookie("refresh_token", httponly=True, secure=False, samesite="strict")
response.delete_cookie("access_token", httponly=True, secure=False, samesite="strict")
response.delete_cookie(
"refresh_token", httponly=True, secure=False, samesite="strict"
)
response.delete_cookie(
"access_token", httponly=True, secure=False, samesite="strict"
)
return response
return app

View File

@ -11,7 +11,7 @@ from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
from sshecret.backend import ClientFilter
from sshecret.backend.models import FilterType
from sshecret.crypto import validate_public_key
from sshecret_admin.auth import User
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
@ -20,7 +20,6 @@ LOG = logging.getLogger(__name__)
class ClientUpdate(BaseModel):
id: uuid.UUID
name: str
description: str
@ -29,7 +28,6 @@ class ClientUpdate(BaseModel):
class ClientCreate(BaseModel):
name: str
public_key: str
description: str | None
@ -39,13 +37,14 @@ class ClientCreate(BaseModel):
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router."""
app = APIRouter()
app = APIRouter(dependencies=[Depends(dependencies.require_login)])
templates = dependencies.templates
@app.get("/clients")
async def get_clients(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Get clients."""
@ -57,16 +56,13 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
{
"page_title": "Clients",
"clients": clients,
"user": current_user.username,
"user": current_user.display_name,
},
)
@app.post("/clients/query")
async def query_clients(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
query: Annotated[str, Form()],
) -> Response:
@ -88,9 +84,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
async def update_client(
request: Request,
id: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
client: Annotated[ClientUpdate, Form()],
):
@ -135,9 +128,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
async def delete_client(
request: Request,
id: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Delete a client."""
@ -156,9 +146,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.post("/clients/")
async def create_client(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
client: Annotated[ClientCreate, Form()],
) -> Response:
@ -183,9 +170,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.post("/clients/validate/source")
async def validate_client_source(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
sources: Annotated[str, Form()],
) -> Response:
"""Validate source."""
@ -217,9 +201,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.post("/clients/validate/public_key")
async def validate_client_public_key(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
public_key: Annotated[str, Form()],
) -> Response:
"""Validate source."""

View File

@ -6,7 +6,7 @@ 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.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
@ -51,25 +51,27 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.get("/dashboard")
async def get_dashboard(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
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)
last_login_events = await admin.get_audit_log_detailed(limit=5, operation="login")
last_login_events = await admin.get_audit_log_detailed(
limit=5, operation="login"
)
last_audit_events = await admin.get_audit_log_detailed(limit=10)
LOG.info("CurrentUser: %r", current_user)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"page_title": "sshecret",
"user": current_user.username,
"user": current_user.display_name,
"stats": stats,
"last_login_events": last_login_events,
"last_audit_events": last_audit_events,
},
)

View File

@ -0,0 +1,142 @@
"""Optional OIDC auth module."""
# pyright: reportUnusedFunction=false
import logging
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth import create_access_token, create_refresh_token
from sshecret_admin.auth.authentication import generate_user_info, handle_oidc_claim
from sshecret_admin.auth.exceptions import AuthenticationFailedError
from sshecret_admin.auth.oidc import AdminOidc
from sshecret_admin.frontend.exceptions import RedirectException
from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret.backend.models import Operation
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
async def audit_login_failure(
admin: AdminBackend,
error_message: str,
request: Request,
) -> None:
"""Write login failure to audit log."""
origin: str | None = None
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.DENY,
message="Login failed",
origin=origin or "UNKNOWN",
provider_error_message=error_message,
)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create auth router."""
app = APIRouter()
def get_oidc_client() -> AdminOidc:
"""Get OIDC client dependency."""
if not dependencies.settings.oidc:
raise RuntimeError("OIDC authentication not configured.")
oidc = AdminOidc(dependencies.settings.oidc)
return oidc
@app.get("/oidc/login")
async def oidc_login(
request: Request, oidc: Annotated[AdminOidc, Depends(get_oidc_client)]
) -> RedirectResponse:
"""Redirect to oidc login."""
redirect_url = request.url_for("oidc_auth")
return await oidc.start_auth(request, redirect_url)
@app.get("/oidc/auth")
async def oidc_auth(
request: Request,
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
oidc: Annotated[AdminOidc, Depends(get_oidc_client)],
):
"""Handle OIDC auth callback."""
try:
claims = await oidc.handle_auth_callback(request)
except AuthenticationFailedError as error:
raise RedirectException(
to=URL("/login").include_query_params(
error_title="Login error from external provider",
error_message=str(error),
)
)
except ValidationError as error:
LOG.error("Validation error: %s", error, exc_info=True)
raise RedirectException(
to=URL("/login").include_query_params(
error_title="Error parsing claim",
error_message="One or more required parameters were not included in the claim.",
)
)
# We now have a IdentityClaims object.
# We need to check if this matches an existing user, or we need to create a new one.
user = await handle_oidc_claim(session, claims)
user.last_login = datetime.now()
session.add(user)
await session.commit()
# Set cookies
token_data: dict[str, str] = {"sub": claims.sub}
access_token = create_access_token(
dependencies.settings, data=token_data, provider=claims.provider
)
refresh_token = create_refresh_token(
dependencies.settings, data=token_data, provider=claims.provider
)
user_info = generate_user_info(user)
response = HTMLResponse("""
<html>
<body>
<p>Login successful. Redirecting...</p>
<script>
setTimeout(() => { window.location.href = "/dashboard"; }, 500);
</script>
</body>
</html>
""")
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",
)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.LOGIN,
message="Logged in to admin frontend",
origin=origin,
username=user_info.display_name,
oidc=claims.provider,
)
return response
return app

View File

@ -8,7 +8,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Form, Request
from pydantic import BaseModel, BeforeValidator, Field
from sshecret_admin.auth import User
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
@ -51,13 +51,13 @@ class CreateSecret(BaseModel):
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create secrets router."""
app = APIRouter()
app = APIRouter(dependencies=[Depends(dependencies.require_login)])
templates = dependencies.templates
@app.get("/secrets/")
async def get_secrets(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get secrets index page."""
@ -69,7 +69,7 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
{
"page_title": "Secrets",
"secrets": secrets,
"user": current_user.username,
"user": current_user.display_name,
"clients": clients,
},
)
@ -77,9 +77,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
@app.post("/secrets/")
async def add_secret(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
@ -108,9 +105,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
request: Request,
name: str,
id: str,
_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."""
@ -132,9 +126,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
request: Request,
name: str,
client: Annotated[str, Form()],
_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."""
@ -157,9 +148,6 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
async def delete_secret(
request: Request,
name: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Delete a secret."""