Files
blog.pyaqa.ru/app/presentation/web/auth.py
Sergey Vanyushkin 46cc06b596 feat: RBAC E2E тесты и фикс admin-прав для редактирования постов
Основные изменения:
- Добавлены E2E тесты для проверки ownership (TC-E2E-102/103):
  * test_admin_can_edit_any_post — admin может редактировать любой пост
  * test_user_cannot_edit_other_users_post — user не может редактировать чужой пост
- Исправлены use cases (UpdatePost, DeletePost, PublishPost) — добавлена проверка роли admin
- Обновлены web routes и API routes для передачи роли в use cases
- Добавлены unit тесты для admin-сценариев

Реструктуризация тестов:
- Удалены старые API тесты (tests/api/) — требуют переработки
- Удалены старые integration тесты (tests/integration/)
- Переработаны E2E тесты: удалены старые, добавлены новые с POM
- Добавлена документация тестов: FEATURE_*.md, TEST_MODEL.md, AGENTS.md

Инфраструктура:
- Добавлен MockKeycloakClient для dev-режима
- Добавлены статические файлы: EasyMDE, Highlight.js, стили markdown
- Обновлены шаблоны: base.html, post_form.html, post_detail.html
- Обновлена DI конфигурация и провайдеры

Документация:
- tests/FEATURE_RBAC.md — матрица тестов RBAC
- tests/FEATURE_POST_LIFECYCLE.md — тесты жизненного цикла поста
- tests/FEATURE_DOMAIN_FOUNDATION.md — тесты доменного слоя
- tests/FEATURE_INFRASTRUCTURE.md — тесты инфраструктуры
- tests/TEST_MODEL.md — глобальная матрица покрытия
- app/presentation/web/AGENTS.md — гайд по Web UI
- tests/AGENTS.md — гайд по тестированию
2026-05-07 19:55:15 +03:00

344 lines
9.8 KiB
Python

