- Implement PKCE (Proof Key for Code Exchange) with S256 method - Add crypto/pkce module with code verifier and challenge generation - Modify OAuth flow to include code_challenge in authorization requests - Update HandleCallback to validate code_verifier during token exchange - Extend session lifetime from 7 to 30 days - Add comprehensive unit tests for PKCE functions - Maintain backward compatibility with fallback for non-PKCE sessions - Add detailed logging for OAuth flow with PKCE tracking PKCE enhances security by preventing authorization code interception attacks, as recommended by OAuth 2.1 and OIDC standards. feat: add encrypted refresh token storage with automatic cleanup - Add oauth_sessions table for storing encrypted refresh tokens - Implement AES-256-GCM encryption for refresh tokens using cookie secret - Create OAuth session repository with full CRUD operations - Add SessionWorker for automatic cleanup of expired sessions - Configure cleanup to run every 24h for sessions older than 37 days - Modify OAuth flow to store refresh tokens after successful authentication - Track client IP and user agent for session security validation - Link OAuth sessions to user sessions via session ID - Add comprehensive encryption tests with security validations - Integrate SessionWorker into server lifecycle with graceful shutdown This enables persistent OAuth sessions with secure token storage, reducing the need for frequent re-authentication from 7 to 30 days.
16 KiB
Database
Schéma PostgreSQL, migrations, et garanties d'intégrité.
Vue d'Ensemble
Ackify utilise PostgreSQL 16+ avec :
- Migrations versionnées SQL
- Contraintes d'intégrité strictes
- Triggers pour immutabilité
- Index pour performances
Schéma Principal
Table signatures
Stocke les signatures cryptographiques Ed25519.
CREATE TABLE signatures (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
user_sub TEXT NOT NULL, -- OAuth user ID (sub claim)
user_email TEXT NOT NULL,
user_name TEXT, -- Nom utilisateur (optionnel)
signed_at TIMESTAMPTZ NOT NULL,
payload_hash TEXT NOT NULL, -- SHA-256 du payload
signature TEXT NOT NULL, -- Signature Ed25519 (base64)
nonce TEXT NOT NULL, -- Anti-replay attack
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
referer TEXT, -- Source (optionnel)
prev_hash TEXT, -- Hash de la signature précédente (chaînage)
UNIQUE (doc_id, user_sub) -- UNE signature par user/document
);
CREATE INDEX idx_signatures_doc_id ON signatures(doc_id);
CREATE INDEX idx_signatures_user_sub ON signatures(user_sub);
Garanties :
- ✅ Une signature par utilisateur/document (contrainte UNIQUE)
- ✅ Horodatage immutable via trigger PostgreSQL
- ✅ Chaînage hash (blockchain-like) via
prev_hash - ✅ Non-répudiation cryptographique (Ed25519)
Table documents
Métadonnées des documents.
CREATE TABLE documents (
doc_id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '', -- URL du document source
checksum TEXT NOT NULL DEFAULT '', -- SHA-256, SHA-512, ou MD5
checksum_algorithm TEXT NOT NULL DEFAULT 'SHA-256',
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL DEFAULT '' -- user_sub de l'admin créateur
);
Utilisation :
- Titre, description affichés dans l'interface
- URL incluse dans les emails de rappel
- Checksum pour vérification d'intégrité (optionnel)
Table expected_signers
Signataires attendus pour tracking.
CREATE TABLE expected_signers (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
email TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '', -- Nom pour personnalisation
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
added_by TEXT NOT NULL, -- Admin qui a ajouté
notes TEXT,
UNIQUE (doc_id, email)
);
CREATE INDEX idx_expected_signers_doc_id ON expected_signers(doc_id);
Fonctionnalités :
- Tracking de complétion (% signé)
- Envoi de rappels email
- Détection de signatures inattendues
Table reminder_logs
Historique des rappels email.
CREATE TABLE reminder_logs (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
recipient_email TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_by TEXT NOT NULL, -- Admin qui a envoyé
template_used TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('sent', 'failed', 'bounced')),
error_message TEXT,
FOREIGN KEY (doc_id, recipient_email)
REFERENCES expected_signers(doc_id, email)
);
CREATE INDEX idx_reminder_logs_doc_id ON reminder_logs(doc_id);
Table checksum_verifications
Historique des vérifications d'intégrité.
CREATE TABLE checksum_verifications (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL,
verified_by TEXT NOT NULL,
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
stored_checksum TEXT NOT NULL,
calculated_checksum TEXT NOT NULL,
algorithm TEXT NOT NULL,
is_valid BOOLEAN NOT NULL,
error_message TEXT,
FOREIGN KEY (doc_id) REFERENCES documents(doc_id)
);
CREATE INDEX idx_checksum_verifications_doc_id ON checksum_verifications(doc_id);
Table oauth_sessions
Sessions OAuth2 avec refresh tokens chiffrés.
CREATE TABLE oauth_sessions (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL UNIQUE, -- ID session Gorilla
user_sub TEXT NOT NULL, -- OAuth user ID
refresh_token_encrypted BYTEA NOT NULL, -- Chiffré AES-256-GCM
access_token_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_refreshed_at TIMESTAMPTZ,
user_agent TEXT,
ip_address INET
);
CREATE INDEX idx_oauth_sessions_session_id ON oauth_sessions(session_id);
CREATE INDEX idx_oauth_sessions_user_sub ON oauth_sessions(user_sub);
CREATE INDEX idx_oauth_sessions_updated_at ON oauth_sessions(updated_at);
Sécurité :
- Refresh tokens chiffrés (AES-256-GCM)
- Cleanup automatique après 37 jours
- Tracking IP + User-Agent pour détecter vols
Table email_queue
File d'attente d'emails asynchrone avec mécanisme de retry.
CREATE TABLE email_queue (
id BIGSERIAL PRIMARY KEY,
-- Métadonnées email
to_addresses TEXT[] NOT NULL, -- Adresses email destinataires
cc_addresses TEXT[], -- Adresses CC (optionnel)
bcc_addresses TEXT[], -- Adresses BCC (optionnel)
subject TEXT NOT NULL, -- Sujet de l'email
template TEXT NOT NULL, -- Nom du template (ex: 'reminder')
locale TEXT NOT NULL DEFAULT 'fr', -- Langue email (en, fr, es, de, it)
data JSONB NOT NULL DEFAULT '{}', -- Variables du template
headers JSONB, -- Headers email personnalisés (optionnel)
-- Gestion de la file
status TEXT NOT NULL DEFAULT 'pending' -- pending, processing, sent, failed, cancelled
CHECK (status IN ('pending', 'processing', 'sent', 'failed', 'cancelled')),
priority INT NOT NULL DEFAULT 0, -- Plus élevé = traité en premier (0=normal, 10=high, 100=urgent)
retry_count INT NOT NULL DEFAULT 0, -- Nombre de tentatives de retry
max_retries INT NOT NULL DEFAULT 3, -- Limite maximale de retry
-- Suivi
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
scheduled_for TIMESTAMPTZ NOT NULL DEFAULT now(), -- Heure de traitement la plus tôt
processed_at TIMESTAMPTZ, -- Quand l'email a été envoyé
next_retry_at TIMESTAMPTZ, -- Heure de retry calculée (exponential backoff)
-- Suivi des erreurs
last_error TEXT, -- Dernier message d'erreur
error_details JSONB, -- Informations d'erreur détaillées
-- Suivi des références (optionnel)
reference_type TEXT, -- ex: 'reminder', 'notification'
reference_id TEXT, -- ex: doc_id
created_by TEXT -- Utilisateur qui a mis en file l'email
);
-- Index pour traitement efficace de la file
CREATE INDEX idx_email_queue_status_scheduled
ON email_queue(status, scheduled_for)
WHERE status IN ('pending', 'processing');
CREATE INDEX idx_email_queue_priority_scheduled
ON email_queue(priority DESC, scheduled_for ASC)
WHERE status = 'pending';
CREATE INDEX idx_email_queue_retry
ON email_queue(next_retry_at)
WHERE status = 'processing' AND retry_count < max_retries;
CREATE INDEX idx_email_queue_reference
ON email_queue(reference_type, reference_id);
CREATE INDEX idx_email_queue_created_at
ON email_queue(created_at DESC);
Fonctionnalités :
- Traitement asynchrone : Emails traités par worker en arrière-plan
- Mécanisme de retry : Exponential backoff (1min, 2min, 4min, 8min, 16min, 32min...)
- Support de priorité : Emails haute priorité traités en premier
- Envoi programmé : Retarder la livraison d'email avec
scheduled_for - Suivi des erreurs : Logging détaillé des erreurs et historique des retries
- Suivi des références : Lier les emails aux documents ou autres entités
Calcul automatique du retry :
-- Fonction pour calculer le temps de retry suivant avec exponential backoff
CREATE OR REPLACE FUNCTION calculate_next_retry_time(retry_count INT)
RETURNS TIMESTAMPTZ AS $$
BEGIN
-- Exponential backoff: 1min, 2min, 4min, 8min, 16min, 32min...
RETURN now() + (interval '1 minute' * power(2, retry_count));
END;
$$ LANGUAGE plpgsql;
Configuration du worker :
- Taille de lot : 10 emails par lot
- Intervalle de polling : 5 secondes
- Envois concurrents : 5 emails simultanés
- Cleanup des anciens emails : Rétention de 7 jours pour emails envoyés/échoués
Migrations
Gestion des Migrations
Les migrations sont dans /backend/migrations/ avec le format :
XXXX_description.up.sql # Migration "up"
XXXX_description.down.sql # Rollback "down"
Fichiers actuels :
0001_init.up.sql- Table signatures0002_expected_signers.up.sql- Expected signers0003_reminder_logs.up.sql- Reminder logs0004_add_name_to_expected_signers.up.sql- Noms signataires0005_create_documents_table.up.sql- Documents metadata0006_create_new_tables.up.sql- Checksum verifications et email queue0007_oauth_sessions.up.sql- OAuth sessions avec refresh tokens
Appliquer les Migrations
Via Docker Compose (automatique) :
docker compose up -d
# Le service ackify-migrate applique les migrations au démarrage
Manuellement :
cd backend
go run ./cmd/migrate up
Rollback dernière migration :
go run ./cmd/migrate down
Migrations Personnalisées
Pour créer une nouvelle migration :
- Créer
XXXX_my_feature.up.sql:
-- Migration up
ALTER TABLE signatures ADD COLUMN new_field TEXT;
- Créer
XXXX_my_feature.down.sql:
-- Rollback
ALTER TABLE signatures DROP COLUMN new_field;
- Appliquer :
go run ./cmd/migrate up
Triggers PostgreSQL
Immutabilité de created_at
Trigger qui empêche la modification de created_at :
CREATE OR REPLACE FUNCTION prevent_created_at_update()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.created_at <> OLD.created_at THEN
RAISE EXCEPTION 'created_at cannot be modified';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER prevent_signatures_created_at_update
BEFORE UPDATE ON signatures
FOR EACH ROW
EXECUTE FUNCTION prevent_created_at_update();
Garantie : Aucune signature ne peut être backdatée.
Auto-update de updated_at
Pour les tables avec updated_at :
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_documents_updated_at
BEFORE UPDATE ON documents
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Requêtes Utiles
Voir les signatures d'un document
SELECT
user_email,
user_name,
signed_at,
payload_hash,
signature
FROM signatures
WHERE doc_id = 'my_document'
ORDER BY signed_at DESC;
Statut de complétion
WITH expected AS (
SELECT COUNT(*) as total
FROM expected_signers
WHERE doc_id = 'my_document'
),
signed AS (
SELECT COUNT(*) as count
FROM signatures s
INNER JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
WHERE s.doc_id = 'my_document'
)
SELECT
e.total as expected,
s.count as signed,
ROUND(100.0 * s.count / NULLIF(e.total, 0), 2) as completion_pct
FROM expected e, signed s;
Signataires manquants
SELECT
e.email,
e.name,
e.added_at
FROM expected_signers e
LEFT JOIN signatures s ON e.email = s.user_email AND e.doc_id = s.doc_id
WHERE e.doc_id = 'my_document' AND s.id IS NULL
ORDER BY e.added_at;
Signatures inattendues
SELECT
s.user_email,
s.signed_at
FROM signatures s
LEFT JOIN expected_signers e ON s.user_email = e.email AND s.doc_id = e.doc_id
WHERE s.doc_id = 'my_document' AND e.id IS NULL
ORDER BY s.signed_at DESC;
Statut de la file d'emails
-- Voir les emails en attente
SELECT
id,
to_addresses,
subject,
status,
priority,
retry_count,
scheduled_for,
created_at
FROM email_queue
WHERE status IN ('pending', 'processing')
ORDER BY priority DESC, scheduled_for ASC
LIMIT 20;
-- Emails échoués nécessitant attention
SELECT
id,
to_addresses,
subject,
retry_count,
max_retries,
last_error,
next_retry_at
FROM email_queue
WHERE status = 'failed'
ORDER BY created_at DESC;
-- Statistiques des emails par statut
SELECT
status,
COUNT(*) as count,
MIN(created_at) as oldest,
MAX(created_at) as newest
FROM email_queue
GROUP BY status
ORDER BY status;
Sauvegarde & Restauration
Backup PostgreSQL
# Backup complet
docker compose exec ackify-db pg_dump -U ackifyr ackify > backup.sql
# Backup avec compression
docker compose exec ackify-db pg_dump -U ackifyr ackify | gzip > backup.sql.gz
Restore
# Restore depuis backup
cat backup.sql | docker compose exec -T ackify-db psql -U ackifyr ackify
# Restore depuis backup compressé
gunzip -c backup.sql.gz | docker compose exec -T ackify-db psql -U ackifyr ackify
Backup Automatisé
Exemple de cron pour backup quotidien :
0 2 * * * docker compose -f /path/to/compose.yml exec -T ackify-db pg_dump -U ackifyr ackify | gzip > /backups/ackify-$(date +\%Y\%m\%d).sql.gz
Performance
Index
Les index sont automatiquement créés pour :
signatures(doc_id)- Requêtes par documentsignatures(user_sub)- Requêtes par utilisateurexpected_signers(doc_id)- Tracking complétionoauth_sessions(session_id)- Lookup sessions
Connection Pooling
Le backend Go gère automatiquement le pooling de connexions :
- Max open connections : 25
- Max idle connections : 5
- Connection max lifetime : 5 minutes
Vacuum & Analyze
PostgreSQL gère automatiquement via autovacuum. Pour forcer :
VACUUM ANALYZE signatures;
VACUUM ANALYZE documents;
Monitoring
Taille des tables
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
Statistiques
SELECT * FROM pg_stat_user_tables WHERE schemaname = 'public';
Connexions actives
SELECT
datname,
usename,
application_name,
client_addr,
state,
query
FROM pg_stat_activity
WHERE datname = 'ackify';
Sécurité
En Production
- ✅ Utiliser SSL :
?sslmode=requiredans le DSN - ✅ Mot de passe fort pour PostgreSQL
- ✅ Restreindre les connexions réseau
- ✅ Sauvegardes chiffrées
- ✅ Rotation régulière des secrets
Configuration SSL
# Dans .env
ACKIFY_DB_DSN=postgres://user:pass@host:5432/ackify?sslmode=require
Audit Trail
Toutes les opérations importantes sont tracées :
signatures.created_at- Horodatage signatureexpected_signers.added_by- Qui a ajoutéreminder_logs.sent_by- Qui a envoyé le rappelchecksum_verifications.verified_by- Qui a vérifié
Troubleshooting
Migrations bloquées
# Vérifier le statut
docker compose logs ackify-migrate
# Forcer le rollback
docker compose exec ackify-ce /app/migrate down
docker compose exec ackify-ce /app/migrate up
Contrainte UNIQUE violée
Erreur : duplicate key value violates unique constraint
Cause : L'utilisateur a déjà signé ce document.
Solution : C'est un comportement normal (une signature par user/doc).
Connection refused
Vérifier que PostgreSQL est démarré :
docker compose ps ackify-db
docker compose logs ackify-db