case 4726 - login lockout per login name and per address

Failed logins are counted in memory (util/LoginThrottle.cs): 10 per login
name or 50 per client address within 15 minutes, then attempts are refused
with 429 for the rest of the window without checking the password. A
correct password clears the login's count; loopback is not counted per
address. Each lockout is logged. F6 fixed (2FA part in case 4710).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 20:12:26 -07:00
parent 80f81aea0f
commit d8be701bcd
5 changed files with 100 additions and 3 deletions

View File

@@ -192,7 +192,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s
| 4723 | Direct SMTP and direct notification: staff only, record rights | F3, F4. **Done**, plan revised with John: staff keep sending to any address (stock report template and a customer script use it); contacts refused in both actions, an address from a record needs read rights on it, direct notifications refused to subcontractors. Test `DirectSend` (the subcontractor case returned 204 on the old code). No changelog line: nothing that works changes | | 4723 | Direct SMTP and direct notification: staff only, record rights | F3, F4. **Done**, plan revised with John: staff keep sending to any address (stock report template and a customer script use it); contacts refused in both actions, an address from a record needs read rights on it, direct notifications refused to subcontractors. Test `DirectSend` (the subcontractor case returned 204 on the old code). No changelog line: nothing that works changes |
| 4724 | SMTP: certificate checked, password never returned | F30, F31. **Done** (certificate checked, opt-out config setting `AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE` with manual page; password masked as `********` and kept on save; test `SmtpPasswordHidden`; self-signed listener proof; changelog lines for both) | | 4724 | SMTP: certificate checked, password never returned | F30, F31. **Done** (certificate checked, opt-out config setting `AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE` with manual page; password masked as `********` and kept on save; test `SmtpPasswordHidden`; self-signed listener proof; changelog lines for both) |
| 4725 | Sessions: password change ends sessions, exact-path token exemption, default SuperUser refused when licensed | F7, F8, F9. **Done** (sessions end on reset, administrator password change and login disabled; exact route exemption, inactive users refused; default-password SuperUser limited to changing it on a purchased licence, option A; test `Sessions`; John's click-through with a temporary licence override; manual and changelog) | | 4725 | Sessions: password change ends sessions, exact-path token exemption, default SuperUser refused when licensed | F7, F8, F9. **Done** (sessions end on reset, administrator password change and login disabled; exact route exemption, inactive users refused; default-password SuperUser limited to changing it on a purchased licence, option A; test `Sessions`; John's click-through with a temporary licence override; manual and changelog) |
| 4726 | Login throttling/lockout | F6 | | 4726 | Login throttling/lockout (raven and sockeye) | F6. **Done** (10 failures per login or 50 per address in 15 minutes, then 429 for the rest of the window; same in Sockeye, whose login is public; test `LoginLockout`; per-address limit proved with a forwarded documentation address) |
| 4727 | Low severity hardening | F10, F11, F12, F22, F23, F25, F29, F35, F36, F38; the AyaNova half of F39 (DB ID not shown anonymously) | | 4727 | Low severity hardening | F10, F11, F12, F22, F23, F25, F29, F35, F36, F38; the AyaNova half of F39 (DB ID not shown anonymously) |
| 4681 | Sockeye: licence/trial request hardening (tagged sockeye, rockfish) | F40. **Done** (sockeye `cd3ebed`: rate limits, email check, cryptographic codes; rvf unchanged) | | 4681 | Sockeye: licence/trial request hardening (tagged sockeye, rockfish) | F40. **Done** (sockeye `cd3ebed`: rate limits, email check, cryptographic codes; rvf unchanged) |
@@ -251,6 +251,7 @@ Found during the port, out of scope. One line each.
- `NotifyController.SendNotifySmtpDirectMessage`, User branch: the missing-address and inactive messages say "Vendor" instead of "User" (found in case 4723) - `NotifyController.SendNotifySmtpDirectMessage`, User branch: the missing-address and inactive messages say "Vendor" instead of "User" (found in case 4723)
- SMTP connection security mapping (`Mailer.cs`): "StartTls" connects with `useSsl: true`, which MailKit treats as TLS from the start (port 465), not STARTTLS (587); "None" uses STARTTLS when offered, which can be stripped. Correcting it could break customers using "StartTls" on port 465 (found in case 4724) - SMTP connection security mapping (`Mailer.cs`): "StartTls" connects with `useSsl: true`, which MailKit treats as TLS from the start (port 465), not STARTTLS (587); "None" uses STARTTLS when offered, which can be stripped. Correcting it could break customers using "StartTls" on port 465 (found in case 4724)
- From F6 (case 4726), split off as separate changes: a password strength rule (not empty is the only rule today; seeded trial users use their login as password) is a new, visible feature; `limit_req` on `/api/v8/auth` in the hosted nginx configuration belongs in the `sockeye-client` setup script and affects every hosted droplet. Both are second layers behind the login lockout
- Race in the notification system: a record saved while a matching notification subscription is being deleted fails with 500 (`anotifyevent_notifysubscriptionid_fkey` violation), and the user's save fails with it. Seen once in the integration suite (`CustomerCrud.CRUD`, run in parallel with subscription tests) during case 4724 - Race in the notification system: a record saved while a matching notification subscription is being deleted fails with 500 (`anotifyevent_notifysubscriptionid_fkey` violation), and the user's save fails with it. Seen once in the integration suite (`CustomerCrud.CRUD`, run in parallel with subscription tests) during case 4724
- **Case 4731 (John, priority 1, after 8.3.0):** a global notification setting "allow email to any address from the API and report scripts" for `notify/direct-smtp`; off for new installs, on for upgraded databases; when off, only a record's saved address. Follows from F3 / case 4723 - **Case 4731 (John, priority 1, after 8.3.0):** a global notification setting "allow email to any address from the API and report scripts" for `notify/direct-smtp`; off for new installs, on for upgraded databases; when off, only a record's saved address. Follows from F3 / case 4723
- `License.DoFetchAsync` (`License.cs:629-661`) rethrows at `x == 3`, so the fifth server (Sockeye) is never tried, and it can return `null` to a caller that dereferences it - `License.DoFetchAsync` (`License.cs:629-661`) rethrows at `x == 3`, so the fifth server (Sockeye) is never tried, and it can return `null` to a caller that dereferences it

