feat(auth): implement web authentication with Keycloak OAuth2

- Add auth routes: /auth/login, /auth/callback, /auth/logout
- Add OAuth2 flow with Keycloak using HTTP-only cookies
- Add web auth dependencies with role checking
- Add profile page (read-only) at /web/profile
- Update header with user menu (sign in/out, profile)
- Filter posts based on user permissions (hide drafts from guests)
- Conditionally show/hide create/edit/delete buttons
- Add authorization rules documentation to AGENTS.md
- Secure post editing/deletion endpoints with auth checks
- Add can_edit, can_delete flags to templates
This commit is contained in:
2026-05-02 15:39:49 +03:00
parent 2aed9f5c8a
commit 0cb706e54b
10 changed files with 915 additions and 26 deletions

View File

@@ -1,17 +1,28 @@
"""Web UI routes for blog application.
"""Web UI routes for blog application with authentication.
This module provides HTML endpoints for the blog web interface.
Currently uses mock data for demonstration purposes.
Integration with use cases will be added in future iterations.
This module provides HTML endpoints for the blog web interface
with role-based access control and user authentication.
"""
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.infrastructure.auth import TokenInfo
from app.presentation.web.deps import (
OptionalUserDep,
RequireUserDep,
can_create_post,
can_delete_post,
can_edit_post,
can_see_draft,
get_user_role,
)
router = APIRouter(prefix="/web", tags=["web"])
templates = Jinja2Templates(directory="app/presentation/templates")
@@ -121,21 +132,66 @@ MOCK_POSTS = [
]
def get_base_context(user: TokenInfo | None) -> dict[str, Any]:
"""Get base template context with user info and permissions.
Args:
user: Current user or None for guest.
Returns:
Dictionary with user, user_role, and can_create flags.
"""
user_role = get_user_role(user)
return {
"user": user,
"user_role": user_role.value if user_role else None,
"can_create": can_create_post(user),
}
def filter_visible_posts(posts: list[MockPost], user: TokenInfo | None) -> list[MockPost]:
"""Filter posts based on user permissions.
Args:
posts: List of all posts.
user: Current user or None for guest.
Returns:
Filtered list of posts visible to the user.
"""
visible_posts = []
for post in posts:
if post.published or can_see_draft(user, post.author_id):
visible_posts.append(post)
return visible_posts
@router.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
async def home(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the home page with list of posts.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
Returns:
HTMLResponse with rendered posts list template.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
"posts": MOCK_POSTS,
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
@@ -145,20 +201,28 @@ async def home(request: Request) -> HTMLResponse:
@router.get("/posts", response_class=HTMLResponse)
async def list_posts(request: Request) -> HTMLResponse:
async def list_posts(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the posts listing page.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
Returns:
HTMLResponse with rendered posts list template.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
"posts": MOCK_POSTS,
**context,
"posts": visible_posts,
"active_page": "posts",
"current_page": 1,
"has_prev": False,
@@ -168,19 +232,26 @@ async def list_posts(request: Request) -> HTMLResponse:
@router.get("/posts/new", response_class=HTMLResponse)
async def new_post_form(request: Request) -> HTMLResponse:
async def new_post_form(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
"""Render the new post creation form.
Args:
request: The HTTP request object for template context.
user: Current user (required).
Returns:
HTMLResponse with rendered post form template.
"""
context = get_base_context(user)
return templates.TemplateResponse(
request,
"pages/post_form.html",
{
**context,
"is_edit": False,
"post": None,
"active_page": "posts",
@@ -189,20 +260,28 @@ async def new_post_form(request: Request) -> HTMLResponse:
@router.post("/posts/new", response_class=HTMLResponse)
async def create_post(request: Request) -> HTMLResponse:
async def create_post(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle new post creation form submission.
Args:
request: The HTTP request object containing form data.
user: Current user (required).
Returns:
HTMLResponse redirecting to the home page.
"""
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
"posts": MOCK_POSTS,
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
@@ -212,43 +291,81 @@ async def create_post(request: Request) -> HTMLResponse:
@router.get("/posts/{post_id}", response_class=HTMLResponse)
async def post_detail(request: Request, post_id: str) -> HTMLResponse:
async def post_detail(
request: Request,
post_id: str,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render a single post detail page.
Args:
request: The HTTP request object for template context.
post_id: The unique identifier of the post to display.
user: Current user from dependency.
Returns:
HTMLResponse with rendered post detail template.
Raises:
HTTPException: If post not found or not visible to user.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0])
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if not post.published and not can_see_draft(user, post.author_id):
raise HTTPException(status_code=404, detail="Post not found")
context = get_base_context(user)
return templates.TemplateResponse(
request,
"pages/post_detail.html",
{
**context,
"post": post,
"active_page": "posts",
"can_edit": can_edit_post(user, post.author_id),
"can_delete": can_delete_post(user, post.author_id),
},
)
@router.get("/posts/{post_id}/edit", response_class=HTMLResponse)
async def edit_post_form(request: Request, post_id: str) -> HTMLResponse:
async def edit_post_form(
request: Request,
post_id: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Render the post edit form.
Args:
request: The HTTP request object for template context.
post_id: The unique identifier of the post to edit.
user: Current user (required).
Returns:
HTMLResponse with rendered post form template.
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0])
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
context = get_base_context(user)
return templates.TemplateResponse(
request,
"pages/post_form.html",
{
**context,
"is_edit": True,
"post": post,
"active_page": "posts",
@@ -257,43 +374,83 @@ async def edit_post_form(request: Request, post_id: str) -> HTMLResponse:
@router.post("/posts/{post_id}/edit", response_class=HTMLResponse)
async def update_post(request: Request, post_id: str) -> HTMLResponse:
async def update_post(
request: Request,
post_id: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle post update form submission.
Args:
request: The HTTP request object containing form data.
post_id: The unique identifier of the post to update.
user: Current user (required).
Returns:
HTMLResponse with rendered post detail template.
Raises:
HTTPException: If post not found or user cannot edit it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), MOCK_POSTS[0])
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if not can_edit_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to edit this post")
context = get_base_context(user)
return templates.TemplateResponse(
request,
"pages/post_detail.html",
{
**context,
"post": post,
"active_page": "posts",
"can_edit": True,
"can_delete": can_delete_post(user, post.author_id),
},
)
@router.post("/posts/{post_id}/delete", response_class=HTMLResponse)
async def delete_post(request: Request, post_id: str) -> HTMLResponse:
async def delete_post(
request: Request,
post_id: str,
user: RequireUserDep,
) -> HTMLResponse:
"""Handle post deletion.
Args:
request: The HTTP request object.
post_id: The unique identifier of the post to delete.
user: Current user (required).
Returns:
HTMLResponse redirecting to the home page.
Raises:
HTTPException: If post not found or user cannot delete it.
"""
post = next((p for p in MOCK_POSTS if str(p.id) == post_id), None)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
if not can_delete_post(user, post.author_id):
raise HTTPException(status_code=403, detail="Not authorized to delete this post")
context = get_base_context(user)
visible_posts = filter_visible_posts(MOCK_POSTS, user)
return templates.TemplateResponse(
request,
"pages/index.html",
{
"posts": MOCK_POSTS,
**context,
"posts": visible_posts,
"active_page": "home",
"current_page": 1,
"has_prev": False,
@@ -302,24 +459,55 @@ async def delete_post(request: Request, post_id: str) -> HTMLResponse:
)
@router.get("/profile", response_class=HTMLResponse)
async def profile(
request: Request,
user: RequireUserDep,
) -> HTMLResponse:
"""Render user profile page.
Args:
request: The HTTP request object for template context.
user: Current user (required).
Returns:
HTMLResponse with rendered profile template.
"""
context = get_base_context(user)
return templates.TemplateResponse(
request,
"pages/profile.html",
{
**context,
"active_page": "profile",
},
)
@router.get("/about", response_class=HTMLResponse)
async def about(request: Request) -> HTMLResponse:
async def about(
request: Request,
user: OptionalUserDep,
) -> HTMLResponse:
"""Render the about page.
Args:
request: The HTTP request object for template context.
user: Current user from dependency.
Returns:
HTMLResponse with rendered about page template.
"""
return HTMLResponse(
content="""
content=f"""
<!DOCTYPE html>
<html>
<head><title>About - Blog</title></head>
<body>
<h1>About</h1>
<p>A modern blog built with FastAPI and DDD architecture.</p>
<p>User: {user.username if user else "Guest"}</p>
<a href="/web/">Back to home</a>
</body>
</html>