diff --git a/README-uptov12.md b/README-uptov12.md index d47469e..f5b220b 100644 --- a/README-uptov12.md +++ b/README-uptov12.md @@ -52,6 +52,7 @@ You can also customise the configuration by setting these settings: | DenyLocalLogin | Allow users to login via Umbraco's standard login | | GroupBindings | The bindings for Entry ID group to Umbraco group | | SetGroupsOnLogin | Whether or not to reset the users assigned groups on each login | +| SetProfileImageOnLogin | Fetch the user's Entra ID profile photo on each login and set it as their Umbraco avatar | | TokenCacheType | Token Cache Type to use: InMemory, Session, Distributed (defaults to InMemory) | | DefaultGroups | The groups to assign to users regardless of any Entry ID groups assigned (defaults to none) | | LogUnmappedRolesAsWarning | Log any roles in Entra ID that aren't mapped as warnings for debugging purposes | @@ -81,6 +82,10 @@ You can now use the guid format for the Group Id like: `"xxxxxxxxx-xxxx-xxxx-xxx You can bind one Entra ID group to multiple Umbraco groups by including a comma separated list of groups. For example: `"GIBE\Producers" : "editors, writers"` will bind the `GIBE\Producers` group to both the `editors` and `writers` Umbraco groups. +## Profile Photo + +Setting `SetProfileImageOnLogin` to `true` requests the `User.Read` Microsoft Graph scope and, on every login, fetches the user's photo from `https://graph.microsoft.com/v1.0/me/photo/$value` and sets it as their Umbraco avatar. This is opt-in and off by default, since it adds an extra Graph API call to every login and requests an additional scope. If the user has no photo set in Entra ID, the login proceeds as normal without setting an avatar. If the Graph call fails for any other reason, the login still proceeds as normal but the failure is logged as a warning. + # Advanced usage ## Multiple tenants diff --git a/README-v13.md b/README-v13.md index 212a109..28ca5be 100644 --- a/README-v13.md +++ b/README-v13.md @@ -52,6 +52,7 @@ You can also customise the configuration by setting these settings: | DenyLocalLogin | Allow users to login via Umbraco's standard login | | GroupBindings | The bindings for Entry ID group to Umbraco group | | SetGroupsOnLogin | Whether or not to reset the users assigned groups on each login | +| SetProfileImageOnLogin | Fetch the user's Entra ID profile photo on each login and set it as their Umbraco avatar | | TokenCacheType | Token Cache Type to use: InMemory, Session, Distributed (defaults to InMemory) | | DefaultGroups | The groups to assign to users regardless of any Entry ID groups assigned (defaults to none) | | LogUnmappedRolesAsWarning | Log any roles in Entra ID that aren't mapped as warnings for debugging purposes | @@ -81,6 +82,10 @@ You can now use the guid format for the Group Id like: `"xxxxxxxxx-xxxx-xxxx-xxx You can bind one Entra ID group to multiple Umbraco groups by including a comma separated list of groups. For example: `"GIBE\Producers" : "editors, writers"` will bind the `GIBE\Producers` group to both the `editors` and `writers` Umbraco groups. +## Profile Photo + +Setting `SetProfileImageOnLogin` to `true` requests the `User.Read` Microsoft Graph scope and, on every login, fetches the user's photo from `https://graph.microsoft.com/v1.0/me/photo/$value` and sets it as their Umbraco avatar. This is opt-in and off by default, since it adds an extra Graph API call to every login and requests an additional scope. If the user has no photo set in Entra ID, the login proceeds as normal without setting an avatar. If the Graph call fails for any other reason, the login still proceeds as normal but the failure is logged as a warning. + # Advanced usage ## Multiple tenants diff --git a/README-v14plus.md b/README-v14plus.md index 2723b60..6d2b355 100644 --- a/README-v14plus.md +++ b/README-v14plus.md @@ -48,6 +48,7 @@ You can also customise the configuration by setting these settings: | DenyLocalLogin | Allow users to login via Umbraco's standard login | | GroupBindings | The bindings for Entry ID group to Umbraco group | | SetGroupsOnLogin | Whether or not to reset the users assigned groups on each login | +| SetProfileImageOnLogin | Fetch the user's Entra ID profile photo on each login and set it as their Umbraco avatar | | TokenCacheType | Token Cache Type to use: InMemory, Session, Distributed (defaults to InMemory) | | DefaultGroups | The groups to assign to users regardless of any Entry ID groups assigned (defaults to none) | | LogUnmappedRolesAsWarning | Log any roles in Entra ID that aren't mapped as warnings for debugging purposes | @@ -68,6 +69,10 @@ If you are having problems with NET BIOS group names, you can set the groups cla You can now use the guid format for the Group Id like: `"xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx": "admin", "44a38651-xxxx-4c92-b1b6-51cf26ff9bab": "editor"` +## Profile Photo + +Setting `SetProfileImageOnLogin` to `true` requests the `User.Read` Microsoft Graph scope and, on every login, fetches the user's photo from `https://graph.microsoft.com/v1.0/me/photo/$value` and sets it as their Umbraco avatar. This is opt-in and off by default, since it adds an extra Graph API call to every login and requests an additional scope. If the user has no photo set in Entra ID, the login proceeds as normal without setting an avatar. If the Graph call fails for any other reason, the login still proceeds as normal but the failure is logged as a warning. + # Advanced usage ## Multiple tenants diff --git a/README-v15plus.md b/README-v15plus.md index a4b88f5..bd53f1c 100644 --- a/README-v15plus.md +++ b/README-v15plus.md @@ -49,6 +49,7 @@ You can also customise the configuration by setting these settings: | DenyLocalLogin | Allow users to login via Umbraco's standard login | | GroupBindings | The bindings for Entry ID group to Umbraco group | | SetGroupsOnLogin | Whether or not to reset the users assigned groups on each login | +| SetProfileImageOnLogin | Fetch the user's Entra ID profile photo on each login and set it as their Umbraco avatar | | TokenCacheType | Token Cache Type to use: InMemory, Session, Distributed (defaults to InMemory) | | DefaultGroups | The groups to assign to users regardless of any Entry ID groups assigned (defaults to none) | | LogUnmappedRolesAsWarning | Log any roles in Entra ID that aren't mapped as warnings for debugging purposes | @@ -74,6 +75,10 @@ You can now use the guid format for the Group Id like: `"xxxxxxxxx-xxxx-xxxx-xxx You can bind one Entra ID group to multiple Umbraco groups by including a comma separated list of groups. For example: `"GIBE\Producers" : "editors, writers"` will bind the `GIBE\Producers` group to both the `editors` and `writers` Umbraco groups. +## Profile Photo + +Setting `SetProfileImageOnLogin` to `true` requests the `User.Read` Microsoft Graph scope and, on every login, fetches the user's photo from `https://graph.microsoft.com/v1.0/me/photo/$value` and sets it as their Umbraco avatar. This is opt-in and off by default, since it adds an extra Graph API call to every login and requests an additional scope. If the user has no photo set in Entra ID, the login proceeds as normal without setting an avatar. If the Graph call fails for any other reason, the login still proceeds as normal but the failure is logged as a warning. + # Advanced usage ## Multiple tenants diff --git a/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs b/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs index 915956a..b2f83c5 100644 --- a/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs +++ b/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs @@ -19,6 +19,8 @@ public class AzureSSOConfiguration public bool? SetGroupsOnLogin { get; set; } + public bool? SetProfileImageOnLogin { get; set; } + public bool? LogUnmappedRolesAsWarning { get; set; } public string[]? DefaultGroups { get; set; } @@ -53,6 +55,7 @@ public bool AllValuesEmpty() string.IsNullOrEmpty(Icon) && !GroupBindings.Any() && SetGroupsOnLogin == null && + SetProfileImageOnLogin == null && (DefaultGroups == null || !DefaultGroups.Any()) && DenyLocalLogin == null && AutoRedirectLoginToExternalProvider == null && diff --git a/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs b/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs index 87d53a5..faff0c9 100644 --- a/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs +++ b/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs @@ -42,17 +42,25 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthenticationInternal(this I builder.Services.ConfigureOptions(); + if (settings.Profiles.Any(x => x.Enabled && x.SetProfileImageOnLogin)) + { + builder.Services.AddHttpClient(); + } + #if NEW_BACKOFFICE builder.Services.AddSingleton(); #endif - var initialScopes = Array.Empty(); builder.AddBackOfficeExternalLogins(logins => { foreach (var profile in settings.Profiles) { if (profile.Enabled) { + var initialScopes = profile.SetProfileImageOnLogin + ? new[] { "User.Read" } + : Array.Empty(); + logins.AddBackOfficeLogin( backOfficeAuthenticationBuilder => { @@ -61,6 +69,7 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthenticationInternal(this I CopyCredentials(options, profile.Credentials); options.SignInScheme = SchemeForBackOffice(profile.Name, backOfficeAuthenticationBuilder); options.Events = new OpenIdConnectEvents(); + options.SaveTokens = profile.SetProfileImageOnLogin; }, displayName: profile.DisplayName ?? "Microsoft Entra ID", diff --git a/src/Umbraco.Community.AzureSSO/MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs b/src/Umbraco.Community.AzureSSO/MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs index 8ddb677..7728ba0 100644 --- a/src/Umbraco.Community.AzureSSO/MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs +++ b/src/Umbraco.Community.AzureSSO/MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs @@ -1,10 +1,17 @@ using System; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Claims; +using System.Security.Cryptography; using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; using Umbraco.Community.AzureSSO.Settings; +using Umbraco.Extensions; using Microsoft.Extensions.Logging; #if NEW_BACKOFFICE @@ -18,7 +25,8 @@ namespace Umbraco.Community.AzureSSO { public class MicrosoftAccountBackOfficeExternalLoginProviderOptions(AzureSsoSettings settings, - ILogger logger) + ILogger logger, + IServiceProvider serviceProvider) : IConfigureNamedOptions { public const string SchemeName = "MicrosoftAccount"; @@ -78,12 +86,19 @@ public void Configure(BackOfficeExternalLoginProviderOptions options, AzureSsoPr }, OnExternalLogin = (user, loginInfo) => { + var existingUsername = user.UserName; + if (profileSettings.SetGroupsOnLogin) { SetGroups(user, loginInfo, profileSettings); } SetName(user, loginInfo); + if (profileSettings.SetProfileImageOnLogin) + { + SetProfileImage(existingUsername, loginInfo); + } + if (user.Roles.Any()) { return true; @@ -153,6 +168,76 @@ private string DisplayName(ClaimsPrincipal claimsPrincipal, string defaultValue) return !string.IsNullOrWhiteSpace(displayName) ? displayName : defaultValue; } + private void SetProfileImage(string? username, ExternalLoginInfo loginInfo) + { + if (string.IsNullOrEmpty(username)) + { + return; + } + + try + { + var accessToken = loginInfo.AuthenticationTokens? + .FirstOrDefault(t => t.Name == "access_token")?.Value; + + if (string.IsNullOrEmpty(accessToken)) + { + return; + } + + var userService = serviceProvider.GetRequiredService(); + + if (userService.GetByUsername(username) is not { } umbracoUser) + { + return; + } + + var httpClientFactory = serviceProvider.GetRequiredService(); + using var httpClient = httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = httpClient + .GetAsync("https://graph.microsoft.com/v1.0/me/photo/$value") + .GetAwaiter() + .GetResult(); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode != System.Net.HttpStatusCode.NotFound) + { + logger.LogWarning( + "Failed to fetch the Entra ID profile picture for user {Username}. Microsoft Graph returned {StatusCode}", + username, response.StatusCode); + } + + return; + } + + var extension = response.Content.Headers.ContentType?.MediaType switch + { + "image/png" => "png", + "image/gif" => "gif", + "image/bmp" => "bmp", + _ => "jpg" + }; + + var avatarPath = $"UserAvatars/{$"{umbracoUser.Key}profile.{extension}".GenerateHash()}.{extension}"; + + var mediaFileManager = serviceProvider.GetRequiredService(); + using (var photoStream = response.Content.ReadAsStream()) + { + mediaFileManager.FileSystem.AddFile(avatarPath, photoStream, true); + } + + umbracoUser.Avatar = avatarPath; + userService.Save(umbracoUser); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch and set the Entra ID profile picture as the avatar for user {Username}", username); + } + } + public void Configure(BackOfficeExternalLoginProviderOptions options) { throw new NotImplementedException( diff --git a/src/Umbraco.Community.AzureSSO/Settings/AzureSSOSettings.cs b/src/Umbraco.Community.AzureSSO/Settings/AzureSSOSettings.cs index 27e919d..8178da4 100644 --- a/src/Umbraco.Community.AzureSSO/Settings/AzureSSOSettings.cs +++ b/src/Umbraco.Community.AzureSSO/Settings/AzureSSOSettings.cs @@ -28,6 +28,7 @@ public class AzureSsoProfileSettings(AzureSSOConfiguration configuration) public string Icon => configuration.Icon ?? "fa fa-lock"; public Dictionary GroupLookup => configuration.GroupBindings; public bool SetGroupsOnLogin => configuration.SetGroupsOnLogin ?? true; + public bool SetProfileImageOnLogin => configuration.SetProfileImageOnLogin ?? false; public bool LogUnmappedRolesAsWarning => configuration.LogUnmappedRolesAsWarning ?? false; public string[] DefaultGroups => configuration.DefaultGroups ?? System.Array.Empty(); public bool DenyLocalLogin => configuration.DenyLocalLogin ?? false; diff --git a/src/Umbraco.Community.AzureSSO/appsettings-schema.UmbracoCommunityAzureSSO.json b/src/Umbraco.Community.AzureSSO/appsettings-schema.UmbracoCommunityAzureSSO.json index bf0f5e1..943175e 100644 --- a/src/Umbraco.Community.AzureSSO/appsettings-schema.UmbracoCommunityAzureSSO.json +++ b/src/Umbraco.Community.AzureSSO/appsettings-schema.UmbracoCommunityAzureSSO.json @@ -106,6 +106,10 @@ "type": "boolean", "description": "The bindings for AD group to Umbraco group." }, + "SetProfileImageOnLogin": { + "type": "boolean", + "description": "Fetch the user's Entra ID profile photo on each login and set it as their Umbraco avatar." + }, "Icon": { "type": "string", "description": "The icon to use on the login button"