=== Nahnu Login Shield ===
Contributors:      ja1me4
Tags:              login security, brute force, captcha, geo blocking, two factor
Requires at least: 6.2
Tested up to:      7.1
Requires PHP:      8.3
Stable tag:        1.0.15
License:           GPL-2.0-or-later
License URI:       https://www.gnu.org/licenses/gpl-2.0.html

Hyperfocused login security. CAPTCHA, brute-force lockout, geo-blocking, 2FA, honeypot, and edge WAF — all from one screen. 100% free.

== Description ==

**Nahnu Login Shield** protects every WordPress login entry point: the login form, WooCommerce My Account, XML-RPC, the REST API and your form plugins. It is a login security plugin, not a general security suite.

No subscription. No license key. No upsell. Everything is free.

= Brute-force lockout =

* Attempt limit, time window and lockout duration, with **escalating lockouts** (default 24 h) for repeat offenders.
* **Per-username lockout** stops distributed botnets trying one username from many IPs.
* **Honeypot usernames** (admin, root, test…) lock the IP on the first attempt.
* IP blocklist and allowlist with CIDR ranges (IPv4 and IPv6), and one-click unlock.

= Early blocking =

An optional must-use plugin turns locked-out IPs away before WordPress loads. It is off by default and never installed automatically: click "Install early-block mu-plugin" on the General tab, and "Remove" to delete it again.

= CAPTCHA / verification providers =

One provider site-wide, shown on every protected form: OOPSpam (with VPN, data-centre and disposable-email blocking), reCAPTCHA v2 and v3, hCaptcha, Cloudflare Turnstile (managed or invisible), Friendly Captcha, Cap, mCaptcha and ALTCHA, or none.

= Two-factor authentication =

* Every user in the roles you choose gets a one-time code after their password. Email codes are always on; users can add an authenticator app (TOTP) or a passkey.
* Backup codes, admin reset for lost phones, and an emergency switch: `define( 'NAHNLS_DISABLE_2FA', true );`.
* Works on wp-login.php and inside WooCommerce My Account.

= Passwordless sign-in =

* **Passkeys (WebAuthn)**: Touch ID, Windows Hello, phones and security keys, with browser autofill. Built-in verifier, no external service.
* **Magic links**: one-time email login links, hashed at rest and rate-limited, with a per-user opt-out.

= White-label emails =

2FA codes, magic links and signup confirmations use your sender name, logo, colours and footer. Subject, heading and intro are editable, with a preview and test send.

= Access control =

* Restrict roles to IP addresses or CIDR ranges.
* Login and logout redirects per role.
* XML-RPC kill switch.
* Signup email verification with pruning of unconfirmed accounts.
* Security checklist with a score and one-click fixes.

= Geo-fencing =

Reads the visitor country from CDN headers (Cloudflare, Bunny.net, CloudFront, Vercel, Fastly/Akamai) with no API quota, falling back to freeipapi.com or ipinfo.io with caching.

= Edge blocking: Bunny.net and Cloudflare =

* Push locked-out and honeypot IPs to the Bunny.net or Cloudflare WAF so attackers are stopped at the CDN edge, with automatic expiry and a block log with one-click unblock.
* **Bunny Shield**: one-click login rate limit (covers wp-login.php, xmlrpc.php and your custom login path), blocked IPs on a Shield access list, "Block network" for attacker ASNs, and warnings for learning mode or log-only WAF. Works with Nahnu Cache Connector for Bunny.net.
* **Cloudflare WAF**: one-click login rate limit on every plan (fixed limits on Free, your own on Pro and up), added next to your existing rules; blocked IPs with Unblock; "Block network" on every plan.

= More protection =

* **JavaScript presence check** blocks headless bots before CAPTCHA or lockout checks run.
* **Custom login URL** renames /wp-login.php; the old URL returns 404 or redirects.
* **REST API**: block user enumeration, apply lockouts to REST authentication, optionally disable Application Passwords.
* **Sessions**: idle timeout with a warning, and force-logout of all sessions.
* **Notifications**: login emails, admin-login alerts, new-country alerts and a weekly security digest.

= WooCommerce My Account =

All protections extend to the My Account login, registration, lost-password, checkout and order-tracking forms, each with its own toggle. Customers get a **Login & security** tab to manage passkeys, 2FA and sign-in options without seeing wp-admin. The same panel is available anywhere with the `[nahnls_account_security]` shortcode.

= Form plugin integrations =

**Bricks Builder** forms (per form), **SureForms** (all forms or the ones you pick) and the **WordPress comment form**, each with its own toggle. Anything else can integrate through the developer API: template tags, shortcodes, actions and filters, REST routes under `nahnls/v1` and a JavaScript API (see docs/API.md).

= Event log and analytics =

* Search, filter by event type and date, and export to CSV. Every event shows the form or endpoint it came from.
* Daily activity, time-of-day heatmap, event breakdown, and top attacking IPs and targeted usernames.
* Configurable retention and row cap. All data stays on your site.

= MainWP extension =

A separate companion extension shows lockouts, edge blocks, recent events and settings across all your MainWP child sites.

= Privacy =

IP addresses and usernames are stored in a local database table for the event log, with configurable retention. No data is sent to any Nahnu server. Third-party services you enable receive only what they need (see below). Deleting the plugin removes all stored data.

= External services =

Nahnu Login Shield works fully offline by default. Each service below is contacted **only** when the site administrator explicitly selects or configures it in the plugin settings. No data is ever sent to a Nahnu server.

