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:
58
Services/ApiClientSecretHasher.cs
Normal file
58
Services/ApiClientSecretHasher.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Services/ApiTokenCreateResult.cs
Normal file
13
Services/ApiTokenCreateResult.cs
Normal 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
162
Services/ApiTokenService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user