case 4725 - sessions end on password reset and login disabled, exact login route, default SuperUser limited when licensed

A password set by someone else (reset code, administrator) or a disabled
login replaces the user's session token and download key; changing one's
own password keeps the session. PasswordResetCodeExpire is cleared (was
DlKeyExpire). The token middleware exempts only the exact login and
notify/hello routes and refuses inactive users and users who may not log
in. With a purchased licence, notify/hello no longer offers the default
SuperUser login and a SuperUser with the default password may only change
it (SuperUserDefaultPasswordRoutes). F7, F8 and F9 fixed. Manual and
changelog updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 19:54:03 -07:00
parent 4141217c04
commit 80f81aea0f
12 changed files with 123 additions and 22 deletions

View File

@@ -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<IAllowAnonymous>() != null)
return true;
if (endpoint is not RouteEndpoint routeEndpoint)
return false;
var key = RouteKey(context);
return key != null && Allowed.Contains(key);
}
/// <summary>
/// "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)
/// </summary>
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}";
}
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
namespace AyaNova.Api.ControllerHelpers
{
/// <summary>
/// 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.
/// </summary>
internal static class SuperUserDefaultPasswordRoutes
{
//"METHOD route template" with the API prefix removed, exactly as in the controllers' attributes
private static readonly HashSet<string> Allowed = new HashSet<string>
{
"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<IAllowAnonymous>() != null)
return true;
var key = CustomerContactRoutes.RouteKey(context);
return key != null && Allowed.Contains(key);
}
}
}

View File

@@ -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();

View File

@@ -405,7 +405,7 @@ namespace AyaNova
var ct = context.RequestServices.GetService<AyContext>();
//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";

View File

@@ -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<string, bool> _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;
}
////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -459,6 +459,15 @@ namespace AyaNova.Core
}
}
/// <summary>
/// case 4725: a purchased licence, active or expired (not unlicensed, trial or revoked)
/// </summary>
internal static bool PurchasedLicense()
{
var status = _ActiveLicense?.Status;
return status == AyaNovaLicenseKey.LicenseStatus.ActivePurchased || status == AyaNovaLicenseKey.LicenseStatus.ExpiredPurchased;
}
#endregion
#region EULA consent