diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md index 4c2d7320..60e5bb7a 100644 --- a/devdocs/modernization/NET10-PORT.md +++ b/devdocs/modernization/NET10-PORT.md @@ -191,7 +191,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s | 4722 | DOMPurify and Vuetify upgrades (raven-client) | F34. **Done** (raven-client `44411ec1`: DOMPurify 2.5.9, Vuetify 2.7.2, builds on Node 12.22.9; Vuetify 2.7's calendar `eventSummary()` returns an element, so the six calendar event templates render it with a new `gz-vnodes` component instead of `v-html`. John checked wiki, schedules (drag, stretch, lock icons), date pickers, lists, dialogs and the reminder and review widgets. John also checked the scheduled work order widget as a Tech user. Still to do: Cypress on `devtestn10` at the final gate) | | 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) | -| 4725 | Sessions: password change ends sessions, exact-path token exemption, default SuperUser refused when licensed | F7, F8, F9 | +| 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 | | 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) | diff --git a/devdocs/modernization/SECURITY-REVIEW.md b/devdocs/modernization/SECURITY-REVIEW.md index bd348d08..78e562c6 100644 --- a/devdocs/modernization/SECURITY-REVIEW.md +++ b/devdocs/modernization/SECURITY-REVIEW.md @@ -215,12 +215,12 @@ take the next free number; a fixed or accepted finding keeps its ID and changes | 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 | | F6 | Medium | confirmed | No lockout or rate limit on login or the 2FA step; no password rules | -| F9 | Medium | confirmed | 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 | | F37 | Low | accepted (John) | Standalone PostgreSQL accepts any local connection without a password (`trust`) | | F2 | Medium | confirmed (code) | Windows installers give all local users modify rights on the program and data folders, `config.json` included | -| F7 | Low | confirmed | Password change or reset does not end existing sessions, and makes the download key non-expiring | -| F8 | Low | confirmed | Latest-token check skipped for any URL ending in `/auth` or `notify/hello` | +| F7 | Low | **fixed in case 4725** | Password change or reset does not end existing sessions, and makes the download key non-expiring | +| F8 | Low | **fixed in case 4725** | Latest-token check skipped for any URL ending in `/auth` or `notify/hello` | | F4 | Low | **fixed in cases 4712 and 4723** | Any logged-in user can send an in-app notification to any user | | F10 | Low | confirmed | Anyone can record acceptance of the licence agreement (`license/lc`) | | F11 | Low | confirmed | API root page shows licensee, version, licence terms (and DB ID when unlicensed) to anyone | @@ -314,7 +314,7 @@ owner changes the password. It also sets `DlKeyExpire = null`, apparently meant `PasswordResetCodeExpire`; with a null expiry `ValidateDownloadTokenAndReturnUserAsync` never treats the key as expired, until the next login replaces it. Setting `AllowLogin` to false does not end a session either (only deactivating does). Fix: on password change, set -`CurrentAuthToken` and `DlKey` to new random values and clear `PasswordResetCodeExpire`. +`CurrentAuthToken` and `DlKey` to new random values and clear `PasswordResetCodeExpire`. **Fixed in case 4725 (2026-10-24):** a password set by someone else (reset code, administrator on the user form) and unticking Allow login replace `CurrentAuthToken` and `DlKey` (key expired); changing one's own password keeps the caller's session, which is the only valid one anyway (one session per user). `PasswordResetCodeExpire` is cleared instead of `DlKeyExpire`. Test `Sessions` (fails on the old code). ### F8 Latest-token check bypassed by the URL suffix (pass 2, confirmed) @@ -326,7 +326,7 @@ on `form-field-reference/Customer` and passed the middleware on `form-field-refe The middleware also does not check `Active`. Impact is small on its own (only those routes, with the user's current roles), larger with F1. Fix: compare the exact route (`/api/v8/auth`, `/api/v8/notify/hello`), or skip the check by endpoint metadata -(`[AllowAnonymous]`) instead of by path. +(`[AllowAnonymous]`) instead of by path. **Fixed in case 4725 (2026-10-24):** the exemption compares the matched route exactly (`POST auth`, `GET notify/hello`, through `CustomerContactRoutes.RouteKey`); the middleware also refuses inactive users and users who may not log in. Test `Sessions.ReplacedTokenRefusedOnRouteEndingInAuth` (old code: passed the middleware, 404 from the action). ### F9 Default SuperUser password, advertised anonymously (pass 2) @@ -338,6 +338,8 @@ Whether hosted droplets set their own SuperUser password at setup: to check in p Fix options: only return `sudf` from local or LAN addresses, or force a password change on first SuperUser login; at least warn in the UI and the manual. +**Fixed in case 4725 (2026-10-24), John's option A:** with a purchased licence (active or expired), `notify/hello` no longer returns `sudf`, and a SuperUser session with the default credentials may only use the routes the change password page needs (`SuperUserDefaultPasswordRoutes`, checked in the token middleware; the default-credentials result is kept per stored hash, so no extra hashing per request). Trial and unlicensed servers unchanged, as designed. Proved locally with a temporary, uncommitted purchased-licence override: other screens 403 until the password was changed, then normal (John's click-through). Hosted setup script still does not set a SuperUser password: accepted for trial mode. + ### F10 Anonymous licence-agreement consent (pass 2) `POST license/lc` with body `"iaccepttheagreement"` records the EULA consent; the comment says diff --git a/docs/8.0/ayanova/docs/ay-evaluate.md b/docs/8.0/ayanova/docs/ay-evaluate.md index 80ce1129..ab65d3c7 100644 --- a/docs/8.0/ayanova/docs/ay-evaluate.md +++ b/docs/8.0/ayanova/docs/ay-evaluate.md @@ -60,7 +60,7 @@ The default login for the AyaNova SuperUser account is user name: `superuser` pa Before AyaNova is licensed and used in production or before you import / migrate or enter any real world data into an evaluation AyaNova you **MUST** change the sample User passwords or anyone could potentially login and view your data. -AyaNova will prompt you to change it if it discovers a login using the default superuser password with a non evaluation license. +With a non evaluation license, AyaNova requires it to be changed: a login using the default superuser password can only be used to change the password, until it is changed. ## Generating sample data diff --git a/docs/8.0/ayanova/docs/ay-start-login.md b/docs/8.0/ayanova/docs/ay-start-login.md index 1ed1b573..bf8c7368 100644 --- a/docs/8.0/ayanova/docs/ay-start-login.md +++ b/docs/8.0/ayanova/docs/ay-start-login.md @@ -36,7 +36,7 @@ The default login for the AyaNova SuperUser account is user name: `superuser` pa Before AyaNova is licensed and used in production you **MUST** change this or anyone could login with this default. -AyaNova will prompt you to change it if it discovers a login using the default superuser password with a non evaluation license. +With a non evaluation license, AyaNova requires it to be changed: a login using the default superuser password can only be used to change the password, until it is changed. The built-in SuperUser User account in AyaNova is not a normal type of User and does not consume a license; it is used for specific special operations in AyaNova that could lead to loss of data or affect the operations of the server over all. diff --git a/docs/8.0/ayanova/docs/changelog.md b/docs/8.0/ayanova/docs/changelog.md index 0bf03fdd..b47437e1 100644 --- a/docs/8.0/ayanova/docs/changelog.md +++ b/docs/8.0/ayanova/docs/changelog.md @@ -27,6 +27,8 @@ 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: 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: 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: 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. - Linux: Ubuntu 24.04 and 26.04 are now tested and supported. On Ubuntu 24.04 and newer the .NET runtime is installed from Ubuntu's own package archive. - Docs: updated the [Linux server](ops-install-linux-server.md), [Linux desktop](ops-install-linux-desktop.md), [Windows local network](ops-install-windows-lan.md) and [Windows IIS](ops-install-windows-iis.md) installation and upgrade instructions for .NET 10 and Ubuntu 24.04 / 26.04. diff --git a/docs/8.0/ayanova/docs/tour-bootstrap-configure.md b/docs/8.0/ayanova/docs/tour-bootstrap-configure.md index 112b2930..0c71e73f 100644 --- a/docs/8.0/ayanova/docs/tour-bootstrap-configure.md +++ b/docs/8.0/ayanova/docs/tour-bootstrap-configure.md @@ -36,7 +36,7 @@ It is **critically important** to do this before taking any further steps; parti Failing to do this would mean you will have no effective security as the AyaNova superuser password is well known and could be used by anyone to cause significant damage to your data or create more Users with administrative level privileges. -Once AyaNova is licensed, if you log in as the superuser with the default password `l3tm3in`, you will be taken automatically to the change password form to encourage it to be changed. +Once AyaNova is licensed, if you log in as the superuser with the default password `l3tm3in`, you will be taken automatically to the change password form, and the rest of AyaNova can not be used by the superuser until the password is changed. We have [secure password guidelines](home-password.md#what-is-a-secure-password) for the latest best practices in choosing a secure password and we encourage you to read about it and consider the advice written there in order to choose an easy to memorize, secure password that does not need to be written down or stored insecurely. diff --git a/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs b/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs index 3f684eab..c68af558 100644 --- a/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs +++ b/server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs @@ -85,12 +85,23 @@ namespace AyaNova.Api.ControllerHelpers return true;//not routed to anything: the request ends in a 404 regardless if (endpoint.Metadata.GetMetadata() != null) return true; - if (endpoint is not RouteEndpoint routeEndpoint) - return false; + var key = RouteKey(context); + return key != null && Allowed.Contains(key); + } + + /// + /// "METHOD route template" of the endpoint this request was routed to, API prefix removed + /// (e.g. "GET user-option/{id}"); null when not routed to a route endpoint. + /// Also used by the token middleware (case 4725) + /// + internal static string RouteKey(HttpContext context) + { + if (context.GetEndpoint() is not RouteEndpoint routeEndpoint) + return null; var template = routeEndpoint.RoutePattern.RawText ?? string.Empty; if (template.StartsWith(API_PREFIX)) template = template.Substring(API_PREFIX.Length); - return Allowed.Contains($"{context.Request.Method.ToUpperInvariant()} {template}"); + return $"{context.Request.Method.ToUpperInvariant()} {template}"; } } } diff --git a/server/AyaNova/ControllerHelpers/SuperUserDefaultPasswordRoutes.cs b/server/AyaNova/ControllerHelpers/SuperUserDefaultPasswordRoutes.cs new file mode 100644 index 00000000..89620987 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/SuperUserDefaultPasswordRoutes.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace AyaNova.Api.ControllerHelpers +{ + /// + /// case 4725: with a purchased licence, a SuperUser still using the default password may only change it: + /// these are the routes the change password page and the app shell need. Everything else answers 403 + /// until the password is changed. + /// See devdocs/modernization/SECURITY-REVIEW.md, F9. + /// + internal static class SuperUserDefaultPasswordRoutes + { + //"METHOD route template" with the API prefix removed, exactly as in the controllers' attributes + private static readonly HashSet Allowed = new HashSet + { + "POST auth/change-password", + "GET user-option/{id}", + "GET global-biz-setting/client", + "GET enum-list/list/{enumkey}", + "GET form-custom/{formkey}", + "GET translation/list", + "POST translation/subset", + "GET server-info", + "GET notify/new-count", + "GET notify/app-notifications", + }; + + internal static bool IsAllowed(HttpContext context) + { + var endpoint = context.GetEndpoint(); + if (endpoint == null) + return true;//not routed to anything: the request ends in a 404 regardless + if (endpoint.Metadata.GetMetadata() != null) + return true; + var key = CustomerContactRoutes.RouteKey(context); + return key != null && Allowed.Contains(key); + } + } +} diff --git a/server/AyaNova/Controllers/NotifyController.cs b/server/AyaNova/Controllers/NotifyController.cs index bcfc8dcb..7cc06b99 100644 --- a/server/AyaNova/Controllers/NotifyController.cs +++ b/server/AyaNova/Controllers/NotifyController.cs @@ -51,7 +51,8 @@ namespace AyaNova.Api.Controllers if (AyaNova.Core.License.ActiveKey.Status == AyaNova.Core.License.AyaNovaLicenseKey.LicenseStatus.ActiveTrial) showSampleLogins = await AyaNova.Util.DbUtil.DBHasTrialUsersAsync(ct, log); - bool suIsDefault = await UserBiz.SuperIsDefaultCredsAsync(ct); + //case 4725: the default SuperUser login is offered only while unlicensed or in trial + bool suIsDefault = !AyaNova.Core.License.PurchasedLicense() && await UserBiz.SuperIsDefaultCredsAsync(ct); //confirm if there are logo's to show as well var logo = await ct.Logo.AsNoTracking().SingleOrDefaultAsync(); diff --git a/server/AyaNova/Startup.cs b/server/AyaNova/Startup.cs index 2f72d4ab..88effdb5 100644 --- a/server/AyaNova/Startup.cs +++ b/server/AyaNova/Startup.cs @@ -405,7 +405,7 @@ namespace AyaNova var ct = context.RequestServices.GetService(); //get the user record - var u = await ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken }).FirstAsync(); + var u = await ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, m.UserType, id = m.Id, translationId = m.UserOptions.TranslationId, currentAuthToken = m.CurrentAuthToken, m.Active, m.AllowLogin, m.Login, m.Salt, m.Password }).FirstAsync(); //case 4713: a customer contact acts only with the customer roles, whatever roles are stored context.Request.HttpContext.Items["AY_ROLES"] = UserBiz.EffectiveRoles(u.UserType, u.roles); context.Request.HttpContext.Items["AY_USERNAME"] = u.name; @@ -422,10 +422,13 @@ namespace AyaNova //CHECK JWT + //case 4725: the login and prelogin routes are matched exactly (was any path ending in "/auth" or "notify/hello"), + //and a user who is inactive or may not log in is refused like a replaced token + var routeKey = AyaNova.Api.ControllerHelpers.CustomerContactRoutes.RouteKey(context); if ( - !context.Request.Path.Value.EndsWith("/auth") && - !context.Request.Path.Value.EndsWith("notify/hello") && - u.currentAuthToken != JWT + routeKey != "POST auth" && + routeKey != "GET notify/hello" && + (u.currentAuthToken != JWT || !u.Active || !u.AllowLogin) )//except "/api/v8/auth" and prelogin notify/hello routes so user can login { @@ -460,7 +463,10 @@ namespace AyaNova else { //case 4712: customer contacts may only call the routes the customer screens use - if ((u.UserType == UserType.Customer || u.UserType == UserType.HeadOffice) && !AyaNova.Api.ControllerHelpers.CustomerContactRoutes.IsAllowed(context)) + //case 4725: with a purchased licence, a SuperUser with the default password may only change it + if (((u.UserType == UserType.Customer || u.UserType == UserType.HeadOffice) && !AyaNova.Api.ControllerHelpers.CustomerContactRoutes.IsAllowed(context)) || + (u.id == 1 && AyaNova.Core.License.PurchasedLicense() && UserBiz.IsDefaultSuperUserCreds(u.Login, u.Salt, u.Password) && + !AyaNova.Api.ControllerHelpers.SuperUserDefaultPasswordRoutes.IsAllowed(context))) { context.Response.StatusCode = 403; context.Response.ContentType = "application/json"; diff --git a/server/AyaNova/biz/UserBiz.cs b/server/AyaNova/biz/UserBiz.cs index 4e366fed..a6c46654 100644 --- a/server/AyaNova/biz/UserBiz.cs +++ b/server/AyaNova/biz/UserBiz.cs @@ -582,6 +582,11 @@ namespace AyaNova.Biz putObject.CurrentAuthToken = Hasher.GenerateSalt();//new random token that will definitely not work } + //case 4725: login disabled, or password set by an administrator for someone else: that user's session ends + if ((putObject.AllowLogin == false && dbObject.AllowLogin == true) || + (putObject.Password != dbObject.Password && putObject.Id != UserId)) + EndSession(putObject); + await ValidateAsync(putObject, dbObject); if (HasErrors) return null; ct.Replace(dbObject, putObject); @@ -616,7 +621,11 @@ namespace AyaNova.Biz //remove reseet code and date so it can't be used again dbObject.PasswordResetCode = null; - dbObject.DlKeyExpire = null; + dbObject.PasswordResetCodeExpire = null;//case 4725: was DlKeyExpire, which left the download key without an expiry + + //case 4725: a password set by someone else (reset code, administrator) ends that user's session + if (userId != UserId) + EndSession(dbObject); await ct.SaveChangesAsync(); @@ -1172,9 +1181,29 @@ namespace AyaNova.Biz { var su = await ct.User.AsNoTracking().Where(z => z.Id == 1).Select(z => new { z.Salt, z.Login, z.Password }).SingleOrDefaultAsync(); if (su == null) return false;//not expected but best not to crash out on this one - if (su.Login == "superuser" && Hasher.hash(su.Salt, "l3tm3in") == su.Password) - return true; - return false; + return IsDefaultSuperUserCreds(su.Login, su.Salt, su.Password); + } + + //case 4725: the result is kept for the stored hash, so the token middleware can check every SuperUser + //request without hashing again; a changed password is a different hash and is checked afresh + private static System.Tuple _superUserDefaultCredsCache; + internal static bool IsDefaultSuperUserCreds(string login, string salt, string passwordHash) + { + var cached = _superUserDefaultCredsCache; + var key = login + "|" + passwordHash; + if (cached != null && cached.Item1 == key) + return cached.Item2; + bool isDefault = login == "superuser" && Hasher.hash(salt, "l3tm3in") == passwordHash; + _superUserDefaultCredsCache = System.Tuple.Create(key, isDefault); + return isDefault; + } + + //case 4725: new random values that match no token or download key: the user must log in again + private static void EndSession(User u) + { + u.CurrentAuthToken = Hasher.GenerateSalt(); + u.DlKey = Hasher.GenerateSalt(); + u.DlKeyExpire = DateTime.UtcNow; } //////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/server/AyaNova/util/License.cs b/server/AyaNova/util/License.cs index 0f6cdc03..1a9da6f0 100644 --- a/server/AyaNova/util/License.cs +++ b/server/AyaNova/util/License.cs @@ -459,6 +459,15 @@ namespace AyaNova.Core } } + /// + /// case 4725: a purchased licence, active or expired (not unlicensed, trial or revoked) + /// + internal static bool PurchasedLicense() + { + var status = _ActiveLicense?.Status; + return status == AyaNovaLicenseKey.LicenseStatus.ActivePurchased || status == AyaNovaLicenseKey.LicenseStatus.ExpiredPurchased; + } + #endregion #region EULA consent