Implement oidc login
This commit is contained in:
@ -11,11 +11,14 @@ from fastapi import Request
|
||||
|
||||
from sshecret_admin.core.dependencies import AdminDep, BaseDependencies
|
||||
|
||||
from sshecret_admin.auth.models import User
|
||||
from sshecret_admin.auth.models import IdentityClaims, LocalUserInfo, User
|
||||
|
||||
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
||||
UserLoginDep = Callable[[Request, Session], Awaitable[bool]]
|
||||
LoginStatusDep = Callable[[Request], Awaitable[bool]]
|
||||
AsyncSessionDep = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
UserInfoDep = Callable[[Request, AsyncSession], Awaitable[LocalUserInfo]]
|
||||
RefreshTokenDep = Callable[[Request], IdentityClaims]
|
||||
LoginGuardDep = Callable[[Request], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -24,10 +27,11 @@ class FrontendDependencies(BaseDependencies):
|
||||
|
||||
get_admin_backend: AdminDep
|
||||
templates: Jinja2Blocks
|
||||
get_user_from_access_token: UserTokenDep
|
||||
get_user_from_refresh_token: UserTokenDep
|
||||
get_login_status: UserLoginDep
|
||||
get_refresh_claims: RefreshTokenDep
|
||||
get_login_status: LoginStatusDep
|
||||
get_user_info: UserInfoDep
|
||||
get_async_session: AsyncSessionDep
|
||||
require_login: LoginGuardDep
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
@ -35,10 +39,11 @@ class FrontendDependencies(BaseDependencies):
|
||||
deps: BaseDependencies,
|
||||
get_admin_backend: AdminDep,
|
||||
templates: Jinja2Blocks,
|
||||
get_user_from_access_token: UserTokenDep,
|
||||
get_user_from_refresh_token: UserTokenDep,
|
||||
get_login_status: UserLoginDep,
|
||||
get_async_session: AsyncSessionDep
|
||||
get_refresh_claims: RefreshTokenDep,
|
||||
get_login_status: LoginStatusDep,
|
||||
get_user_info: UserInfoDep,
|
||||
get_async_session: AsyncSessionDep,
|
||||
require_login: LoginGuardDep,
|
||||
) -> Self:
|
||||
"""Create from base dependencies."""
|
||||
return cls(
|
||||
@ -46,8 +51,9 @@ class FrontendDependencies(BaseDependencies):
|
||||
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_refresh_claims=get_refresh_claims,
|
||||
get_login_status=get_login_status,
|
||||
get_user_info=get_user_info,
|
||||
get_async_session=get_async_session,
|
||||
require_login=require_login,
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Frontend exceptions."""
|
||||
|
||||
from starlette.datastructures import URL
|
||||
|
||||
|
||||
|
||||
@ -12,18 +12,23 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sshecret_admin.auth.authentication import generate_user_info
|
||||
from sshecret_admin.auth.models import AuthProvider, IdentityClaims, LocalUserInfo
|
||||
from starlette.datastructures import URL
|
||||
|
||||
|
||||
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||
from sshecret_admin.auth.constants import LOCAL_ISSUER
|
||||
|
||||
from sshecret_admin.core.dependencies import BaseDependencies
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
from sshecret_admin.core.db import DatabaseSessionManager
|
||||
|
||||
from .dependencies import FrontendDependencies
|
||||
from .exceptions import RedirectException
|
||||
from .views import audit, auth, clients, index, secrets
|
||||
from .views import audit, auth, clients, index, secrets, oidc_auth
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -45,7 +50,7 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
templates = Jinja2Blocks(directory=template_path)
|
||||
|
||||
async def get_admin_backend(
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)]
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
):
|
||||
"""Get admin backend API."""
|
||||
password_db = session.scalars(
|
||||
@ -58,66 +63,50 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
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.scalars(
|
||||
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."""
|
||||
def get_identity_claims(request: Request) -> IdentityClaims:
|
||||
"""Get identity claim from session."""
|
||||
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:
|
||||
claims = decode_token(dependencies.settings, token)
|
||||
if not claims:
|
||||
raise credentials_error
|
||||
return user
|
||||
return claims
|
||||
|
||||
async def get_login_status(
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
) -> bool:
|
||||
def refresh_identity_claims(request: Request) -> IdentityClaims:
|
||||
"""Get identity claim from session for refreshing the token."""
|
||||
token = request.cookies.get("refresh_token")
|
||||
next = URL("/login").include_query_params(next=request.url.path)
|
||||
credentials_error = RedirectException(to=next)
|
||||
if not token:
|
||||
raise credentials_error
|
||||
claims = decode_token(dependencies.settings, token)
|
||||
if not claims:
|
||||
raise credentials_error
|
||||
return claims
|
||||
|
||||
async def get_login_status(request: Request) -> 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
|
||||
claims = decode_token(dependencies.settings, token)
|
||||
return claims is not None
|
||||
|
||||
async def require_login(request: Request) -> None:
|
||||
"""Enforce login requirement."""
|
||||
token = request.cookies.get("access_token")
|
||||
LOG.info("User has no cookie")
|
||||
if not token:
|
||||
url = URL("/login").include_query_params(next=request.url.path)
|
||||
raise RedirectException(to=url)
|
||||
is_logged_in = await get_login_status(request)
|
||||
if not is_logged_in:
|
||||
next = URL("/refresh").include_query_params(next=request.url.path)
|
||||
raise RedirectException(to=next)
|
||||
|
||||
async def get_async_session():
|
||||
"""Get async session."""
|
||||
@ -125,14 +114,43 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
async with sessionmanager.session() as session:
|
||||
yield session
|
||||
|
||||
async def get_user_info(
|
||||
request: Request, session: Annotated[AsyncSession, Depends(get_async_session)]
|
||||
) -> LocalUserInfo:
|
||||
"""Get User information."""
|
||||
claims = get_identity_claims(request)
|
||||
if claims.provider == LOCAL_ISSUER:
|
||||
LOG.info("Local user, finding username %s", claims.sub)
|
||||
query = (
|
||||
select(User)
|
||||
.where(User.username == claims.sub)
|
||||
.where(User.provider == AuthProvider.LOCAL)
|
||||
)
|
||||
else:
|
||||
query = (
|
||||
select(User)
|
||||
.where(User.oidc_issuer == claims.provider)
|
||||
.where(User.oidc_sub == claims.sub)
|
||||
)
|
||||
|
||||
result = await session.scalars(query)
|
||||
if user := result.first():
|
||||
if user.disabled:
|
||||
raise RedirectException(to=URL("/logout"))
|
||||
return generate_user_info(user)
|
||||
|
||||
next = URL("/refresh").include_query_params(next=request.url.path)
|
||||
raise RedirectException(to=next)
|
||||
|
||||
view_dependencies = FrontendDependencies.create(
|
||||
dependencies,
|
||||
get_admin_backend,
|
||||
templates,
|
||||
get_user_from_access_token,
|
||||
get_user_from_refresh_token,
|
||||
refresh_identity_claims,
|
||||
get_login_status,
|
||||
get_user_info,
|
||||
get_async_session,
|
||||
require_login,
|
||||
)
|
||||
|
||||
app.include_router(audit.create_router(view_dependencies))
|
||||
@ -140,5 +158,7 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
app.include_router(clients.create_router(view_dependencies))
|
||||
app.include_router(index.create_router(view_dependencies))
|
||||
app.include_router(secrets.create_router(view_dependencies))
|
||||
if dependencies.settings.oidc:
|
||||
app.include_router(oidc_auth.create_router(view_dependencies))
|
||||
|
||||
return app
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
<div
|
||||
id="drawer-create-client-default"
|
||||
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
|
||||
tabindex="-1"
|
||||
aria-labelledby="drawer-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h5
|
||||
id="drawer-label"
|
||||
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
New Client
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-create-client-default"
|
||||
aria-controls="drawer-create-client-default"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close menu</span>
|
||||
</button>
|
||||
<form hx-post="/clients/" hx-target="none">
|
||||
{% include '/clients/drawer_client_create_inner.html.j2' %}
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,38 @@
|
||||
<div
|
||||
id="drawer-create-secret-default"
|
||||
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
|
||||
tabindex="-1"
|
||||
aria-labelledby="drawer-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h5
|
||||
id="drawer-label"
|
||||
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
New Secret
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-create-secret-default"
|
||||
aria-controls="drawer-create-secret-default"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close menu</span>
|
||||
</button>
|
||||
<form hx-post="/secrets/" hx-target="none">
|
||||
{% include '/secrets/drawer_secret_create_inner.html.j2' %}
|
||||
</form>
|
||||
</div>
|
||||
@ -64,7 +64,31 @@
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
{% if oidc.enabled %}
|
||||
<div class="w-full items-center text-center my-4 flex">
|
||||
<div
|
||||
class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"
|
||||
></div>
|
||||
<div
|
||||
class="px-4 text-lg text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Or
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-full text-center my-4">
|
||||
<a href="/oidc/login">
|
||||
<button
|
||||
class="w-full bg-white hover:bg-gray-100 text-gray-900 border border-gray-300 transition-colors font-medium py-2.5 rounded-lg dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Sign in with {{ oidc.provider_name }}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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,
|
||||
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user