//****************************************************************************************************** // AdoSecurityProvider.cs - Gbtc // // Copyright © 2012, Grid Protection Alliance. All Rights Reserved. // // Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See // the NOTICE file distributed with this work for additional information regarding copyright ownership. // The GPA licenses this file to you under the MIT License (MIT), the "License"; you may // not use this file except in compliance with the License. You may obtain a copy of the License at: // // http://www.opensource.org/licenses/MIT // // Unless agreed to in writing, the subject software distributed under the License is distributed on an // "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the // License for the specific language governing permissions and limitations. // // Code Modification History: // ---------------------------------------------------------------------------------------------------- // 11/24/2010 - Mehulbhai P Thakkar // Generated original version of source code. // 07/18/2011 - Stephen C. Wills // Modified a SELECT statement in RefreshData to explicitly alias columns to // fix an error when using SQLite databases. // 09/01/2011 - Stephen C. Wills // Modified references to views in the database whose names have changed. // 09/28/2011 - Stephen C. Wills // Modified database queries to work with Oracle database. // 12/20/2012 - Starlynn Danyelle Gilliam // Modified Header. // 01/16/2014 - J. Ritchie Carroll // Added security context caching so security system can load without database access // using last cached security state. // //****************************************************************************************************** using System; using System.Collections.Generic; using System.Configuration; using System.Data; using System.Diagnostics; using System.Linq; using System.Security; using System.Text.RegularExpressions; using System.Threading; using GSF.Collections; using GSF.Configuration; using GSF.Data; using GSF.Diagnostics; using GSF.Identity; #pragma warning disable S2068 namespace GSF.Security { /// /// Represents an that uses ADO.NET data source (SQL Server, MySQL, Oracle, etc.) for its /// backend data store and authenticates internal users against Active Directory and external users against the database. /// /// /// Required config file entries (automatically added): /// /// /// /// ///
/// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// /// ]]> /// /// /// /// Minimum expected table schema for ADO Security Provider: /// /// /// /// public class AdoSecurityProvider : LdapSecurityProvider { #region [ Members ] // Constants private const string UserAccountTable = "UserAccount"; // Table name for user accounts private const string SecurityGroupTable = "SecurityGroup"; // Table name for security groups private const string SecurityGroupUserAccountTable = "SecurityGroupUserAccount"; // Table name for security group user accounts private const string ApplicationRoleTable = "ApplicationRole"; // Table name for application roles private const string ApplicationRoleUserAccountTable = "ApplicationRoleUserAccount"; // Table name for application role assignments for user accounts private const string ApplicationRoleSecurityGroupTable = "ApplicationRoleSecurityGroup"; // Table name for application role assignments for security groups /// /// Default Roles to be used if no Roles are supplied for a user. /// private const string DefaultDefaultRoles = ""; /// /// Default regular expression used to validate new database user passwords. /// public const string DefaultPasswordRequirementsRegex = "^.*(?=.{8,})(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).*$"; /// /// Default error message displayed when databases users fail regular expression test. /// public const string DefaultPasswordRequirementsError = "Invalid Password: Password must be at least 8 characters; must contain at least 1 number, 1 upper case letter, and 1 lower case letter"; /// /// Default value for . /// public const bool DefaultUseDatabaseLogging = true; /// /// Defines the provider ID for the . /// public new const int ProviderID = 1; private bool m_successfulPassThroughAuthentication; private string m_passwordRequirementsRegex; private string m_passwordRequirementsError; private bool? m_lastLoggedLoginResult; #endregion #region [ Constructor ] /// /// Initializes a new instance of the class. /// /// Name that uniquely identifies the user. public AdoSecurityProvider(string username) : this(username, true, false, true) { } /// /// Initializes a new instance of the class. /// /// Name that uniquely identifies the user. /// true if the security provider can refresh from the backend data store, otherwise false. /// true if the security provider can reset user password, otherwise false. /// true if the security provider can change user password, otherwise false. protected AdoSecurityProvider(string username, bool canRefreshData, bool canResetPassword, bool canChangePassword) : base(username, canRefreshData, canResetPassword, canChangePassword) { base.ConnectionString = "Eval(systemSettings.ConnectionString)"; m_passwordRequirementsRegex = DefaultPasswordRequirementsRegex; m_passwordRequirementsError = DefaultPasswordRequirementsError; UseDatabaseLogging = DefaultUseDatabaseLogging; } #endregion #region [ Properties ] /// /// Gets last exception reported by the . /// public Exception LastException { get; set; } /// /// Gets or sets flag that determines if and should /// write to the database. Defaults to true. /// /// /// Setting this flag to false may be necessary in cases where a database has been setup to use authentication /// but does not include an "AccessLog" or "ErrorLog" table. /// public bool UseDatabaseLogging { get; set; } /// /// Gets or sets the Default Roles used when a user does not have a role defined. /// The user still needs to exist but they won't require a Role and will be assigned the DefaultRoles. /// It is a comma separate list for multiple Roles. If an empty String is supplied a Role is required for the user. /// public string DefaultRoles { get; set; } #endregion #region [ Methods ] /// /// Loads saved security provider settings from the config file if the property is set to true. /// /// has a value of null or empty string. public override void LoadSettings() { base.LoadSettings(); // Make sure default settings exist ConfigurationFile config = ConfigurationFile.Current; CategorizedSettingsElementCollection settings = config.Settings[SettingsCategory]; settings.Add("DataProviderString", "Eval(systemSettings.DataProviderString)", "Configuration database ADO.NET data provider assembly type creation string to be used for connection to the backend security data store."); settings.Add("LdapPath", "", "Specifies the LDAP path used to initialize the security provider."); settings.Add("PasswordRequirementsRegex", DefaultPasswordRequirementsRegex, "Regular expression used to validate new passwords for database users."); settings.Add("PasswordRequirementsError", DefaultPasswordRequirementsError, "Error message to be displayed when new database user password fails regular expression test."); settings.Add("UseDatabaseLogging", DefaultUseDatabaseLogging, "Flag that determines if provider should write logs to the database."); settings.Add("DefaultRoles", DefaultDefaultRoles, "If set this is a list of Roles assigned to a user that has no defined Roles."); DefaultRoles = settings["DefaultRoles"].ValueAs(DefaultRoles); m_passwordRequirementsRegex = settings["PasswordRequirementsRegex"].ValueAs(m_passwordRequirementsRegex); m_passwordRequirementsError = settings["PasswordRequirementsError"].ValueAs(m_passwordRequirementsError); } /// /// Refreshes the . /// /// true if is refreshed, otherwise false. public override bool RefreshData() { if (string.IsNullOrEmpty(UserData.Username)) return false; try { UserData userData = new UserData(UserData.Username); // Initialize user data. userData.Initialize(); // We'll retrieve all the data we need about a user. // Table1: Information about the user. // Table2: Groups the user is a member of. // Table3: Roles that are assigned to the user either implicitly (NT groups) or explicitly (database) or through a group. DataSet securityContext; try { // Attempt to extract current security context from the database using (AdoDataConnection database = new AdoDataConnection(SettingsCategory)) { securityContext = ExtractSecurityContext(database.Connection, ex => LogError(ex.Source, ex.ToString()), UserData.Username); } } catch (InvalidOperationException) { // Failed to open ADO connection, attempt to fall back on using security context from last known cached information using (AdoSecurityCache cache = AdoSecurityCache.GetCurrentCache()) securityContext = cache.DataSet; } try { // Make sure primary keys are defined on tables that we use "Find" function on later... UpdatePrimaryKey(securityContext.Tables[SecurityGroupTable], "SecurityGroupID"); UpdatePrimaryKey(securityContext.Tables[ApplicationRoleTable], "ApplicationRoleID"); } catch (Exception ex) { // Just going to fall back on slower "Select" function if this fails... Logger.SwallowException(ex, "Failed to update primary key from cached security context, falling back on slower select function.", additionalFlags: MessageFlags.PerformanceIssue); } // Validate that needed security tables exist in the data set if ((object)securityContext == null) throw new SecurityException("Failed to load a valid security context. Cannot proceed with user authentication."); foreach (string securityTable in s_securityTables) { if (!securityContext.Tables.Contains(securityTable)) throw new SecurityException($"Failed to load a valid security context - missing table '{securityTable}'. Cannot proceed with user authentication."); } if (securityContext.Tables[ApplicationRoleTable].Rows.Count == 0 && string.IsNullOrEmpty(DefaultRoles)) throw new SecurityException($"Failed to load a valid security context - no application roles were found for node ID '{DefaultNodeID}', verify the node ID in the config file '{ConfigurationFile.Current.Configuration.FilePath}'. Cannot proceed with user authentication."); DataRow userAccount = null; Guid userAccountID = Guid.Empty; string userSID = UserInfo.UserNameToSID(userData.Username); // Filter user account data for the current user. DataRow[] userAccounts = securityContext.Tables[UserAccountTable].Select($"Name = '{EncodeEscapeSequences(userSID)}'"); // If SID based lookup failed, try lookup by user name. Note that is critical that SID based lookup // take precedence over name based lookup for proper cross-platform authentication. if (userAccounts.Length == 0) userAccounts = securityContext.Tables[UserAccountTable].Select($"Name = '{EncodeEscapeSequences(userData.Username)}'"); if (userAccounts.Length == 0) { // User doesn't exist in the database, however, user may exist in an NT authentication group which // may have an explicit role assignment. To test for this case we make the assumption that this is // a Windows authenticated user and test for rights within groups userData.IsDefined = true; userData.IsExternal = false; } else { userAccount = userAccounts[0]; userData.IsDefined = true; userData.IsExternal = !Convert.ToBoolean(userAccount["UseADAuthentication"]); userAccountID = Guid.Parse(Convert.ToString(userAccount["ID"])); } if (userData.IsExternal && (object)userAccount != null) { // Load database user details if (string.IsNullOrEmpty(userData.LoginID)) userData.LoginID = userData.Username; if (!Convert.IsDBNull(userAccount["Password"])) userData.Password = Convert.ToString(userAccount["Password"]); if (!Convert.IsDBNull(userAccount["FirstName"])) userData.FirstName = Convert.ToString(userAccount["FirstName"]); if (!Convert.IsDBNull(userAccount["LastName"])) userData.LastName = Convert.ToString(userAccount["LastName"]); if (!Convert.IsDBNull(userAccount["Phone"])) userData.PhoneNumber = Convert.ToString(userAccount["Phone"]); if (!Convert.IsDBNull(userAccount["Email"])) userData.EmailAddress = Convert.ToString(userAccount["Email"]); if (!Convert.IsDBNull(userAccount["ChangePasswordOn"])) userData.PasswordChangeDateTime = Convert.ToDateTime(userAccount["ChangePasswordOn"]); if (!Convert.IsDBNull(userAccount["CreatedOn"])) userData.AccountCreatedDateTime = Convert.ToDateTime(userAccount["CreatedOn"]); // For possible future use: //if (!Convert.IsDBNull(userDataRow["UserCompanyName"])) // userData.CompanyName = Convert.ToString(userDataRow["UserCompanyName"]); //if (!Convert.IsDBNull(userDataRow["UserSecurityQuestion"])) // userData.SecurityQuestion = Convert.ToString(userDataRow["UserSecurityQuestion"]); //if (!Convert.IsDBNull(userDataRow["UserSecurityAnswer"])) // userData.SecurityAnswer = Convert.ToString(userDataRow["UserSecurityAnswer"]); } else { // Load implicitly assigned groups - this happens via user's NT/AD groups that get loaded into the // user data group collection. When database group definitions are defined with the same name as // their NT/AD equivalents, this will allow automatic external group management. base.RefreshData(userData, userData.Groups, ProviderID); } // Administrator can lock out NT/AD user as well as database-only user via database if (!userData.IsLockedOut && (object)userAccount != null && !Convert.IsDBNull(userAccount["LockedOut"])) userData.IsLockedOut = Convert.ToBoolean(userAccount["LockedOut"]); // At this point an NT/AD based user will have a list of groups that is known to be available to the user. A database // user will have no groups defined yet. The next step will be to load any explicitly assigned groups the user is a // member of. Users can be explicitly assigned to any group, database or NT/AD group. This allows flexibility for // local database users to be assigned to an NT/AD group (in the database) as well as NT/AD users to be a member of // local database groups - allowing customizable role assignments to be fully managed via groups. // Note: some groups, such as "Everyone" (S-1-1-0), will not be automatically returned as valid groups by the base // LdapSecurityProvider (see UserInfo class for details). If the security provider needs to support such groups, // these groups will need to be manually added to the groups list here before testing to see if the user is a // member of these groups. Since these types of groups could be considered a security risk by some applications, // it is recommended that if this type of functionality is enabled, that it can be disabled in the configuration. if (userAccountID != Guid.Empty) { // Filter explicitly assigned security groups for current user DataRow[] userGroups = securityContext.Tables[SecurityGroupUserAccountTable].Select($"UserAccountID = '{EncodeEscapeSequences(userAccountID.ToString())}'"); foreach (DataRow row in userGroups) { // Locate associated security group record DataRow securityGroup = null; if (securityContext.Tables[SecurityGroupTable].PrimaryKey.Length > 0) { securityGroup = securityContext.Tables[SecurityGroupTable].Rows.Find(row["SecurityGroupID"]); } else { DataRow[] securityGroups = securityContext.Tables[SecurityGroupTable].Select($"ID = '{EncodeEscapeSequences(row["SecurityGroupID"].ToString())}'"); if (securityGroups.Length > 0) securityGroup = securityGroups[0]; } if ((object)securityGroup == null || Convert.IsDBNull(securityGroup["Name"])) continue; // Just in case a database user was manually assigned to an NT/AD group, make sure to convert // the group name back to it's human readable name (from a SID). The UserInfo.SIDToAccountName // function will return the original parameter value if name cannot be converted - this allows // the function to work for database group names as well. string groupName = UserInfo.SIDToAccountName(Convert.ToString(securityGroup["Name"])); if (!userData.Groups.Contains(groupName, StringComparer.OrdinalIgnoreCase)) userData.Groups.Add(groupName); } } // Explicitly assigned user roles will take precedence over any roles that may be implicitly available to the // user based on what group they may be a member of. In practice this means even though a user may be a member // of a group with role assignments, if there are any roles directly assigned to the user these will be their // effective roles. For example, if a user is in a group that is in the "Administrator" role and the user has // an explicit role assignment for the "Viewer" role - the user will be in the "Viewer" role only. This allows // individual users to have overridden role assignments even though they may also be part of a group. userData.Roles.Clear(); // Filter explicitly assigned application roles for current user - this will return an empty set if no // explicitly defined roles exist for the user -or- user doesn't exist in the database. DataRow[] userApplicationRoles = securityContext.Tables[ApplicationRoleUserAccountTable].Select($"UserAccountID = '{EncodeEscapeSequences(userAccountID.ToString())}'"); // If no explicitly assigned application roles are found for the current user, we check for implicitly assigned // application roles based on the role assignments of the groups the user is a member of. if (userApplicationRoles.Length == 0) { List implicitRoles = new List(); // Filter implicitly assigned application roles for each of the user's database and NT/AD groups. Note that // even if user is not defined in the database, an NT/AD group they are a member of may be associated with // a role - this allows the user to get a role assignment based on this group. foreach (string groupName in userData.Groups) { // Convert NT/AD group names back to SIDs for lookup in the database string groupSID = UserInfo.GroupNameToSID(groupName); // Locate associated security group record DataRow[] securityGroups = securityContext.Tables[SecurityGroupTable].Select($"Name = '{EncodeEscapeSequences(groupSID)}'"); // If SID based lookup failed, try lookup by group name. Note that is critical that SID based lookup // take precedence over name based lookup for proper cross-platform authentication. if (securityGroups.Length == 0) securityGroups = securityContext.Tables[SecurityGroupTable].Select($"Name = '{EncodeEscapeSequences(groupName)}'"); if (securityGroups.Length > 0) { // Found security group by name, access group ID to lookup application roles defined for the group DataRow securityGroup = securityGroups[0]; if (!Convert.IsDBNull(securityGroup["ID"])) implicitRoles.AddRange(securityContext.Tables[ApplicationRoleSecurityGroupTable].Select($"SecurityGroupID = '{EncodeEscapeSequences(securityGroup["ID"].ToString())}'")); } } userApplicationRoles = implicitRoles.ToArray(); } // Populate user roles collection - both ApplicationRoleUserAccount and ApplicationRoleSecurityGroup tables contain ApplicationRoleID column foreach (DataRow role in userApplicationRoles) { if (Convert.IsDBNull(role["ApplicationRoleID"])) continue; // Locate associated application role record DataRow applicationRole = null; if (securityContext.Tables[ApplicationRoleTable].PrimaryKey.Length > 0) { applicationRole = securityContext.Tables[ApplicationRoleTable].Rows.Find(role["ApplicationRoleID"]); } else { DataRow[] applicationRoles = securityContext.Tables[ApplicationRoleTable].Select($"ID = '{EncodeEscapeSequences(role["ApplicationRoleID"].ToString())}'"); if (applicationRoles.Length > 0) applicationRole = applicationRoles[0]; } if ((object)applicationRole == null || Convert.IsDBNull(applicationRole["Name"])) continue; // Found application role by ID, add role name to user roles if not already defined string roleName = Convert.ToString(applicationRole["Name"]); if (!string.IsNullOrEmpty(roleName) && !userData.Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)) userData.Roles.Add(roleName); } // Add DefaultRoles if no Roles are present if (!string.IsNullOrEmpty(DefaultRoles) && userData.Roles.Count() == 0) foreach(string role in DefaultRoles.Split(',')) userData.Roles.Add(role); UserData = userData; // Cache last user roles ThreadPool.QueueUserWorkItem(CacheLastUserRoles); return true; } catch (Exception ex) { LastException = ex; LogError(ex.Source, ex.ToString()); throw; } } /// /// Authenticates the user. /// /// true if the user is authenticated, otherwise false. public override bool Authenticate() { Exception authenticationException = null; // Reset authenticated state and failure reason bool isAuthenticated = false; AuthenticationFailureReason = null; m_successfulPassThroughAuthentication = false; // Test for pre-authentication failure modes. Note that blank password should be allowed so that LDAP // can authenticate current credentials for pass through authentication, if desired. if (!UserData.IsDefined) { AuthenticationFailureReason = $"User \"{UserData.LoginID}\" is not defined."; } else if (UserData.IsDisabled) { AuthenticationFailureReason = $"User \"{UserData.LoginID}\" is disabled."; } else if (UserData.IsLockedOut) { AuthenticationFailureReason = $"User \"{UserData.LoginID}\" is locked out."; } else if (UserData.PasswordChangeDateTime != DateTime.MinValue && UserData.PasswordChangeDateTime <= DateTime.UtcNow) { AuthenticationFailureReason = $"User \"{UserData.LoginID}\" has an expired password or password has not been set."; } else if (UserData.Roles.Count == 0) { AuthenticationFailureReason = $"User \"{UserData.LoginID}\" has not been assigned any roles and therefore has no rights. Contact your administrator."; } else { try { // Determine if user is LDAP or database authenticated if (UserData.IsExternal) { // Test password for database user authentication isAuthenticated = UserData.Password == Password || UserData.Password == SecurityProviderUtility.EncryptPassword(Password); } else { // Execute operating system authentication using provided credentials isAuthenticated = base.Authenticate(); } } catch (Exception ex) { authenticationException = ex; } } // Update the UserData object with new authentication state IsUserAuthenticated = isAuthenticated; // Determine if user succeeded with pass-through authentication m_successfulPassThroughAuthentication = (string.IsNullOrWhiteSpace(Password) && IsUserAuthenticated); try { // Log user authentication result LogAuthenticationAttempt(IsUserAuthenticated); } catch (Exception ex) { // Writing data will fail for read-only databases; // all we can do is track last exception in this case LastException = ex; Log.Publish(MessageLevel.Warning, MessageFlags.SecurityMessage, "Authenticate", "Failed to log authentication attempt to database.", "Database or AccessLog table may be read-only or inaccessible.", ex); } // If an exception occurred during authentication, rethrow it after logging authentication attempt if ((object)authenticationException != null) { LastException = authenticationException; LogError(authenticationException.Source, authenticationException.ToString()); throw authenticationException; } return IsUserAuthenticated; } /// /// Changes user password in the backend data store. /// /// User's current password. /// User's new password. /// true if the password is changed, otherwise false. /// does not meet password requirements. public override bool ChangePassword(string oldPassword, string newPassword) { // Check prerequisites. if (!UserData.IsDefined || UserData.IsDisabled || UserData.IsLockedOut) return false; try { // Check if old and new passwords are different. if (oldPassword == newPassword) throw new Exception("New password cannot be same as old password."); // If needed, perform password change for internal NT/AD users. if (!UserData.IsExternal) return base.ChangePassword(oldPassword, newPassword); // Verify old password. if (oldPassword != UserData.Password && SecurityProviderUtility.EncryptPassword(oldPassword) != UserData.Password) return false; // Verify new password. if (!Regex.IsMatch(newPassword, m_passwordRequirementsRegex)) throw new SecurityException(m_passwordRequirementsError); using (IDbConnection dbConnection = (new AdoDataConnection(SettingsCategory)).Connection) { if ((object)dbConnection == null) return false; bool oracle = dbConnection.GetType().Name == "OracleConnection"; IDbCommand command = dbConnection.CreateCommand(); command.CommandType = CommandType.Text; if (command.Connection.ConnectionString.Contains("Microsoft.Jet.OLEDB")) command.CommandText = "UPDATE UserAccount SET [Password] = @newPassword, ChangePasswordOn = @changePasswordOn WHERE Name = @name AND [Password] = @oldPassword"; else if (oracle) command.CommandText = "UPDATE UserAccount SET Password = :newPassword, ChangePasswordOn = :changePasswordOn WHERE Name = :name AND Password = :oldPassword"; else command.CommandText = "UPDATE UserAccount SET Password = @newPassword, ChangePasswordOn = @changePasswordOn WHERE Name = @name AND Password = @oldPassword"; IDbDataParameter param = command.CreateParameter(); param.ParameterName = oracle ? ":newPassword" : "@newPassword"; param.Value = SecurityProviderUtility.EncryptPassword(newPassword); command.Parameters.Add(param); param = command.CreateParameter(); param.ParameterName = oracle ? ":changePasswordOn" : "@changePasswordOn"; param.Value = DateTime.UtcNow.AddDays(90.0D); command.Parameters.Add(param); param = command.CreateParameter(); param.ParameterName = oracle ? ":name" : "@name"; param.Value = UserData.Username; command.Parameters.Add(param); param = command.CreateParameter(); param.ParameterName = oracle ? ":oldPassword" : "@oldPassword"; param.Value = UserData.Password; command.Parameters.Add(param); command.ExecuteNonQuery(); } return true; } catch (SecurityException ex) { LastException = ex; Log.Publish(MessageLevel.Warning, MessageFlags.SecurityMessage, "ChangePassword", "Security exception occurred during attempt to change password.", exception: ex); throw; } catch (Exception ex) { LastException = ex; LogError(ex.Source, ex.ToString()); throw; } finally { RefreshData(); } } /// /// Logs user authentication attempt. /// /// true if user authentication was successful, otherwise false. protected virtual void LogAuthenticationAttempt(bool loginSuccess) { if (m_lastLoggedLoginResult == loginSuccess) return; if ((object)UserData != null && !string.IsNullOrWhiteSpace(UserData.Username)) { string message = $"User \"{UserData.Username}\" login attempt {(loginSuccess ? "succeeded using " + (m_successfulPassThroughAuthentication ? "pass-through authentication" : "user acquired password") : "failed")}."; EventLogEntryType entryType = loginSuccess ? EventLogEntryType.SuccessAudit : EventLogEntryType.FailureAudit; // Suffix authentication failure reason on failed logins if available if (!loginSuccess && !string.IsNullOrWhiteSpace(AuthenticationFailureReason)) message = string.Concat(message, " ", AuthenticationFailureReason); // Attempt to write success or failure to the event log try { Log.Publish(MessageLevel.Info, MessageFlags.SecurityMessage, "AuthenticationAttempt", message); LogEvent(ApplicationName, message, entryType, 1); } catch (Exception ex) { LogError(ex.Source, ex.ToString()); } // Attempt to write success or failure to the database - we allow caller to catch any possible exceptions here so that // database exceptions can be tracked separately (via LastException property) from other login exceptions, e.g., when // a read-only database is being used or current user only has read-only access to database. if (!string.IsNullOrWhiteSpace(SettingsCategory) && UseDatabaseLogging) { using (AdoDataConnection connection = new AdoDataConnection(SettingsCategory)) { connection.ExecuteNonQuery("INSERT INTO AccessLog (UserName, AccessGranted) VALUES ({0}, {1})", UserData.Username, loginSuccess); } } } m_lastLoggedLoginResult = loginSuccess; } /// /// Logs information about an encountered exception to the backend data store. /// /// Source of the exception. /// Detailed description of the exception. /// true if logging was successful, otherwise false. protected virtual bool LogError(string source, string message) { if (!string.IsNullOrWhiteSpace(SettingsCategory) && !string.IsNullOrWhiteSpace(source) && !string.IsNullOrWhiteSpace(message)) { Log.Publish(MessageLevel.Error, MessageFlags.SecurityMessage, source, message); if (UseDatabaseLogging) { try { using (AdoDataConnection connection = new AdoDataConnection(SettingsCategory)) { connection.ExecuteNonQuery("INSERT INTO ErrorLog (Source, Message) VALUES ({0}, {1})", source, message); } return true; } catch (Exception ex) { // Writing data will fail for read-only databases; // all we can do is track last exception in this case LastException = ex; Log.Publish(MessageLevel.Warning, MessageFlags.SecurityMessage, "LogErrorToDatabase", "Failed to log error to database.", "Database or ErrorLog table may be read-only or inaccessible.", ex); } } } return false; } /// /// Gets the LDAP path. /// /// The LDAP path. protected override string GetLdapPath() { // Load connection settings from the system settings category ConfigurationFile config = ConfigurationFile.Current; CategorizedSettingsElementCollection configSettings = config.Settings[SettingsCategory]; return configSettings["LdapPath"].Value; } // Cache last user roles and monitor for changes private void CacheLastUserRoles(object state) { try { // Using an inter-process cache for user roles using (UserRoleCache userRoleCache = UserRoleCache.GetCurrentCache()) { HashSet currentRoles; string[] cachedRoles; // Retrieve current user roles currentRoles = new HashSet(UserData.Roles, StringComparer.OrdinalIgnoreCase); // Attempt to retrieve cached user roles cachedRoles = userRoleCache[UserData.Username]; bool rolesChanged = false; string message; EventLogEntryType entryType; if ((object)cachedRoles == null) { if (currentRoles.Count == 0) { // New user access granted message = $"Initial Encounter: user \"{UserData.Username}\" attempted login with no assigned roles."; entryType = EventLogEntryType.FailureAudit; rolesChanged = true; } else { // New user access granted message = $"Initial Encounter: user \"{UserData.Username}\" granted access with role{(currentRoles.Count == 1 ? "" : "s")} \"{currentRoles.ToDelimitedString(", ")}\"."; entryType = EventLogEntryType.Information; rolesChanged = true; } } else if (!currentRoles.SetEquals(cachedRoles)) { if (currentRoles.Count == 0) { // New user access granted message = $"Subsequent Encounter: user \"{UserData.Username}\" attempted login with no assigned roles - role assignment that existed at last login was \"{cachedRoles.ToDelimitedString(", ")}\"."; entryType = EventLogEntryType.FailureAudit; rolesChanged = true; } else { // User role access changed message = $"Subsequent Encounter: user \"{UserData.Username}\" granted access with new role{(currentRoles.Count == 1 ? "" : "s")} \"{currentRoles.ToDelimitedString(", ")}\" - role assignment is different from last login, was \"{cachedRoles.ToDelimitedString(", ")}\"."; entryType = EventLogEntryType.Warning; rolesChanged = true; } } else { if (currentRoles.Count == 0) { // New user access granted message = $"Subsequent Encounter: user \"{UserData.Username}\" attempted login with no assigned roles - same as last login attempt."; entryType = EventLogEntryType.FailureAudit; rolesChanged = true; } else { message = $"Subsequent Encounter: user \"{UserData.Username}\" granted access with role{(currentRoles.Count == 1 ? "" : "s")} \"{currentRoles.ToDelimitedString(", ")}\" - role assignment is the same as last login."; entryType = EventLogEntryType.SuccessAudit; } } // Log granted role access to event log try { LogEvent(ApplicationName, message, entryType, 0); } catch (Exception ex) { LogError(ex.Source, ex.ToString()); } // If role has changed, update cache if (rolesChanged) { userRoleCache[UserData.Username] = currentRoles.ToArray(); userRoleCache.WaitForSave(); MessageLevel level; switch (entryType) { case EventLogEntryType.SuccessAudit: case EventLogEntryType.Information: level = MessageLevel.Info; break; case EventLogEntryType.FailureAudit: case EventLogEntryType.Warning: level = MessageLevel.Warning; break; default: level = MessageLevel.Error; break; } Log.Publish(level, MessageFlags.SecurityMessage, "UserRoleAccessChanged", message); } } } catch (Exception ex) { LogError(ex.Source, ex.ToString()); } } /// /// Gets a list of Roles for this user for a specified ApplicationId. /// /// The applicationId for the roles to be returned. /// The roles that the specified user has. public override List GetUserRoles(string applicationId) { List roles = new List(); DataSet securityContext = new DataSet("AdoSecurityContext"); try { Guid nodeId = new Guid(applicationId); // Attempt to extract current security context from the database using (AdoDataConnection database = new AdoDataConnection(SettingsCategory)) { // Read the security context tables from the database connection foreach (string securityTable in s_securityTables) { AddSecurityContextTable(database.Connection, securityContext, securityTable, securityTable == ApplicationRoleTable ? nodeId : default(Guid)); } } } catch (InvalidOperationException) { // Failed to open ADO connection, return empty roleset instead return roles; } string userSID = UserInfo.UserNameToSID(UserData.Username); // Filter user account data for the current user. DataRow[] userAccounts = securityContext.Tables[UserAccountTable].Select($"Name = '{EncodeEscapeSequences(userSID)}'"); // If SID based lookup failed, try lookup by user name. Note that is critical that SID based lookup // take precedence over name based lookup for proper cross-platform authentication. if (userAccounts.Length == 0) userAccounts = securityContext.Tables[UserAccountTable].Select($"Name = '{EncodeEscapeSequences(UserData.Username)}'"); Guid userAccountID = new Guid(); if (userAccounts.Length > 0) userAccountID = Guid.Parse(Convert.ToString(userAccounts[0]["ID"])); // Filter explicitly assigned application roles for current user - this will return an empty set if no // explicitly defined roles exist for the user -or- user doesn't exist in the database. DataRow[] userApplicationRoles = securityContext.Tables[ApplicationRoleUserAccountTable].Select($"UserAccountID = '{EncodeEscapeSequences(userAccountID.ToString())}'"); // If no explicitly assigned application roles are found for the current user, we check for implicitly assigned // application roles based on the role assignments of the groups the user is a member of. if (userApplicationRoles.Length == 0) { List implicitRoles = new List(); // Filter implicitly assigned application roles for each of the user's database and NT/AD groups. Note that // even if user is not defined in the database, an NT/AD group they are a member of may be associated with // a role - this allows the user to get a role assignment based on this group. foreach (string groupName in UserData.Groups) { // Convert NT/AD group names back to SIDs for lookup in the database string groupSID = UserInfo.GroupNameToSID(groupName); // Locate associated security group record DataRow[] securityGroups = securityContext.Tables[SecurityGroupTable].Select($"Name = '{EncodeEscapeSequences(groupSID)}'"); // If SID based lookup failed, try lookup by group name. Note that is critical that SID based lookup // take precedence over name based lookup for proper cross-platform authentication. if (securityGroups.Length == 0) securityGroups = securityContext.Tables[SecurityGroupTable].Select($"Name = '{EncodeEscapeSequences(groupName)}'"); if (securityGroups.Length > 0) { // Found security group by name, access group ID to lookup application roles defined for the group DataRow securityGroup = securityGroups[0]; if (!Convert.IsDBNull(securityGroup["ID"])) implicitRoles.AddRange(securityContext.Tables[ApplicationRoleSecurityGroupTable].Select($"SecurityGroupID = '{EncodeEscapeSequences(securityGroup["ID"].ToString())}'")); } } userApplicationRoles = implicitRoles.ToArray(); } // Populate user roles collection - both ApplicationRoleUserAccount and ApplicationRoleSecurityGroup tables contain ApplicationRoleID column foreach (DataRow role in userApplicationRoles) { if (Convert.IsDBNull(role["ApplicationRoleID"])) continue; // Locate associated application role record DataRow applicationRole = null; if (securityContext.Tables[ApplicationRoleTable].PrimaryKey.Length > 0) { applicationRole = securityContext.Tables[ApplicationRoleTable].Rows.Find(role["ApplicationRoleID"]); } else { DataRow[] applicationRoles = securityContext.Tables[ApplicationRoleTable].Select($"ID = '{EncodeEscapeSequences(role["ApplicationRoleID"].ToString())}'"); if (applicationRoles.Length > 0) applicationRole = applicationRoles[0]; } if ((object)applicationRole == null || Convert.IsDBNull(applicationRole["Name"])) continue; // Found application role by ID, add role name to user roles if not already defined string roleName = Convert.ToString(applicationRole["Name"]); if (!string.IsNullOrEmpty(roleName) && !roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)) roles.Add(roleName); } // Add DefaultRoles if no Roles are present if (!string.IsNullOrEmpty(DefaultRoles) && roles.Count() == 0) foreach (string role in DefaultRoles.Split(',')) roles.Add(role); return roles; } #endregion #region [ Static ] // Static Events /// /// Raised when the security context is refreshed. /// public static event EventHandler>> SecurityContextRefreshed; // Static Fields private static readonly LogPublisher Log = Logger.CreatePublisher(typeof(AdoSecurityProvider), MessageClass.Component); private static readonly string[] s_securityTables = { UserAccountTable, // User accounts SecurityGroupTable, // Security groups SecurityGroupUserAccountTable, // Security group user accounts (i.e., users in a group) ApplicationRoleTable, // Application roles (node specific) ApplicationRoleUserAccountTable, // Application role assignments for user accounts ApplicationRoleSecurityGroupTable // Application role assignments for security groups }; /// /// Gets current default Node ID for security. /// public static readonly Guid DefaultNodeID; // Static Constructor static AdoSecurityProvider() { // Access configuration file system settings CategorizedSettingsElementCollection systemSettings = ConfigurationFile.Current.Settings["systemSettings"]; // Make sure NodeID setting exists systemSettings.Add("NodeID", Guid.NewGuid().ToString(), "Unique Node ID"); // Get NodeID as currently defined in configuration file DefaultNodeID = Guid.Parse(systemSettings["NodeID"].Value.ToNonNullString(Guid.NewGuid().ToString())); // Determine whether the node exists in the database and create it if it doesn't if (DefaultNodeID != Guid.Empty) { try { using (AdoDataConnection connection = new AdoDataConnection(DefaultSettingsCategory)) { const string NodeCountFormat = "SELECT COUNT(*) FROM Node"; const string NodeInsertFormat = "INSERT INTO Node(Name, Description, Enabled) VALUES('Default', 'Default node', 1)"; const string NodeUpdateFormat = "UPDATE Node SET ID = {0}"; int nodeCount = connection.ExecuteScalar(NodeCountFormat) ?? 0; if (nodeCount == 0) { connection.ExecuteNonQuery(NodeInsertFormat); connection.ExecuteNonQuery(NodeUpdateFormat, connection.Guid(DefaultNodeID)); } } } catch (Exception ex) { Logger.SwallowException(ex, "AdoSecurityProvider: Failed to create default database node ID"); } } } // Static Methods /// /// Extracts the current security context from the database. /// /// Existing database connection used to extract security context. /// Exception handler to use for any exceptions encountered while updating security cache. /// Current user name, if applicable in calling context. /// A new containing the latest security context. public static DataSet ExtractSecurityContext(IDbConnection connection, Action exceptionHandler, string currentUserName = null) { DataSet securityContext = new DataSet("AdoSecurityContext"); // Read the security context tables from the database connection foreach (string securityTable in s_securityTables) { AddSecurityContextTable(connection, securityContext, securityTable, securityTable == ApplicationRoleTable ? DefaultNodeID : default(Guid)); } // Always cache security context after successful extraction Thread cacheSecurityContext = new Thread(() => { try { using (AdoSecurityCache cache = AdoSecurityCache.GetCurrentCache()) { cache.DataSet = securityContext; cache.WaitForSave(); } } catch (Exception ex) { exceptionHandler(ex); } }); cacheSecurityContext.IsBackground = true; cacheSecurityContext.Start(); // Raise an event that will send a notification when the security context for a user has been refreshed if ((object)SecurityContextRefreshed != null) { try { Dictionary userRoles = new Dictionary(StringComparer.OrdinalIgnoreCase); string[] roles; using (UserRoleCache userRoleCache = UserRoleCache.GetCurrentCache()) { foreach (DataRow row in securityContext.Tables[UserAccountTable].Rows) { string userName = UserInfo.SIDToAccountName(Convert.ToString(row["Name"])); if (userRoleCache.TryGetUserRole(userName, out roles)) userRoles[userName] = roles; } // Also make sure current user is added since user may have implicit rights based on group if (!string.IsNullOrEmpty(currentUserName)) { if (!userRoles.ContainsKey(currentUserName) && userRoleCache.TryGetUserRole(currentUserName, out roles)) userRoles[currentUserName] = roles; } if (userRoles.Count > 0) SecurityContextRefreshed(typeof(AdoSecurityProvider), new EventArgs>(userRoles)); } } catch (Exception ex) { exceptionHandler(new InvalidOperationException($"Failed to raise \"SecurtyContextRefreshed\" event: {ex.Message}", ex)); } } return securityContext; } private static void UpdatePrimaryKey(DataTable table, string columnName) { if (table.PrimaryKey.Length == 0) table.PrimaryKey = new[] { table.Columns[columnName] }; } private static void AddSecurityContextTable(IDbConnection connection, DataSet securityContext, string tableName, Guid nodeID) { string tableQuery; if (nodeID == default(Guid)) tableQuery = $"SELECT * FROM {tableName}"; else tableQuery = $"SELECT * FROM {tableName} WHERE NodeID = '{nodeID}'"; using (IDataReader reader = connection.ExecuteReader(tableQuery)) { securityContext.Tables.Add(tableName).Load(reader); } } private static string EncodeEscapeSequences(string value) { return value.ToNonNullString().Replace("\\", "\\\\"); } #endregion } }