* **Google reCAPTCHA (v2 / v3)** — Used as the CAPTCHA provider when selected on the Widget tab. The visitor's browser loads the widget script from `https://www.google.com/recaptcha/api.js` on protected forms, and Google receives the visitor's IP address and browser information per its own policy. On every form submit the plugin sends the reCAPTCHA response token together with your reCAPTCHA secret key server-to-server to `https://www.google.com/recaptcha/api/siteverify`. [Terms of Service](https://policies.google.com/terms) · [Privacy Policy](https://policies.google.com/privacy)
* **hCaptcha** — Used as the CAPTCHA provider when selected. The visitor's browser loads the widget from `https://js.hcaptcha.com/1/api.js` on protected forms (hCaptcha receives the visitor's IP and browser data). On form submit the plugin sends the hCaptcha response token and your secret key server-to-server to `https://api.hcaptcha.com/siteverify`. [Terms of Service](https://www.hcaptcha.com/terms) · [Privacy Policy](https://www.hcaptcha.com/privacy)
* **Cloudflare Turnstile** — Used as the CAPTCHA provider when selected (managed or invisible mode). The visitor's browser loads the widget from `https://challenges.cloudflare.com/turnstile/v0/api.js` on protected forms (Cloudflare receives the visitor's IP and browser data). On form submit the plugin sends the Turnstile token and your secret key server-to-server to `https://challenges.cloudflare.com/turnstile/v0/siteverify`. [Terms of Service](https://www.cloudflare.com/website-terms/) · [Privacy Policy](https://www.cloudflare.com/privacypolicy/)
* **Friendly Captcha** — Used as the CAPTCHA provider when selected. The widget script itself is bundled with the plugin (no CDN), but the visitor's browser fetches a proof-of-work puzzle from `api.friendlycaptcha.com` while the form is displayed (Friendly Captcha receives the visitor's IP address). On form submit the plugin sends the solved puzzle token (`frc-captcha-solution`) plus your site key and your Friendly Captcha API key server-to-server to `https://global.frcapi.com/api/v2/captcha/siteverify`. [Terms of Service](https://friendlycaptcha.com/legal/terms/) · [Privacy Policy](https://friendlycaptcha.com/legal/privacy-end-users/)
* **OOPSpam Anti-Spam** — Used as the verification provider when selected; requires the separate OOPSpam Anti-Spam plugin with its own API key. On login/registration submit (and optionally at widget render) the plugin calls the OOPSpam plugin, which sends the visitor's IP address and the submitted username/email, together with your OOPSpam API key, to the OOPSpam API to obtain a spam/VPN/data-centre score. [Terms of Service](https://www.oopspam.com/terms) · [Privacy Policy](https://www.oopspam.com/privacy)
* **Bunny.net API** (`https://api.bunny.net`) — Used only when the Bunny WAF edge-blocking integration is enabled on the Edge Blocking tab. When a lockout or honeypot event fires, the plugin sends the blocked IP address, an expiry timestamp and a short note, authenticated with your Bunny API key, to the Bunny WAF IP-block endpoint for your pull zone. The "Test connection" button reads your pull zone details with the same key. [Terms of Service](https://bunny.net/tos/) · [Privacy Policy](https://bunny.net/privacy)
* **Bunny Shield API** (`https://api.bunny.net/shield/...`) — Used only when a Bunny connection is configured on the Edge Blocking tab (your own key, or the one from the Nahnu Cache Connector for Bunny.net plugin when you choose to reuse it). The Edge Blocking tab reads your Shield zone's plan and status, and when you click "Turn off learning mode" or "Switch the WAF to Block" it changes that one setting on your Shield zone. When you click "Create rule", the plugin creates (and later updates or removes) one rate-limit rule containing your login paths and the limits you chose. When a lockout is pushed to Bunny and the pull zone has Shield, it creates a Shield access list with the first IP and sends it the IP addresses of locked-out visitors, removing them when the lockout expires or you unlock them. When you block a network, it sends the AS number to a second access list. All requests are authenticated with your Bunny API key. [Terms of Service](https://bunny.net/tos/) · [Privacy Policy](https://bunny.net/privacy)
* **Team Cymru IP-to-ASN mapping** (DNS: `origin.asn.cymru.com`, `origin6.asn.cymru.com`, `asn.cymru.com`) — Used only when an administrator clicks "Block network…" on a locked-out IP. The plugin makes a DNS lookup containing that IP address (reversed, as DNS requires) to find the network (AS number and name) it belongs to, and shows it for confirmation before anything is blocked. No other data is sent. [Service description](https://team-cymru.com/ip-asn-mapping) · [Privacy Policy](https://www.team-cymru.com/privacy)
* **Cloudflare API** (`https://api.cloudflare.com/client/v4`) — Used only when the Cloudflare WAF edge-blocking integration is enabled. When a lockout or honeypot event fires, the plugin creates an IP access rule containing the blocked IP address and a note, authenticated with your Cloudflare API token; a scheduled task later deletes that rule when the block expires (or when you unlock the IP), and the "Test connection" button verifies the token. On the Cloudflare WAF tab, the plugin reads the zone's name and plan, and, only when you use them, creates, updates or deletes one rate limiting rule (the login paths and the limits) and IP access rules for the network AS numbers you block. [Terms of Service](https://www.cloudflare.com/website-terms/) · [Privacy Policy](https://www.cloudflare.com/privacypolicy/)
* **freeipapi.com** — Geo-lookup fallback, used only when geo-blocking is enabled and "freeipapi" is selected as the API source and no CDN country header is present. On a login attempt the plugin sends the visitor's IP address (and your optional API key as a bearer token) to `https://free.freeipapi.com/api/json/{ip}` to learn its country code; results are cached locally per IP. [Terms of Use](https://freeipapi.com/terms) · [Privacy Policy](https://freeipapi.com/privacy)
* **ipinfo.io** — Geo-lookup fallback, used only when geo-blocking is enabled and "ipinfo" is selected as the API source. On a login attempt the plugin sends the visitor's IP address (and your optional API token) to `https://api.ipinfo.io/lite/{ip}` to learn its country code; results are cached locally per IP. [Terms of Service](https://ipinfo.io/terms-of-service) · [Privacy Policy](https://ipinfo.io/privacy-policy)
* **Self-hosted Cap and mCaptcha instances** — If you select the Cap or mCaptcha provider you must enter the URL of an instance **you** host. The visitor's browser loads that instance's widget script, and on form submit the plugin sends the challenge token and your secret key server-to-server to `{your-instance}/siteverify` (Cap) or `{your-instance}/api/v1/pow/siteverify` (mCaptcha). No third party is involved unless you point the URL at one; see the [Cap](https://trycap.dev/) and [mCaptcha](https://mcaptcha.org/) documentation for their respective terms.
* **ALTCHA** — If selected, verification is handled locally by the separately installed ALTCHA plugin; this plugin makes no external request for it.

Country flags shown in the admin log are SVG files bundled with the plugin (`assets/img/flags/`); no flag CDN or other external image service is used.

= Bundled third-party libraries =

No scripts are loaded from a CDN. These libraries ship inside the plugin (assets/vendor/ unless noted otherwise):

* **Chart.js 4.4.1** — MIT License, https://www.chartjs.org. Used on the Analytics admin tab.
* **Friendly Captcha widget (friendly-challenge 0.9.18)** — MIT License, https://github.com/FriendlyCaptcha/friendly-challenge. Used only when Friendly Captcha is the selected CAPTCHA provider.
* **flag-icons** (country flag SVGs in assets/img/flags/) — MIT License, https://github.com/lipis/flag-icons. Displayed in the admin event log; loaded from the plugin folder, never from a CDN.

== Installation ==

1. Upload the `nahnu-login-shield` folder to `/wp-content/plugins/`.
2. Activate through **Plugins** in the WordPress admin.
3. Go to **Settings > Login Shield** to configure.

**Quick start:**

1. On the **Widget** tab, choose a CAPTCHA provider (or leave it as None to use lockout only).
2. On the **Lockout** tab, review the attempt thresholds.
3. On the **Geo** tab, enable geo-blocking and select blocked countries if needed.
4. On the **Integrations** tab, enable protections for WooCommerce My Account, Bricks Builder forms, SureForms and WordPress comments as needed.
5. On the **Edge Blocking** tab, connect your Bunny.net or Cloudflare account to push blocks to the edge.

To use OOPSpam, install and activate the OOPSpam Anti-Spam plugin and enter your API key there first. Nahnu Login Shield detects it automatically.

== Frequently Asked Questions ==

= Do I need OOPSpam installed? =

No. OOPSpam is one of seven CAPTCHA provider options. All other providers and all other features work independently.

= Does this plugin slow down my site? =

No. Assets load only on wp-login.php and any pages you opt into via the Integrations tab. The JS check uses a tiny inline script with no external request. Geo lookups from CDN headers are zero-latency. API fallback results are cached per IP for a configurable period. If you opt in to the early-block mu-plugin, it turns away locked-out IPs before WordPress fully loads.

= What happens if the CAPTCHA API goes down? =

All providers fail open. A network or API error never locks out legitimate users.

= Will this conflict with other security plugins? =

It hooks specifically into WordPress login actions and standard WooCommerce and form-plugin hooks. If another plugin hooks the same actions it may conflict. Open a support thread with details if you see an issue.

= Can I use this without any CAPTCHA? =

Yes. Set the provider to None. Geo-blocking, brute-force lockout, honeypot username trapping, the JS presence check, and 2FA all work independently of CAPTCHA.

= Does it work with WooCommerce? =

Yes. The WooCommerce My Account login, registration, lost-password, checkout, and order-tracking forms each have their own toggle in the WooCommerce section of the Integrations tab.

= I locked myself out. How do I get back in? =

Two options. First, add `define( 'NAHNLS_BYPASS_IP', 'your.ip.address' );` to wp-config.php (above the "That's all, stop editing" line). It accepts a comma-separated list of IPs and CIDR ranges, and exempts them from lockouts, the IP blocklist, geo blocking and the early-block rate limit - no deactivation needed. Second, rename the plugin folder from `nahnu-login-shield` to `_nahnu-login-shield` via SFTP, which deactivates it. If you are stuck at the two-factor step instead, see the next question.

= Can I put my own username on the honeypot list? =

Yes, and it is a good idea: usernames are public (author pages, feeds, the REST API), so bots try them. Add your username to the Honeypot list on the Lockout tab and sign in with your email address from then on. Anyone who tries the username is locked out like a bot. Email addresses of real accounts are never blocked, so patterns like `admin@*` do not affect your own sign-in. The Lockout tab lists which entries are real accounts and reminds you if your own username is on the list. If you get locked out anyway, add `define( 'NAHNLS_DISABLE_HONEYPOT', true );` to wp-config.php (above the "That's all, stop editing" line) to switch the honeypot list off, sign in, fix the list, then remove the line. To clear an IP that is already locked, also see the question above.

= I can't receive the 2FA email / I'm locked out =

When two-factor authentication is on, every sign-in asks for a code, and email codes are the baseline. If the email does not arrive (a mail problem on the server, spam filtering), first try a backup code or "Email me a code" again. If that does not help, add `define( 'NAHNLS_DISABLE_2FA', true );` to wp-config.php (above the "That's all, stop editing" line). This switches the 2FA check off for everyone, so you can sign in with just your password; the plugin's settings page shows a notice while it is set. Fix email delivery (an SMTP plugin usually does it), add an authenticator app on your Login & security page so you are not dependent on email, then remove the line. Note: `NAHNLS_BYPASS_IP` does NOT bypass 2FA - it only exempts addresses from lockouts, the IP blocklist, geo blocking and the early-block rate limit.

= Can my login, registration and My Account pages be cached? =

Mostly yes. Every protected form carries a hidden security token that lives about a day. On page load the plugin's small script fetches a fresh token (one request to admin-ajax.php), so pages served from a full-page cache keep working as long as JavaScript runs - including cached blog posts with a comment form. Without JavaScript, a cached copy of the form older than a day fails the security check and asks the visitor to reload. Login, registration and My Account pages should still be excluded from full-page caching (WooCommerce requires this anyway). Developers can lengthen the token life with the `nahnls_form_nonce_life` filter.

= Is this compatible with multisite? =

It is not tested on multisite and is not recommended for network activation.

= How does geo-blocking work when I do not use a CDN? =

It falls back to one of two free IP geolocation APIs (freeipapi.com or ipinfo.io) with a configurable cache. CDN headers are always checked first and take priority.

= How do I get a new form plugin supported? =

Open a support thread or submit a request at https://nahnuloginshield.com/support/. Bricks Builder, SureForms and WordPress comments are the supported integrations today; anything else can be wired up through the developer API (docs/API.md) while a request is considered.

= What is the mu-plugin and is it safe to have? =

The mu-plugin is optional and opt-in. Nothing is copied until you, as an administrator, click **Install early-block mu-plugin** on the General tab. That button copies `nahnls-early-block.php` from the plugin folder into your must-use plugins directory (`WPMU_PLUGIN_DIR`, normally `wp-content/mu-plugins/`). The file checks whether the requesting IP is locked out or rate-limited and, if so, sends a 403/429 response before WordPress fully loads — dramatically reducing server load during bot floods. The General tab shows whether it is installed, not installed or outdated; after a plugin update you are offered an **Update mu-plugin** button (it is never refreshed automatically). Clicking **Remove** deletes the file, and it is also removed on plugin deactivation and deletion. If the directory is not writable, the settings page shows manual copy instructions instead.

The per-IP rate-limit counters the mu-plugin uses are tiny text files stored inside your WordPress uploads directory at `wp-content/uploads/nahnu-login-shield/ratelimit/` (protected by an `index.php` and `.htaccess`; files older than five minutes are pruned automatically). Nothing is written outside the WordPress installation; the folder is deleted when the plugin is uninstalled.

= Can attackers bypass the lockout by rotating IPs? =

The per-username lockout catches this: if the same username is attempted from too many different IPs within the time window, the username itself is locked regardless of IP. Adding common targets (admin, administrator, root) to the Honeypot username list gives the strongest protection — one attempt from any IP is enough to lock both the IP and the username globally.

== Screenshots ==

1. General tab — today's stats, the master switch, which forms to protect, the dashboard widget, the optional early-block mu-plugin and the JavaScript check.
2. Widget tab — pick the CAPTCHA provider (OOPSpam, reCAPTCHA v2/v3, hCaptcha, Turnstile, Friendly Captcha) with a live preview.
3. Geo tab — block the login form by country using CDN geo headers or an IP lookup API.
4. Edge Blocking tab, IP Blocking — push lockouts and honeypot hits to the Bunny.net or Cloudflare WAF so attackers are stopped at the edge.
5. Lockout tab — failed-attempt thresholds, escalation to long bans, the lockout message and proxy header settings.
6. Integrations tab — WooCommerce My Account forms plus Bricks Builder, SureForms and WordPress comment protection.
7. 2FA tab — two-factor authentication for selected roles, with email codes as the baseline and authenticator apps or passkeys as stronger options.
8. Passwordless tab — magic login links and passkeys (WebAuthn).
9. Settings tab — log retention and white-label email branding for every email the plugin sends.

== Changelog ==

= 1.0.15 =
* Changed: the account security panel in right-to-left languages (Arabic, Hebrew, Persian): the sign-in arrow icon is mirrored, and email addresses stay readable inside right-to-left text.
* Fixed: on some themes and with translation plugins, the account security panel's card icons sat above the title instead of beside it. The icon and its text now always share one row.
* Changed: the plugin description on WordPress.org is shorter (under the 2,500-word limit, so it is no longer cut off), with the same features listed more compactly.

= 1.0.14 =
* Changed: the front-end account security panel (WooCommerce My Account > Security and the [nahnls_account_security] shortcode) has a cleaner, store-style design: each section is a card with an icon and a one-line summary, and every sign-in method and two-factor method is a row with its status (On, Always on, Not set up) or a switch. Email login links are turned on and off with a switch. The two-factor section no longer repeats the email address four times: one status line, one row per method, backup codes as a row, and a short tip when email codes are the only method. Works with phone screens and right-to-left languages, and follows the existing colour variables and dark palette.

= 1.0.13 =
* New: Cloudflare WAF sub-tab on Edge Blocking, with the same tools as Bunny Shield.
* New: Cloudflare login rate limit. An on/off switch adds a rate limiting rule to your zone (through the Cloudflare API) that blocks any IP flooding wp-login.php, xmlrpc.php or your custom login path. It is added next to any rate limiting rules you already have, never replacing them. On the Free plan the limits are Cloudflare's fixed ones (more than 10 requests in 10 seconds → blocked for 10 seconds); on Pro and up you choose the requests, the time window and the block length (up to 1 hour on Pro, 1 day on Business). The rule follows your custom login path, including subdirectory installs. Needs the API token permission Zone → Zone WAF → Edit; the tab says so if it is missing.
* New: blocked IPs on Cloudflare are listed on the Cloudflare WAF tab with an Unblock button, and unlocking an IP on the Lockout tab now lifts its Cloudflare block right away instead of waiting for it to expire.
* New: "Block network…" blocks attacker networks (AS numbers) on Cloudflare too, on every Cloudflare plan (IP Access Rules). With Bunny Shield Advanced and Cloudflare both connected, one click blocks the network on both.
* New: the Cloudflare WAF tab warns when the zone is paused in Cloudflare (nothing is blocked then).
* Fixed: Cloudflare blocks were never lifted when they expired: the scheduled unblock task failed. Blocks now end on time.
* Fixed: blocking an IP that was already blocked on Cloudflare lost the record of its rule, so it could not be unblocked from the plugin.
* Fixed: status shown on the settings page now follows a save without a reload: the custom login path On/Off badge, the paths the Bunny rate limit covers and the cache note, "API key is set" notes and placeholders, the 2FA status box, the header status and the blocked-IP log (unblocking on the Bunny Shield tab also removes the row on the IP Blocking tab).
* Fixed: the Cloudflare "Test connection" button appears right after saving the token and zone, without a reload.
* Changed: the custom login path cache note on the Bunny Shield tab links to Nahnu Cache Connector for Bunny.net on WordPress.org.

= 1.0.12 =
* New: Bunny Shield login rate limit. An on/off switch on the Edge Blocking tab creates a Shield rate-limit rule (directly through the Bunny API, no other plugin needed) that blocks any IP flooding wp-login.php, xmlrpc.php or your custom login path. On the free Shield Basic plan the limits are fixed at what Bunny allows there (more than 10 requests in 10 seconds → blocked for 1 minute); on Shield Advanced and up you choose the requests, time window and block length (up to 1 hour). Floods are stopped at the edge before they reach your server. When you change the custom login path, the rule is updated automatically.
* Fixed: after saving the Bunny API key or pull zone, the key line could show "✗ Request failed" and the Bunny Shield tab still said "Connect Bunny.net first" until a reload. Both now update right away, and the Test connection button appears without a reload.
* New: blocked IPs on the edge. When your pull zone has Bunny Shield, lockouts go to a Shield access list ("Nahnu Login Shield lockouts", action Block) automatically: it propagates globally within seconds, the Bunny Shield tab lists every blocked IP with an Unblock button, entries are removed when the lockout expires or you unlock the IP, and the pull-zone IP block list stays as the automatic fallback. The list is created with the first blocked IP (Bunny does not accept an empty list) and removed when it empties. Bunny error messages are now shown in full instead of a bare HTTP code.
* New: "Block network…" on active lockouts looks up the IP's network (AS number) and, after you confirm, blocks the whole network on a Shield access list. Attackers rotate IPs but usually stay on the same hosting network. Blocked networks are listed (and can be unblocked) on the Edge Blocking tab. Needs Shield Advanced: the free plan allows one custom access list, which is used for blocked IPs.
* New: the Bunny Shield tab shows your Shield plan and warns when Shield is in learning mode or the WAF is set to "Log only", the two settings that silently stop Shield from blocking. Each warning has a one-click fix ("Turn off learning mode", "Switch the WAF to Block").
* New: works with the Nahnu Cache Connector for Bunny.net. Reuse its Bunny connection instead of entering the API key twice, and the custom login path is kept out of its edge HTML cache automatically (updated as soon as you change the path). Without the Cache Connector, the Bunny Shield tab reminds you to exclude the custom login path from Bunny's HTML cache yourself.
* Changed: the Edge Blocking tab has two sub-tabs: IP Blocking (the Bunny.net and Cloudflare IP blocks and the blocked-IP log, as before) and Bunny Shield (rate limit, lockout access list and blocked networks). The tab you used last is remembered.
* Changed: the "Access temporarily restricted" and geo-block pages are now sent with HTTP 403 and no-cache headers instead of 200, so no CDN or page cache can store a block page and serve it to other visitors.

= 1.0.11 =
* Changed: real usernames on the honeypot list are blocked again. Usernames are public, so you can put your own on the list and sign in with your email address instead; bots that try the username are locked out. Email addresses of real accounts are never treated as honeypots. The Lockout tab shows which entries are real accounts and warns you when your own username is on the list.
* New: `define( 'NAHNLS_DISABLE_HONEYPOT', true );` in wp-config.php switches the honeypot username list off (emergency recovery, see FAQ).
* Fixed: SureForms. The widget was added by a browser-side scan that looked for a submit button SureForms 2.x no longer marks as type="submit", so nothing was ever injected and the check could not pass. The JS marker and CAPTCHA are now printed by the server inside each SureForms form (above the submit button, or after the fields for inline buttons).
* New: protect all SureForms forms or only the ones you tick (Integrations tab, shown while the SureForms integration is on).
* Fixed: with reCAPTCHA v3 or invisible Turnstile, a SureForms form could not submit; inside SureForms forms the token is now kept fresh in the form at all times.
* Fixed: the hidden form nonce was tied to the logged-in user, so a logged-in administrator testing a form that submits over the REST API (SureForms) always got "Security check expired". The nonce now depends on the site and time only.

= 1.0.10 =
* New: white-label emails. Settings > Email branding sets the sender name and address, a logo from the media library, accent and header colours, footer text and the subject / heading / intro (with placeholders) of the 2FA code, magic link and signup confirmation emails, which now share one template. Includes a browser preview and a "Send test email" button. The "Nahnu Shield" badge and "Sent by Nahnu Login Shield" footer are gone; an opt-in "Protected by Nahnu Login Shield" line is available (off by default). New filters nahnls_email_args and nahnls_email_html.
* Changed: two-factor authentication now protects everyone it applies to. With 2FA on, every user in the roles selected under "Apply to roles" (all roles by default; roles added later are covered too) is asked for a code on every password login. Email codes are always on as the baseline; users can add an authenticator app or passkey for stronger protection, and the code is emailed automatically only to users with nothing stronger. Users can no longer turn 2FA off for themselves; administrators can "Reset 2FA" for another user (falls back to email codes). The Remind / Enforce mode is gone: sites that used Enforce keep their selected roles, every other site now applies 2FA to all roles. New wp-config.php recovery switch NAHNLS_DISABLE_2FA (see FAQ).
* Security: every form the plugin protects (wp-login.php, WooCommerce, comments, SureForms, Bricks, nahnls_widget_html()) now carries a hidden form nonce that is verified before any protection field is read; the 2FA challenge form, the emailed magic-link and signup-verification links and the 2FA challenge URLs carry their own nonces, verified before the token or key is read. Developers rendering their own CAPTCHA markup must echo nahnls_form_nonce_field() (see docs/API.md). The nonce is refreshed by JavaScript on page load, so cached pages keep working (see FAQ).
* Fixed: a honeypot entry that is also a real account (the default list includes admin, support, info@* and admin@*) locked that account's owner out for 24 hours as soon as they signed in with it. Real usernames and emails are no longer treated as honeypots; the Lockout tab shows which entries are ignored for that reason, and "Add to honeypot" in the log refuses real accounts.
* Fixed: with REST protection on, every anonymous REST request that carried an Authorization header WordPress does not understand (Bearer / JWT tokens from other plugins, server-level HTTP auth) counted as a failed login, so five ordinary API calls could lock out an IP. Only credentials WordPress actually rejected count now.
* Fixed: on servers with APCu, the failed-attempt counter was not reset by a successful login.
* Changed: the signup confirmation email's subject, heading and message are edited on the Access tab, directly below the signup verification settings, with their own Preview and "Send test" buttons. Sender, logo and colours stay under Settings > Email branding, which now holds the two-factor and magic-link wording.
* Fixed: on the Edge Blocking tab, the status badge could say "Not configured" right after saving a key that was saved fine (it reflected a momentary API check instead of the saved settings) and then "Active" after a reload. The badge now shows the saved state, with a new "Key saved, choose a zone" step; the API check result is shown under the key field.
* Improved: the "Active IP lockouts" table has a "Triggered by" column (form or API, number of failed attempts, username, time) and marks your own IP.
* Changed: the username lockout and honeypot-username checks on wp-login.php now run from the wp_authenticate action with the username WordPress hands over, instead of reading the posted form on login_init.
* Changed: plugin prefix renamed from the three-letter "n"+"ls" to "nahnls" to meet the WordPress.org prefix guideline. Functions, classes, options, the log table, user meta, cron hooks, shortcodes, hooks, the REST namespace (now nahnls/v1) and JavaScript globals (window.NAHNLSAuth etc.) all use the new prefix. A one-time migration runs automatically on upgrade and carries over settings, the event log, user 2FA/passkey data and cron schedules. The wp-config constant is now NAHNLS_BYPASS_IP (the old name is still honoured). Developers integrating via docs/API.md must update their hook, shortcode, REST and JS names.
* Removed: the server-side handlers for ~40 form, membership, LMS and community plugins that were present in the code but not exposed in the UI. The supported integrations are Bricks Builder, SureForms and WordPress comments; everything else integrates through the developer API.
* Changed: the early-block mu-plugin is now strictly opt-in. It is no longer copied into the must-use plugins directory on activation, update or settings-page load; install, update or remove it with the explicit buttons on the General tab. Existing installs: the old copy is removed during migration, so click "Install early-block mu-plugin" if you want it back.
* Changed: the mu-plugin's per-IP rate-limit counters now live in wp-content/uploads/nahnu-login-shield/ratelimit/ (with index.php and .htaccess) instead of the system temp directory, and are removed on uninstall.
* Changed: every script and style is a static file loaded with wp_enqueue_script / wp_enqueue_style; dynamic values are passed with wp_localize_script. The plugin prints no inline <script> or <style> (the early-block error pages use inline style attributes only).
* Changed: all SQL uses $wpdb->prepare() with %i identifiers; the time-zone offset in analytics queries is a numeric placeholder in minutes (fixes half-hour time zones such as UTC+5:30).
* Security: the account-panel and passkey AJAX endpoints verify the nonce from a request header before reading the JSON body; magic-link tokens must match the stored hash and are single-use.
* Security: form-widget markup injected by JavaScript is now passed as a JSON-encoded string instead of addslashes(); all plugin-built HTML is run through wp_kses() with an explicit allow-list at output.
* Security: nonce added to the idle-session ping; the profile-page 2FA reset/regenerate actions now verify the plugin nonce before acting; settings import is sanitized through the same path as the settings form; several raw $_GET/$_POST/$_SERVER reads are now unslashed and sanitized.
* Changed: bundled translation (.po/.mo) files removed in favour of translate.wordpress.org language packs.
* Docs: every external service is now documented with what is sent, when, and links to both its terms and privacy policy.
* New (developer API, NAHNLS_API_VERSION 2): nahnls_user_requires_2fa() and nahnls_start_2fa_challenge() start a 2FA challenge without logging the user out or redirecting, for AJAX/drawer login forms. Four public REST routes (nahnls/v1/2fa/verify, 2fa/email, 2fa/passkey/options, 2fa/passkey/verify) and NAHNLSAuth.twoFactorVerify / twoFactorEmail / twoFactorPasskey finish the second factor without a page change. Same origin check, IP/geo/lockout gates, 5-attempt limit and 15-minute lockout as the challenge page.
* New: window.NAHNLSWidget.init( container ) / reset( container ) render every supported CAPTCHA (OOPSpam, reCAPTCHA v2/v3, hCaptcha, Turnstile, Friendly Captcha) inside forms built by JavaScript, with several widgets per page. nahnls_widget_html() accepts 'context' => 'ajax'.
* New: nahnls_feature_enabled( $feature ), an 'origin' parameter on magic-link requests (a 2FA challenge after a link requested from My Account now opens on My Account), and the NAHNLS_API_VERSION constant.
* New: the Login & security panel and the WooCommerce 2FA challenge use CSS variables (--nahnls-primary, --nahnls-surface, ...), support dark mode via .nahnls-dark or [data-theme="dark"], and are RTL-safe. Filter nahnls_front_css_enabled lets a theme drop Shield's front-end CSS. Primary buttons now use --nahnls-primary (blue by default).
* i18n: all visitor-facing strings are translatable (plurals and context added where missing), JavaScript messages are localized, and the front end follows the WPML / Polylang language, including magic-link emails sent by cron.
* Fix: Friendly Captcha on wp-login.php and protected forms read the wrong POST field (frc-captcha-response instead of frc-captcha-solution), so verification could never pass.
* Fix: with reCAPTCHA v3, the WooCommerce login/register forms never received a token. A scoped script now fills it and keeps the clicked button (WooCommerce needs it).
* Fix: nahnls_check_submission() now verifies reCAPTCHA v3 tokens and score for custom forms.
* Security: a 2FA challenge is now cancelled after 5 wrong codes in total (previously counted per IP), and a challenge can live at most 30 minutes even while retries keep it alive.

= 1.0.9 =
* Compliance: Chart.js and the Friendly Captcha widget are now bundled with the plugin instead of loaded from cdnjs/jsDelivr. No third-party scripts are fetched from a CDN, and the admin Analytics tab works offline.

= 1.0.8 =
* New: "When attacks happen" heatmap on the Event log tab - blocked/failed attempts by weekday and hour (site time, last 30 days) with the busiest slot called out; hover any square for the count.
* Security: the early-block mu-plugin only trusts CF-Connecting-IP / X-Forwarded-For / X-Real-IP when the plugin's "Trust proxy headers" setting is on, matching the main plugin. Previously spoofed headers could dodge the per-IP rate limit or get a victim's IP rate-limited.
* Security: ip-api.com geo source removed - its free tier is HTTP-only (visitor IPs left the server unencrypted) and non-commercial. Existing selections migrate to freeipapi (HTTPS).
* New: NAHNLS_BYPASS_IP constant actually implemented. define( 'NAHNLS_BYPASS_IP', '203.0.113.5, 10.0.0.0/8' ) in wp-config.php exempts those addresses from lockouts, the IP blocklist, geo blocking and the mu-plugin rate limit.
* Fixed: readme contradictions - removed changelog/upgrade-notice entries that never shipped, corrected the PHP requirement to 8.3, aligned the plugin name to "Nahnu Login Shield" across header, API docs, emails and translations, clarified the form-integration list.
* Tidy: custom-table SQL now uses the %i identifier placeholder; stale comments removed; Tested up to 7.1.
= 1.0.7 =
* Fixed: a JavaScript error (undefined v3ThreshRow) aborted the provider-card handler on every click, so the provider-specific settings fields and card states never updated without a reload.
* Improved: Widget tab provider cards now distinguish the live provider (blue outline, blue check, "Active") from a newly picked one (amber dashed outline, "Save to activate"); the picked card turns blue once saved.
* New: WooCommerce customers now complete the 2FA challenge on the My Account page, styled by the theme, instead of being sent to wp-login.php. Passkey, app/email code, "Email me a code" and backup codes all work there; lockout and timeout notices show inline.
* Changed: 2FA now supports any combination of methods per user (authenticator app, email code, passkey) instead of one. The challenge page offers every enabled method at once: a passkey button (auto-prompted when it is the primary method), a code box that accepts either an app code or an emailed code, and "Email me a code" on demand. Existing users keep their current method.
* Fixed: "Set up authenticator" was disabled when 2FA was already marked on for the account but no authenticator had been confirmed, leaving no way to add one.
* Improved: the Login & security page now says why the Passkeys section is missing (feature off / role not allowed) with a link to the setting for admins, instead of omitting it silently.
* Improved: 2FA challenge - session lasts 10 minutes (was 5) and is extended on every attempt, so a wrong code no longer runs the clock out; the error shows how many attempts remain (5 per 10 minutes), and the timeout message explains what to do.
* Changed: 2FA and passkey setup moved off the WordPress profile form to a dedicated "Login & security" page (Users -> Login & security, or Profile -> Login & security for non-admins). Codes are confirmed inline with instant feedback instead of requiring "Update Profile". The profile page now shows a status summary with a link. Admins editing another user still see that user's passkeys so a lost one can be removed.
* Fixed: "Verification was issued for a different network" on the magic-link request form (and any widget-protected form) behind Cloudflare/Bunny or on mobile networks - the verification token is now bound to the real client network (/24, /48) instead of the CDN edge address.
* Fixed: 2FA profile section offered "Passkey" with a hint pointing at a Passkeys section that was not there when the Passkeys feature is off; the option is now hidden until Passkeys are enabled.
* Fixed: 2FA authenticator QR code did not scan (the built-in encoder produced invalid symbols and fell back to plain text for long URIs). Replaced with a spec-complete QR encoder (versions 1-20, all error-correction levels), verified against a real decoder. The otpauth URI is also shorter (default algorithm/digits/period omitted).
* New: Email-code 2FA. Users can choose authenticator app, email code, or passkey as their 2FA method (profile page). Backup codes work for all three.
* New: Passkey as a 2FA method (password + passkey).
* New: 2FA per-role challenge. Roles you select are challenged on every login; users with nothing set up get a one-time email code and a prompt to add a stronger method. (Since 1.0.10 this is how 2FA always works for the selected roles.)
* New: Access tab - Disable XML-RPC (403 before WordPress loads, pingback header and RSD link removed).
* New: Restrict roles to IP addresses / CIDR ranges (IPv4 + IPv6). Applies to wp-login, WooCommerce, magic links, passkeys, REST and XML-RPC. Refuses a rule that would lock out the admin saving it.
* New: Login and logout redirects, per role and default. Explicit deep links (redirect_to) are still honoured. WooCommerce My Account supported.
* New: Signup email verification. New registrations are held until the link is clicked; unconfirmed accounts are pruned daily; Users list gets a "Pending verification" filter. WooCommerce checkout registrations are exempt (a paid order confirms the address).
* New: Security Checklist tab - 24 checks with a score, one-click fixes for our own settings, and the score on the dashboard widget.
* Fixed: 2FA logins now honour login redirects and fire wp_login for login alerts.
* Fixed: settings toggles on phones - WordPress's mobile checkbox styling was bleeding through and overlapping the label; toggles are now larger and show an explicit On/Off badge.
* Fixed: early-block mu-plugin matched /wp-admin/ in the query string, letting crafted login/xmlrpc URLs skip it.

= 1.0.4 =
* New: Magic link login. "Email me a login link" on the login page (and WooCommerce My Account). Single-use, hashed-at-rest tokens, configurable lifetime, role allow-list, rate-limited, goes through lockout/geo/CAPTCHA checks. Users with 2FA are still challenged.
* New: Passkeys (WebAuthn). "Sign in with a passkey" on the login page, plus browser autofill (conditional mediation). Users register passkeys from their profile (Touch ID, Windows Hello, phone, security key). Built-in verifier supports ES256, RS256 and Ed25519 with no external libraries. Passkey sign-in satisfies 2FA by default (configurable). Requires HTTPS.
* New: "Login & security" tab in WooCommerce My Account (/my-account/security/) - customers manage passkeys, two-factor authentication (authenticator app with QR setup, email code, passkey, backup codes) and sign-in options without wp-admin. Dashboard hint until the account has a passkey or 2FA.
* New: [nahnls_account_security] shortcode renders the same panel on any page, for non-WooCommerce and page-builder sites.
* New: per-user "Allow email login links for my account" opt-out.
* New: Developer API for passwordless login - template tags (nahnls_passkey_button, nahnls_magic_link_form, nahnls_send_magic_link, nahnls_account_security_panel, ...), [nahnls_passkey_button] / [nahnls_magic_link_form] shortcodes, 22 actions and filters, REST routes under nahnls/v1 (passkeys login/register/list/delete, magic-link request, me/security) and a window.NAHNLSAuth JavaScript API. See docs/API.md.
* New: "Passwordless" settings tab.
* Fixed: 2FA tab toggles could inherit their previously saved value when unchecked.
* Changed: Requires PHP 8.3 (typed class constants were already in use; the header previously said 8.2). Added polyfills for json_validate() and array_any().
* Fixed: Edge Blocking - zone picker now appears immediately after saving a Bunny API key or Cloudflare token (no reload needed); status badge updates from the saved state.
* Fixed: Saving the Bunny form no longer clears the Cloudflare zone/enable state and vice-versa (regression from 1.0.3).
* Fixed: SureForms and WP Comments widgets were never rendered unless another form integration was also enabled.
* Fixed: DOM-injected form widgets (SureForms) were broken by an unescaped </script> in the injector; the JS-presence marker is now set on injected forms and the OOPSpam widget initialises on them.
* Fixed: Unchecked toggles in the Forms tab could inherit their previously saved value.
* Performance: removed per-request opcache invalidation (it was disabling opcache for the whole plugin); now runs once on activation/update.
* Performance: WAF pushes (Bunny/Cloudflare) and notification emails on lockouts and honeypot hits now run asynchronously via WP-Cron instead of inside the attacker's request.
* Performance: widget CSS/JS and CAPTCHA provider scripts are only enqueued on pages that actually render a protected form.
* Performance: added (event, created_at) index to the log table; schema version 1.1.
* Performance: DB-version check and mu-plugin install no longer run on every front-end request.
* Performance: analytics queries and Chart.js only load on the Analytics tab and are cached for 5 minutes; username-lockout scan only on the Lockout tab.
* Performance: log table geo/lockout lookups batched into a single query; fixed a geo cache key that never matched.
* Performance: idle-timeout activity write throttled to once per minute.
* Fixed: dashboard widget and CSV export were silently capped at 50 rows; MainWP stats queried a non-existent table.

= 1.0.3 =
* Fixed: WooCommerce checkout captcha showing when "Protect WC checkout" toggle is off.
* Fixed: WC checkout/order-tracking only active when another WC option was also enabled.
* Fixed: OOPSpam widget CSS/JS not enqueued on the WC checkout page (assets were only loaded on My Account pages).

= 1.0.2 =
* Security hardening: json_validate() guards on all external API responses.
* Fixed void function return type error in nahnls_ajax_bunny_fetch_zones().
* Fixed parse errors caused by PHP string escaping in settings and form-protector classes.
* Fixed multi-value match arm syntax for broader PHP 8.x compatibility.

= 1.0.1 =
* Minor stability and compatibility fixes.

= 1.0.0 =
* Initial public release.
* Brute-force lockout with escalation, per-username lockout, and honeypot usernames.
* CAPTCHA providers: OOPSpam, reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile (managed/invisible), Friendly Captcha.
* Two-factor authentication (TOTP) with per-role enforcement.
* Geo-blocking via CDN headers (Cloudflare, Bunny, CloudFront, Vercel, Fastly) with IP API fallback.
* Edge WAF integration for Bunny.net and Cloudflare with auto-expiry and block log.
* JavaScript presence check.
* Custom login URL with configurable 404/redirect behaviour.
* REST API protection: username enumeration block, lockout enforcement, Application Password toggle.
* Session security: idle timeout with warning dialog, force-logout all sessions.
* Login notifications: per-login email, admin alerts, new-country alerts, weekly digest.
* WooCommerce My Account integration: login, registration, lost-password, checkout, order tracking.
* Form protection framework with CAPTCHA + JS marker for form plugins and WooCommerce.
* Full event log with search, filter, date range, CSV export, and source-form detail column.
* Analytics: daily chart, time-of-day heatmap, event breakdown, top IPs, top usernames.
* Early-block mu-plugin: locked-out IPs blocked before WordPress loads.
* APCu support for attempt counters (100x faster than DB transients on supported hosts).
* MainWP extension companion plugin available separately.

== Upgrade Notice ==

= 1.0.15 =
Better right-to-left display of the account security panel.

= 1.0.14 =
Cleaner design for the customer-facing account security panel (My Account > Security and the shortcode).

= 1.0.13 =
New Cloudflare WAF tab: one-click login rate limit on every Cloudflare plan, blocked IPs with Unblock, and "Block network" for attacker ASNs. Fixes Cloudflare blocks never being lifted on expiry. The rate limit needs the API token permission Zone → Zone WAF → Edit.

= 1.0.12 =
New Bunny Shield tab under Edge Blocking: a one-click login rate limit (fixed limits on the free plan, your own on Advanced), blocked IPs on a Shield access list automatically when your pull zone has Shield, and "Block network" for attacker ASNs (Shield Advanced). Block pages now return HTTP 403.

= 1.0.11 =
SureForms protection now works (per form), and your own username can go on the honeypot list again: sign in with your email instead. If you are locked out, NAHNLS_DISABLE_HONEYPOT in wp-config.php switches the list off.

= 1.0.10 =
Prefix rename release. Settings, the event log, user 2FA/passkey data and cron schedules migrate automatically on upgrade. The early-block mu-plugin is now opt-in: the old copy is removed and nothing is copied into mu-plugins automatically - open Settings > Login Shield > General and click "Install early-block mu-plugin" if you want it. If you defined NLS_BYPASS_IP in wp-config.php it still works, but the new name is NAHNLS_BYPASS_IP. 2FA, when on, now challenges every user in the selected roles (all roles unless you used Enforce mode) with email codes as the baseline - make sure your site sends email, or add an authenticator app first. Developers: hook, shortcode, REST (nahnls/v1) and JS names changed - see docs/API.md.

= 1.0.9 =
Chart.js and the Friendly Captcha widget are now served from the plugin itself rather than a CDN. No settings changes needed.

= 1.0.8 =
Requires PHP 8.3. The early-block mu-plugin now only trusts proxy headers when "Trust proxy headers" is on - if your site sits behind Cloudflare/Bunny/a reverse proxy, make sure that setting is enabled (Lockout tab) or lockouts will be keyed to the proxy address. ip-api.com geo source removed; sites using it are switched to freeipapi.

= 1.0.0 =
Initial public release. No upgrade path from pre-release builds.
