Please note that the link is valid for #expiry minutes.
` |
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) | `
#name,
Here's your link to confirm your registration: #link
Please note that the link is valid for #expiry minutes.
` |
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) | `
#name (#email) has registered on #sitename.
If admin approval is needed goto to the plugin and decide your action for this new user.
` |
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) | `
#name,
Your registration has been approved. You can now login here: Click here to login
This link is valid for #expiry minutes.
` |
Beschikbare placeholders: `#name`, `#link`. Alleen daadwerkelijk verzonden als het account op het moment van goedkeuring de e-mailverificatie al had afgerond (zie [ARCHITECTURE.md](ARCHITECTURE.md#registration--approval-workflow)).
#### 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) | `
#name,
Your registration has been rejected.
Reason: #reason
` |
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 dat `show_login_mail`, `show_invite_mail`, `show_admin_mail`, `show_approval_mail` en `show_rejection_mail` in 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
```mermaid
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](#beveiligingsreview-110)).
### 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](#adr-0002-geen-third-party-composer-dependencies))
---
## 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**:
1. **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 helper `extractImageUrlsFromHtml()`) is verwijderd.
2. **Te brede pending-approvals-query.** `ReportHelper::getPendingApprovals()` had zijn beoogde `activation 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 wordt `activation` geleegd 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](#adr-0006-goedkeuringswachtrij-scopen-via-invite-token-niet-via-generieke-blocked-status).
3. **Stilzwijgend uitgeschakelde auditlogging voor approve/reject.** De statussen `admin_approved_registration` en `admin_rejected_registration` hadden geen entry in `LogTrait::getStatusDefinition()`'s status→type-tabel. De fallback voor een onbekende status is `debugonly = true`, dus deze twee gevoelige, nieuwe admin-acties werden buiten debugmodus nooit gelogd. Beide statussen hebben nu een expliciete, niet-debug-only definitie.
4. **Onge-escapete placeholdersubstitutie in HTML-mail.** `MailService::sendMail()` substitueerde `#name`/`#email`/`#reason`/... met een ruwe `str_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 met `htmlspecialchars()` wanneer de mail in HTML-modus wordt verstuurd (onderwerpregel bewust niet geëscaped, want platte tekst). De sequentiële `str_replace()`-lus is tegelijk vervangen door één simultane `strtr()`-pass, die niet kan dubbel-substitueren.
5. **Twee parameters worden in code gelezen zonder admin-UI-veld.** `password_login_itemid` en `token_min_age_seconds` worden gelezen via `$this->params->get(...)` maar hebben geen `` in `simplelogin.xml`. Onschadelijk (harde fallback-defaults `0` en `5`), maar een beheerder kan ze niet aanpassen. Bewust laten staan voor 1.1.0, gedocumenteerd in [DEFAULT.md](DEFAULT.md), geagendeerd voor 1.2.0.
6. **IP-gebaseerde rate limiting en cooldown waren stilzwijgend inactief.** `#__simple_login_throttle.ip` is `VARBINARY(16)`; de INSERT-kant converteerde correct met `UNHEX()`, maar `SecurityTrait::isRateLimitedIp()` en de IP-tak van `isCooldown()` vergeleken de binaire kolom direct met een niet-geconverteerde hex-string. Een `VARBINARY`-kolom kan nooit gelijk zijn aan een letterlijke ASCII-hex-string, dus beide checks retourneerden altijd `false` — 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 in `UNHEX()` te verpakken.
7. **PHP 8.2-deprecation corrumpeerde stilzwijgend de approve/reject AJAX JSON-response.** `MailService::processImagesForCidEmbedding()` gebruikte `mb_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 de `com_ajax`/`format=json` Approve/Reject-eindpunten, waar PHP's deprecation-tekst — als `display_errors` deprecations 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 (``-declaratie die libxml als hint consumeert).
8. **Per-user rate limiting telde elk geslaagd loginlink-verzoek dubbel.** `SecurityTrait::isRateLimitedUser()` telde rijen met `status IN ('login_attempt_existing', 'link_sent')`. Voor een normale login-formulierinzending logt `LoginFlowTrait::handlePost()` `login_attempt_existing` en roept vervolgens `sendLoginLink()` aan, die bij succes zélf `link_sent` logt — 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 in `isRateLimitedUser()` alleen `login_attempt_existing` te tellen. Gevonden door de product owner die de query doorredeneerde, niet door reproductie van een storing.
9. **Registratiegerelateerde configuratievelden bleven zichtbaar ondanks uitgeschakelde Joomla-registratie.** `simplelogin.xml` had geen `showon`-conditie die de invite-/goedkeuringsvelden koppelde aan `com_users.allowUserRegistration`. Twee mislukte pogingen, beide het vastleggen waard:
- **Eerste poging**, `showon="com_users.allowUserRegistration:1"` op fieldset-niveau: werkt niet. Joomla's `showon` wordt 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**, `showon` direct op het `