Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ class SyncSettings extends Component {
userSyncEnabled: (key === "userSyncEnabled") ? !props.userSyncEnabled : props.userSyncEnabled,
profileSyncEnabled: (key === "profileSyncEnabled") ? !props.profileSyncEnabled : props.profileSyncEnabled,
usernamePrefixEnabled: (key === "usernamePrefixEnabled") ? !props.usernamePrefixEnabled : props.usernamePrefixEnabled,
groupNamePrefixEnabled: (key === "groupNamePrefixEnabled") ? !props.groupNamePrefixEnabled : props.groupNamePrefixEnabled
groupNamePrefixEnabled: (key === "groupNamePrefixEnabled") ? !props.groupNamePrefixEnabled : props.groupNamePrefixEnabled,
removeExpiredRoleMembershipsEnabled: (key === "removeExpiredRoleMembershipsEnabled") ? !props.removeExpiredRoleMembershipsEnabled : props.removeExpiredRoleMembershipsEnabled
}));
}

Expand All @@ -71,7 +72,8 @@ class SyncSettings extends Component {
userSyncEnabled: props.userSyncEnabled,
profileSyncEnabled: props.profileSyncEnabled,
usernamePrefixEnabled: props.usernamePrefixEnabled,
groupNamePrefixEnabled: props.groupNamePrefixEnabled
groupNamePrefixEnabled: props.groupNamePrefixEnabled,
removeExpiredRoleMembershipsEnabled: props.removeExpiredRoleMembershipsEnabled
}, () => {
utils.utilities.notify(resx.get("SettingsUpdateSuccess"));
this.setState({
Expand Down Expand Up @@ -103,6 +105,10 @@ class SyncSettings extends Component {
tooltipMessage={resx.get("lblProfileSyncEnabled.Help")}
value={this.props.profileSyncEnabled}
onChange={this.onSettingChange.bind(this, "profileSyncEnabled")} />
<Switch label={resx.get("lblRemoveExpiredRoleMembershipsEnabled")} onText="" offText=""
tooltipMessage={resx.get("lblRemoveExpiredRoleMembershipsEnabled.Help")}
value={this.props.removeExpiredRoleMembershipsEnabled}
onChange={this.onSettingChange.bind(this, "removeExpiredRoleMembershipsEnabled")} />
</GridCell>
<GridCell columnSize={100}>
<h1 className={"sectionLabel"}>{resx.get("lblAADSettings")}</h1>
Expand Down Expand Up @@ -199,7 +205,8 @@ SyncSettings.propTypes = {
userSyncEnabled: PropTypes.bool,
profileSyncEnabled: PropTypes.bool,
usernamePrefixEnabled: PropTypes.bool,
groupNamePrefixEnabled: PropTypes.bool
groupNamePrefixEnabled: PropTypes.bool,
removeExpiredRoleMembershipsEnabled: PropTypes.bool
};


Expand All @@ -213,7 +220,8 @@ function mapStateToProps(state) {
userSyncEnabled: state.settings.userSyncEnabled,
profileSyncEnabled: state.settings.profileSyncEnabled,
usernamePrefixEnabled: state.settings.usernamePrefixEnabled,
groupNamePrefixEnabled: state.settings.groupNamePrefixEnabled
groupNamePrefixEnabled: state.settings.groupNamePrefixEnabled,
removeExpiredRoleMembershipsEnabled: state.settings.removeExpiredRoleMembershipsEnabled
};
}
export default connect(mapStateToProps)(SyncSettings);
4 changes: 4 additions & 0 deletions DotNetNuke.Authentication.Azure/Components/AzureConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ protected internal AzureConfig(string service, int portalId) : base(service, por
AutoMatchExistingUsers = bool.Parse(GetScopedSetting(Service + "_AutoMatchExistingUsers", portalId, "false"));
AuthorizationCodePrompt = GetScopedSetting(Service + "_AuthorizationCodePrompt", portalId, "");
DomainHint = GetScopedSetting(Service + "_DomainHint", portalId, "");
RemoveExpiredRoleMembershipsEnabled = bool.Parse(GetScopedSetting(Service + "_RemoveExpiredRoleMembershipsEnabled", portalId, "false"));
}

public static string GetSetting(string service, string key, int portalId, string defaultValue)
Expand Down Expand Up @@ -130,6 +131,8 @@ internal string GetScopedSetting(string key, int portalId, string defaultValue)
public bool UserSyncEnabled { get; set; }
[SortOrder(24)]
public bool AutoMatchExistingUsers { get; set; }
[SortOrder(25)]
public bool RemoveExpiredRoleMembershipsEnabled { get; set; }



Expand Down Expand Up @@ -177,6 +180,7 @@ public static void UpdateConfig(AzureConfig config)
UpdateScopedSetting(config.UseGlobalSettings, config.PortalID, config.Service + "_AutoMatchExistingUsers", config.AutoMatchExistingUsers.ToString());
UpdateScopedSetting(config.UseGlobalSettings, config.PortalID, config.Service + "_AuthorizationCodePrompt", config.AuthorizationCodePrompt);
UpdateScopedSetting(config.UseGlobalSettings, config.PortalID, config.Service + "_DomainHint", config.DomainHint);
UpdateScopedSetting(config.UseGlobalSettings, config.PortalID, config.Service + "_RemoveExpiredRoleMembershipsEnabled", config.RemoveExpiredRoleMembershipsEnabled.ToString());

UpdateConfig((OAuthConfigBase)config);

Expand Down
175 changes: 175 additions & 0 deletions DotNetNuke.Authentication.Azure/ScheduledTasks/SyncSchedule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ public override void DoWork()
{
var message = SyncRoles(portal.PortalID, settings);
ScheduleHistoryItem.AddLogNote(message);

// Remove expired role memberships from Entra ID if enabled
if (settings.RemoveExpiredRoleMembershipsEnabled)
{
var expiredMessage = RemoveExpiredRoleMemberships(portal.PortalID, settings);
ScheduleHistoryItem.AddLogNote(expiredMessage);
}
}
if (settings.UserSyncEnabled)
{
Expand Down Expand Up @@ -240,6 +247,174 @@ internal string SyncRoles(int portalId, AzureConfig settings)
}
}

internal string RemoveExpiredRoleMemberships(int portalId, AzureConfig settings)
{
try
{
if (!settings.RemoveExpiredRoleMembershipsEnabled || !settings.RoleSyncEnabled)
{
return $"Expired role membership removal is disabled for portal {portalId}.\n";
}

var syncErrorsDesc = "";
var syncErrors = 0;
var membershipsRemoved = 0;
var customRoleMappings = GetRoleMappingsForPortal(portalId, settings);

Utils.ValidateAadParameters(portalId, settings);
var graphClient = settings.GraphUseCustomParams
? new GraphClient(settings.AADApplicationId, settings.AADApplicationKey, settings.AADTenantId)
: new GraphClient(settings.APIKey, settings.APISecret, settings.TenantId);

// Get all DNN roles imported from AAD
var dnnAadRoles = GetDnnAadRoles(portalId);

foreach (var dnnRole in dnnAadRoles)
{
try
{
// Get expired role memberships for this role
var expiredMemberships = RoleController.Instance.GetUserRoles(portalId, null, dnnRole.RoleName)
.Where(ur => ur.ExpiryDate != Null.NullDate && ur.ExpiryDate < DateTime.Now && ur.Status == RoleStatus.Approved)
.ToList();

foreach (var expiredMembership in expiredMemberships)
{
try
{
// Get the user
var userInfo = UserController.GetUserById(portalId, expiredMembership.UserID);
if (userInfo == null) continue;

// Find the AAD user ID from the user authentication records
var aadUserId = GetAadUserIdFromDnnUser(userInfo, graphClient, settings);
if (string.IsNullOrEmpty(aadUserId)) continue;

// Determine the AAD group name
var aadGroupName = dnnRole.RoleName;
var mapping = customRoleMappings?.FirstOrDefault(x => x.DnnRoleName == dnnRole.RoleName);
if (mapping != null)
{
aadGroupName = mapping.AadRoleName;
}
else if (settings.GroupNamePrefixEnabled && dnnRole.RoleName.StartsWith($"{AzureConfig.ServiceName}-"))
{
aadGroupName = dnnRole.RoleName.Substring($"{AzureConfig.ServiceName}-".Length);
}

// Get the AAD group ID by name
var aadGroups = graphClient.GetAllGroups(aadGroupName);
var aadGroup = aadGroups?.CurrentPage?.FirstOrDefault(g => g.DisplayName == aadGroupName);
if (aadGroup != null)
{
// Remove the user from the AAD group
graphClient.RemoveGroupMember(aadGroup.Id, aadUserId);
membershipsRemoved++;
}
}
catch (Exception ex)
{
syncErrors++;
syncErrorsDesc += $"\nError removing expired role membership for user {expiredMembership.UserID} from role {dnnRole.RoleName}: {ex.Message}";
}
}
}
catch (Exception ex)
{
syncErrors++;
syncErrorsDesc += $"\nError processing expired memberships for role {dnnRole.RoleName}: {ex.Message}";
}
}

var syncResultDesc = "";
var syncResultStats = $"sync errors: {syncErrors}; expired memberships removed: {membershipsRemoved}";
if (!string.IsNullOrEmpty(syncErrorsDesc))
{
Logger.Error($"AAD Expired Role Membership Removal errors detected: {syncErrorsDesc}");
syncResultDesc = $"Portal {portalId} expired role membership removal completed with errors, check logs for more information ({syncResultStats}).\n";
}
else
{
syncResultDesc = $"Successfully removed expired role memberships for portal {portalId} ({syncResultStats}).\n";
}
return syncResultDesc;
}
catch (Exception e)
{
string message = $"Error while removing expired role memberships from portal {portalId}: {e}.\n";
Logger.Error(message);
return message;
}
}

private string GetAadUserIdFromDnnUser(UserInfo userInfo, GraphClient graphClient, AzureConfig settings)
{
try
{
// Get the authentication record for this user using AuthenticationController
var userAuthentications = AuthenticationController.GetUserAuthentications(userInfo.UserID);
var aadAuth = userAuthentications?.FirstOrDefault(ua => ua.AuthenticationType == AzureConfig.ServiceName);
if (aadAuth == null)
{
return null; // User is not an AAD user
}

// The AuthenticationToken contains the username used during authentication
var authToken = aadAuth.AuthenticationToken;
if (string.IsNullOrEmpty(authToken))
{
return null;
}

// If the username has a prefix, remove it to get the original AAD identifier
var aadIdentifier = authToken;
if (settings.UsernamePrefixEnabled && authToken.StartsWith($"{AzureConfig.ServiceName}-"))
{
aadIdentifier = authToken.Substring($"{AzureConfig.ServiceName}-".Length);
}

// Try to get the user from AAD using the identifier
// This could be a UPN, email, or object ID depending on the user mapping configuration
try
{
// First, try to get by object ID (if it looks like a GUID)
if (Guid.TryParse(aadIdentifier, out _))
{
var user = graphClient.GetUser(aadIdentifier);
return user?.Id;
}

// If not a GUID, search for the user by UPN/email
var users = graphClient.GetAllUsers();
while (users != null && users.Count > 0)
{
var foundUser = users.CurrentPage?.FirstOrDefault(u =>
u.UserPrincipalName?.Equals(aadIdentifier, StringComparison.OrdinalIgnoreCase) == true ||
u.Mail?.Equals(aadIdentifier, StringComparison.OrdinalIgnoreCase) == true ||
u.Id?.Equals(aadIdentifier, StringComparison.OrdinalIgnoreCase) == true);

if (foundUser != null)
{
return foundUser.Id;
}

users = users.NextPageRequest?.GetSync();
}
}
catch (Exception ex)
{
Logger.Warn($"Error looking up AAD user for DNN user {userInfo.UserID}: {ex.Message}", ex);
}

return null;
}
catch (Exception ex)
{
Logger.Error($"Error getting AAD user ID for DNN user {userInfo.UserID}: {ex.Message}", ex);
return null;
}
}

private UserInfo AddUser(int portalId, string userName, string displayName, string firstName, string lastName, string eMail)
{
var user = new UserInfo()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public class AzureADProviderSettings
public string DomainHint { get; set; }
[DataMember(Name = "autoMatchExistingUsers")]
public bool AutoMatchExistingUsers { get; set; }
[DataMember(Name = "removeExpiredRoleMembershipsEnabled")]
public bool RemoveExpiredRoleMembershipsEnabled { get; set; }



Expand Down Expand Up @@ -108,7 +110,8 @@ public static AzureADProviderSettings LoadSettings(string service, int portalId)
UsernamePrefixEnabled = config.UsernamePrefixEnabled,
GroupNamePrefixEnabled = config.GroupNamePrefixEnabled,
AuthorizationCodePrompt = config.AuthorizationCodePrompt,
DomainHint = config.DomainHint
DomainHint = config.DomainHint,
RemoveExpiredRoleMembershipsEnabled = config.RemoveExpiredRoleMembershipsEnabled
};
}

Expand Down Expand Up @@ -143,7 +146,8 @@ public static void SaveAdvancedSyncSettings(string service, int portalId, AzureA
UserSyncEnabled = settings.UserSyncEnabled,
ProfileSyncEnabled = settings.ProfileSyncEnabled,
UsernamePrefixEnabled = settings.UsernamePrefixEnabled,
GroupNamePrefixEnabled = settings.GroupNamePrefixEnabled
GroupNamePrefixEnabled = settings.GroupNamePrefixEnabled,
RemoveExpiredRoleMembershipsEnabled = settings.RemoveExpiredRoleMembershipsEnabled
};

AzureConfig.UpdateConfig(config);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@
<data name="lblProfileSyncEnabled.Text" xml:space="preserve">
<value>Profile Sync</value>
</data>
<data name="lblRemoveExpiredRoleMembershipsEnabled.Help" xml:space="preserve">
<value>When enabled, users with expired role memberships in DNN will be removed from corresponding groups in Entra ID during synchronization</value>
</data>
<data name="lblRemoveExpiredRoleMembershipsEnabled.Text" xml:space="preserve">
<value>Remove Expired Role Memberships from Entra ID</value>
</data>
<data name="lblRedirectUri.Help" xml:space="preserve">
<value>If specified, the redirect uri after a successful login. By default (blank), the user will be redirected to the page originating the login redirection.</value>
</data>
Expand Down