Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Presentation Control EPIC 1 : deux weekends pour un système de login (et pourquoi c'était nécessaire)

Ou : Comment passer deux weekends sur un système de login pour une app qui n’a pas encore d’utilisateurs

Il y a un paradoxe assez savoureux dans le développement logiciel : le code le plus paranoïaque qu’on puisse écrire, c’est celui qui protège des gens qui n’existent pas encore. Anti-énumération d’emails, rotation atomique de tokens, cookies httpOnly avec SameSite strict — pour un total actuel de zéro utilisateurs.

Bienvenue dans l’EPIC 1 de Presentation Control. Deux gros weekends, quelques soirées en semaine, ~12 000 lignes de Go, 7 stories, et un système d’authentification complet par magic link. C’est un side project parmi d’autres — pas le genre de truc où on s’enferme pendant six semaines. Plutôt le genre où on code entre deux priorités, le samedi matin avec un café, et le dimanche soir quand les enfants dorment. Voici ce que j’ai appris.


Le projet en bref

Presentation Control est une plateforme pour piloter des démos mobiles en temps réel. Un dashboard web, une app Flutter, du WebSocket entre les deux. Le code source est sur GitLab.

L’EPIC 1 couvre la fondation : inscription, authentification par magic link, gestion des tokens JWT, et tout le middleware qui va avec. C’est le socle sur lequel tout le reste sera construit — le WebSocket, la gestion des présentations, le monitoring.

Huit endpoints. Trois tables PostgreSQL. Un système de tokens à deux niveaux. Et une pipeline d’erreurs qui va des sentinel errors Go jusqu’aux réponses HTTP RFC 9457.


L’architecture : handlers → services → repositories

L’architecture suit un pattern classique en trois couches :

  • Handlers : reçoivent les requêtes HTTP, valident les entrées, appellent les services, formatent les réponses
  • Services : contiennent la logique métier, ne savent rien de HTTP
  • Repositories : parlent à PostgreSQL, ne savent rien de la logique métier

C’est le genre de découpage qu’on voit dans tous les cours d’architecture logicielle. Là où ça devient intéressant, c’est pourquoi les interfaces existent entre chaque couche.

La réponse courte : pas pour l’abstraction. Pour les tests.

Chaque service dépend d’une interface, pas d’une implémentation concrète. En production, derrière l’interface UserRepository, il y a PostgreSQL. Dans les tests unitaires, il y a un mock. Le service ne sait pas, ne veut pas savoir, et c’est exactement le point.


Le flow d’authentification par magic link est simple en apparence : vous entrez votre email, vous recevez un lien, vous cliquez, vous êtes connecté. Pas de mot de passe. Slack et Notion font pareil1.

La subtilité est dans ce qui se passe quand l’email n’existe pas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// RequestMagicLink handles the magic link request flow.
// Returns nil for unknown emails to prevent email enumeration.
func (s *AuthService) RequestMagicLink(ctx context.Context, email string) error {
	email = strings.TrimSpace(email)

	if err := model.ValidateEmail(email); err != nil {
		return err
	}

	// Find user — return nil for unknown emails (anti-enumeration)
	user, err := s.userRepo.FindByEmail(ctx, email)
	if err != nil {
		if errors.Is(err, model.ErrUserNotFound) {
			return nil // <-- Même réponse que si l'email existait
		}
		return fmt.Errorf("finding user: %w", err)
	}

	// Check rate limit — silently enforce to prevent email enumeration via 429 timing
	since := time.Now().UTC().Add(-24 * time.Hour)
	count, err := s.mlRepo.CountRecentByUserID(ctx, user.ID, since)
	if err != nil {
		return fmt.Errorf("checking rate limit: %w", err)
	}
	if count >= model.MagicLinkMaxPerDay {
		return nil // <-- Silencieux aussi
	}

	// Generate and store token
	token, rawToken, err := model.NewMagicLinkToken(user.ID)
	if err != nil {
		return fmt.Errorf("generating magic link token: %w", err)
	}

	if err := s.mlRepo.Create(ctx, token); err != nil {
		return fmt.Errorf("storing magic link token: %w", err)
	}

	if err := s.emailSender.SendMagicLink(ctx, user.Email, user.Name, rawToken); err != nil {
		return fmt.Errorf("sending magic link email: %w", err)
	}

	return nil
}

Trois return nil pour trois situations différentes : email inconnu, rate limit dépassé, succès. L’API répond toujours la même chose : “If this email is registered, you’ll receive a link.” Un attaquant ne peut pas distinguer un email valide d’un email qui n’existe pas.

DevOps Dave : Attends, tu retournes nil quand le rate limit est atteint ? L’utilisateur ne saura jamais qu’il a trop demandé de liens ?

Security Sarah : C’est le point. Si tu renvoies un 429 quand l’email existe et un 200 quand il n’existe pas, tu viens de construire un oracle d’énumération d’emails. L’anti-énumération silencieuse, c’est le choix de Slack, de Notion, et de n’importe qui de sérieux sur le sujet.


Les erreurs comme citoyens de première classe

Un des choix architecturaux dont je suis le plus content : la pipeline d’erreurs. Le principe est simple — chaque erreur du domaine est un sentinel error Go, mappé à un code HTTP, et transformé en réponse RFC 9457.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var errorStatusMap = map[error]int{
	ErrNotFound:              http.StatusNotFound,
	ErrAlreadyExists:         http.StatusConflict,
	ErrUnauthorized:          http.StatusUnauthorized,
	ErrForbidden:             http.StatusForbidden,
	ErrValidation:            http.StatusBadRequest,
	ErrDatabaseUnavailable:   http.StatusServiceUnavailable,
	ErrDatabaseTimeout:       http.StatusServiceUnavailable,
	// Auth errors
	ErrRateLimitExceeded:     http.StatusTooManyRequests,
	ErrInvalidMagicLink:      http.StatusUnauthorized,
	ErrMagicLinkExpired:      http.StatusUnauthorized,
	ErrRefreshTokenNotFound:  http.StatusUnauthorized,
	ErrRefreshTokenExpired:   http.StatusUnauthorized,
	// ... et d'autres
}

func HTTPStatusCode(err error) int {
	for sentinelErr, statusCode := range errorStatusMap {
		if errors.Is(err, sentinelErr) {
			return statusCode
		}
	}
	return http.StatusInternalServerError
}

errors.Is() traverse la chaîne de wrapping. Un service peut faire fmt.Errorf("checking rate limit: %w", model.ErrRateLimitExceeded) et le handler récupère quand même le bon code HTTP. Pas de switch/case géant. Pas de conversion manuelle. Une seule map, un seul point de vérité.

Le tout se termine en RFC 9457 Problem Details — un format standard pour les erreurs HTTP avec type, title, status, detail. Les clients de l’API savent exactement quoi attendre2.

L’aveu honnête : en review, on a trouvé des _ = dans les tests — des erreurs retournées par des fonctions qu’on ignorait silencieusement. Le genre de truc que le linter attrape mais que le cerveau laisse passer quand on est dans le flow. C’est corrigé. C’est le genre de détail que seule une review systématique fichier par fichier révèle.


JWT RS256 et la rotation atomique

Le choix de RS256 (asymétrique) plutôt que HS256 (symétrique) est motivé par un cas d’usage futur : quand le frontend et le mobile devront vérifier les tokens, ils n’auront besoin que de la clé publique. Pas besoin de distribuer un secret partagé.

Le schéma de tokens est classique :

  • Access token (JWT RS256, 15 minutes) : en mémoire côté client. Jamais dans localStorage.
  • Refresh token (opaque, 7 jours) : en cookie httpOnly, Secure, SameSite=Strict.

La partie intéressante, c’est la rotation. Quand le client utilise son refresh token pour obtenir de nouveaux tokens, l’ancien refresh token est supprimé et un nouveau est créé — dans la même transaction PostgreSQL.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (r *RefreshTokenRepository) RotateToken(
	ctx context.Context,
	oldTokenID uuid.UUID,
	newToken *model.RefreshToken,
) error {
	tx, err := r.db.Begin(ctx)
	if err != nil {
		return fmt.Errorf("beginning transaction: %w", err)
	}
	defer tx.Rollback(ctx)

	// Delete the specific old token
	const deleteQuery = `DELETE FROM refresh_tokens WHERE id = $1`
	result, err := tx.Exec(ctx, deleteQuery, oldTokenID)
	if err != nil {
		return fmt.Errorf("deleting old refresh token: %w", err)
	}
	if result.RowsAffected() == 0 {
		return model.ErrRefreshTokenNotFound
	}

	// Insert the new token
	const insertQuery = `
		INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at)
		VALUES ($1, $2, $3, $4, $5)
	`
	_, err = tx.Exec(ctx, insertQuery,
		newToken.ID, newToken.UserID, newToken.TokenHash,
		newToken.ExpiresAt, newToken.CreatedAt,
	)
	if err != nil {
		return fmt.Errorf("inserting new refresh token: %w", err)
	}

	return tx.Commit(ctx)
}

Le defer tx.Rollback(ctx) est la ligne la plus importante. Si l’insert du nouveau token échoue, la transaction est annulée. L’ancien token est préservé. L’utilisateur n’est jamais verrouillé hors de son compte à cause d’une erreur transitoire de base de données.

Atomicité. C’est un joli mot pour dire “soit tout marche, soit rien ne change”.


Les tests : la couverture est un indicateur, pas un objectif

L’EPIC 1 tourne à ~90% de couverture avec les tests d’intégration. En unitaire seul, c’est autour de 80%. Ce gap de 10% est révélateur : il correspond aux chemins qui traversent plusieurs couches — exactement ce que les tests d’intégration avec testcontainers couvrent.

Les tests suivent le pattern table-driven idiomatique Go :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func TestHTTPStatusCode_SentinelErrors(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name           string
		err            error
		expectedStatus int
	}{
		{"NotFound", ErrNotFound, http.StatusNotFound},
		{"AlreadyExists", ErrAlreadyExists, http.StatusConflict},
		{"Unauthorized", ErrUnauthorized, http.StatusUnauthorized},
		{"Forbidden", ErrForbidden, http.StatusForbidden},
		{"DatabaseTimeout", ErrDatabaseTimeout, http.StatusServiceUnavailable},
		{"InvalidMagicLink", ErrInvalidMagicLink, http.StatusUnauthorized},
		{"RefreshTokenExpired", ErrRefreshTokenExpired, http.StatusUnauthorized},
		// ... chaque sentinel error a son test
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			status := HTTPStatusCode(tt.err)
			assert.Equal(t, tt.expectedStatus, status)
		})
	}
}

