Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs
T
2026-02-13 22:14:02 +01:00

248 lines
9.9 KiB
C#

using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates serial numbers against Intune Autopilot device registration via Microsoft Graph.
/// Caches positive/negative lookups to reduce Graph traffic.
/// </summary>
public class SerialNumberValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2);
private readonly ILogger<SerialNumberValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
public SerialNumberValidator(
ILogger<SerialNumberValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IConfiguration configuration)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_configuration = configuration;
}
public async Task<SerialNumberValidationResult> ValidateSerialNumberAsync(
string tenantId,
string? serialNumber,
string? sessionId = null)
{
if (string.IsNullOrWhiteSpace(serialNumber))
{
return new SerialNumberValidationResult
{
IsValid = false,
ErrorMessage = "Serial number header not provided"
};
}
var normalizedSerial = serialNumber.Trim();
var cacheKey = BuildCacheKey(tenantId, normalizedSerial, sessionId);
if (_cache.TryGetValue(cacheKey, out SerialNumberValidationResult? cached) && cached != null)
{
return cached;
}
try
{
var accessToken = await GetGraphAccessTokenAsync(tenantId);
if (string.IsNullOrEmpty(accessToken))
{
return CacheAndReturn(cacheKey, new SerialNumberValidationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = "Graph access token could not be acquired"
}, isPositive: false);
}
var graphClient = _httpClientFactory.CreateClient();
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
// Escape single quotes to prevent OData injection and malformed filters.
var escapedSerial = normalizedSerial.Replace("'", "''");
var filter = Uri.EscapeDataString($"serialNumber eq '{escapedSerial}'");
var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?$top=1&$filter={filter}";
var response = await graphClient.GetAsync(graphUrl);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Graph serial validation failed for tenant {TenantId}. Status: {StatusCode}. Body: {ResponseBody}",
tenantId,
(int)response.StatusCode,
responseBody);
return CacheAndReturn(cacheKey, new SerialNumberValidationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
}, isPositive: false);
}
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
{
return CacheAndReturn(cacheKey, new SerialNumberValidationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Serial number '{normalizedSerial}' is not registered in Autopilot"
}, isPositive: false);
}
var result = new SerialNumberValidationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AutopilotDeviceId = devices[0]?["id"]?.ToString()
};
_logger.LogInformation(
"Serial number validation succeeded for tenant {TenantId}, session {SessionId}, serial {SerialNumber}, autopilotId {AutopilotDeviceId}",
tenantId,
sessionId ?? "<none>",
normalizedSerial,
result.AutopilotDeviceId ?? "<none>");
return CacheAndReturn(cacheKey, result, isPositive: true);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error validating serial number for tenant {TenantId}, session {SessionId}, serial {SerialNumber}",
tenantId,
sessionId ?? "<none>",
normalizedSerial);
return CacheAndReturn(cacheKey, new SerialNumberValidationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Error validating serial number: {ex.Message}"
}, isPositive: false);
}
}
public async Task<GraphConsentStatusResult> GetConsentStatusAsync(string tenantId)
{
var cacheKey = $"serial-validation-consent:{tenantId}";
if (_cache.TryGetValue(cacheKey, out GraphConsentStatusResult? cached) && cached != null)
{
return cached;
}
var accessToken = await GetGraphAccessTokenAsync(tenantId);
var result = new GraphConsentStatusResult
{
IsConsented = !string.IsNullOrWhiteSpace(accessToken),
Message = string.IsNullOrWhiteSpace(accessToken)
? "Admin consent for Graph application permissions is missing or app credentials are invalid."
: "Admin consent is available for this tenant."
};
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ConsentStatusTtl
});
return result;
}
private async Task<string?> GetGraphAccessTokenAsync(string tenantId)
{
var clientId = _configuration["Graph:ClientId"] ?? _configuration["AzureAd:ClientId"];
var clientSecret = _configuration["Graph:ClientSecret"] ?? _configuration["AzureAd:ClientSecret"];
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
_logger.LogError(
"Serial validation is enabled but Graph app credentials are not configured. Set Graph:ClientId and Graph:ClientSecret.");
return null;
}
var tokenClient = _httpClientFactory.CreateClient();
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
var body = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "https://graph.microsoft.com/.default",
["grant_type"] = "client_credentials"
});
var response = await tokenClient.PostAsync(tokenUrl, body);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Failed to acquire Graph token for tenant {TenantId}. Status: {StatusCode}. Body: {ResponseBody}",
tenantId,
(int)response.StatusCode,
responseBody);
return null;
}
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
return tokenJson?["access_token"]?.ToString();
}
private static string BuildCacheKey(string tenantId, string serialNumber, string? sessionId)
{
if (!string.IsNullOrWhiteSpace(sessionId))
{
return $"serial-validation:{tenantId}:{sessionId}:{serialNumber}";
}
return $"serial-validation:{tenantId}:{serialNumber}";
}
private SerialNumberValidationResult CacheAndReturn(
string cacheKey,
SerialNumberValidationResult result,
bool isPositive)
{
var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl;
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
});
return result;
}
}
public class SerialNumberValidationResult
{
public bool IsValid { get; set; }
public string? SerialNumber { get; set; }
public string? AutopilotDeviceId { get; set; }
public string? ErrorMessage { get; set; }
}
public class GraphConsentStatusResult
{
public bool IsConsented { get; set; }
public string? Message { get; set; }
}
}