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:
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user