Un slice de structs, une boucle, t.Run() pour des sous-tests nommés, t.Parallel() partout. Ajouter un cas de test, c’est ajouter une ligne dans le slice. Le pattern scale sans ajouter de complexité.

Le test qui me plaît le plus, c’est celui qui vérifie explicitement le comportement d’anti-énumération :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
t.Run("unknown email returns nil (anti-enumeration)", func(t *testing.T) {
	t.Parallel()
	userRepo := new(MockAuthUserRepository)
	svc := NewAuthService(userRepo, mlRepo, emailSender, nil, nil)

	userRepo.On("FindByEmail", mock.Anything, "unknown@example.com").
		Return(nil, model.ErrUserNotFound)

	err := svc.RequestMagicLink(context.Background(), "unknown@example.com")

	require.NoError(t, err)
	userRepo.AssertExpectations(t)
	emailSender.AssertNotCalled(t, "SendMagicLink") // <-- Pas d'email envoyé
})

Ce test documente un choix de sécurité. Si quelqu’un modifie RequestMagicLink pour retourner une erreur sur un email inconnu, ce test casse. C’est de la sécurité par les tests, pas par les commentaires.

Si le sujet du testing avec l’IA vous intéresse, j’ai écrit un article plus général sur pourquoi le testing est la vraie valeur ajoutée du développeur en 2026.