View File

@@ -214,7 +214,7 @@ take the next free number; a fixed or accepted finding keeps its ID and changes
| F31 | Medium | **fixed in case 4724** | The SMTP password is returned to read-only roles (OpsAdminRestricted, BizAdmin, BizAdminRestricted) | | F31 | Medium | **fixed in case 4724** | The SMTP password is returned to read-only roles (OpsAdminRestricted, BizAdmin, BizAdminRestricted) |
| F32 | Medium | confirmed (code) | A real-looking SMTP account and password are in the DEBUG defaults in `raven` and `sockeye` source | | F32 | Medium | confirmed (code) | A real-looking SMTP account and password are in the DEBUG defaults in `raven` and `sockeye` source |
| F34 | Medium | **fixed in case 4722** | Shipped client libraries behind on security fixes: DOMPurify 2.3.8 (wiki sanitiser), Vuetify 2.6.6 | | F34 | Medium | **fixed in case 4722** | Shipped client libraries behind on security fixes: DOMPurify 2.3.8 (wiki sanitiser), Vuetify 2.6.6 |
| F6 | Medium | confirmed | No lockout or rate limit on login or the 2FA step; no password rules | | F6 | Medium | **fixed in cases 4710 and 4726** | No lockout or rate limit on login or the 2FA step; no password rules |
| F9 | Medium | **fixed in case 4725** | Default SuperUser password (by design for trials), and an anonymous route that says whether it is still set | | F9 | Medium | **fixed in case 4725** | Default SuperUser password (by design for trials), and an anonymous route that says whether it is still set |
| F36 | Low | confirmed (code) | AyaNova listens on all interfaces on plain HTTP port 7575 in every template; hosted droplets are protected by a cloud firewall | | F36 | Low | confirmed (code) | AyaNova listens on all interfaces on plain HTTP port 7575 in every template; hosted droplets are protected by a cloud firewall |
| F37 | Low | accepted (John) | Standalone PostgreSQL accepts any local connection without a password (`trust`) | | F37 | Low | accepted (John) | Standalone PostgreSQL accepts any local connection without a password (`trust`) |
@@ -306,6 +306,8 @@ per-user or per-address lockout, and parallel requests are not slowed. Hosted ng
as password). Fix options: count failures per login and per temp token with a short lockout; as password). Fix options: count failures per login and per temp token with a short lockout;
`limit_req` on `/api/v8/auth` in the hosted nginx template. `limit_req` on `/api/v8/auth` in the hosted nginx template.
**Fixed (2026-10-24).** 2FA: case 4710 (5 wrong codes clear the temporary token). Password login: case 4726, `util/LoginThrottle.cs` in AyaNova and Sockeye (Sockeye's login is public too; only SSH to its droplet is limited to the office address). Failures are counted in memory: 10 per login name or 50 per client address within 15 minutes, then attempts are refused with 429 "Too many failed login attempts" for the rest of the window, without checking the password; a correct password clears the login's count; unknown logins are counted the same way. Loopback is not counted per address (the hosted nginx and IIS pass the real client address through the forwarded headers, which are trusted from loopback only). Each lockout is logged as a warning. Accepted: an attacker can keep a known login locked by failing 10 times every 15 minutes. Proved in both servers, the per-address limit through `X-Forwarded-For` with a documentation address; test `LoginLockout`. The password rule and nginx `limit_req` are in Deferred in `NET10-PORT.md`.
### F7 Password change does not end sessions; download key loses its expiry (pass 2) ### F7 Password change does not end sessions; download key loses its expiry (pass 2)
`UserBiz.ChangePasswordAsync` (used by change-password and reset-password) does not replace `UserBiz.ChangePasswordAsync` (used by change-password and reset-password) does not replace

View File

@@ -27,6 +27,7 @@ IN PROGRESS
- Server: log files are now named with an increasing number when they are archived; the [server log](ops-log.md) form lists them newest first. - Server: log files are now named with an increasing number when they are archived; the [server log](ops-log.md) form lists them newest first.
- Server: removed an unused internal performance profiling component. - Server: removed an unused internal performance profiling component.
- Server: AyaNova now checks the email (SMTP) server's security certificate when sending email. If your mail server uses a self-signed or internal certificate and email notifications stop after upgrading, see [SMTP certificate check](ops-config-smtp-accept-any-certificate.md). - Server: AyaNova now checks the email (SMTP) server's security certificate when sending email. If your mail server uses a self-signed or internal certificate and email notifications stop after upgrading, see [SMTP certificate check](ops-config-smtp-accept-any-certificate.md).
- Server: after repeated failed login attempts, further attempts for that login, or from that address, are refused for a few minutes.
- Server: when a user's password is reset or set by an administrator, or their login is disabled, they are signed out and must sign in again. - Server: when a user's password is reset or set by an administrator, or their login is disabled, they are signed out and must sign in again.
- Server: on a server with a purchased license, the SuperUser account can only be used to change its password while it still has the default password, and the login form no longer offers the default SuperUser login. See [SuperUser login](ay-start-login.md). - Server: on a server with a purchased license, the SuperUser account can only be used to change its password while it still has the default password, and the login form no longer offers the default SuperUser login. See [SuperUser login](ay-start-login.md).
- Server: the SMTP server password is no longer shown in the [notification settings](ops-notification-system.md) form; it shows `********` when a password is set. Type a new password to change it. - Server: the SMTP server password is no longer shown in the [notification settings](ops-notification-system.md) form; it shows `********` when a password is set. Type a new password to change it.

View File

@@ -82,8 +82,14 @@ namespace AyaNova.Api.Controllers
return StatusCode(451, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, "generalerror", "License agreement consent required")); return StatusCode(451, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, "generalerror", "License agreement consent required"));
} }
//case 4726: too many failed logins for this login name or from this address: refused at once, the password is not checked
var address = HttpContext.Connection.RemoteIpAddress;
if (LoginThrottle.IsRefused(creds.Login, address))
return StatusCode(429, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED, null, "Too many failed login attempts, please try again later"));
if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password)) if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password))
{ {
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait //Make a failed pw wait
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
@@ -99,7 +105,7 @@ namespace AyaNova.Api.Controllers
string hashed = Hasher.hash(u.Salt, creds.Password); string hashed = Hasher.hash(u.Salt, creds.Password);
if (hashed == u.Password) if (hashed == u.Password)
{ {
LoginThrottle.RecordSuccess(creds.Login);
//TWO FACTOR ENABLED?? //TWO FACTOR ENABLED??
//if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt //if 2fa enabled then need to validate it before sending token, so we're halfway there and need to send a 2fa prompt
@@ -134,11 +140,20 @@ namespace AyaNova.Api.Controllers
} }
//No users matched, it's a failed login //No users matched, it's a failed login
RecordLoginFailure(creds.Login, address);
//Make a failed pw wait //Make a failed pw wait
await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY); await Task.Delay(AyaNova.Util.ServerBootConfig.FAILED_AUTH_DELAY);
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
} }
//case 4726
private void RecordLoginFailure(string login, System.Net.IPAddress address)
{
var reached = LoginThrottle.RecordFailure(login, address);
if (reached != null)
log.LogWarning($"Too many failed login attempts for {reached}: further attempts refused for up to 15 minutes");
}

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Concurrent;
using System.Net;
namespace AyaNova.Util
{
/// <summary>
/// case 4726: failed logins counted per login name and per client address (in memory, a restart resets the counts).
/// Too many failures within the window and further attempts are refused until the window ends.
/// Login names that don't exist are counted the same way, so the refusal says nothing about which logins exist.
/// Requests from this machine (loopback) are not counted per address: behind the hosted nginx and IIS the real
/// client address is seen, so loopback is only the machine itself (and the integration tests)
/// </summary>
internal static class LoginThrottle
{
internal const int MAX_LOGIN_FAILURES = 10;
internal const int MAX_ADDRESS_FAILURES = 50;
private static readonly TimeSpan WINDOW = TimeSpan.FromMinutes(15);
private const int PRUNE_ABOVE = 10000;
private record Window(DateTime Start, int Count);
private static readonly ConcurrentDictionary<string, Window> Failures = new ConcurrentDictionary<string, Window>();
private static string LoginKey(string login) => "L:" + (login ?? string.Empty).Trim().ToLowerInvariant();
private static string AddressKey(IPAddress address) => address == null || IPAddress.IsLoopback(address) ? null : "A:" + address.ToString();
/// <summary>
/// True if this login or this address has reached its limit in the current window
/// </summary>
internal static bool IsRefused(string login, IPAddress address)
{
return Reached(LoginKey(login), MAX_LOGIN_FAILURES) || Reached(AddressKey(address), MAX_ADDRESS_FAILURES);
}
/// <summary>
/// Counts a failed login; returns a description when this failure reached a limit (for the log), otherwise null
/// </summary>
internal static string RecordFailure(string login, IPAddress address)
{
if (Failures.Count > PRUNE_ABOVE)
Prune();
string reached = null;
if (Count(LoginKey(login)) == MAX_LOGIN_FAILURES)
reached = $"login \"{login}\"";
var addressKey = AddressKey(address);
if (addressKey != null && Count(addressKey) == MAX_ADDRESS_FAILURES)
reached = (reached == null ? "" : reached + " and ") + $"address {address}";
return reached;
}
/// <summary>
/// A correct password clears the count for that login
/// </summary>
internal static void RecordSuccess(string login)
{
Failures.TryRemove(LoginKey(login), out _);
}
private static bool Reached(string key, int max)
{
return key != null && Failures.TryGetValue(key, out var w) && DateTime.UtcNow - w.Start < WINDOW && w.Count >= max;
}
private static int Count(string key)
{
var now = DateTime.UtcNow;
return Failures.AddOrUpdate(key, new Window(now, 1), (_, w) => now - w.Start < WINDOW ? w with { Count = w.Count + 1 } : new Window(now, 1)).Count;
}
private static void Prune()
{
var now = DateTime.UtcNow;
foreach (var kv in Failures)
if (now - kv.Value.Start >= WINDOW)
Failures.TryRemove(kv.Key, out _);
}
}
}