"""Web authentication routes for blog application.
This module provides OAuth2/OIDC authentication flow with Keycloak
for the web UI. Uses HTTP-only cookies for token storage.
"""
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from app.infrastructure.config.settings import settings
router = APIRouter(prefix="/auth", tags=["auth"])
def get_keycloak_login_url(redirect_uri: str) -> str:
"""Build Keycloak authorization URL.
Args:
redirect_uri: Callback URL after Keycloak authentication.
Returns:
Full Keycloak authorization endpoint URL.
"""
base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}"
return (
f"{base_url}/protocol/openid-connect/auth"
f"?client_id={settings.kc.client_id}"
f"&response_type=code"
f"&redirect_uri={redirect_uri}"
f"&scope=openid"
)
def get_keycloak_logout_url(redirect_uri: str) -> str:
"""Build Keycloak logout URL.
Args:
redirect_uri: URL to redirect after logout.
Returns:
Full Keycloak logout endpoint URL.
"""
base_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}"
return (
f"{base_url}/protocol/openid-connect/logout"
f"?client_id={settings.kc.client_id}"
f"&post_logout_redirect_uri={redirect_uri}"
)
async def exchange_code_for_token(code: str, redirect_uri: str) -> dict[str, Any]:
"""Exchange authorization code for access token.
Args:
code: Authorization code from Keycloak.
redirect_uri: Callback URL used during login.
Returns:
Token response containing access_token, refresh_token, etc.
Raises:
HTTPException: If token exchange fails.
"""
token_url = f"{settings.kc.server_url}/realms/{settings.kc.realm}/protocol/openid-connect/token"
data = {
"grant_type": "authorization_code",
"code": code,
"client_id": settings.kc.client_id,
"client_secret": settings.kc.client_secret,
"redirect_uri": redirect_uri,
}
async with httpx.AsyncClient() as client:
response = await client.post(token_url, data=data)
if response.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to exchange code for token")
result: dict[str, Any] = response.json()
return result
@router.get("/login")
async def login(request: Request) -> RedirectResponse:
"""Redirect to Keycloak login page or dev login in development mode.
In development mode redirects to the local dev login page
instead of the external Keycloak server.
Args:
request: HTTP request object.
Returns:
RedirectResponse to Keycloak or dev login endpoint.
"""
if settings.is_dev:
return RedirectResponse(url="/auth/dev-login")
callback_url = str(request.base_url).rstrip("/") + "/auth/callback"
login_url = get_keycloak_login_url(callback_url)
return RedirectResponse(url=login_url)
@router.get("/callback")
async def callback(request: Request, code: str | None = None) -> Response:
"""Handle OAuth callback from Keycloak.
Exchanges authorization code for tokens and sets HTTP-only cookie.
Args:
request: HTTP request object.
code: Authorization code from Keycloak.
Returns:
RedirectResponse to home page with token cookie set.
Raises:
HTTPException: If code is missing or token exchange fails.
"""
if not code:
raise HTTPException(status_code=400, detail="Authorization code not provided")
callback_url = str(request.base_url).rstrip("/") + "/auth/callback"
token_data = await exchange_code_for_token(code, callback_url)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="No access token received")
response = RedirectResponse(url="/web/", status_code=302)
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=not settings.is_dev, # Secure in production
samesite="lax",
max_age=token_data.get("expires_in", 3600),
)
return response
@router.get("/logout")
async def logout(request: Request) -> Response:
"""Logout user and clear token cookie.
In development mode redirects directly to home page.
In production redirects to Keycloak logout endpoint.
Args:
request: HTTP request object.
Returns:
RedirectResponse with cookie cleared.
"""
home_url = str(request.base_url).rstrip("/") + "/web/"
response = RedirectResponse(url=home_url)
response.delete_cookie(key="access_token")
if not settings.is_dev:
logout_url = get_keycloak_logout_url(home_url)
response = RedirectResponse(url=logout_url)
response.delete_cookie(key="access_token")
return response
@router.get("/dev-login")
async def dev_login(request: Request) -> Response:
"""Show dev login page for development mode.
Only available in development mode. Provides a simple form
to select role and log in without a real Keycloak server.
Args:
request: HTTP request object.
Returns:
HTMLResponse with dev login form.
Raises:
HTTPException: If accessed outside development mode.
"""
if not settings.is_dev:
raise HTTPException(status_code=404, detail="Not found")
from fastapi.responses import HTMLResponse
return HTMLResponse(
content="""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dev Login - Blog</title>
<style>
:root {
--bg: #f5f5f5;
--card: #fff;
--text: #333;
--border: #ddd;
--primary: #0366d6;
--primary-text: #fff;
--error: #d73a49;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--card: #161b22;
--text: #c9d1d9;
--border: #30363d;
--primary: #58a6ff;
--primary-text: #0d1117;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
.badge {
display: inline-block;
background: var(--error);
color: #fff;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
input, select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 0.9375rem;
margin-bottom: 1rem;
}
button {
width: 100%;
padding: 0.75rem;
background: var(--primary);
color: var(--primary-text);
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
button:hover { opacity: 0.9; }
.hint {
margin-top: 1.5rem;
font-size: 0.8125rem;
color: var(--text);
opacity: 0.7;
}
</style>
</head>
<body>
<div class="card">
<h1>Development Login</h1>
<span class="badge">DEV ONLY</span>
<form method="POST" action="/auth/dev-login">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="Dev User" required>
<label for="role">Role</label>
<select id="role" name="role">
<option value="user">User</option>
<option value="user2">Test User</option>
<option value="admin">Admin</option>
<option value="guest">Guest (unauthenticated)</option>
</select>
<button type="submit">Sign In</button>
</form>
<p class="hint">This bypasses Keycloak for local development only.</p>
</div>
</body>
</html>"""
)
@router.post("/dev-login")
async def dev_login_submit(request: Request) -> Response:
"""Handle dev login form submission.
Sets a dev-specific cookie that MockKeycloakClient recognizes.
Args:
request: HTTP request object with form data.
Returns:
RedirectResponse to home page with dev token cookie set.
Raises:
HTTPException: If accessed outside development mode.
"""
if not settings.is_dev:
raise HTTPException(status_code=404, detail="Not found")
form = await request.form()
role = str(form.get("role", "user")).strip()
token = f"dev-token-{role}"
response = RedirectResponse(url="/web/", status_code=302)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=86400,
)
return response