fix(security): resolve Security-004..007 — configurable user-id attribute, DN escaping, JWT issuer/audience validation, idle-timeout preservation
This commit is contained in:
@@ -18,6 +18,17 @@ public class JwtTokenService
|
||||
public const string SiteIdClaimType = "SiteId";
|
||||
public const string LastActivityClaimType = "LastActivity";
|
||||
|
||||
/// <summary>
|
||||
/// Fixed issuer bound into every token and required on validation. Binding
|
||||
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
|
||||
/// between the two central nodes, accidental reuse of the same secret for an
|
||||
/// unrelated internal token would otherwise be silently exploitable.
|
||||
/// </summary>
|
||||
public const string TokenIssuer = "scadalink-central";
|
||||
|
||||
/// <summary>Fixed audience bound into every token and required on validation.</summary>
|
||||
public const string TokenAudience = "scadalink-central";
|
||||
|
||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
@@ -37,11 +48,19 @@ public class JwtTokenService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
|
||||
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
|
||||
/// refresh the caller MUST pass the existing anchor forward so the idle window
|
||||
/// continues to be measured from the user's last genuine activity rather than
|
||||
/// from token issuance time.
|
||||
/// </summary>
|
||||
public string GenerateToken(
|
||||
string displayName,
|
||||
string username,
|
||||
IReadOnlyList<string> roles,
|
||||
IReadOnlyList<string>? permittedSiteIds)
|
||||
IReadOnlyList<string>? permittedSiteIds,
|
||||
DateTimeOffset? lastActivity = null)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
@@ -50,7 +69,7 @@ public class JwtTokenService
|
||||
{
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o"))
|
||||
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
|
||||
};
|
||||
|
||||
foreach (var role in roles)
|
||||
@@ -67,6 +86,8 @@ public class JwtTokenService
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: TokenIssuer,
|
||||
audience: TokenAudience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||
signingCredentials: credentials);
|
||||
@@ -79,8 +100,10 @@ public class JwtTokenService
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = TokenIssuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = TokenAudience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
@@ -121,6 +144,15 @@ public class JwtTokenService
|
||||
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
|
||||
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
|
||||
/// triggered by a request, but it must not be treated as user activity — the
|
||||
/// idle window must keep being measured from the user's last genuine interaction,
|
||||
/// otherwise the documented 30-minute idle timeout could never fire for a client
|
||||
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
|
||||
/// anchor when handling a genuine user request.
|
||||
/// </summary>
|
||||
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
@@ -132,6 +164,36 @@ public class JwtTokenService
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds);
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
ReadLastActivity(currentPrincipal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
|
||||
/// advanced to now. This is the explicit "user did something" path — distinct
|
||||
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
|
||||
/// handling a genuine user interaction.
|
||||
/// </summary>
|
||||
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||
|
||||
if (displayName == null || username == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
|
||||
{
|
||||
var claim = principal.FindFirst(LastActivityClaimType);
|
||||
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user