mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-23 20:26:10 +00:00
SerialNumber Validaion implementation against Intune Auopilot
This commit is contained in:
@@ -80,6 +80,9 @@ try {
|
||||
# ── Pre-flight: Skip installation on already-enrolled devices ──
|
||||
|
||||
# Check 1: OS install date must be within threshold (fresh device)
|
||||
# TODO: issue is the OS might got installed a long time ago but the OOBE just started now (e.g. old image, or re-flash).
|
||||
# Ideally we want to detect the OOBE start time... need to investigate if there's a reliable way to get that (event log? registry? WMI?).
|
||||
# For now we just check OS install date as a proxy, which should work in most cases except for old images or re-flashed devices.
|
||||
$osInstallDate = (Get-CimInstance Win32_OperatingSystem).InstallDate
|
||||
$osAge = (Get-Date) - $osInstallDate
|
||||
$osAgeMinutesRounded = [int][Math]::Round($osAge.TotalMinutes)
|
||||
|
||||
@@ -241,12 +241,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
|
||||
_logger?.Debug($" X-Device-Model: {_model}");
|
||||
}
|
||||
|
||||
// TODO: Enable serial number validation when multi-tenant Graph API integration is ready
|
||||
// This provides the strongest security by validating against Intune Autopilot registration
|
||||
// if (!string.IsNullOrEmpty(_serialNumber))
|
||||
// {
|
||||
// request.Headers.Add("X-Device-SerialNumber", _serialNumber);
|
||||
// }
|
||||
// Add serial number for optional backend validation against Intune Autopilot registration
|
||||
if (!string.IsNullOrEmpty(_serialNumber))
|
||||
{
|
||||
request.Headers.Add("X-Device-SerialNumber", _serialNumber);
|
||||
_logger?.Debug($" X-Device-SerialNumber: {_serialNumber}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1 +1 @@
|
||||
13
|
||||
17
|
||||
|
||||
@@ -19,17 +19,20 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly GatherRuleService _gatherRuleService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly SerialNumberValidator _serialNumberValidator;
|
||||
|
||||
public GetAgentConfigFunction(
|
||||
ILogger<GetAgentConfigFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
GatherRuleService gatherRuleService,
|
||||
RateLimitService rateLimitService)
|
||||
RateLimitService rateLimitService,
|
||||
SerialNumberValidator serialNumberValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_gatherRuleService = gatherRuleService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_serialNumberValidator = serialNumberValidator;
|
||||
}
|
||||
|
||||
[Function("GetAgentConfig")]
|
||||
@@ -57,6 +60,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
tenantId,
|
||||
_configService,
|
||||
_rateLimitService,
|
||||
_serialNumberValidator,
|
||||
_logger
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
private readonly TableStorageService _storageService;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly SerialNumberValidator _serialNumberValidator;
|
||||
private readonly AnalyzeRuleService _analyzeRuleService;
|
||||
|
||||
public IngestEventsFunction(
|
||||
@@ -26,12 +27,14 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
TableStorageService storageService,
|
||||
TenantConfigurationService configService,
|
||||
RateLimitService rateLimitService,
|
||||
SerialNumberValidator serialNumberValidator,
|
||||
AnalyzeRuleService analyzeRuleService)
|
||||
{
|
||||
_logger = logger;
|
||||
_storageService = storageService;
|
||||
_configService = configService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_serialNumberValidator = serialNumberValidator;
|
||||
_analyzeRuleService = analyzeRuleService;
|
||||
}
|
||||
|
||||
@@ -96,7 +99,9 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
request.TenantId,
|
||||
_configService,
|
||||
_rateLimitService,
|
||||
_logger
|
||||
_serialNumberValidator,
|
||||
_logger,
|
||||
request.SessionId
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
@@ -16,17 +16,20 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
private readonly TableStorageService _storageService;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly SerialNumberValidator _serialNumberValidator;
|
||||
|
||||
public RegisterSessionFunction(
|
||||
ILogger<RegisterSessionFunction> logger,
|
||||
TableStorageService storageService,
|
||||
TenantConfigurationService configService,
|
||||
RateLimitService rateLimitService)
|
||||
RateLimitService rateLimitService,
|
||||
SerialNumberValidator serialNumberValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_storageService = storageService;
|
||||
_configService = configService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_serialNumberValidator = serialNumberValidator;
|
||||
}
|
||||
|
||||
[Function("RegisterSession")]
|
||||
@@ -61,7 +64,9 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
registration.TenantId,
|
||||
_configService,
|
||||
_rateLimitService,
|
||||
_logger
|
||||
_serialNumberValidator,
|
||||
_logger,
|
||||
registration.SessionId
|
||||
);
|
||||
|
||||
if (errorResponse2 != null)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions;
|
||||
|
||||
public class SerialValidationConsentFunction
|
||||
{
|
||||
private readonly ILogger<SerialValidationConsentFunction> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly GalacticAdminService _galacticAdminService;
|
||||
private readonly SerialNumberValidator _serialNumberValidator;
|
||||
|
||||
public SerialValidationConsentFunction(
|
||||
ILogger<SerialValidationConsentFunction> logger,
|
||||
IConfiguration configuration,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
GalacticAdminService galacticAdminService,
|
||||
SerialNumberValidator serialNumberValidator)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_galacticAdminService = galacticAdminService;
|
||||
_serialNumberValidator = serialNumberValidator;
|
||||
}
|
||||
|
||||
[Function("GetSerialValidationConsentUrl")]
|
||||
public async Task<HttpResponseData> GetConsentUrl(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/serial-validation/consent-url")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
var authError = await EnsureAuthorizedTenantAdminAsync(req, tenantId);
|
||||
if (authError != null)
|
||||
{
|
||||
return authError;
|
||||
}
|
||||
|
||||
var validatorClientId = _configuration["Graph:ClientId"] ?? _configuration["AzureAd:ClientId"];
|
||||
if (string.IsNullOrWhiteSpace(validatorClientId))
|
||||
{
|
||||
var badConfig = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await badConfig.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Validator app client ID is not configured on the backend."
|
||||
});
|
||||
return badConfig;
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var redirectUri = query["redirectUri"];
|
||||
if (string.IsNullOrWhiteSpace(redirectUri))
|
||||
{
|
||||
redirectUri = $"{req.Url.Scheme}://{req.Url.Authority}/settings";
|
||||
}
|
||||
|
||||
var consentUrl =
|
||||
$"https://login.microsoftonline.com/{Uri.EscapeDataString(tenantId)}/v2.0/adminconsent" +
|
||||
$"?client_id={Uri.EscapeDataString(validatorClientId)}" +
|
||||
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
|
||||
$"&state={Uri.EscapeDataString("serial-validation-enable")}";
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
consentUrl
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
[Function("GetSerialValidationConsentStatus")]
|
||||
public async Task<HttpResponseData> GetConsentStatus(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/serial-validation/consent-status")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
var authError = await EnsureAuthorizedTenantAdminAsync(req, tenantId);
|
||||
if (authError != null)
|
||||
{
|
||||
return authError;
|
||||
}
|
||||
|
||||
var result = await _serialNumberValidator.GetConsentStatusAsync(tenantId);
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
isConsented = result.IsConsented,
|
||||
message = result.Message
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData?> EnsureAuthorizedTenantAdminAsync(HttpRequestData req, string tenantId)
|
||||
{
|
||||
if (!TenantHelper.IsAuthenticated(req))
|
||||
{
|
||||
var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await unauthorized.WriteAsJsonAsync(new { error = "Authentication required." });
|
||||
return unauthorized;
|
||||
}
|
||||
|
||||
var authenticatedTenantId = TenantHelper.GetTenantId(req);
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new { error = "Access denied. You can only manage your own tenant." });
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
|
||||
var isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, userIdentifier);
|
||||
|
||||
if (!isGalacticAdmin && !isTenantAdmin)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"User {User} attempted serial-validation consent operation without admin rights for tenant {TenantId}",
|
||||
userIdentifier,
|
||||
tenantId);
|
||||
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Access denied. Tenant Admin or Galactic Admin required."
|
||||
});
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
public class UpdateTenantSecurityBypassFunction
|
||||
{
|
||||
private readonly ILogger<UpdateTenantSecurityBypassFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly GalacticAdminService _galacticAdminService;
|
||||
|
||||
public UpdateTenantSecurityBypassFunction(
|
||||
ILogger<UpdateTenantSecurityBypassFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
GalacticAdminService galacticAdminService)
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_galacticAdminService = galacticAdminService;
|
||||
}
|
||||
|
||||
[Function("UpdateTenantSecurityBypass")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/security-bypass")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TenantHelper.IsAuthenticated(req))
|
||||
{
|
||||
var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await unauthorized.WriteAsJsonAsync(new { error = "Authentication required." });
|
||||
return unauthorized;
|
||||
}
|
||||
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier);
|
||||
if (!isGalacticAdmin)
|
||||
{
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new { error = "Access denied. Galactic Admin required." });
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
var body = await req.ReadFromJsonAsync<UpdateBypassRequest>();
|
||||
if (body == null)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error = "Invalid request body." });
|
||||
return bad;
|
||||
}
|
||||
|
||||
var config = await _configService.GetConfigurationAsync(tenantId);
|
||||
config.AllowInsecureAgentRequests = body.AllowInsecureAgentRequests;
|
||||
config.UpdatedBy = userIdentifier;
|
||||
await _configService.SaveConfigurationAsync(config);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Galactic Admin {User} set AllowInsecureAgentRequests={Enabled} for tenant {TenantId}",
|
||||
userIdentifier,
|
||||
body.AllowInsecureAgentRequests,
|
||||
tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
tenantId,
|
||||
allowInsecureAgentRequests = config.AllowInsecureAgentRequests,
|
||||
message = config.AllowInsecureAgentRequests
|
||||
? "Security bypass enabled for test purposes."
|
||||
: "Security bypass disabled."
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating security bypass for tenant {TenantId}", tenantId);
|
||||
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateBypassRequest
|
||||
{
|
||||
public bool AllowInsecureAgentRequests { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// Register our services
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<TableStorageService>();
|
||||
builder.Services.AddHostedService<TableInitializerService>(); // Initialize all tables at startup
|
||||
builder.Services.AddSingleton<TenantConfigurationService>();
|
||||
@@ -103,5 +104,6 @@ builder.Services.AddSingleton<HealthCheckService>();
|
||||
builder.Services.AddSingleton<GatherRuleService>();
|
||||
builder.Services.AddSingleton<AnalyzeRuleService>();
|
||||
builder.Services.AddSingleton<MaintenanceService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.SerialNumberValidator>();
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
@@ -27,10 +27,12 @@ namespace AutopilotMonitor.Functions.Security
|
||||
string tenantId,
|
||||
TenantConfigurationService configService,
|
||||
RateLimitService rateLimitService,
|
||||
ILogger logger)
|
||||
SerialNumberValidator serialNumberValidator,
|
||||
ILogger logger,
|
||||
string? sessionId = null)
|
||||
{
|
||||
var validator = new SecurityValidator(configService, rateLimitService, logger);
|
||||
var validation = await validator.ValidateRequestAsync(req, tenantId);
|
||||
var validator = new SecurityValidator(configService, rateLimitService, serialNumberValidator, logger);
|
||||
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -43,15 +43,18 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly SerialNumberValidator _serialNumberValidator;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public SecurityValidator(
|
||||
TenantConfigurationService configService,
|
||||
RateLimitService rateLimitService,
|
||||
SerialNumberValidator serialNumberValidator,
|
||||
ILogger logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_serialNumberValidator = serialNumberValidator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -61,12 +64,24 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// <param name="req">HTTP request</param>
|
||||
/// <param name="tenantId">Tenant ID for configuration lookup</param>
|
||||
/// <returns>Security validation result</returns>
|
||||
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId)
|
||||
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null)
|
||||
{
|
||||
// Load tenant configuration
|
||||
var config = await _configService.GetConfigurationAsync(tenantId);
|
||||
|
||||
// Security validation is always enforced (no longer configurable per tenant)
|
||||
// Hard gate: tenant must enable serial validation before agent traffic is accepted.
|
||||
// Galactic Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
|
||||
if (!config.ValidateSerialNumber && !config.AllowInsecureAgentRequests)
|
||||
{
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
StatusCode = HttpStatusCode.Forbidden,
|
||||
ErrorMessage = "Serial number validation is required",
|
||||
Details = "Enable 'Validate Serial Number' in Configuration before using the agent ingestion endpoints."
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Validate client certificate
|
||||
var certHeader = req.Headers.Contains("X-Client-Certificate")
|
||||
@@ -133,25 +148,25 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Serial number validation when Graph API integration is ready
|
||||
// if (config.ValidateSerialNumber)
|
||||
// {
|
||||
// var serialNumber = req.Headers.Contains("X-Device-SerialNumber")
|
||||
// ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||||
// : null;
|
||||
//
|
||||
// var serialValidation = await _serialNumberValidator.ValidateSerialNumberAsync(tenantId, serialNumber);
|
||||
// if (!serialValidation.IsValid)
|
||||
// {
|
||||
// return new SecurityValidationResult
|
||||
// {
|
||||
// IsValid = false,
|
||||
// StatusCode = HttpStatusCode.Forbidden,
|
||||
// ErrorMessage = "Device not registered in Autopilot",
|
||||
// Details = serialValidation.ErrorMessage
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// 4. Validate serial number against Intune Autopilot (optional, tenant setting)
|
||||
if (config.ValidateSerialNumber)
|
||||
{
|
||||
var serialNumber = req.Headers.Contains("X-Device-SerialNumber")
|
||||
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||||
: null;
|
||||
|
||||
var serialValidation = await _serialNumberValidator.ValidateSerialNumberAsync(tenantId, serialNumber, sessionId);
|
||||
if (!serialValidation.IsValid)
|
||||
{
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
StatusCode = HttpStatusCode.Forbidden,
|
||||
ErrorMessage = "Device not registered in Autopilot",
|
||||
Details = serialValidation.ErrorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return new SecurityValidationResult
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
/*
|
||||
* SERIAL NUMBER VALIDATION AGAINST INTUNE AUTOPILOT
|
||||
*
|
||||
* This validator is prepared but commented out because it requires:
|
||||
* 1. Multi-tenant Graph API integration
|
||||
* 2. App registration with proper permissions (DeviceManagementServiceConfig.Read.All)
|
||||
* 3. Tenant-specific Graph API credentials
|
||||
*
|
||||
* SECURITY BENEFITS:
|
||||
* When enabled, this provides the strongest authentication by validating that:
|
||||
* - The serial number exists in the tenant's Autopilot registration
|
||||
* - Combined with cert thumbprint, manufacturer, and model, this makes attacks nearly impossible
|
||||
*
|
||||
* An attacker would need to:
|
||||
* 1. Obtain a valid MDM client certificate from an enrolled device
|
||||
* 2. Know the exact manufacturer and model (whitelisted)
|
||||
* 3. Know a valid serial number registered in Autopilot for that tenant
|
||||
*
|
||||
* TO ENABLE:
|
||||
* 1. Uncomment this code
|
||||
* 2. Set up multi-tenant Graph API authentication
|
||||
* 3. Uncomment the serial number header in BackendApiClient.cs
|
||||
* 4. Uncomment the validation call in IngestEventsFunction.cs
|
||||
*/
|
||||
|
||||
/*
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -35,134 +9,239 @@ using Newtonsoft.Json.Linq;
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates device serial numbers against Intune Autopilot registration
|
||||
/// Requires Graph API integration with DeviceManagementServiceConfig.Read.All permission
|
||||
/// Validates serial numbers against Intune Autopilot device registration via Microsoft Graph.
|
||||
/// Caches positive/negative lookups to reduce Graph traffic.
|
||||
/// </summary>
|
||||
public class SerialNumberValidator
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
// TODO: Replace with proper multi-tenant token provider
|
||||
// private readonly IGraphTokenProvider _tokenProvider;
|
||||
private readonly ILogger<SerialNumberValidator> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SerialNumberValidator(ILogger logger)
|
||||
public SerialNumberValidator(
|
||||
ILogger<SerialNumberValidator> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a serial number is registered in Autopilot for the given tenant
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID</param>
|
||||
/// <param name="serialNumber">Device serial number</param>
|
||||
/// <returns>Validation result</returns>
|
||||
public async Task<SerialNumberValidationResult> ValidateSerialNumberAsync(string tenantId, string serialNumber)
|
||||
public async Task<SerialNumberValidationResult> ValidateSerialNumberAsync(
|
||||
string tenantId,
|
||||
string? serialNumber,
|
||||
string? sessionId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(serialNumber))
|
||||
if (string.IsNullOrWhiteSpace(serialNumber))
|
||||
{
|
||||
return new SerialNumberValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Serial number not provided"
|
||||
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
|
||||
{
|
||||
// TODO: Get Graph API access token for the tenant
|
||||
// var accessToken = await _tokenProvider.GetTokenForTenantAsync(tenantId);
|
||||
|
||||
// Graph API endpoint to query Autopilot devices
|
||||
var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" +
|
||||
$"?$filter=serialNumber eq '{serialNumber}'";
|
||||
|
||||
// TODO: Add authentication header
|
||||
// _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var response = await _httpClient.GetAsync(graphUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonConvert.DeserializeObject<JObject>(responseJson);
|
||||
|
||||
var devices = data["value"] as JArray;
|
||||
if (devices == null || devices.Count == 0)
|
||||
var accessToken = await GetGraphAccessTokenAsync(tenantId);
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
_logger.LogWarning($"Serial number {serialNumber} not found in Autopilot for tenant {tenantId}");
|
||||
return new SerialNumberValidationResult
|
||||
return CacheAndReturn(cacheKey, new SerialNumberValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = $"Serial number '{serialNumber}' is not registered in Autopilot",
|
||||
SerialNumber = serialNumber
|
||||
};
|
||||
SerialNumber = normalizedSerial,
|
||||
ErrorMessage = "Graph access token could not be acquired"
|
||||
}, isPositive: false);
|
||||
}
|
||||
|
||||
// Serial number found in Autopilot - device is authorized
|
||||
_logger.LogInformation($"Serial number {serialNumber} validated successfully in Autopilot");
|
||||
return new SerialNumberValidationResult
|
||||
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 = serialNumber,
|
||||
AutopilotDeviceId = devices[0]["id"]?.ToString()
|
||||
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 {serialNumber}");
|
||||
return new SerialNumberValidationResult
|
||||
_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,
|
||||
ErrorMessage = $"Error validating serial number: {ex.Message}",
|
||||
SerialNumber = serialNumber
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of serial number validation
|
||||
/// </summary>
|
||||
public class SerialNumberValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the serial number is registered in Autopilot
|
||||
/// </summary>
|
||||
public bool IsValid { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? AutopilotDeviceId { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Device serial number
|
||||
/// </summary>
|
||||
public string SerialNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Autopilot device ID (if found)
|
||||
/// </summary>
|
||||
public string AutopilotDeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if validation failed
|
||||
/// </summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
public class GraphConsentStatusResult
|
||||
{
|
||||
public bool IsConsented { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// USAGE EXAMPLE (to be added to IngestEventsFunction.cs when ready):
|
||||
/*
|
||||
// TODO: Uncomment when multi-tenant Graph API integration is ready
|
||||
// Validate serial number against Intune Autopilot (strongest security check)
|
||||
// var serialNumber = req.Headers.Contains("X-Device-SerialNumber")
|
||||
// ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||||
// : null;
|
||||
//
|
||||
// var serialNumberValidation = await _serialNumberValidator.ValidateSerialNumberAsync(request.TenantId, serialNumber);
|
||||
// if (!serialNumberValidation.IsValid)
|
||||
// {
|
||||
// _logger.LogWarning($"Serial number validation failed: {serialNumberValidation.ErrorMessage}");
|
||||
// return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "Device not registered in Autopilot");
|
||||
// }
|
||||
//
|
||||
// _logger.LogInformation($"Serial number validated: {serialNumber} (Autopilot ID: {serialNumberValidation.AutopilotDeviceId})");
|
||||
*/
|
||||
|
||||
@@ -184,6 +184,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
|
||||
{ "ModelWhitelist", config.ModelWhitelist },
|
||||
{ "ValidateSerialNumber", config.ValidateSerialNumber },
|
||||
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
|
||||
{ "DataRetentionDays", config.DataRetentionDays },
|
||||
{ "SessionTimeoutHours", config.SessionTimeoutHours },
|
||||
{ "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB },
|
||||
@@ -211,6 +212,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*",
|
||||
ValidateSerialNumber = entity.GetBoolean("ValidateSerialNumber") ?? false,
|
||||
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
|
||||
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
|
||||
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
|
||||
MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5,
|
||||
|
||||
@@ -89,6 +89,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool ValidateSerialNumber { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emergency bypass for agent security gate (Galactic Admin use only).
|
||||
/// If true, agent requests are accepted even when ValidateSerialNumber is false.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool AllowInsecureAgentRequests { get; set; } = false;
|
||||
|
||||
// ===== DATA MANAGEMENT SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -198,6 +205,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
ModelWhitelist = "*",
|
||||
ValidateSerialNumber = false,
|
||||
AllowInsecureAgentRequests = false,
|
||||
DataRetentionDays = 90,
|
||||
SessionTimeoutHours = 5,
|
||||
MaxNdjsonPayloadSizeMB = 5,
|
||||
|
||||
Reference in New Issue
Block a user