Основные изменения: - Добавлены 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 — гайд по тестированию
344 lines
9.8 KiB
Python
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
|