Ce que la review a révélé (et ce que ça m’a appris sur l’IA)

C’est la partie la plus intéressante, et la plus honnête.

J’ai construit l’EPIC 1 avec Claude Code comme assistant de développement. Et au début, le code généré me laissait perplexe. Pas mauvais au sens où il ne compilait pas — il compilait, les tests passaient, les fonctionnalités marchaient. Mais il y avait des approximations. Le genre de petites choses qui séparément sont bénignes et qui cumulées sentent le “ça a été écrit vite”.

Alors j’ai mis en place un process de review systématique. Fichier par fichier, avec un prompt de review structuré — sévérité, explication, solution concrète avec diff, références. Les premières reviews étaient dures. Beaucoup de HIGH et de CRITICAL. Des _ = qui ignoraient des erreurs. Des tests qui testaient le happy path mais oubliaient les cas limites. De la documentation incomplète.

Le problème, c’est que faire ces reviews prenait du temps. Beaucoup de temps. J’ai essayé plusieurs approches :

  • Claude lui-même comme reviewer : l’auto-évaluation d’un étudiant très vite satisfait de lui-même. “Le code est globalement bon, quelques suggestions mineures…” Non. Prochain.
  • Gemini : les reviews étaient correctes, détaillées, pertinentes. Le problème n’était pas la qualité — c’était les crédits. Une review d’une story et j’avais épuisé mon quota pour la semaine. Pas viable pour 7 stories.
  • Qwen3 : fonctionnait bien, mais avec de l’inconsistance. Il fallait un preprompt très strict qui lui demande d’être rigoureux et de ne rien laisser passer. Sans ça, il avait tendance à survoler.
  • Perplexity (via API) : le meilleur rapport qualité-prix. Reviews détaillées, références pertinentes, pas de complaisance, et assez de crédits pour reviewer systématiquement.
  • Codex (OpenAI) : pas testé sur ce projet, mais c’est également une option envisageable vu la bonne réputation du modèle pour la review de code.

