SimpleLogin Plugin — Architecture Appendix
Component: plg_system_simplelogin
Geteste versie: 1.1.05 (Joomla 6.x, PHP 8.1+)
Laatst bijgewerkt: 2 augustus 2026
Doel van dit document: documenteert de actuele implementatie — bestanden, klassen, database, afhankelijkheden, bekende beperkingen — en het ADR-archief. Voor de architectuurprincipes die hieraan ten grondslag liggen, zie ARCHITECTURE.md.
Inhoudsopgave
- Bestandsstructuur
- Klassenstructuur
- Component diagram (routing)
- Registratie- en goedkeuringsflow (implementatie)
- Standaardinstellingen
- Database
- Afhankelijkheden
- Beveiligingsreview 1.1.0
- Bekende beperkingen
- Versiegeschiedenis
- Architecture Decision Records
Bestandsstructuur
plg_system_simplelogin/
├── ARCHITECTURE.md
├── ARCHITECTURE_APPENDIX.md
├── DEFAULT.md
├── README.md
├── CHANGELOG.md
├── script.php # postflight/uninstall: cache clearing, default waarden HTML body
├── simplelogin.xml
│
├── src/
│ ├── Extension/
│ │ └── Simplelogin.php # Hoofdklasse, event-subscripties
│ │
│ ├── Field/ # Custom admin form fields
│ │ ├── ApprovalreportField.php # Wachtrij goedkeuring (tabel)
│ │ ├── BodybuttonsField.php
│ │ ├── ExportlogField.php
│ │ ├── HashpasswordsField.php
│ │ ├── LogreportField.php
│ │ ├── RegistrationstatusField.php # Lokale, live mirror van
│ │ │ # com_users.allowUserRegistration
│ │ │ # t.b.v. showon (zie ADR-0005)
│ │ └── ThrottlereportField.php
│ │
│ ├── Helper/
│ │ └── ReportHelper.php # Queryhelpers voor de admin fields
│ │
│ ├── Service/
│ │ ├── MailService.php # Verzending + HTML-placeholders
│ │ │ # + local-image CID-embedding
│ │ └── MailServiceInterface.php
│ │
│ ├── Traits/
│ │ ├── AjaxTrait.php # com_ajax dispatcher + admin-only acties
│ │ ├── LoginFlowTrait.php
│ │ ├── LogTrait.php
│ │ ├── RegisterFlowTrait.php # incl. invite/goedkeuringsflow
│ │ ├── SecurityTrait.php
│ │ └── UtilityTrait.php
│ │
│ └── tmpl/
│ ├── approvals.php # Approve/reject admin UI
│ ├── logs.php
│ ├── logs_table.php
│ └── throttle.php
│
├── services/
│ └── provider.php # DI: registreert MailServiceInterface + plugin
│
├── layouts/
│ └── simplelogin/
│ ├── overlay.php # Login-modal (frontend)
│ └── register.php # Registratie-modal (frontend)
│
├── language/
│ ├── de-DE/ · en-GB/ · es-ES/ · fr-FR/ · nl-NL/
│
├── media/
│ └── js/
│ ├── bodybuttons.js # Variabele-invoegknoppen, editor-agnostisch
│ ├── hashpasswords.js
│ ├── logreport.js
│ └── simplelogin.js # Frontend overlay-gedrag (autosubmit/redirect)
│
└── sql/
├── install.mysql.utf8.sql
├── uninstall.mysql.utf8.sql
└── updates/mysql/1.1.0.sql
Klassenstructuur
Simplelogin (extends CMSPlugin) stelt zijn gedrag volledig samen uit traits:
| Trait | Verantwoordelijkheid |
|---|---|
LoginFlowTrait |
Hoofdrouting (handleInitialise), overlay-rendering (handleRender), login-/tokenflows, versturen van loginlinks |
RegisterFlowTrait |
Registratieformulier, invite-tokenactivatie (GET + POST), versturen van invite-links |
SecurityTrait |
Rate limiting (IP/user), cooldown, scanner-/preflight-detectie, wachtwoordafdwinging |
LogTrait |
Centrale log()-methode, status→type/throttle/debug-only-definitietabel, IP/UA/e-mail-hashing |
UtilityTrait |
Tokengeneratie/-consumptie, cleanup, e-mailvalidatie, gebruikersnaamgeneratie, activatiemarker-helpers, PRG-redirect |
AjaxTrait |
onAjaxSimplelogin-dispatcher en de zes admin-only AJAX-acties (hash passwords, get/purge logs, export log, approve/reject user) |
MailService (implementeert MailServiceInterface) wordt via de DI-container (services/provider.php) geïnjecteerd in plaats van direct geïnstantieerd, zodat de service vervangbaar/mockbaar is.
Component diagram (routing)
Frontend request
│
▼
onAfterInitialise (LoginFlowTrait::handleInitialise)
│
├── sl_task=register ──────────► RegisterFlowTrait::handleRegister()
│ │
│ ├── GET → toon registratieformulier
│ └── POST → maak gebruiker aan (block=1 indien
│ require_admin_approval), stuur invite
│
├── simplelogin=1 + selector/validator (GET)
│ └──► LoginFlowTrait::handleTokenFlow()
│ ├── type=invite ──► RegisterFlowTrait::handleInviteActivation()
│ └── type=login ──► toont "bezig met inloggen…" + auto-submittend
│ verborgen POST-formulier (geen login op GET)
│
├── simplelogin=1 + selector/validator (POST)
│ ├── type=invite ──► RegisterFlowTrait::handleInvitePostActivation()
│ └── type=login ──► LoginFlowTrait::handleTokenPost()
│ └── password_verify() → handleTokenLogin()
│
└── simplelogin=1 (POST, geen token) ──► LoginFlowTrait::handlePost()
└── SecurityTrait rate/cooldown
checks → sendLoginLink()
onAfterRender → injecteert overlay/register-layout HTML in de pagina
onAjaxSimplelogin (com_ajax) → AjaxTrait-dispatcher (admin-only: HashPasswords,
GetLogRows, PurgeLogRows, ExportLog, ApproveUser, RejectUser)
onContentPrepareForm → client-side afbeelding-URL-validatie voor het eigen
configuratieformulier (alleen admin)
Registratie- en goedkeuringsflow (implementatie)
POST register form
│
▼
Gebruiker aangemaakt: block = (require_admin_approval ? 1 : 0)
activation = 'sl-pending:<random>'
│
▼
Invite-mail verstuurd (selector/validator link, type=invite)
│
▼
Gebruiker klikt invite-link (GET) ──► handleInviteActivation()
│
├── require_admin_approval = 0
│ └──► token direct verwerkt, block blijft zoals gezet,
│ account bruikbaar, loginlink direct verstuurd
│
└── require_admin_approval = 1
└──► token verwerkt, activation geleegd (e-mail nu geverifieerd),
block blijft = 1 → account in "pending approval"-wachtrij
│
▼
Admin opent Plugins → System - Simplelogin → ziet de
pending-approval-tabel (ApprovalreportField / approvals.php)
│
┌──────────┴──────────┐
▼ ▼
Approve Reject
(block = 0, verstuurt (verstuurt afkeurmail met
approval-mail indien door admin ingevoerde reden,
al geactiveerd) VERVOLGENS permanente verwijdering
van gebruiker + usergroup-mappings)
Scoping van de wachtrij: ReportHelper::getPendingApprovals() beperkt zich bewust tot geblokkeerde gebruikers mét een gekoppelde #__simple_login-rij van type = 'invite'. Dit koppelt de lijst — en dus wat Approve/Reject kan raken — specifiek aan accounts die via déze plugin zijn geregistreerd. Zie Beveiligingsreview, bevinding 2 en ADR-0006 voor de achtergrond.
Standaard instellingen
Dit hoofdstuk vermeldt elke configuratieparameter die door de plugin wordt aangeboden (Extensies → Plugins → Systeem - Simplelogin), gegroepeerd per veldset waarin deze verschijnt, met de standaardwaarde 'out-of-the-box' en wat de parameter regelt. Zie README.md voor een algemene handleiding om te beginnen; zie ARCHITECTURE.md voor hoe deze parameters intern worden gebruikt.
Tenzij anders vermeld betekent "standaard": de waarde die Joomla gebruikt wanneer de plugin nieuw is geïnstalleerd en de beheerder het veld nog niet heeft aangepast.
Veldset: Inloginstellingen (general)
| Parameter | Type | Standaard | Betekenis |
|---|---|---|---|
landing_page_option |
radio | homepage |
Waar een gebruiker terechtkomt na een succesvolle inlog: de homepage van de site, of een gekozen menu-item. |
landing_itemid |
menu item | (leeg) | Het menu-item waarnaar wordt omgeleid wanneer landing_page_option = custom. Anders geen effect. |
allow_password_login |
radio | 0 (Nee) |
Indien Nee, vervangt de plugin de standaard Joomla-wachtwoordinlog/-registratie volledig door de wachtwoordloze stroom, en dwingt dit af door wachtwoorden te husselen (scramblen) bij elke succesvolle wachtwoordloze inlog. Indien Ja, wordt er ook een optie "inloggen met wachtwoord" aangeboden. |
bulk_hash_passwords |
knop | n.v.t. | Eenmalige beheerdersactie: randomizeert onmiddellijk het wachtwoord van elke non-admin, niet-geblokkeerde frontend-gebruiker, waardoor inloggen met wachtwoord voor hen voortaan onmogelijk wordt. Onomkeerbaar; bedoeld om te gebruiken direct nadat allow_password_login op Nee is gezet op een bestaande site. |
Veldset: Goedkeuring (approvalinformation) — nieuw in 1.1.0
| Parameter | Type | Standaard | Betekenis |
|---|---|---|---|
require_admin_approval |
radio | 0 (Nee) |
Indien Ja, blijft een nieuw zelfgeregistreerd account geblokkeerd na e-mailverificatie totdat een beheerder het expliciet goedkeurt in de goedkeuringstabel van de plugin. Indien Nee, is het verifiëren van de uitnodigings-e-mail voldoende om het account te activeren (onder voorbehoud van bestaande Joomla-registratie-instellingen). |
notify_admin_registration |
radio | 0 (Nee) |
Indien Ja, ontvangt het mailfrom-adres van de site bij elke nieuwe registratie een notificatie-e-mail (zie mail_admin_* hieronder). Onafhankelijk van require_admin_approval. |
approval_report |
(beheerders-UI) | n.v.t. | Toont de tabel met in afwachting zijnde goedkeuringen met Goedkeuren/Afwijzen-knoppen. Alleen zichtbaar wanneer require_admin_approval = Ja. |
Veldset: E-mailinstellingen (mail_instellingen)
| Parameter | Type | Standaard | Betekenis |
|---|---|---|---|
mail_format |
radio | html |
Of alle uitgaande e-mails van de plugin worden verzonden als HTML (opgemaakte tekst, ondersteunt ingesloten lokale afbeeldingen) of platte tekst. Het wijzigen hiervan bepaalt welke van de gekoppelde *_body / *_body_html velden hieronder daadwerkelijk wordt gebruikt. |
Elk van de vier e-mailtypen hieronder volgt hetzelfde patroon: een show_*_mail schakelaar (standaard uit voor alle vier) die alleen bepaalt of die veldgroep wordt weergegeven in het beheerdersformulier (het schakelt het verzenden van de e-mail zelf niet uit — de e-mail wordt altijd verzonden wanneer de stroom wordt geactiveerd), een onderwerpregel, een platte-tekst body en een HTML body. Alleen de body die overeenkomt met het huidige mail_format wordt daadwerkelijk gebruikt bij het verzenden.
Inlog-e-mail
| Parameter | Standaard |
|---|---|
show_login_mail |
0 (standaard verborgen in het beheerdersformulier) |
mail_login_subject |
Here's your login link |
mail_login_body (tekst) |
#name, / lege regel / Here's your link to login: / #link / lege regel / Please note that the link is valid for #expiry minutes. |
mail_login_body_html (HTML) |
<p>#name,</p><p>Here's your link to login:<br><a href="#link">#link</a></p><p>Please note that the link is valid for #expiry minutes.</p> |
Beschikbare placeholders: #name, #link, #expiry, #sitename.
Uitnodiging / registratie-e-mail
| Parameter | Standaard |
|---|---|
show_invite_mail |
0 |
mail_invite_subject |
Your confirmation link |
mail_invite_body (tekst) |
#name, / lege regel / Here's your link to confirm your registration: / #link / lege regel / Please note that the link is valid for #expiry minutes. |
mail_invite_body_html (HTML) |
<p>#name,</p><p>Here's your link to confirm your registration:<br><a href="#link">#link</a></p><p>Please note that the link is valid for #expiry minutes.</p> |
Beschikbare placeholders: #name, #link, #expiry, #sitename.
Beheerdersnotificatie-e-mail
Alleen verzonden/getoond wanneer notify_admin_registration = Ja.
| Parameter | Standaard |
|---|---|
show_admin_mail |
0 |
mail_admin_subject |
New registration on #sitename |
mail_admin_body (tekst) |
#name (#email) has registered on #sitename. / lege regel / If admin approval is needed goto to the plugin and decide your action for this new user. |
mail_admin_body_html (HTML) |
<p>#name (#email) has registered on #sitename.</p><p>If admin approval is needed goto to the plugin and decide your action for this new user.</p> |
Beschikbare placeholders: #name, #email, #sitename. Verzonden naar het ingestelde mailfrom-adres van de site, niet naar de registrant.
Goedkeurings-e-mail — nieuw in 1.1.0
Alleen relevant/getoond wanneer require_admin_approval = Ja.
| Parameter | Standaard |
|---|---|
show_approval_mail |
0 |
mail_approval_subject |
Your registration has been approved |
mail_approval_body (tekst) |
#name, / lege regel / Your registration has been approved. You can now login here: / #link / lege regel / This link is valid for #expiry minutes. |
mail_approval_body_html (HTML) |
<p>#name,</p><p>Your registration has been approved. You can now login here:<br><a href="#link">Click here to login</a></p><p>This link is valid for #expiry minutes.</p> |
Beschikbare placeholders: #name, #link. Alleen daadwerkelijk verzonden als het account op het moment van goedkeuring de e-mailverificatie al had afgerond (zie ARCHITECTURE.md).
Afwijzings-e-mail — nieuw in 1.1.0
Alleen relevant/getoond wanneer require_admin_approval = Ja.
| Parameter | Standaard |
|---|---|
show_rejection_mail |
0 |
mail_rejection_subject |
Your registration has been rejected |
mail_rejection_body (tekst) |
#name, / lege regel / Your registration has been rejected. / lege regel / Reason: #reason |
mail_rejection_body_html (HTML) |
<p>#name,</p><p>Your registration has been rejected.</p><p>Reason: #reason</p> |
Beschikbare placeholders: #name, #reason (de reden wordt door de beheerder ingevoerd bij het afwijzen en is — sinds de beveiligingsfixes van 1.1.0 — HTML-escaped voordat deze in een HTML-e-mail wordt ingevoegd).
Veldset: Beveiliging (security)
Basislimieten
| Parameter | Standaard | Betekenis |
|---|---|---|
expiry_minutes |
15 |
Hoe lang een inloglink geldig blijft. |
invite_expiry_minutes |
30 |
Hoe lang een uitnodigings-/registratielink geldig blijft. Wordt ook gebruikt als terugvalminimum (max(1, ...)) bij verkeerde configuratie. |
request_cooldown_seconds |
30 |
Minimale wachttijd die wordt afgedwongen tussen opeenvolgende inloglinkaanvragen voor hetzelfde IP of dezelfde gebruiker, onafhankelijk van de rate-limit-tellers hieronder. |
Rate limits (snelheidslimieten)
| Parameter | Standaard | Betekenis |
|---|---|---|
rate_limit_ip_max |
10 |
Maximaal toegestane inloglinkaanvragen vanaf één IP binnen het onderstaande venster. |
rate_limit_ip_window |
5 |
Venster, in minuten, voor de rate-limit per IP. |
rate_limit_user_max |
5 |
Maximaal toegestane inloglinkaanvragen voor één gebruikersaccount binnen het onderstaande venster. |
rate_limit_user_window |
10 |
Venster, in minuten, voor de rate-limit per gebruiker. |
Niet zichtbaar in de beheerders-UI (alleen code-standaarden)
Deze twee worden door de code uitgelezen maar hebben geen bijbehorend beheerdersveld in deze release — een beheerder kan ze momenteel niet wijzigen zonder de opgeslagen pluginparameters rechtstreeks te bewerken:
| Parameter | Code-standaard | Betekenis |
|---|---|---|
token_min_age_seconds |
5 |
Een token-POST die minder dan dit aantal seconden na het aanmaken van het token aankomt, wordt geweigerd als ongeloofwaardig (beveiliging tegen automatisering). |
password_login_itemid |
0 |
Menu-item ID om naartoe te linken voor de terugvaloptie "inloggen met een wachtwoord", wanneer allow_password_login = Ja. 0 valt terug op de standaard com_users inlogweergave. |
Veldset: Sessies (throttle_report)
| Parameter | Standaard | Betekenis |
|---|---|---|
throttle_cleanup_time |
60 |
Minuten waarna oude rijen in #__simple_login_throttle worden opgeschoond. Opschonen draait als bijeffect van een succesvolle inlog (UtilityTrait::cleanup()), niet via een planning (cron). |
throttle_report |
(beheerders-UI) | n.v.t. — toont de live throttle-tabel (alleen lezen). |
Veldset: Logging (log_report)
| Parameter | Standaard | Betekenis |
|---|---|---|
log_export |
(beheerders-UI) | n.v.t. — knop die de logrijen van de afgelopen 24 uur plus het ruwe plugin-logbestand e-mailt naar het mailfrom-adres van de site. |
log_retention_days |
30 |
Dagen waarna rijen in #__simple_login_log worden opgeschoond (zelfde opschoontrigger als de throttle-tabel hierboven). 0 schakelt log-opschoning volledig uit. |
log_report |
(beheerders-UI) | n.v.t. — toont de filterbare/verwijderbare logtabel. |
Snelreferentie: standaardgedrag
Bij een schone installatie zonder configuratiewijzigingen zal SimpleLogin:
- De standaard Joomla-wachtwoordinlog/-registratie aan de frontend volledig vervangen (
allow_password_login = Nee). - HTML e-mail verzenden (
mail_format = html), met behulp van de ingebouwde standaardsjablonen die hierboven staan vermeld — maar let op datshow_login_mail,show_invite_mail,show_admin_mail,show_approval_mailenshow_rejection_mailin het beheerdersformulier allemaal standaard op uit staan; dit verbergt alleen de velden; de bijbehorende e-mail wordt nog steeds verzonden wanneer de stroom wordt uitgevoerd, met de bovenstaande standaardinhoud. - Geen beheerdersgoedkeuring vereisen voor nieuwe registraties (
require_admin_approval = Nee) en de beheerder niet op de hoogte stellen van nieuwe registraties (notify_admin_registration = Nee). - Inloglinks laten verlopen na 15 minuten en uitnodigingslinks na 30 minuten, met een afkoelperiode (cooldown) van 30 seconden tussen aanvragen.
- Maximaal 10 aanvragen per IP per 5 minuten en 5 aanvragen per gebruiker per 10 minuten toestaan.
- Throttle-rijen ouder dan 60 minuten en logrijen ouder dan 30 dagen opschonen bij elke succesvolle inlog.
Als u require_admin_approval voor het eerst inschakelt, zet dan ook show_approval_mail en show_rejection_mail aan, zodat u de formulering van deze twee nieuwe 1.1.0-e-mailsjablonen kunt zien en desgewenst aanpassen — ze worden verzonden ongeacht of hun velden zichtbaar zijn in het beheerdersformulier.
Database
Entity-Relationship Diagram
erDiagram
simple_login ||--o{ simple_login_throttle : "1-op-veel"
simple_login ||--o{ simple_login_log : "1-op-veel"
users ||--o{ simple_login : "1-op-veel"
users ||--o{ simple_login_throttle : "1-op-veel"
users ||--o{ simple_login_log : "1-op-veel"
simple_login {
int id PK
int user_id FK
char(16) selector
varchar(255) token
enum('login','invite') type
datetime created
datetime expires
tinyint(1) used
}
simple_login_throttle {
bigint id PK
int user_id FK
varchar(150) username
char(64) email_hash
varbinary(16) ip
varchar(50) status
int login_id FK
datetime created
}
simple_login_log {
bigint id PK
enum type
int user_id FK
varchar(150) username
char(64) email_hash
varbinary(16) ip
varchar(512) user_agent
varchar(50) status
int login_id FK
datetime created
}
users {
int id PK
varchar(150) username
varchar(255) email
varchar(255) password
varchar(255) activation
tinyint(1) block
...
}
#__simple_login
Bewaart login- en invite-/registratietokens. De kolom token bevat een password_hash() van de validator-helft van het selector/validator-paar — de ruwe validator wordt nooit opgeslagen, alleen ooit gemaild.
| Kolom | Type | Omschrijving | Voorbeeld |
|---|---|---|---|
id |
INT UNSIGNED | Primary key | 1 |
user_id |
INT UNSIGNED | Joomla user ID | 42 |
selector |
CHAR(16) | Publieke token-identifier (lookup) | a1b2c3d4e5f6g7h8 |
token |
VARCHAR(255) | Gehashte validator (password_hash) |
$2y$10$... |
type |
ENUM('login','invite') | Tokendoel | login |
created |
DATETIME | Aanmaaktijdstip | 2026-07-05 10:00:00 |
expires |
DATETIME | Vervaltijdstip | 2026-07-05 10:15:00 |
used |
TINYINT(1) | Of token gebruikt is | 0 of 1 |
Indexen: PRIMARY (id), UNIQUE (selector), INDEX (user_id, expires, used)
#__simple_login_throttle
Houdt aanvraagfrequentie bij voor rate limiting en beveiligingsmonitoring. Rijen worden periodiek opgeruimd (throttle_cleanup_time, default 60 minuten) via cleanup() na een succesvolle login.
| Kolom | Type | Omschrijving |
|---|---|---|
id |
BIGINT UNSIGNED | Primary key |
user_id |
INT UNSIGNED | Joomla user ID (nullable) |
username |
VARCHAR(150) | Gebruikersnaam (nullable) |
ip |
VARBINARY(16) | Packed IP-adres (IPv4/IPv6) |
status |
VARCHAR(50) | Actietype |
login_id |
INT UNSIGNED | Verwijzing naar simple_login.id |
created |
DATETIME | Tijdstip |
Indexen: PRIMARY (id), INDEX (ip+created, user_id+created, login_id+created, status+created)
#__simple_login_log
Auditlog voor alle plugin-acties.
| Kolom | Type | Omschrijving |
|---|---|---|
id |
BIGINT UNSIGNED | Primary key |
type |
ENUM | Logcategorie (zie tabel hieronder) |
user_id |
INT UNSIGNED | Joomla user ID (nullable) |
username |
VARCHAR(150) | Gebruikersnaam (nullable) |
email_hash |
CHAR(64) | SHA-256-hash van e-mail |
ip |
VARBINARY(16) | Packed IP-adres |
user_agent |
VARCHAR(512) | Browser user agent |
status |
VARCHAR(50) | Specifieke actie |
login_id |
INT UNSIGNED | Verwijzing naar simple_login.id |
created |
DATETIME | Tijdstip |
Indexen: PRIMARY (id), INDEX (type+created, status+created, user_id, login_id)
Migratie 1.1.0 (sql/updates/mysql/1.1.0.sql): verbreedt de type-ENUM met ImageError (de eerdere admin_approved_registration/admin_rejected_registration-waarden zijn status-waarden, geen type-waarden — zie Beveiligingsreview, bevinding 3).
Log-types & statussen
| Type | Omschrijving | Voorbeeldstatussen |
|---|---|---|
AccountEvent |
Gebruikersaccount-gerelateerde events | password_updated, register_success, admin_approved_registration, admin_rejected_registration |
DebugDiagnostics |
Debug-only diagnostische info | invite_email_not_found, token_row_missing |
DebugFlowTrace |
Debug flow tracing | core_login_blocked, simplelogin_triggered |
DebugRequestTrace |
Request-parameter tracing | selector_xxx, validator_present_yes |
InviteFlow |
Registratie-/uitnodigingsflow | invite_sent, invite_activated, invite_pending_approval |
LoginFlow |
Login-flow events | link_request, link_sent, login_success, token_hit |
SecurityIncident |
Beveiligingsgerelateerde events | rate_limited_ip, rate_limited_user, scanner_detected |
ImageError |
Problemen bij CID-embedding van mailafbeeldingen | image_not_found, image_too_large |
Afhankelijkheden
- Joomla 6.x core (Session, Factory, Mailer, User, Router, Uri, Layout, Log, HTML, Form, DI-container)
- PHP 8.1+ (gebruikt
str_contains/str_starts_with/str_ends_with, typed properties, arrow functions) - Geen third-party Composer-packages naast wat Joomla core al aanbiedt (zie ADR-0002)
Beveiligingsreview 1.1.0
Ter voorbereiding van de 1.1.0-release is de codebase gecontroleerd op defecten die niet noodzakelijk via normaal handmatig testen aan het licht komen. De volgende bevindingen zijn gevonden en direct in deze release opgelost:
-
Ongebruikte
onExtensionBeforeSave-eventsubscriptie.getSubscribedEvents()declareerde een handler zonder bijbehorende methode. Omdat dit een system-plugin is, wordt dit event sitewide gedispatcht bij het opslaan van elke extensie — niet alleen deze. Kon, afhankelijk van de defensiviteit van de core-versie, een fatale fout veroorzaken bij het opslaan van een ongerelateerde extensie. De dode subscriptie (en de bijbehorende ongebruikte helperextractImageUrlsFromHtml()) is verwijderd. -
Te brede pending-approvals-query.
ReportHelper::getPendingApprovals()had zijn beoogdeactivation LIKE 'sl-pending:%'-filter uitgecommentarieerd, waardoor elke geblokkeerde gebruiker werd geretourneerd, niet alleen wachtende SimpleLogin-registraties. Omdat "Reject" een gebruiker permanent verwijdert, kon een beheerder onbedoeld een ongerelateerd account verwijderen. Root cause: na e-mailverificatie wordtactivationgeleegd naar'', ononderscheidbaar van een normaal actief, later geblokkeerd account. Opgelost door de query te scopen op geblokkeerde gebruikers mét een gekoppelde invite-tokenrij in#__simple_login. Zie ook ADR-0006. -
Stilzwijgend uitgeschakelde auditlogging voor approve/reject. De statussen
admin_approved_registrationenadmin_rejected_registrationhadden geen entry inLogTrait::getStatusDefinition()'s status→type-tabel. De fallback voor een onbekende status isdebugonly = true, dus deze twee gevoelige, nieuwe admin-acties werden buiten debugmodus nooit gelogd. Beide statussen hebben nu een expliciete, niet-debug-only definitie. -
Onge-escapete placeholdersubstitutie in HTML-mail.
MailService::sendMail()substitueerde#name/#email/#reason/... met een ruwestr_replace(), zonder HTML-escaping. Een gebruiker die zich registreerde met HTML-dragende naam kreeg die HTML verbatim ingebed in HTML-mails. Opgelost door alle placeholderwaarden te escapen methtmlspecialchars()wanneer de mail in HTML-modus wordt verstuurd (onderwerpregel bewust niet geëscaped, want platte tekst). De sequentiëlestr_replace()-lus is tegelijk vervangen door één simultanestrtr()-pass, die niet kan dubbel-substitueren. -
Twee parameters worden in code gelezen zonder admin-UI-veld.
password_login_itemidentoken_min_age_secondsworden gelezen via$this->params->get(...)maar hebben geen<field>insimplelogin.xml. Onschadelijk (harde fallback-defaults0en5), maar een beheerder kan ze niet aanpassen. Bewust laten staan voor 1.1.0, gedocumenteerd in DEFAULT.md, geagendeerd voor 1.2.0. -
IP-gebaseerde rate limiting en cooldown waren stilzwijgend inactief.
#__simple_login_throttle.ipisVARBINARY(16); de INSERT-kant converteerde correct metUNHEX(), maarSecurityTrait::isRateLimitedIp()en de IP-tak vanisCooldown()vergeleken de binaire kolom direct met een niet-geconverteerde hex-string. EenVARBINARY-kolom kan nooit gelijk zijn aan een letterlijke ASCII-hex-string, dus beide checks retourneerden altijdfalse— geen enkele IP werd ooit geratelimiteerd, terwijl elke poging wél trouw werd weggeschreven (vandaar dat het probleem niet zichtbaar was in de admin-rapportages). Per-user rate limiting was niet geraakt. Alleen gevonden door actief te testen (de limiet bewust overschrijden), niet via codereview. Opgelost door beide vergelijkingen inUNHEX()te verpakken. -
PHP 8.2-deprecation corrumpeerde stilzwijgend de approve/reject AJAX JSON-response.
MailService::processImagesForCidEmbedding()gebruiktemb_convert_encoding($body, 'HTML-ENTITIES', 'UTF-8')— de doelencoding'HTML-ENTITIES'is deprecated sinds PHP 8.2. Op een normale paginaweergave verdwijnt een stray deprecation-notice in de gerenderde HTML, maar ditzelfde codepad draait ook binnen decom_ajax/format=jsonApprove/Reject-eindpunten, waar PHP's deprecation-tekst — alsdisplay_errorsdeprecations toont — vóór de JSON-envelope in de response terechtkomt en de JSON ongeldig maakt. De browser's.catch()toont dan de generieke foutmelding, terwijl de onderliggende actie al succesvol was voltooid. Opgelost door de deprecated aanroep te vervangen door de huidige, niet-deprecated techniek (<?xml encoding="UTF-8">-declaratie die libxml als hint consumeert). -
Per-user rate limiting telde elk geslaagd loginlink-verzoek dubbel.
SecurityTrait::isRateLimitedUser()telde rijen metstatus IN ('login_attempt_existing', 'link_sent'). Voor een normale login-formulierinzending logtLoginFlowTrait::handlePost()login_attempt_existingen roept vervolgenssendLoginLink()aan, die bij succes zélflink_sentlogt — voor hetzelfde verzoek. Eén ingediend verzoek produceerde dus twee getelde rijen, waardoor gebruikers na de helft van het geconfigureerde aantal pogingen al geratelimiteerd werden.isRateLimitedIp()was niet geraakt. Opgelost door inisRateLimitedUser()alleenlogin_attempt_existingte tellen. Gevonden door de product owner die de query doorredeneerde, niet door reproductie van een storing. -
Registratiegerelateerde configuratievelden bleven zichtbaar ondanks uitgeschakelde Joomla-registratie.
simplelogin.xmlhad geenshowon-conditie die de invite-/goedkeuringsvelden koppelde aancom_users.allowUserRegistration. Twee mislukte pogingen, beide het vastleggen waard:- Eerste poging,
showon="com_users.allowUserRegistration:1"op fieldset-niveau: werkt niet. Joomla'sshowonwordt client-side geresolved door JavaScript dat zoekt naar een formulierveld met matchende naam in het DOM van het eigen formulier. Een waarde uit een andere component wordt daar nooit gerenderd, dus de conditie faalt stil (toont alles) in plaats van een fout te geven. - Tweede poging,
showondirect op het<fieldset>-element: werkte niet zichtbaar in deze Joomla-installatie, ook niet met een lokaal veld — bevestigd door de product owner, die dit al eerder zonder succes had geprobeerd. Per-veldshowonwerkt wél betrouwbaar. - Werkende oplossing: zie ADR-0005.
- Resterende, bewust geaccepteerde beperking voor 1.1.0: het tabblad zelf blijft altijd zichtbaar (nu met alleen een toelichtende notitie in plaats van een lege tab). Zie Bekende beperkingen.
- Eerste poging,
Gevonden maar bewust niet automatisch opgelost (afwegingen, geen ondubbelzinnige bugs — zie Bekende beperkingen):
- Het registratie-eindpunt heeft geen rate limiting/scanner-detectie, in tegenstelling tot het login-eindpunt.
- De GET-tokenflow onthult of een selector geldig/gebruikt/verlopen is vóórdat de secret validator gecontroleerd wordt (lage ernst — geen accountdata wordt blootgesteld).
- IP-gebaseerde rate limiting gebruikt alleen
REMOTE_ADDR, zonder reverse-proxy/X-Forwarded-For-bewustzijn.
Eén aanvullende, risicoloze opschoning: een PHPUnit-teststub (src/Service/MailServiceTest.php) die in de gepackagede plugin zat, is verwijderd uit de productiezip (inert, maar hoorde daar sowieso niet thuis).
Deze review was gescoped op correctheids- en beveiligingsdefecten voor de 1.1.0-release, niet op een algemene kwaliteits-/duplicatiepas — die staat gepland voor 1.2.0.
Bekende beperkingen
Gepland voor 1.2.0:
- Algemene technical-debt-pas: duplicatie tussen
LoginFlowTrait/RegisterFlowTraitverminderen (o.a. herhaalde "resolve return URL"- en "check user exists/not blocked"-blokken), methodelengte/cohesie doorlichten. - Onderzoeken of het tabblad voor registratie-instellingen volledig verborgen kan worden (niet alleen de velden erin) wanneer
com_users.allowUserRegistrationuitstaat. Fieldset-levelshowonwerkte niet bij directe test (zie beveiligingsreview, bevinding 9); voor 1.1.0 blijft de tab zichtbaar met een toelichtende notitie. - Een echte PHPUnit-testsuite opbouwen (in een top-level
tests/-map, uitgesloten van de installatiepackage) rondMailServiceen de token-helpers. - Een klein aantal aanvullende beveiligingsverhardingen (rond throttling en tokenhandling) is voor 1.2.0 geagendeerd in een separaat, niet-publiek planningsdocument.
- Het registratie-eindpunt krijgt mogelijk dezelfde rate limiting/scanner-detectie als het login-eindpunt (product owner-afweging, nog geen besluit).
Versiegeschiedenis
- 1.1.0 (2026-07-29) — Admin-goedkeuringsflow, HTML-mail + CID-afbeeldingsembedding, editor-agnostische variabele-knoppen, beveiligings-/defectreview (zie hierboven).
- 1.0.5 (2026-07-05) — Bugfixrelease: overlay toont alleen neutrale melding na link-opvraag; meldingsstijl gecorrigeerd.
- 1.0.x — Initiële wachtwoordloze login/registratie, rate limiting, logging, introductie
MailService.
Architecture Decision Records
Vaste template per ADR: Status, Datum, Context, Beslissing, Consequenties, Alternatieven overwogen. Een geaccepteerde ADR wordt niet met terugwerkende kracht gewijzigd; een herzien besluit krijgt een nieuwe ADR die de vorige expliciet "supersedet" (zie WAYOFWORK.md).
Inhoudsopgave ADR's
| Nr | Titel | Status | Datum |
|---|---|---|---|
| ADR-0001 | Trait-gebaseerde architectuur met geïnjecteerde MailService | Accepted | 2025 (pre-1.0) |
| ADR-0002 | Geen third-party Composer-dependencies | Accepted | 2025 (pre-1.0) |
| ADR-0003 | Selector/validator-tokens met verplichte POST voor login | Accepted | 2025 (pre-1.0) |
| ADR-0004 | Packed binary opslag van IP-adressen | Accepted | 2025 (pre-1.0) |
| ADR-0005 | RegistrationStatusField als lokale mirror voor showon |
Accepted | 2026-07-29 |
| ADR-0006 | Goedkeuringswachtrij scopen via invite-token, niet via generieke blocked-status | Accepted | 2026-07-29 |
ADR-0001: Trait-gebaseerde architectuur met geïnjecteerde MailService
Status: Accepted Datum: 2025 (pre-1.0)
Context
Een Joomla system-plugin heeft één centraal aanknopingspunt (getSubscribedEvents()) en gedraagt zich in de praktijk als één samenhangende requesthandler voor meerdere domeinen (login, registratie, beveiliging, logging). Een architectuur moest gekozen worden die deze domeinen scheidt zonder overhead die niet in verhouding staat tot de omvang van de plugin.
Beslissing
Businesslogica wordt verdeeld over traits, één per domein, samengesteld in de hoofdklasse. Alleen functionaliteit die vervangbaar/mockbaar moet zijn — op dit moment uitsluitend mailverzending — wordt als losse service via DI geïnjecteerd achter een interface (MailServiceInterface).
Consequenties
- Domeinen zijn gescheiden en individueel leesbaar zonder een eigen DI-graaf per domein.
- Traits delen impliciet de staat van de hoofdklasse (
$this->params,$this->app, ...), wat lichte koppeling geeft die bij een servicelaag-per-domein niet zou bestaan. - Nieuwe cross-cutting concerns die vervangbaar moeten zijn (bijv. een toekomstige SMS-service) volgen hetzelfde patroon als
MailService, niet het traitpatroon.
Alternatieven overwogen
- Eigen servicelaag per domein via DI: verworpen als speculatieve architectuur voor een plugin van deze omvang; voegt DI-boilerplate toe zonder dat er een concrete behoefte is om deze domeinen los van elkaar te vervangen of te mocken.
- Alles in de hoofdklasse: verworpen, schendt Single Responsibility en maakt de klasse onhandelbaar naarmate flows toenemen.
ADR-0002: Geen third-party Composer-dependencies
Status: Accepted Datum: 2025 (pre-1.0)
Context Joomla core biedt al Session-, Mailer-, HTML-, Form- en DI-functionaliteit. De vraag was of voor specifieke onderdelen (bijv. tokengeneratie, HTML-parsing voor CID-embedding) een gespecialiseerd package toegevoegd zou worden.
Beslissing
Geen third-party Composer-packages naast wat Joomla core al aanbiedt. HTML-parsing voor CID-embedding gebeurt met de ingebouwde DOMDocument, tokens met PHP's eigen random_bytes()/password_hash().
Consequenties
- Geen extra kwetsbaarheidsoppervlak of versiebeheer-last van externe packages.
- Update-installaties blijven eenvoudig: geen
composer installnodig als onderdeel van de Joomla-extensie-installatie. - Sommige taken (zoals de PHP 8.2
mb_convert_encoding-deprecation, zie beveiligingsreview bevinding 7) vragen meer eigen zorgvuldigheid dan wanneer een onderhouden package dat zou afvangen.
Alternatieven overwogen
- Gespecialiseerd HTML-sanitisation-package: verworpen;
DOMDocumentvolstaat voor de beperkte, lokale CID-embeddingbehoefte en voorkomt een Composer-afhankelijkheid in een Joomla-extensiecontext waar dat installatiecomplicaties kan geven.
ADR-0003: Selector/validator-tokens met verplichte POST voor login
Status: Accepted Datum: 2025 (pre-1.0)
Context E-mailclients, linkscanners en preview-bots volgen links in mails automatisch via GET-requests, vaak vóórdat de daadwerkelijke ontvanger de mail zelf opent. Een simpel "klik-om-in-te-loggen"-token zou hierdoor per ongeluk geconsumeerd kunnen worden door een bot in plaats van de gebruiker.
Beslissing Tokens bestaan uit een publieke selector (lookup-sleutel) en een geheime validator (nooit opgeslagen, alleen gehasht). Een GET op de link toont alleen een pagina met een auto-submittend, verborgen POST-formulier; de daadwerkelijke login/activatie voltrekt zich uitsluitend op POST.
Consequenties
- Linkscanners en preview-bots die alleen GET doen, kunnen een token nooit consumeren.
- Een echte gebruiker ervaart geen extra klik: het formulier submit automatisch via JavaScript.
- Gebruikers zonder JavaScript kunnen niet automatisch inloggen via de link (bewust geaccepteerde trade-off; niet apart gedocumenteerd als beperking omdat dit inherent is aan de beveiligingseis, geen implementatiegat).
Alternatieven overwogen
- Directe GET-login: verworpen, kwetsbaar voor bot-consumptie van tokens.
- Extra bevestigingsstap voor de gebruiker (klik nogmaals): verworpen als onnodige wrijving; het auto-submittende formulier bereikt dezelfde beveiliging zonder gebruikersactie.
ADR-0004: Packed binary opslag van IP-adressen
Status: Accepted Datum: 2025 (pre-1.0)
Context IP-adressen worden bewaard voor rate limiting en auditlogging. Zowel IPv4 als IPv6 moesten ondersteund worden, en opslag/privacy-overwegingen speelden mee bij de keuze van het kolomtype.
Beslissing
IP-adressen worden opgeslagen als VARBINARY(16) (packed binary), niet als leesbare string. Schrijven gebeurt via UNHEX() op een hex-representatie van het packed adres.
Consequenties
- Compacte, uniforme opslag voor zowel IPv4 als IPv6.
- Elke leesactie moet consistent dezelfde
UNHEX()-conversie toepassen als de schrijfactie — het missen hiervan is precies wat maakte dat rate limiting een tijd lang stil faalde (zie beveiligingsreview, bevinding 6). Deze ADR legt vast waarom de kolom binary is; toekomstige code die deze kolom raakt moet de conversie expliciet toepassen, niet aannemen dat een stringvergelijking werkt.
Alternatieven overwogen
- Leesbare VARCHAR-opslag: verworpen vanwege minder compacte opslag en minder eenduidige IPv4/IPv6-normalisatie; ook overwogen vanuit privacy-oogpunt (packed binary is niet direct leesbaar bij een losse databasedump-inspectie, al is dit geen vervanging voor echte encryptie).
ADR-0005: RegistrationStatusField als lokale mirror voor showon
Status: Accepted Datum: 2026-07-29 (sprint 1.1.0)
Context
Registratiegerelateerde configuratievelden moesten verborgen worden wanneer Joomla's eigen com_users.allowUserRegistration uit staat, omdat de plugin's eigen registratiefunctionaliteit dan sowieso onbruikbaar is. Twee voor de hand liggende pogingen faalden (zie beveiligingsreview, bevinding 9): showon verwijzend naar een andere component se instelling werkt niet, omdat Joomla's showon-JavaScript alleen zoekt binnen het DOM van het eigen formulier; en fieldset-level showon bleek in deze installatie niet betrouwbaar te werken, ook niet met een lokaal veld.
Beslissing
Een nieuw custom field type, RegistrationStatusField (type registrationstatus), leest ComponentHelper::getParams('com_users')->get('allowUserRegistration') server-side en rendert dit als een echt, verborgen <input>-veld (registration_enabled) in het eigen formulier — met filter="unset" zodat de waarde nooit in de plugin's eigen parameters wordt opgeslagen, maar bij elke paginaweergave opnieuw wordt berekend. Elk registratiegerelateerd veld's showon bevat voortaan registration_enabled:1, volgens hetzelfde per-veld-patroon dat al gebruikt werd voor allow_password_login.
Consequenties
- Per-veld
showonis de enigeshowon-mechaniek waarop in deze codebase vertrouwd wordt; fieldset-levelshowonwordt vermeden totdat onafhankelijk bevestigd is dat het werkt. - Het patroon (custom field die een externe/globale instelling lokaal spiegelt voor
showon-doeleinden) is herbruikbaar voor toekomstige velden die van een instelling buiten het eigen formulier afhangen. - Bekende beperking: dit verbergt individuele velden, niet het tabblad zelf — zie Bekende beperkingen.
Alternatieven overwogen
showonrechtstreeks naarcom_users.allowUserRegistration: niet werkend, zie Context.- Fieldset-level
showonop een lokaal veld: niet betrouwbaar werkend in de geteste Joomla-installatie, ook eerder al zonder succes geprobeerd door de product owner. - Volledig handmatige documentatie/waarschuwing zonder conditionele zichtbaarheid: verworpen, want lost de kern van het probleem (misleidende schijnbare configureerbaarheid) niet op.
ADR-0006: Goedkeuringswachtrij scopen via invite-token, niet via generieke blocked-status
Status: Accepted Datum: 2026-07-29 (sprint 1.1.0)
Context
De pending-approvals-wachtrij moet exact de accounts tonen die via de eigen registratieflow zijn aangemaakt en op goedkeuring wachten. Een naïeve activation LIKE 'sl-pending:%'-filter bleek onbetrouwbaar: na e-mailverificatie wordt activation geleegd naar '', ononderscheidbaar van een normaal actief account dat later om een andere reden geblokkeerd is. Omdat "Reject" een account permanent verwijdert, is een foutieve match hier geen cosmetisch probleem maar een dataverlies-risico.
Beslissing
De wachtrij-query scoped op geblokkeerde gebruikers die bovendien een gekoppelde rij van type = 'invite' hebben in #__simple_login. Dit identificeert betrouwbaar accounts die door déze plugin zijn geregistreerd, ongeacht de actuele waarde van activation.
Consequenties
- Approve/Reject kan nooit een account raken dat om een andere reden dan deze plugin's registratieflow geblokkeerd is.
- Elke toekomstige wijziging aan de goedkeuringsflow moet deze scoping-eis expliciet in stand houden; het is een architectuurprincipe (zie ARCHITECTURE.md), niet een detail dat losstaat van de query-implementatie.
Alternatieven overwogen
- Filteren op
activation LIKE 'sl-pending:%': verworpen, faalt na e-mailverificatie (zie Context) — dit was de daadwerkelijke bug die werd hersteld. - Apart, eigen kolom op
#__usersom SimpleLogin-herkomst te markeren: verworpen als onnodige schema-uitbreiding; de bestaande koppeling via#__simple_login.type = 'invite'volstaat en vermijdt een wijziging aan Joomla's eigen#__users-tabel.
