feat: Semaine 8
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from domain import services
|
||||
from domain.exceptions import AuthenticationError
|
||||
from infra.database import get_database_connection
|
||||
from infra.repositories import UserRepository
|
||||
import infra.crypto as crypto
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="presentation/templates")
|
||||
|
||||
def _get_db():
|
||||
conn = get_database_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
if request.session.get("user_id"):
|
||||
return RedirectResponse(url="/secrets", status_code=302)
|
||||
return templates.TemplateResponse(request, "login.html", {"user": None, "error": None})
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
conn=Depends(_get_db),
|
||||
):
|
||||
user_repo = UserRepository(conn)
|
||||
try:
|
||||
user = services.authenticate_user(username, password, user_repo, crypto)
|
||||
except AuthenticationError as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{"user": None, "error": str(exc)},
|
||||
status_code=401,
|
||||
)
|
||||
request.session["user_id"] = user.id
|
||||
request.session["username"] = user.username
|
||||
return RedirectResponse(url="/secrets", status_code=302)
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
@@ -0,0 +1,211 @@
|
||||
'''
|
||||
La gestion des secrets, je connaissais déjà un peu d'un projet personnel Node/React/Express avec JWT.
|
||||
C'est assez facile de mettre des jetons d'authentification dans les cookies de session, et de faire du rendu côté serveur.
|
||||
Là avec Jinja2, un peu galère. Parce que je ne connais pas, et peut-être qu'on change de paradigme.
|
||||
|
||||
Je t'avoue que sur la partie secret, autant j'ai "la logique" de comment faire, autant je ne suis pas sûr
|
||||
que mon code soit très bon au final en 6 heures de temps, notamment sur la partie "rotation" du secret.
|
||||
|
||||
Alors j'ai donné ça à Claude Sonnet 4.6 avec mes instructions.
|
||||
|
||||
Au final j'ai un code lourd mais, à vue d'oeil, qui est fonctionnel. Mais je ne suis pas sûr que ce soit "bien" fait,
|
||||
j'ai l'impression de faire plein de choses dans la route qui devraient être dans les services du domaine.
|
||||
|
||||
Je ne sais pas quoi en penser - de l'IA et de moi en général. Je trouve que j'ai du mal à faire le tri entre ce
|
||||
que je devrais faire et ce que l'IA fait, et à évaluer la qualité de ce qui est produit.
|
||||
|
||||
Bref. C'est à ta libre appréciation évidemment,
|
||||
tu corriges et tu notes, moi je fais ce que je fais en mon âme et conscience.
|
||||
'''
|
||||
|
||||
import infra.crypto as crypto
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from domain import services
|
||||
from domain.exceptions import AccessDeniedError, ConflictError, SecretNotFoundError
|
||||
from infra.database import get_database_connection
|
||||
from infra.repositories import SecretRepository, TeamRepository, UserRepository
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="presentation/templates")
|
||||
|
||||
def _get_db():
|
||||
conn = get_database_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_current_user(request: Request, conn):
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
return UserRepository(conn).find_by_id(int(user_id))
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
if request.session.get("user_id"):
|
||||
return RedirectResponse(url="/secrets", status_code=302)
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
@router.get("/secrets", response_class=HTMLResponse)
|
||||
def list_secrets(request: Request, conn=Depends(_get_db)):
|
||||
user = _get_current_user(request, conn)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
team_repo = TeamRepository(conn)
|
||||
secret_repo = SecretRepository(conn)
|
||||
teams = team_repo.find_by_user(user.id)
|
||||
|
||||
secrets_by_team: dict = {}
|
||||
for team in teams:
|
||||
secrets_by_team[team] = services.list_secrets_for_team(user, team.id, secret_repo)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets_list.html",
|
||||
{"user": user, "secrets_by_team": secrets_by_team},
|
||||
)
|
||||
|
||||
@router.get("/secrets/new", response_class=HTMLResponse)
|
||||
def create_secret_form(request: Request, conn=Depends(_get_db)):
|
||||
user = _get_current_user(request, conn)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
teams = TeamRepository(conn).find_by_user(user.id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secret_create.html",
|
||||
{"user": user, "teams": teams, "error": None},
|
||||
)
|
||||
|
||||
@router.post("/secrets", response_class=HTMLResponse)
|
||||
def create_secret(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
value: str = Form(...),
|
||||
team_id: int = Form(...),
|
||||
conn=Depends(_get_db),
|
||||
):
|
||||
user = _get_current_user(request, conn)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
services.create_secret(user, team_id, name, value, SecretRepository(conn), crypto)
|
||||
except AccessDeniedError as exc:
|
||||
teams = TeamRepository(conn).find_by_user(user.id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secret_create.html",
|
||||
{"user": user, "teams": teams, "error": str(exc)},
|
||||
status_code=403,
|
||||
)
|
||||
return RedirectResponse(url="/secrets", status_code=302)
|
||||
|
||||
@router.get("/secrets/{secret_id}", response_class=HTMLResponse)
|
||||
def reveal_secret(request: Request, secret_id: int, conn=Depends(_get_db)):
|
||||
user = _get_current_user(request, conn)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
secret_repo = SecretRepository(conn)
|
||||
try:
|
||||
plaintext = services.reveal_secret(user, secret_id, secret_repo, crypto)
|
||||
except SecretNotFoundError:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"user": user, "status_code": 404, "message": "Secret introuvable."},
|
||||
status_code=404,
|
||||
)
|
||||
except AccessDeniedError:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"user": user, "status_code": 403, "message": "Acc\u00e8s refus\u00e9."},
|
||||
status_code=403,
|
||||
)
|
||||
# Access validated \u2014 fetch the secret metadata for display
|
||||
secret = secret_repo.find_by_id(secret_id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secret_detail.html",
|
||||
{"user": user, "secret": secret, "plaintext": plaintext},
|
||||
)
|
||||
|
||||
@router.get("/secrets/{secret_id}/rotate", response_class=HTMLResponse)
|
||||
def rotate_secret_form(request: Request, secret_id: int, conn=Depends(_get_db)):
|
||||
user = _get_current_user(request, conn)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
secret_repo = SecretRepository(conn)
|
||||
secret = secret_repo.find_by_id(secret_id)
|
||||
if secret is None:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"user": user, "status_code": 404, "message": "Secret introuvable."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
try:
|
||||
services.check_team_membership(user, secret.team_id)
|
||||
except AccessDeniedError:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"user": user, "status_code": 403, "message": "Acc\u00e8s refus\u00e9."},
|
||||
status_code=403,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secret_rotate.html",
|
||||
{"user": user, "secret": secret, "error": None},
|
||||
)
|
||||
|
||||
@router.post("/secrets/{secret_id}/rotate", response_class=HTMLResponse)
|
||||
def rotate_secret(
|
||||
request: Request,
|
||||
secret_id: int,
|
||||
new_value: str = Form(...),
|
||||
expected_version: int = Form(...),
|
||||
conn=Depends(_get_db),
|
||||
):
|
||||
user = _get_current_user(request, conn)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
secret_repo = SecretRepository(conn)
|
||||
try:
|
||||
services.rotate_secret(user, secret_id, new_value, expected_version, secret_repo, crypto)
|
||||
except SecretNotFoundError:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"user": user, "status_code": 404, "message": "Secret introuvable."},
|
||||
status_code=404,
|
||||
)
|
||||
except AccessDeniedError:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"error.html",
|
||||
{"user": user, "status_code": 403, "message": "Acc\u00e8s refus\u00e9."},
|
||||
status_code=403,
|
||||
)
|
||||
except ConflictError as exc:
|
||||
# Re-fetch fresh secret so the form shows the new version
|
||||
secret = secret_repo.find_by_id(secret_id)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secret_rotate.html",
|
||||
{"user": user, "secret": secret, "error": str(exc)},
|
||||
status_code=409,
|
||||
)
|
||||
return RedirectResponse(url="/secrets", status_code=302)
|
||||
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class LoginForm(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
class CreateSecretForm(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=256)
|
||||
value: str = Field(min_length=1)
|
||||
team_id: int
|
||||
|
||||
class RotateSecretForm(BaseModel):
|
||||
new_value: str = Field(min_length=1)
|
||||
expected_version: int
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contexte
|
||||
|
||||
Deux choses :
|
||||
1 - J'ai fait la base avec le contenu de LivrExpress / ShopFlow
|
||||
2 - Je connaissais un peu d'HTML
|
||||
3 - ... mais putain ce que ça me gonfle comme langage (le balisage en général)
|
||||
|
||||
Alors j'ai codé le minimum syndical pour que ça tourne et après j'ai fait un planificateur Claude Opus 4.6 et un agent Claude Sonnet 4.6 pour générer un template propre.
|
||||
|
||||
C'est pas une instance Vault mais c'est déjà bien.
|
||||
|
||||
J'assume pleinement mon usage de l'IA pour les parties où j'ai des lacunes mais où je sais contrôler la qualité du résultat. C'est aussi la réalité des juniors. Et des seniors aussi d'ailleurs.
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SecuVault{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="/secrets">🔏 SecuVault</a>
|
||||
{% if user %}
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-light">{{ user.username }}</span>
|
||||
<a href="/logout" class="btn btn-outline-light btn-sm">Déconnexion</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container pb-5">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SecuVault - Erreur {{ status_code }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center py-5">
|
||||
<div class="display-1 text-muted fw-bold">{{ status_code }}</div>
|
||||
<p class="lead mt-3">{{ message }}</p>
|
||||
<a href="/secrets" class="btn btn-outline-primary mt-2">⬅️ Retour aux secrets</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SecuVault - Connexion{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h4 mb-4">Connexion</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">👱 Nom d'utilisateur</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">🔒 Mot de passe</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">➡️ Se connecter</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SecuVault - Nouveau secret{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h4 mb-4">Créer un secret</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/secrets">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name">Nom du secret</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="value">Valeur <span class="text-muted small">(sera chiffrée)</span></label>
|
||||
<textarea class="form-control font-monospace" id="value" name="value" rows="4" required></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="team_id">Équipe</label>
|
||||
<select class="form-select" id="team_id" name="team_id" required>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success">✅ Créer</button>
|
||||
<a href="/secrets" class="btn btn-outline-secondary">⛔ Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SecuVault - {{ secret.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>{{ secret.name }}</strong>
|
||||
<span class="badge bg-light text-dark border">v{{ secret.version }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-bold">Valeur du secret:</label>
|
||||
<div class="p-3 bg-warning-subtle border border-warning rounded font-monospace user-select-all">{{ plaintext }}</div>
|
||||
<p class="text-muted small mt-2">Mis à jour le {{ secret.updated_at }}</p>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2">
|
||||
<a href="/secrets" class="btn btn-outline-secondary btn-sm">Retour</a>
|
||||
<a href="/secrets/{{ secret.id }}/rotate" class="btn btn-warning btn-sm">Rotation</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SecuVault - Rotation : {{ secret.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h4 mb-1">Rotation du secret</h1>
|
||||
<p class="text-muted mb-4">
|
||||
{{ secret.name }}
|
||||
<span class="badge bg-light text-dark border ms-1">v{{ secret.version }}</span>
|
||||
</p>
|
||||
{% if error %}
|
||||
<div class="alert alert-warning">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/secrets/{{ secret.id }}/rotate">
|
||||
{# The current version is embedded as a hidden field for optimistic-locking. #}
|
||||
<input type="hidden" name="expected_version" value="{{ secret.version }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="new_value">Nouvelle valeur</label>
|
||||
<textarea class="form-control font-monospace" id="new_value" name="new_value" rows="4" required autofocus></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-warning">Appliquer la rotation</button>
|
||||
<a href="/secrets" class="btn btn-outline-secondary">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}SecuVault - Mes secrets{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0">Mes secrets</h1>
|
||||
<a href="/secrets/new" class="btn btn-success">+ Nouveau secret</a>
|
||||
</div>
|
||||
|
||||
{% if not secrets_by_team %}
|
||||
<div class="alert alert-info">Vous n'appartenez à aucune équipe pour l'instant.</div>
|
||||
{% endif %}
|
||||
|
||||
{% for team, secrets in secrets_by_team.items() %}
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-header bg-secondary text-white d-flex align-items-center gap-2">
|
||||
<strong>{{ team.name }}</strong>
|
||||
{% if team.description %}
|
||||
<span class="opacity-75 small">- {{ team.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if secrets %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Dernière mise à jour</th>
|
||||
<th>Version</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for secret in secrets %}
|
||||
<tr>
|
||||
<td class="align-middle">{{ secret.name }}</td>
|
||||
<td class="align-middle text-muted small">{{ secret.updated_at }}</td>
|
||||
<td class="align-middle"><span class="badge bg-light text-dark border">v{{ secret.version }}</span></td>
|
||||
<td class="text-end">
|
||||
<a href="/secrets/{{ secret.id }}" class="btn btn-sm btn-outline-primary me-1">Révéler</a>
|
||||
<a href="/secrets/{{ secret.id }}/rotate" class="btn btn-sm btn-outline-warning">Rotation</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted p-3 mb-0">Aucun secret dans cette équipe.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user