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