From 76c2e8de4e1f1fe5b5e98ddfdea472f130d005d8 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Fri, 26 Sep 2025 21:27:17 +0200 Subject: [PATCH] feat: enhance admin dashboard with chain integrity verification - Add chain integrity verification system for document signatures - Implement VerifyDocumentChainIntegrity method in AdminRepository - Add ChainIntegrityResult struct to track validation status - Display chain integrity status in admin document details page - Add API endpoint for programmatic chain integrity checks - Add admin access link in main interface for authorized users - Update templates to show integrity verification results - Add admin configuration to docker-compose environment --- docker-compose.yml | 1 + .../database/admin_repository.go | 83 +++++++++++++++++++ internal/presentation/admin/handlers_admin.go | 70 +++++++++++++--- .../presentation/admin/middleware_admin.go | 24 ++++++ internal/presentation/admin/routes_admin.go | 1 + internal/presentation/handlers/signature.go | 6 ++ templates/admin_dashboard.html.tpl | 15 ---- templates/admin_doc_details.html.tpl | 32 ++++++- templates/base.html.tpl | 22 ++--- templates/index.html.tpl | 23 +++-- 10 files changed, 233 insertions(+), 44 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index dff8ae0..289e1e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: ACKIFY_OAUTH_COOKIE_SECRET: "${ACKIFY_OAUTH_COOKIE_SECRET}" ACKIFY_ED25519_PRIVATE_KEY: "${ACKIFY_ED25519_PRIVATE_KEY}" ACKIFY_LISTEN_ADDR: ":8080" + ACKIFY_ADMIN_EMAILS: "${ACKIFY_ADMIN_EMAILS}" depends_on: ackify-migrate: condition: service_completed_successfully diff --git a/internal/infrastructure/database/admin_repository.go b/internal/infrastructure/database/admin_repository.go index c3b6eac..af1804d 100644 --- a/internal/infrastructure/database/admin_repository.go +++ b/internal/infrastructure/database/admin_repository.go @@ -108,6 +108,89 @@ func (r *AdminRepository) ListSignaturesByDoc(ctx context.Context, docID string) return signatures, nil } +// VerifyDocumentChainIntegrity vérifie l'intégrité de la chaîne pour un document donné +func (r *AdminRepository) VerifyDocumentChainIntegrity(ctx context.Context, docID string) (*ChainIntegrityResult, error) { + signatures, err := r.ListSignaturesByDoc(ctx, docID) + if err != nil { + return nil, fmt.Errorf("failed to get signatures for document %s: %w", docID, err) + } + + return r.verifyChainIntegrity(signatures), nil +} + +// ChainIntegrityResult contient le résultat de la vérification d'intégrité +type ChainIntegrityResult struct { + IsValid bool `json:"is_valid"` + TotalSigs int `json:"total_signatures"` + ValidSigs int `json:"valid_signatures"` + InvalidSigs int `json:"invalid_signatures"` + Errors []string `json:"errors,omitempty"` + DocID string `json:"doc_id"` +} + +// verifyChainIntegrity vérifie l'intégrité de la chaîne de signatures +func (r *AdminRepository) verifyChainIntegrity(signatures []*models.Signature) *ChainIntegrityResult { + result := &ChainIntegrityResult{ + IsValid: true, + TotalSigs: len(signatures), + ValidSigs: 0, + InvalidSigs: 0, + Errors: []string{}, + } + + if len(signatures) == 0 { + return result + } + + // Trier par ID pour vérifier dans l'ordre chronologique + sortedSigs := make([]*models.Signature, len(signatures)) + copy(sortedSigs, signatures) + + // Tri manuel par ID (ordre croissant) + for i := 0; i < len(sortedSigs)-1; i++ { + for j := i + 1; j < len(sortedSigs); j++ { + if sortedSigs[i].ID > sortedSigs[j].ID { + sortedSigs[i], sortedSigs[j] = sortedSigs[j], sortedSigs[i] + } + } + } + + result.DocID = sortedSigs[0].DocID + + // Vérification de la première signature (genesis) + firstSig := sortedSigs[0] + if firstSig.PrevHash != nil && *firstSig.PrevHash != "" { + result.IsValid = false + result.InvalidSigs++ + result.Errors = append(result.Errors, fmt.Sprintf("Genesis signature ID:%d has prev_hash (should be null)", firstSig.ID)) + } else { + result.ValidSigs++ + } + + // Vérification des signatures suivantes + for i := 1; i < len(sortedSigs); i++ { + currentSig := sortedSigs[i] + prevSig := sortedSigs[i-1] + + expectedPrevHash := prevSig.ComputeRecordHash() + + if currentSig.PrevHash == nil { + result.IsValid = false + result.InvalidSigs++ + result.Errors = append(result.Errors, fmt.Sprintf("Signature ID:%d missing prev_hash", currentSig.ID)) + } else if *currentSig.PrevHash != expectedPrevHash { + result.IsValid = false + result.InvalidSigs++ + result.Errors = append(result.Errors, fmt.Sprintf("Signature ID:%d has invalid prev_hash: expected %s, got %s", + currentSig.ID, expectedPrevHash[:12]+"...", (*currentSig.PrevHash)[:12]+"...")) + } else { + result.ValidSigs++ + } + } + + return result +} + // Close closes the database connection func (r *AdminRepository) Close() error { if r.db != nil { diff --git a/internal/presentation/admin/handlers_admin.go b/internal/presentation/admin/handlers_admin.go index dcef470..637d695 100644 --- a/internal/presentation/admin/handlers_admin.go +++ b/internal/presentation/admin/handlers_admin.go @@ -1,6 +1,7 @@ package admin import ( + "encoding/json" "html/template" "net/http" @@ -54,16 +55,20 @@ func (h *AdminHandlers) HandleDashboard(w http.ResponseWriter, r *http.Request) User *models.User BaseURL string Documents []database.DocumentAgg + DocID *string + IsAdmin bool }{ TemplateName: "admin_dashboard", User: user, BaseURL: h.baseURL, Documents: documents, + DocID: nil, + IsAdmin: true, // L'utilisateur est forcément admin pour accéder à cette page } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.templates.ExecuteTemplate(w, "base", data); err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) return } } @@ -90,23 +95,64 @@ func (h *AdminHandlers) HandleDocumentDetails(w http.ResponseWriter, r *http.Req return } + // Vérifier l'intégrité de la chaîne pour ce document + chainIntegrity, err := h.adminRepo.VerifyDocumentChainIntegrity(ctx, docID) + if err != nil { + // Log l'erreur mais continue l'affichage + chainIntegrity = &database.ChainIntegrityResult{ + IsValid: false, + TotalSigs: len(signatures), + ValidSigs: 0, + InvalidSigs: len(signatures), + Errors: []string{"Failed to verify chain integrity: " + err.Error()}, + DocID: docID, + } + } + data := struct { - TemplateName string - User *models.User - BaseURL string - DocID string - Signatures []*models.Signature + TemplateName string + User *models.User + BaseURL string + DocID *string + Signatures []*models.Signature + ChainIntegrity *database.ChainIntegrityResult + IsAdmin bool }{ - TemplateName: "admin_doc_details", - User: user, - BaseURL: h.baseURL, - DocID: docID, - Signatures: signatures, + TemplateName: "admin_doc_details", + User: user, + BaseURL: h.baseURL, + DocID: &docID, + Signatures: signatures, + ChainIntegrity: chainIntegrity, + IsAdmin: true, // L'utilisateur est forcément admin pour accéder à cette page } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := h.templates.ExecuteTemplate(w, "base", data); err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + return + } +} + +// HandleChainIntegrityAPI handles GET /admin/api/chain-integrity/{docID} - returns JSON +func (h *AdminHandlers) HandleChainIntegrityAPI(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + docID := chi.URLParam(r, "docID") + + if docID == "" { + http.Error(w, "Document ID required", http.StatusBadRequest) + return + } + + result, err := h.adminRepo.VerifyDocumentChainIntegrity(ctx, docID) + if err != nil { + http.Error(w, "Failed to verify chain integrity", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } } diff --git a/internal/presentation/admin/middleware_admin.go b/internal/presentation/admin/middleware_admin.go index 90ad2c8..5c2afa1 100644 --- a/internal/presentation/admin/middleware_admin.go +++ b/internal/presentation/admin/middleware_admin.go @@ -65,3 +65,27 @@ func (m *AdminMiddleware) isAdminUser(user *models.User) bool { return false } + +// IsAdminUser is a public function to check if a user is admin (for templates) +func IsAdminUser(user *models.User) bool { + if user == nil { + return false + } + + adminEmails := os.Getenv("ACKIFY_ADMIN_EMAILS") + if adminEmails == "" { + return false + } + + userEmail := strings.ToLower(strings.TrimSpace(user.Email)) + emails := strings.Split(adminEmails, ",") + + for _, email := range emails { + adminEmail := strings.ToLower(strings.TrimSpace(email)) + if userEmail == adminEmail { + return true + } + } + + return false +} diff --git a/internal/presentation/admin/routes_admin.go b/internal/presentation/admin/routes_admin.go index 06ec18a..5cac6fd 100644 --- a/internal/presentation/admin/routes_admin.go +++ b/internal/presentation/admin/routes_admin.go @@ -51,5 +51,6 @@ func RegisterAdminRoutes(baseURL string, templates *template.Template) func(r *c // Register admin routes r.Get("/admin", adminMiddleware.RequireAdmin(adminHandlers.HandleDashboard)) r.Get("/admin/docs/{docID}", adminMiddleware.RequireAdmin(adminHandlers.HandleDocumentDetails)) + r.Get("/admin/api/chain-integrity/{docID}", adminMiddleware.RequireAdmin(adminHandlers.HandleChainIntegrityAPI)) } } diff --git a/internal/presentation/handlers/signature.go b/internal/presentation/handlers/signature.go index 7aa902b..e3d02c5 100644 --- a/internal/presentation/handlers/signature.go +++ b/internal/presentation/handlers/signature.go @@ -10,6 +10,7 @@ import ( "time" "github.com/btouchard/ackify-ce/internal/domain/models" + "github.com/btouchard/ackify-ce/internal/presentation/admin" "github.com/btouchard/ackify-ce/pkg/services" ) @@ -53,6 +54,7 @@ type PageData struct { TemplateName string BaseURL string Signatures []*models.Signature + IsAdmin bool ServiceInfo *struct { Name string Icon string @@ -275,6 +277,9 @@ func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templ if data.TemplateName == "" { data.TemplateName = templateName } + if !data.IsAdmin { + data.IsAdmin = admin.IsAdminUser(data.User) + } templateData := map[string]interface{}{ "User": data.User, @@ -286,6 +291,7 @@ func (h *SignatureHandlers) render(w http.ResponseWriter, _ *http.Request, templ "BaseURL": data.BaseURL, "Signatures": data.Signatures, "ServiceInfo": data.ServiceInfo, + "IsAdmin": data.IsAdmin, } if err := h.template.ExecuteTemplate(w, "base", templateData); err != nil { diff --git a/templates/admin_dashboard.html.tpl b/templates/admin_dashboard.html.tpl index 14ad116..e6863da 100644 --- a/templates/admin_dashboard.html.tpl +++ b/templates/admin_dashboard.html.tpl @@ -63,20 +63,5 @@ {{end}} - -
-
-
- - - -
-
-

