feat(security): JwtTokenService with HS256 + 15-min expiry
This commit is contained in:
16
src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs
Normal file
16
src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
public sealed class JwtOptions
|
||||
{
|
||||
public const string SectionName = "Security:Jwt";
|
||||
public const int MinSigningKeyBytes = 32;
|
||||
|
||||
/// <summary>HS256 signing key. Must be at least 32 bytes (256 bits) UTF-8.</summary>
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
|
||||
public string Issuer { get; set; } = "otopcua";
|
||||
public string Audience { get; set; } = "otopcua";
|
||||
|
||||
/// <summary>Default token expiry. Mirrors ScadaLink (15 min).</summary>
|
||||
public int ExpiryMinutes { get; set; } = 15;
|
||||
}
|
||||
102
src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
Normal file
102
src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
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 ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
public sealed class JwtTokenService
|
||||
{
|
||||
public const string DisplayNameClaimType = "DisplayName";
|
||||
public const string UsernameClaimType = "Username";
|
||||
public const string RoleClaimType = "Role";
|
||||
|
||||
private readonly JwtOptions _options;
|
||||
private readonly ILogger<JwtTokenService> _logger;
|
||||
|
||||
public JwtTokenService(IOptions<JwtOptions> options, ILogger<JwtTokenService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
|
||||
var keyByteLength = string.IsNullOrEmpty(_options.SigningKey)
|
||||
? 0
|
||||
: Encoding.UTF8.GetByteCount(_options.SigningKey);
|
||||
if (keyByteLength < JwtOptions.MinSigningKeyBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"JwtOptions.SigningKey must be at least {JwtOptions.MinSigningKeyBytes} bytes " +
|
||||
$"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s).");
|
||||
}
|
||||
}
|
||||
|
||||
public string Issue(string displayName, string username, IReadOnlyList<string> roles)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
};
|
||||
foreach (var role in roles)
|
||||
claims.Add(new Claim(RoleClaimType, role));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.ExpiryMinutes),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public bool TryValidate(string token, out ClaimsPrincipal? principal)
|
||||
{
|
||||
principal = null;
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _options.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _options.Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
principal = handler.ValidateToken(token, parameters, out _);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
||||
{
|
||||
_logger.LogDebug(ex, "JWT validation failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the validation parameters that the JwtBearer middleware should use. Centralised
|
||||
/// so the bearer pipeline can't drift from <see cref="TryValidate"/>.
|
||||
/// </summary>
|
||||
public TokenValidationParameters BuildValidationParameters() => new()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = _options.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = _options.Audience,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)),
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user