diff --git a/src/AdminConsole/Program.cs b/src/AdminConsole/Program.cs index 1a31887..1a138c3 100644 --- a/src/AdminConsole/Program.cs +++ b/src/AdminConsole/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Identity.Web; using Microsoft.Identity.Web.UI; +using SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; using SmallsOnline.PasswordExpirationNotifier.Lib.Services; var builder = WebApplication.CreateBuilder(args); @@ -45,9 +46,19 @@ builder.Services .AddSingleton( provider => new( - clientId: builder.Configuration.GetSection("backendClientID").Value!, - clientSecret: builder.Configuration.GetSection("backendClientSecret").Value!, - tenantId: builder.Configuration.GetSection("backendTenantId").Value! + config: new() + { + ClientId = builder.Configuration.GetSection("backendClientId").Value!, + TenantId = builder.Configuration.GetSection("backendTenantId").Value!, + Credential = new GraphClientCredential( + credentialType: GraphClientCredentialType.ClientSecret, + clientSecret: builder.Configuration.GetSection("backendClientSecret").Value! + ), + ApiScopes = new[] + { + "https://graph.microsoft.com/.default" + } + } ) ); diff --git a/src/AdminConsole/Shared/UserSearchConfigs/UserSearchConfigForm.razor b/src/AdminConsole/Shared/UserSearchConfigs/UserSearchConfigForm.razor index 63f9cff..e99efed 100644 --- a/src/AdminConsole/Shared/UserSearchConfigs/UserSearchConfigForm.razor +++ b/src/AdminConsole/Shared/UserSearchConfigs/UserSearchConfigForm.razor @@ -205,6 +205,16 @@ } + else if (_users is null) + { +
+
+

+ Click the button above to get users. +

+
+
+ } else {
diff --git a/src/FunctionApp/Program.cs b/src/FunctionApp/Program.cs index 56c77d4..8697774 100644 --- a/src/FunctionApp/Program.cs +++ b/src/FunctionApp/Program.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Hosting; using SmallsOnline.PasswordExpirationNotifier.FunctionApp; using SmallsOnline.PasswordExpirationNotifier.FunctionApp.Services; +using SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; using SmallsOnline.PasswordExpirationNotifier.Lib.Services; IHostBuilder hostBuilder = new HostBuilder() @@ -20,9 +21,19 @@ { services.AddSingleton( provider => new GraphClientService( - clientId: AppSettingsHelper.GetSettingValue("clientId")!, - clientSecret: AppSettingsHelper.GetSettingValue("clientSecret")!, - tenantId: AppSettingsHelper.GetSettingValue("tenantId")! + config: new() + { + ClientId = AppSettingsHelper.GetSettingValue("clientId")!, + TenantId = AppSettingsHelper.GetSettingValue("tenantId")!, + Credential = new GraphClientCredential( + credentialType: GraphClientCredentialType.ClientSecret, + clientSecret: AppSettingsHelper.GetSettingValue("clientSecret")! + ), + ApiScopes = new[] + { + "https://graph.microsoft.com/.default" + } + } ) ); diff --git a/src/Lib/Lib.csproj b/src/Lib/Lib.csproj index 8968b26..609e73c 100644 --- a/src/Lib/Lib.csproj +++ b/src/Lib/Lib.csproj @@ -24,9 +24,7 @@ all - - all - + diff --git a/src/Lib/Models/Graph/GraphClientConfig.cs b/src/Lib/Models/Graph/GraphClientConfig.cs new file mode 100644 index 0000000..1850322 --- /dev/null +++ b/src/Lib/Models/Graph/GraphClientConfig.cs @@ -0,0 +1,19 @@ +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; + +/// +/// Holds the configuration for the Microsoft Graph API client used in . +/// +public class GraphClientConfig : IGraphClientConfig +{ + /// + public string ClientId { get; set; } = null!; + + /// + public string TenantId { get; set; } = null!; + + /// + public string[] ApiScopes { get; set; } = null!; + + /// + public IGraphClientCredential Credential { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Lib/Models/Graph/GraphClientCredential.cs b/src/Lib/Models/Graph/GraphClientCredential.cs new file mode 100644 index 0000000..9069baa --- /dev/null +++ b/src/Lib/Models/Graph/GraphClientCredential.cs @@ -0,0 +1,30 @@ +using System.Security.Cryptography.X509Certificates; + +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; + +/// +/// Holds the credentials for authenticating with the Microsoft Graph API. +/// +public class GraphClientCredential : IGraphClientCredential +{ + public GraphClientCredential(GraphClientCredentialType credentialType, string clientSecret) + { + CredentialType = credentialType; + ClientSecret = clientSecret; + } + + public GraphClientCredential(GraphClientCredentialType credentialType, X509Certificate2 clientCertificate) + { + CredentialType = credentialType; + ClientCertificate = clientCertificate; + } + + /// + public GraphClientCredentialType CredentialType { get; } + + /// + public string? ClientSecret { get; } + + /// + public X509Certificate2? ClientCertificate { get; } +} \ No newline at end of file diff --git a/src/Lib/Models/Graph/GraphClientCredentialType.cs b/src/Lib/Models/Graph/GraphClientCredentialType.cs new file mode 100644 index 0000000..271b9bb --- /dev/null +++ b/src/Lib/Models/Graph/GraphClientCredentialType.cs @@ -0,0 +1,17 @@ +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; + +/// +/// The type of credential to use for authenticating an Azure AD app with the Microsoft Graph API. +/// +public enum GraphClientCredentialType +{ + /// + /// The app uses a client secret for authentication. + /// + ClientSecret, + + /// + /// The app uses a certificate for authentication. + /// + ClientCertificate +} \ No newline at end of file diff --git a/src/Lib/Models/Graph/interfaces/IGraphClientConfig.cs b/src/Lib/Models/Graph/interfaces/IGraphClientConfig.cs new file mode 100644 index 0000000..8dc91cb --- /dev/null +++ b/src/Lib/Models/Graph/interfaces/IGraphClientConfig.cs @@ -0,0 +1,27 @@ +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; + +/// +/// Interface for configuring the Microsoft Graph client in . +/// +public interface IGraphClientConfig +{ + /// + /// The client ID of the Azure AD app. + /// + string ClientId { get; set; } + + /// + /// The tenant ID of the Azure AD app. + /// + string TenantId { get; set; } + + /// + /// The API scopes to request. + /// + string[] ApiScopes { get; set; } + + /// + /// The credential to use for authenticating with the Microsoft Graph API. + /// + IGraphClientCredential Credential { get; set; } +} \ No newline at end of file diff --git a/src/Lib/Models/Graph/interfaces/IGraphClientCredential.cs b/src/Lib/Models/Graph/interfaces/IGraphClientCredential.cs new file mode 100644 index 0000000..6f2bfd1 --- /dev/null +++ b/src/Lib/Models/Graph/interfaces/IGraphClientCredential.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography.X509Certificates; + +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; + +/// +/// Interface for holding credentials for authenticating with the Microsoft Graph API. +/// +public interface IGraphClientCredential +{ + /// + /// The type of the credential. + /// + GraphClientCredentialType CredentialType { get; } + + /// + /// The client secret for the app. + /// + string? ClientSecret { get; } + + /// + /// The certificate for the app. + /// + X509Certificate2? ClientCertificate { get; } +} \ No newline at end of file diff --git a/src/Lib/Services/GraphClientService/GraphClientService.cs b/src/Lib/Services/GraphClientService/GraphClientService.cs index cc1612d..8170a69 100644 --- a/src/Lib/Services/GraphClientService/GraphClientService.cs +++ b/src/Lib/Services/GraphClientService/GraphClientService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; -using SmallsOnline.MsGraphClient.Models; +using Microsoft.Identity.Client; +using SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; @@ -8,7 +9,9 @@ namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; /// public partial class GraphClientService : IGraphClientService { - private readonly GraphClient _graphClient; + private readonly IEnumerable _apiScopes; + private readonly HttpClient _graphClient; + private readonly IConfidentialClientApplication _confidentialClientApplication; private readonly JsonSourceGenerationContext _jsonSourceGenerationContext = new(); private readonly string[] _graphUserProps = new[] { @@ -22,22 +25,28 @@ public partial class GraphClientService : IGraphClientService "onPremisesDistinguishedName" }; - public GraphClientService(string clientId, string tenantId, string clientSecret) + public GraphClientService(GraphClientConfig config) { - _graphClient = new( - baseUri: new("https://graph.microsoft.com/beta/"), - clientId: clientId, - tenantId: tenantId, - credentialType: GraphClientCredentialType.Secret, - clientSecret: clientSecret, - apiScopes: new ApiScopesConfig(new[] { "https://graph.microsoft.com/.default" }) - ); - - _graphClient.ConnectClient(); + _apiScopes = config.ApiScopes; + + _confidentialClientApplication = ConfidentialClientApplicationBuilder + .Create(config.ClientId) + .WithTenantId(config.TenantId) + .WithClientSecret(config.Credential.ClientSecret!) + .Build(); + + _graphClient = new() + { + BaseAddress = new Uri("https://graph.microsoft.com/beta/") + }; + _graphClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); } /// - public GraphClient GraphClient => _graphClient; + public HttpClient GraphClient => _graphClient; + + private bool _isConnected => _authToken is not null; + private AuthenticationResult? _authToken; [GeneratedRegex("^https:\\/\\/graph.microsoft.com\\/(?'version'v1\\.0|beta)\\/(?'endpoint'.+?)$")] private partial Regex _nextLinkRegex(); diff --git a/src/Lib/Services/GraphClientService/authentication/ConnectAsync.cs b/src/Lib/Services/GraphClientService/authentication/ConnectAsync.cs new file mode 100644 index 0000000..d875364 --- /dev/null +++ b/src/Lib/Services/GraphClientService/authentication/ConnectAsync.cs @@ -0,0 +1,30 @@ +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; + +public partial class GraphClientService +{ + /// + /// Connects to the Graph API and/or refreshes the authentication token if necessary. + /// + private async Task ConnectAsync() + { + // Invert the current value of _isConnected to determine if we need to connect. + bool needsToConnect = !_isConnected; + + // If we already have an authentication token, check if it's expired. + // If it is, we need to set the value for 'needsToConnect' to true to get a new token. + if (_authToken is not null) + { + if (DateTimeOffset.Now >= _authToken.ExpiresOn) + { + needsToConnect = true; + } + } + + // If needed, get a new authentication token to connect + // to the Graph API. + if (needsToConnect) + { + _authToken = await GetAuthTokenAsync(); + } + } +} \ No newline at end of file diff --git a/src/Lib/Services/GraphClientService/authentication/GetAuthTokenAsync.cs b/src/Lib/Services/GraphClientService/authentication/GetAuthTokenAsync.cs new file mode 100644 index 0000000..546d00d --- /dev/null +++ b/src/Lib/Services/GraphClientService/authentication/GetAuthTokenAsync.cs @@ -0,0 +1,20 @@ +using Microsoft.Identity.Client; +using SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; + +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; + +public partial class GraphClientService +{ + /// + /// Get an authentication token to connect to the Graph API. + /// + /// + private async Task GetAuthTokenAsync() + { + AuthenticationResult? authToken = await _confidentialClientApplication + .AcquireTokenForClient(_apiScopes) + .ExecuteAsync(); + + return authToken; + } +} \ No newline at end of file diff --git a/src/Lib/Services/GraphClientService/general/SendApiCallAsync.cs b/src/Lib/Services/GraphClientService/general/SendApiCallAsync.cs new file mode 100644 index 0000000..483de3a --- /dev/null +++ b/src/Lib/Services/GraphClientService/general/SendApiCallAsync.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; + +public partial class GraphClientService +{ + /// + /// Send an API call to the Graph API. + /// + /// The endpoint to call. + /// The HTTP method to use. + /// Contents to use in the body of the API call. + /// If any, the response from the Graph API in string form. + private async Task SendApiCallAsync(string endpoint, HttpMethod httpMethod, string? body) + { + await ConnectAsync(); + + string? content = null; + bool isFinished = false; + + while (!isFinished) + { + using HttpRequestMessage request = new( + method: httpMethod, + requestUri: endpoint + ); + + request.Headers.Authorization = new AuthenticationHeaderValue( + scheme: "Bearer", + parameter: _authToken!.AccessToken + ); + + if (body is not null) + { + request.Content = new StringContent( + content: body, + encoding: Encoding.UTF8, + mediaType: "application/json" + ); + } + + using HttpResponseMessage response = await _graphClient.SendAsync(request); + + switch (response.StatusCode) + { + case HttpStatusCode.TooManyRequests: + RetryConditionHeaderValue retryAfter = response.Headers.RetryAfter!; + + TimeSpan retryBuffer = retryAfter.Delta!.Value.Add(TimeSpan.FromSeconds(15)); + + await Task.Delay(retryBuffer); + break; + + default: + content = await response.Content.ReadAsStringAsync(); + isFinished = true; + break; + } + } + + return content; + } + + /// + /// Send an API call to the Graph API. + /// + /// The endpoint to call. + /// The HTTP method to use. + /// If any, the response from the Graph API in string form. + private async Task SendApiCallAsync(string endpoint, HttpMethod httpMethod) => await SendApiCallAsync(endpoint, httpMethod, null); +} \ No newline at end of file diff --git a/src/Lib/Services/GraphClientService/GraphClientService.GetUserAsync.cs b/src/Lib/Services/GraphClientService/graph-calls/GetUserAsync.cs similarity index 86% rename from src/Lib/Services/GraphClientService/GraphClientService.GetUserAsync.cs rename to src/Lib/Services/GraphClientService/graph-calls/GetUserAsync.cs index 92d0bcf..222f7f3 100644 --- a/src/Lib/Services/GraphClientService/GraphClientService.GetUserAsync.cs +++ b/src/Lib/Services/GraphClientService/graph-calls/GetUserAsync.cs @@ -10,12 +10,16 @@ public async Task GetUserAsync(string userId) { string apiEndpoint = $"users/{userId}?$select={string.Join(",", _graphUserProps)}"; - string? apiResultString = await _graphClient.SendApiCallAsync( + string? apiResultString = await SendApiCallAsync( endpoint: apiEndpoint, - apiPostBody: null, httpMethod: HttpMethod.Get ); + if (apiResultString is null) + { + throw new Exception("API result string is null."); + } + User user; try { diff --git a/src/Lib/Services/GraphClientService/GraphClientService.GetUsersAsync.cs b/src/Lib/Services/GraphClientService/graph-calls/GetUsersAsync.cs similarity index 96% rename from src/Lib/Services/GraphClientService/GraphClientService.GetUsersAsync.cs rename to src/Lib/Services/GraphClientService/graph-calls/GetUsersAsync.cs index 0bacf67..3b9fee3 100644 --- a/src/Lib/Services/GraphClientService/GraphClientService.GetUsersAsync.cs +++ b/src/Lib/Services/GraphClientService/graph-calls/GetUsersAsync.cs @@ -28,9 +28,8 @@ public partial class GraphClientService apiEndpoint ??= $"users?$select={string.Join(",", _graphUserProps)}&$filter={queryFilter}&$count=true"; // Call the API to get the users. - string? apiResultString = await _graphClient.SendApiCallAsync( + string? apiResultString = await SendApiCallAsync( endpoint: apiEndpoint!, - apiPostBody: null, httpMethod: HttpMethod.Get ); diff --git a/src/Lib/Services/GraphClientService/GraphClientService.SendEmailAsync.cs b/src/Lib/Services/GraphClientService/graph-calls/SendEmailAsync.cs similarity index 80% rename from src/Lib/Services/GraphClientService/GraphClientService.SendEmailAsync.cs rename to src/Lib/Services/GraphClientService/graph-calls/SendEmailAsync.cs index 2e53ae5..718e68d 100644 --- a/src/Lib/Services/GraphClientService/GraphClientService.SendEmailAsync.cs +++ b/src/Lib/Services/GraphClientService/graph-calls/SendEmailAsync.cs @@ -17,10 +17,10 @@ public async Task SendEmailAsync(Message message, string sendAsUser) try { // Create a draft message. - string? draftResponse = await _graphClient.SendApiCallAsync( + string? draftResponse = await SendApiCallAsync( endpoint: $"users/{sendAsUser}/messages", - apiPostBody: messageJson, - httpMethod: HttpMethod.Post + httpMethod: HttpMethod.Post, + body: messageJson ); Message draftItem = JsonSerializer.Deserialize( @@ -29,16 +29,14 @@ public async Task SendEmailAsync(Message message, string sendAsUser) )!; // Send the draft message. - await _graphClient.SendApiCallAsync( + await SendApiCallAsync( endpoint: $"users/{sendAsUser}/messages/{draftItem.Id!}/send", - apiPostBody: null, httpMethod: HttpMethod.Post ); // Delete the draft message. - await _graphClient.SendApiCallAsync( + await SendApiCallAsync( endpoint: $"users/{sendAsUser}/messages/{draftItem.Id!}", - apiPostBody: null, httpMethod: HttpMethod.Delete ); diff --git a/src/Lib/Services/GraphClientService/interfaces/IGraphClientService.cs b/src/Lib/Services/GraphClientService/interfaces/IGraphClientService.cs index c88b0d7..003620e 100644 --- a/src/Lib/Services/GraphClientService/interfaces/IGraphClientService.cs +++ b/src/Lib/Services/GraphClientService/interfaces/IGraphClientService.cs @@ -1,5 +1,4 @@ -using SmallsOnline.MsGraphClient.Models; -using SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; +using SmallsOnline.PasswordExpirationNotifier.Lib.Models.Graph; namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; @@ -9,9 +8,9 @@ namespace SmallsOnline.PasswordExpirationNotifier.Lib.Services; public interface IGraphClientService { /// - /// The underlying instance. + /// The underlying for making the API calls. /// - GraphClient GraphClient { get; } + HttpClient GraphClient { get; } /// /// Get user information from the Microsoft Graph API.