feat: Implement API client and token models with hashing and JWT authentication

- Added ApiClientModel and ApiTokenModel for managing API clients and tokens.
- Introduced ConfigurationDefinitionModel and ConfigurationValueModel for configuration management.
- Created CredentialSecretModel for storing credential secrets.
- Developed DeploymentArtifactModel and DeploymentBatchModel for deployment management.
- Enhanced DeploymentTargetModel and DeploymentTemplateSelectionModel to support template revisions.
- Added TemplateRevisionModel and TemplateVersionModel for versioning templates.
- Implemented ApiClientSecretHasher for secure secret hashing.
- Created ApiTokenService for generating and validating JWT tokens.
- Updated QueueJobService to handle deployment requests with artifacts.
- Configured authentication settings in appsettings.json for JWT and Negotiate authentication.
This commit is contained in:
Torsten Brendgen
2026-07-09 14:44:38 +02:00
parent 1aa2adac08
commit dc93ea0813
72 changed files with 32906 additions and 516 deletions

View File

@@ -0,0 +1,58 @@
using System.Security.Cryptography;
namespace Microsoft.SelfService.Portal.Core.API.Services
{
public static class ApiClientSecretHasher
{
private const int SaltSize = 16;
private const int KeySize = 32;
private const int DefaultIterations = 100_000;
public static string HashSecret(string secret)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(secret, salt, DefaultIterations, HashAlgorithmName.SHA256, KeySize);
return string.Join(
'.',
"PBKDF2-SHA256",
DefaultIterations.ToString(),
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
public static bool VerifySecret(string secret, string storedHash)
{
var parts = storedHash.Split('.');
if (parts.Length != 4 || parts[0] != "PBKDF2-SHA256")
{
return false;
}
if (!int.TryParse(parts[1], out var iterations))
{
return false;
}
var salt = Convert.FromBase64String(parts[2]);
var expectedHash = Convert.FromBase64String(parts[3]);
var actualHash = Rfc2898DeriveBytes.Pbkdf2(secret, salt, iterations, HashAlgorithmName.SHA256, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
}
internal static string HashSecretForDemoData(string secret, string saltText)
{
var salt = System.Text.Encoding.UTF8.GetBytes(saltText);
Array.Resize(ref salt, SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(secret, salt, DefaultIterations, HashAlgorithmName.SHA256, KeySize);
return string.Join(
'.',
"PBKDF2-SHA256",
DefaultIterations.ToString(),
Convert.ToBase64String(salt),
Convert.ToBase64String(hash));
}
}
}

View File

@@ -0,0 +1,13 @@
namespace Microsoft.SelfService.Portal.Core.API.Services
{
public class ApiTokenCreateResult
{
public string AccessToken { get; set; } = string.Empty;
public string Jti { get; set; } = string.Empty;
public DateTime IssuedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
}

162
Services/ApiTokenService.cs Normal file
View File

@@ -0,0 +1,162 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using Microsoft.SelfService.Portal.Core.API.Models;
namespace Microsoft.SelfService.Portal.Core.API.Services
{
public class ApiTokenService
{
private readonly IConfiguration _configuration;
public ApiTokenService(IConfiguration configuration)
{
_configuration = configuration;
}
public int TokenLifetimeSeconds =>
_configuration.GetValue<int?>("Authentication:OnPremClientCredentials:TokenLifetimeSeconds") ?? 3600;
public string Issuer =>
_configuration["Authentication:OnPremClientCredentials:Issuer"] ?? "Microsoft.SelfService.Portal.Core.API";
public string Audience =>
_configuration["Authentication:OnPremClientCredentials:Audience"] ?? "Microsoft.SelfService.Portal.Core.API";
public ApiTokenCreateResult CreateAccessToken(ApiClientModel client, IReadOnlyCollection<string> scopes)
{
return CreateAccessToken(client.ClientId, client.ClientId, scopes);
}
public ApiTokenCreateResult CreateAccessToken(string subject, string? clientId, IReadOnlyCollection<string> scopes)
{
var signingKey = GetSigningKey();
var credentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var now = DateTime.UtcNow;
var jti = Guid.NewGuid().ToString("N");
var expiresAt = now.AddSeconds(TokenLifetimeSeconds);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, subject),
new(JwtRegisteredClaimNames.Jti, jti)
};
if (!string.IsNullOrWhiteSpace(clientId))
{
claims.Add(new Claim("client_id", clientId));
}
foreach (var scope in scopes)
{
claims.Add(new Claim("scope", scope));
}
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
notBefore: now,
expires: expiresAt,
signingCredentials: credentials);
return new ApiTokenCreateResult
{
AccessToken = new JwtSecurityTokenHandler().WriteToken(token),
Jti = jti,
IssuedAt = now,
ExpiresAt = expiresAt
};
}
public IReadOnlyCollection<string> ReadClientScopes(ApiClientModel client)
{
if (string.IsNullOrWhiteSpace(client.ScopesJson))
{
return Array.Empty<string>();
}
return JsonSerializer.Deserialize<string[]>(client.ScopesJson) ?? Array.Empty<string>();
}
public IReadOnlyCollection<string> ResolveRequestedScopes(ApiClientModel client, string? requestedScope)
{
var allowedScopes = ReadClientScopes(client);
if (string.IsNullOrWhiteSpace(requestedScope))
{
return allowedScopes;
}
var requestedScopes = requestedScope
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (requestedScopes.Any(scope => !allowedScopes.Contains(scope, StringComparer.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("Requested scope is not allowed for this client.");
}
return requestedScopes;
}
public IReadOnlyCollection<string> ResolveRequestedUserScopes(string? requestedScope)
{
if (string.IsNullOrWhiteSpace(requestedScope))
{
throw new InvalidOperationException("At least one scope is required.");
}
var allowedScopes = ReadAllowedTokenScopes();
var requestedScopes = requestedScope
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (requestedScopes.Length == 0)
{
throw new InvalidOperationException("At least one scope is required.");
}
if (requestedScopes.Any(scope => !allowedScopes.Contains(scope, StringComparer.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("Requested scope is not allowed.");
}
return requestedScopes;
}
public IReadOnlyCollection<string> ReadAllowedTokenScopes()
{
var configuredScopes = _configuration.GetSection("Authentication:OnPremClientCredentials:AllowedTokenScopes").Get<string[]>();
if (configuredScopes != null && configuredScopes.Length > 0)
{
return configuredScopes;
}
return
[
"deployment.read",
"deployment.write",
"queue.process",
"template.read",
"credential.resolve",
"token.manage",
"token.admin"
];
}
public SymmetricSecurityKey GetSigningKey()
{
var signingKey = _configuration["Authentication:OnPremClientCredentials:SigningKey"];
if (string.IsNullOrWhiteSpace(signingKey))
{
signingKey = "DevelopmentOnly-OnPremClientCredentials-SigningKey-ChangeMe";
}
return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
}
}
}

View File

@@ -55,13 +55,8 @@ namespace Microsoft.SelfService.Portal.Core.API.Services
return queueJob.Id;
}
public Guid EnqueueDeploymentRequest(Guid deploymentGroupId, ICollection<Guid> targetIds, string jsonData)
public Guid EnqueueDeploymentRequest(Guid deploymentGroupId, ICollection<Guid> targetIds, string jsonData, Guid? deploymentArtifactId = null)
{
var deploymentDocument = ConfigurationDocumentValidator.NormalizeAndValidate(
jsonData,
ConfigurationDocumentKind.DeploymentOverride);
jsonData = deploymentDocument.JsonData;
var deploymentGroup = _context.DeploymentGroups
.AsNoTracking()
.Include(group => group.Template)
@@ -80,6 +75,39 @@ namespace Microsoft.SelfService.Portal.Core.API.Services
throw new InvalidOperationException("DeploymentGroup does not exist.");
}
var currentArtifact = deploymentArtifactId.HasValue
? _context.DeploymentArtifacts
.AsNoTracking()
.FirstOrDefault(artifact =>
artifact.Id == deploymentArtifactId.Value
&& artifact.DeploymentGroupId == deploymentGroupId
&& !artifact.IsStale)
: _context.DeploymentArtifacts
.AsNoTracking()
.Where(artifact =>
artifact.DeploymentGroupId == deploymentGroupId
&& artifact.ArtifactType == DeploymentArtifactTypes.ResolvedConfigurationData
&& !artifact.IsStale)
.OrderByDescending(artifact => artifact.Created)
.FirstOrDefault();
if (deploymentArtifactId.HasValue && currentArtifact == null)
{
throw new InvalidOperationException("Deployment artifact does not exist or is stale.");
}
if (currentArtifact != null)
{
jsonData = currentArtifact.DeploymentJson;
}
else
{
var deploymentDocument = ConfigurationDocumentValidator.NormalizeAndValidate(
jsonData,
ConfigurationDocumentKind.DeploymentOverride);
jsonData = deploymentDocument.JsonData;
}
var primaryTemplateSelection = deploymentGroup.TemplateSelections
.OrderBy(selection => selection.SortOrder)
.FirstOrDefault();
@@ -156,6 +184,7 @@ namespace Microsoft.SelfService.Portal.Core.API.Services
{
selection.Id,
selection.TemplateVersionId,
selection.TemplateRevisionId,
TemplateId = selection.TemplateVersion.TemplateId,
TemplateName = selection.TemplateVersion.Template.Name,
selection.TemplateVersion.Version,
@@ -176,6 +205,8 @@ namespace Microsoft.SelfService.Portal.Core.API.Services
assignment.NodeDataJson
}),
TargetIds = resolvedTargetIds,
DeploymentArtifactId = currentArtifact?.Id,
DeploymentJson = currentArtifact?.DeploymentJson,
JsonData = jsonData,
TargetCount = resolvedTargetIds.Count,
Created = DateTime.UtcNow