Au final, c’est Perplexity qui est devenu le reviewer principal. 27 fichiers de review rien que pour les tests, avec un plan d’action de 10 groupes de corrections priorisées par dépendance. Ce n’est pas le genre de choses qu’on fait pour un side project. Mais c’est le genre de choses qui transforment un side project en quelque chose dont on n’a pas honte.

Les findings principaux de la rétrospective :

CatégorieConstatAction
Erreurs ignorées_ = dans les tests au lieu de require.NoErrorCorrigé systématiquement
Assertions incomplètesTests qui vérifient le status code mais pas le body RFC 9457Plan d’action en 10 groupes
Cas limites absentsConfig avec valeurs invalides non testée (port > 65535, rate limit = 0)Tests ajoutés
Headers manquantsContent-Type: application/json non vérifié dans certains testsAssertions ajoutées

Rien de dramatique. Rien qui aurait causé un incident en production. Mais le genre de dette technique qu’on accumule quand on avance vite avec l’IA et qu’on ne prend pas le temps de relire.

La leçon : l’IA est un excellent premier brouillon, un reviewer médiocre de son propre travail3, et rien ne remplace un regard extérieur — même si ce regard extérieur est aussi une IA, tant que c’est une autre IA.


La suite

L’EPIC 2, c’est le cœur de Presentation Control : la gestion des présentations et le WebSocket temps réel. Les vignettes, le dashboard, le contrôle du mobile. Le truc pour lequel tout ce système d’auth a été construit.

Le code est sur GitLab.

Des articles suivront pour chaque epic. En attendant, la page du projet Presentation Control donne la vue d’ensemble.



  1. Le magic link est devenu un standard de l’authentification passwordless. Slack l’utilise largement, Notion en a fait sa méthode exclusive. L’avantage principal : pas de mot de passe à stocker, pas de mot de passe à fuiter. L’inconvénient : la sécurité de votre compte dépend de la sécurité de votre boîte email. Ce qui, honnêtement, est déjà le cas avec le “Mot de passe oublié” de n’importe quel service. ↩︎

  2. RFC 9457, anciennement RFC 7807, définit un format standard pour les erreurs HTTP. Au lieu de recevoir un {"error": "something went wrong"} et de devoir deviner ce qui s’est passé, le client reçoit un objet structuré avec un type, un titre, un code, un détail, et l’URL de la requête qui a échoué. C’est le genre de standard que tout le monde devrait utiliser et que presque personne n’utilise. ↩︎

  3. Ce n’est pas une critique de Claude — c’est un biais structurel. Demander à un modèle de reviewer du code qu’il a lui-même généré, c’est comme demander à un étudiant de noter sa propre copie. Il voit ce qu’il voulait écrire, pas ce qu’il a effectivement écrit. Le résultat : “globalement bien, quelques points d’attention mineurs”. La solution : un deuxième modèle. Gemini était bon mais trop cher en crédits. Qwen3 marchait avec un prompt strict. Perplexity avait le bon ratio rigueur/coût. ↩︎