Phase 1 WP-2–10: Repositories, audit service, security & auth (LDAP, JWT, roles, policies, data protection)
- WP-2: SecurityRepository + CentralUiRepository with audit log queries - WP-3: AuditService with transactional guarantee (same SaveChangesAsync) - WP-4: Optimistic concurrency tests (deployment records vs template last-write-wins) - WP-5: Seed data (SCADA-Admins → Admin role mapping) - WP-6: LdapAuthService (direct bind, TLS enforcement, group query) - WP-7: JwtTokenService (HMAC-SHA256, 15-min refresh, 30-min idle timeout) - WP-8: RoleMapper (LDAP groups → roles with site-scoped deployment) - WP-9: Authorization policies (Admin/Design/Deployment + site scope handler) - WP-10: Shared Data Protection keys via EF Core 141 tests pass, zero warnings.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly ILogger<JwtTokenService> _logger;
|
||||
|
||||
public const string DisplayNameClaimType = "DisplayName";
|
||||
public const string UsernameClaimType = "Username";
|
||||
public const string RoleClaimType = "Role";
|
||||
public const string SiteIdClaimType = "SiteId";
|
||||
public const string LastActivityClaimType = "LastActivity";
|
||||
|
||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string GenerateToken(
|
||||
string displayName,
|
||||
string username,
|
||||
IReadOnlyList<string> roles,
|
||||
IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o"))
|
||||
};
|
||||
|
||||
foreach (var role in roles)
|
||||
{
|
||||
claims.Add(new Claim(RoleClaimType, role));
|
||||
}
|
||||
|
||||
if (permittedSiteIds != null)
|
||||
{
|
||||
foreach (var siteId in permittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, validationParameters, out _);
|
||||
return principal;
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Token validation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShouldRefresh(ClaimsPrincipal principal)
|
||||
{
|
||||
var expClaim = principal.FindFirst("exp");
|
||||
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
|
||||
return false;
|
||||
|
||||
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
|
||||
var remaining = expiry - DateTimeOffset.UtcNow;
|
||||
|
||||
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
|
||||
}
|
||||
|
||||
public bool IsIdleTimedOut(ClaimsPrincipal principal)
|
||||
{
|
||||
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
|
||||
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
|
||||
return true;
|
||||
|
||||
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
public string? RefreshToken(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 refresh token: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user