- Accès admin : Configuré via la variable d'environnement ACKIFY_ADMIN_EMAILS -

-
-
-
{{end}} \ No newline at end of file diff --git a/templates/admin_doc_details.html.tpl b/templates/admin_doc_details.html.tpl index 03ddfd3..0e28a30 100644 --- a/templates/admin_doc_details.html.tpl +++ b/templates/admin_doc_details.html.tpl @@ -93,6 +93,9 @@ {{if .Signatures}} + + {{if .ChainIntegrity}} + {{if .ChainIntegrity.IsValid}}
@@ -102,11 +105,38 @@

- Document vérifié : Toutes les signatures utilisent la cryptographie Ed25519 pour garantir l'authenticité et la non-répudiation. + Chaîne de blocs intègre : {{.ChainIntegrity.ValidSigs}}/{{.ChainIntegrity.TotalSigs}} signatures valides

+ {{else}} +
+
+
+ + + +
+
+

+ Problème d'intégrité détecté : {{.ChainIntegrity.InvalidSigs}} signature(s) invalide(s) +

+ {{if .ChainIntegrity.Errors}} +
+

Erreurs détectées :

+
    + {{range .ChainIntegrity.Errors}} +
  • {{.}}
  • + {{end}} +
+
+ {{end}} +
+
+
+ {{end}} + {{end}} {{end}} {{end}} \ No newline at end of file diff --git a/templates/base.html.tpl b/templates/base.html.tpl index a84a398..1492808 100644 --- a/templates/base.html.tpl +++ b/templates/base.html.tpl @@ -4,9 +4,9 @@ Ackify - Proof of Read -{{if .DocID}} +{{if and (ne .TemplateName "admin_dashboard") (ne .TemplateName "admin_doc_details")}}{{if .DocID}} -{{end}} +{{end}}{{end}}