Some dudes here have resolved their issues and wrapped them up with "Thanks! It works".
Good to know things are working out for you ...
So ... back to the same question, thus wasting everybody's time, mostly Volkan's.
Need: Use Active Directory credentials for a login. (_100% the case for internal apps_).
Forbidden: App must absolutely _not_ create a login automatically.
Logins: Created by an admin via the Serene's own user management pages.
Password: Same for all users (_just to make this fit inside the framework_)
Page Rights: At page level permission keys and/or Authorization.UserDefinition can be used. Details are out-of-scope here.
Starting Point: Serene's login page has been reduced to only the logo and the "Sign In" button.
Entry Point:" Modules\Membership\Account\AccountPage.cs (_after a button click_).
Method: Is the suggested code below OK? It works but is there a better way to go about it. The goal is to get the AD name, and if the user has an account let the user into the app where presumably all is ready to regulate right of access based on user-name and/or permission keys.
public Result
{
return this.ExecuteMethod(() =>
{
var pwd = "1234567"; //Same for all. For simplicity hard-coded here.
//IS THIS OK?
var username = Request.LogonUserIdentity.Name;
int offset = username.IndexOf("\");
username = (offset > -1) ? username.Substring(offset + 1, username.Length - offset - 1) : string.Empty;
if (WebSecurityHelper.Authenticate(ref username, pwd, false))
return new ServiceResponse();
throw new ValidationError("AuthenticationError", Texts.Validation.AuthenticationError);
});
}
IIS settings are important to get the above code to work.
I still do not have it at 100% but so far this appears to be the case:
IIS Authentication:
If I set Anonymous = Enabled but going through Pool Identity the above code gets the name of the account used for the pool.
From what I see in the web.config the app is set to require instead Forms to be enabled.
However if I set Forms Authentication = true - I get errors.
Not sure what IIS setting will make the login page stay and pass the user credentials to the username in the above code.
Don't use Windows Authentication. If you don't want users to be created automatically in first AD login disable code that does that.
OK with Anonymous + Forms Authentication = Enabled I depend on the app pool for the login. If I set the app pool to go via a universal login (like my own) then all people login as me.
I need exact instructions on how to make the IIS deployment catch the individual user login in the above code.
I don't have your exact instructions. Serenity is an ASP.NET MVC application, and handles authentication as is. See guides about .NET.
OK, I see I struck a nerve :)
Sorry for that man.
It is true though, exact (bold removed) instructions do help. What I mentioned is a perfectly valid use-case. Maybe you can include something in the docs for the future.
Thanks man.
Again - sorry for the bold font.
I have single single on enabled, it uses AD for 100% authentication and permissions for pages are based on AD group membership or username. Happy to share if you need,
I would love to see an example of what you have in place abelal83.
Of course!
modify
web.config
<system.web>
<authentication mode="Windows" />
<roleManager enabled="true" defaultProvider="AspNetWindowsTokenRoleProvider">
<providers>
<clear />
<add name="AspNetWindowsTokenRoleProvider" type="System.Web.Security.WindowsTokenRoleProvider" />
</providers>
</roleManager>
<authorization>
<deny users="?" />
</authorization>
PermissionService.cs
public virtual bool HasPermission(string permission)
{
if (Authorization.Username == "admin")
return true;
string[] permissionItems = permission.Split(',');
foreach (string permissionItem in permissionItems)
{
if (Roles.IsUserInRole(permissionItem.Trim()))
return true;
if (Authorization.Username == permissionItem.Trim())
return true;
}
return false;
}
UserEndPoint.cs
[NonAction, DataScript("UserData", CacheDuration = 1)]
public ScriptUserDefinition GetUserData()
{
PrincipalContext yourDomain = new PrincipalContext(ContextType.Domain, Authorization.Username.Split('\\')[0]);
UserPrincipal userPrincipal = UserPrincipal.FindByIdentity(yourDomain, Authorization.Username.Split('\\')[1]);
var result = new ScriptUserDefinition();
var user = Authorization.UserDefinition as UserDefinition;
if (user == null)
{
result.Permissions = new Dictionary<string, bool>();
return result;
}
result.Username = user.Username;
result.DisplayName = user.DisplayName;
result.Permissions = TwoLevelCache.GetLocalStoreOnly("ScriptUserPermissions:" + user.Id, TimeSpan.FromMinutes(10),
"ScriptUserPermissions:" + user.Id, () =>
{
var permissions = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
//if (permissionsUsedFromScript == null)
if (permissionsUsedFromScript.Count == 0)
{
if (userPrincipal != null)
{
PrincipalSearchResult<Principal> groups = userPrincipal.GetAuthorizationGroups();
foreach (Principal p in groups)
{
// make sure to add only group principals
if (p is GroupPrincipal)
{
try
{
var account = p.Sid.Translate(typeof(NTAccount));
permissionsUsedFromScript.Add(account.Value);
permissions[account.Value] = true;
}
catch
{
}
}
}
}
}
permissionsUsedFromScript.Clear();
return permissions;
});
return result;
}
then wherever you use a permission attribute [ReadPermission], [ModifyPermission] etc.. simply use like so
[ReadPermission("YourDomain\\ADGroup, YourDomain\\Username, YourDomain\\AnotherGroup")]
I'm no expert in C# or even OO languages but these changes work very well for me. It uses AD for all permissions, I'm sure Volkan can spot reasons why it's not the best way to do so!
Hope this helps and good luck!
@abelal83 In PermissionService.cs, where does Roles.IsUserInRole come from?
Ah, nevermind. I was missing a reference to System.Web.Security.
@volkanceylan any real reason this is a terrible approach? I am asking as role management in applications beyond about 20 users can become a bit cumbersome within the Serenity user/role system. It's great for specific applications, but I am hoping to be able to rest a corporate campus Intranet within Serenity.
We are managing 100K+ users simply, while you find this cumbersome for some corporate?
You're always free to write your own if you don't like the one i designed.
That wasn't meant to be a dig. I was just asking a question.
I am using your AD integration but it felt like re-inventing the wheel by duplicating roles from AD into Serene and I just came across @abelal83 's implementation. Since we spent so much time sorting out our roles in AD I didn't want to duplicate effort was all.
@abelal83 Looks like there are some issues in your UserEndpoint.cs
Won't resolve the permissionsUsedFromScript.Count or Add or Clear on the default type of private static string[] permissionsUsedFromScript;
Are you using something different or am I being dumb?
Also as an aside if I convert to Add.Range and Count() etc I get a 401.2 did you employ some specific permissions at a site level? How did you handle spoofing the admin inbuilt user or do you have an admin user in AD?
I am quite keen to try to directly tie Serenity to our AD if I can.
Hi,
I made some additional changes to resolve issue with permissionusedfromscript. I'm on holiday until Friday so will post the changes I made then.
My implementation works perfectly with AD. In fact I deleted the permission table used by serene as I have no use for it.
@solabar I ran into the same issue, but it turns out to just be an undocumented change so that it's an array I believe instead of a string. Synchronizing or offboarding the definition of users and roles is a relevant gain in a large scale application because then you're not adding a new digital identity and authorization system to the mix to maintain.
@abelal83 Hope you had a nice holiday. Did you manage to dig out your changes?? I'll just work on amending it myself if you didn't :D
@solabar sorry I didn't get a chance to update this.
so here is the change I made in userendpoint.cs to resolve that issue
private static List<string> permissionsUsedFromScript = new List<string>();
/// <summary>
/// This declares a dynamic script with key 'UserData' that will be available from client side.
/// We don't cache it at dynamic script manager, because dynamic scripts are cached globally,
/// similar to static variables, not per user.
/// </summary>
[NonAction, DataScript("UserData", CacheDuration = -1)]
public ScriptUserDefinition GetUserData()
{
PrincipalContext yourDomain = new PrincipalContext(ContextType.Domain, Authorization.Username.Split('\\')[0]);
UserPrincipal userPrincipal = UserPrincipal.FindByIdentity(yourDomain, Authorization.Username.Split('\\')[1]);
var result = new ScriptUserDefinition();
var user = Authorization.UserDefinition as UserDefinition;
if (user == null)
{
result.Permissions = new Dictionary<string, bool>();
return result;
}
result.Username = user.Username;
result.DisplayName = user.DisplayName;
//result.Permissions = TwoLevelCache.GetLocalStoreOnly("ScriptUserPermissions:" + user.Id, TimeSpan.Zero,
result.Permissions = TwoLevelCache.GetLocalStoreOnly("ScriptUserPermissions:" + user.Id, TimeSpan.FromMinutes(10),
//UserPermissionRow.Fields.GenerationKey, () =>
"ScriptUserPermissions:" + user.Id, () =>
{
var permissions = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
//if (permissionsUsedFromScript == null)
if (permissionsUsedFromScript.Count == 0)
{
//permissionsUsedFromScript = new UserPermissionRepository().ListPermissionKeys().Entities
// .Where(permissionKey =>
// {
// // this sends permission information for all permission keys to client side.
// // if you don't need all of them to be available from script, filter them here.
// // this is recommended for security / performance reasons...
// return true;
// }).ToArray();
if (userPrincipal != null)
{
PrincipalSearchResult<Principal> groups = userPrincipal.GetAuthorizationGroups();
foreach (Principal p in groups)
{
// make sure to add only group principals
if (p is GroupPrincipal)
{
try
{
var account = p.Sid.Translate(typeof(NTAccount));
permissionsUsedFromScript.Add(account.Value);
permissions[account.Value] = true;
}
catch
{
}
}
}
}
}
//foreach (var permissionKey in permissionsUsedFromScript)
//{
//if (Authorization.HasPermission(permissionKey))
//permissions[permissionKey] = true;
//}
permissionsUsedFromScript.Clear();
//TwoLevelCache.Remove("ScriptUserPermissions:" + user.Id);
return permissions;
});
return result;
}
}
you may have to change code in some other parts, just debug and it should be obvious.
@abelal83 Just another quick question. Did you do something within UserRetrieveService.cs to redirect population of UserDefinition to populate from AD?
can't recall but I think I did. here is the content of my UserRetrieveService.cs, just compare against yours. the bits of code that uses database can probably be discarded, I've only left for backup purposes. Hope this helps!
namespace Serene.Administration
{
using Serenity;
using Serenity.Abstractions;
using Serenity.Data;
using System;
using System.Data;
using MyRow = Entities.UserRow;
using System.DirectoryServices.AccountManagement;
public class UserRetrieveService : IUserRetrieveService
{
private static MyRow.RowFields fld { get { return MyRow.Fields; } }
private UserDefinition GetFirst()
{
PrincipalContext yourDomain = new PrincipalContext(ContextType.Domain, Authorization.Username.Split('\\')[0]);
UserPrincipal user = UserPrincipal.FindByIdentity(yourDomain, Authorization.Username.Split('\\')[1]);
if (user != null)
return new UserDefinition
{
UserId = Authorization.Username,
Username = user.SamAccountName,
Email = user.EmailAddress,
DisplayName = user.DisplayName,
IsActive = 1, //user.Enabled.Value, // TODO: change to use enabled value
Source = "AD",
PasswordHash = "ADBASED",
PasswordSalt = "ADBASED",
UpdateDate = user.LastPasswordSet,
LastDirectoryUpdate = user.LastLogon
};
return null;
}
public IUserDefinition ById(string id)
{
return TwoLevelCache.Get<UserDefinition>("UserByID_" + id, TimeSpan.Zero, TimeSpan.FromDays(1), fld.GenerationKey, () =>
{
using (var connection = SqlConnections.NewByKey("Default"))
return GetFirst();
});
}
public IUserDefinition ByUsername(string username)
{
if (username.IsEmptyOrNull())
return null;
return TwoLevelCache.Get<UserDefinition>("UserByName_" + username.ToLowerInvariant(),
TimeSpan.Zero, TimeSpan.FromDays(1), "Default.Users", () =>
{
return GetFirst();
});
}
public static void RemoveCachedUser(string userId, string username)
{
if (userId != null)
TwoLevelCache.Remove("UserByID_" + userId);
if (username != null)
TwoLevelCache.Remove("UserByName_" + username.ToLowerInvariant());
}
}
}
@abelal83 Great that does change things. Next step then is checking what you did with UserId in UserDefinition.cs because that's an int and you are passing Authorisation.Username which is a string. Did you retype it as a string? If you did that it opens up a different can of worms. Haha. Sorry I am being kind of dumb today. But I am spinning so many plates at the moment that trying to focus on one thing is hard.
If you can drop UserDefinition.cs and AuthenticationService.cs in here I might be able to work it out from there, but no promises.
yep I changed to take string, here you go
userdefinition.cs
namespace Serene
{
using Serenity;
using System;
[Serializable]
public class UserDefinition : IUserDefinition
{
public string Id { get { return UserId; } }
public string DisplayName { get; set; }
public string Email { get; set; }
public string UserImage { get; set; }
public short IsActive { get; set; }
public string UserId { get; set; }
public string Username { get; set; }
public string PasswordHash { get; set; }
public string PasswordSalt { get; set; }
public string Source { get; set; }
public DateTime? UpdateDate { get; set; }
public DateTime? LastDirectoryUpdate { get; set; }
}
}
and authenticationservice.cs
namespace Serene.Administration
{
using Entities;
using Repositories;
using Serenity;
using Serenity.Abstractions;
using Serenity.Data;
using System;
public class AuthenticationService : IAuthenticationService
{
public bool Validate(ref string username, string password)
{
if (username.IsTrimmedEmpty() || string.IsNullOrEmpty(password))
return false;
username = username.TrimToEmpty();
var user = Dependency.Resolve<IUserRetrieveService>().ByUsername(username) as UserDefinition;
if (user != null)
return ValidateExistingUser(ref username, password, user);
return ValidateFirstTimeUser(ref username, password);
}
private bool ValidateExistingUser(ref string username, string password, UserDefinition user)
{
username = user.Username;
if (user.IsActive != 1)
{
if (Log.IsInfoEnabled)
Log.Error(String.Format("Inactive user login attempt: {0}", username), this.GetType());
return false;
}
// prevent more than 50 invalid login attempts in 30 minutes
var throttler = new Throttler("ValidateUser:" + username.ToLowerInvariant(), TimeSpan.FromMinutes(30), 50);
if (!throttler.Check())
return false;
var directoryService = Dependency.TryResolve<IDirectoryService>();
Func<bool> validatePassword = () => UserRepository.CalculateHash(password, user.PasswordSalt)
.Equals(user.PasswordHash, StringComparison.OrdinalIgnoreCase);
if (user.Source == "site" || user.Source == "sign" || directoryService == null)
{
if (validatePassword())
{
throttler.Reset();
return true;
}
return false;
}
if (user.Source != "ldap")
throw new ArgumentOutOfRangeException("userSource");
if (!string.IsNullOrEmpty(user.PasswordHash) &&
user.LastDirectoryUpdate != null &&
user.LastDirectoryUpdate.Value.AddHours(1) >= DateTime.Now)
{
if (validatePassword())
{
throttler.Reset();
return true;
}
return false;
}
DirectoryEntry entry;
try
{
entry = directoryService.Validate(username, password);
if (entry == null)
return false;
throttler.Reset();
}
catch (Exception ex)
{
Log.Error("Error on directory access", ex, this.GetType());
// couldn't access directory. allow user to login with cached password
if (!user.PasswordHash.IsTrimmedEmpty())
{
if (validatePassword())
{
throttler.Reset();
return true;
}
return false;
}
throw;
}
try
{
string salt = user.PasswordSalt.TrimToNull();
var hash = UserRepository.GenerateHash(password, ref salt);
var displayName = entry.FirstName + " " + entry.LastName;
var email = entry.Email.TrimToNull() ?? user.Email ?? (username + "@yourdefaultdomain.com");
using (var connection = SqlConnections.NewFor<UserRow>())
using (var uow = new UnitOfWork(connection))
{
var fld = UserRow.Fields;
new SqlUpdate(fld.TableName)
.Set(fld.DisplayName, displayName)
.Set(fld.PasswordHash, hash)
.Set(fld.PasswordSalt, salt)
.Set(fld.Email, email)
.Set(fld.LastDirectoryUpdate, DateTime.Now)
.WhereEqual(fld.UserId, user.UserId)
.Execute(connection, ExpectedRows.One);
uow.Commit();
UserRetrieveService.RemoveCachedUser(user.UserId, username);
}
return true;
}
catch (Exception ex)
{
Log.Error("Error while updating directory user", ex, this.GetType());
return true;
}
}
private bool ValidateFirstTimeUser(ref string username, string password)
{
var throttler = new Throttler("ValidateUser:" + username.ToLowerInvariant(), TimeSpan.FromMinutes(30), 50);
if (!throttler.Check())
return false;
var directoryService = Dependency.TryResolve<IDirectoryService>();
if (directoryService == null)
return false;
DirectoryEntry entry;
try
{
entry = directoryService.Validate(username, password);
if (entry == null)
return false;
throttler.Reset();
}
catch (Exception ex)
{
Log.Error("Error on directory first time authentication", ex, this.GetType());
return false;
}
try
{
string salt = null;
var hash = UserRepository.GenerateHash(password, ref salt);
var displayName = entry.FirstName + " " + entry.LastName;
var email = entry.Email.TrimToNull() ?? (username + "@yourdefaultdomain.com");
username = entry.Username.TrimToNull() ?? username;
using (var connection = SqlConnections.NewFor<UserRow>())
using (var uow = new UnitOfWork(connection))
{
var fld = UserRow.Fields;
var userId = (int?)new SqlInsert(fld.TableName)
.Set(fld.Username, username)
.Set(fld.Source, "ldap")
.Set(fld.DisplayName, displayName)
.Set(fld.Email, email)
.Set(fld.PasswordHash, hash)
.Set(fld.PasswordSalt, salt)
.Set(fld.IsActive, 1)
.Set(fld.InsertDate, DateTime.Now)
.Set(fld.InsertUserId, 1)
.Set(fld.LastDirectoryUpdate, DateTime.Now)
.ExecuteAndGetID(connection);
uow.Commit();
//UserRetrieveService.RemoveCachedUser(userId, username);
}
return true;
}
catch (Exception ex)
{
Log.Error("Error while importing directory user", ex, this.GetType());
return false;
}
}
}
}
you'll probably want to tidy it up a bit but for my purposes it works fine.
That's it. FYI all you changed in the authenticationservice.cs was comment out the code that caused the error on build :)
@abelal83 , @solabar Ok I tried following the posted code here , but for some reason I am not getting past the GetFirst method - I get object reference not set to an instance of an object at
private UserDefinition GetFirst()
{
PrincipalContext yourDomain = new PrincipalContext(ContextType.Domain, Authorization.Username.Split('\')[0]);
The username value is null , I do see it being passed to the validate method prior to all of this code but not here.
I am guessing that since UserId is converted to string that the dbusers table and related tables the same was done ?
Unless, there is a "new/better" implementation someone has shared since this (I am still working through all the Search Results for "authentication" in "Issues")... I believe this is EXACTLY what I am looking for! Thanks for sharing.
(I should have read just one more entry in my search results before posting my previous comment in Implementing ASP.Net Identity #857)
Most helpful comment
@solabar sorry I didn't get a chance to update this.
so here is the change I made in userendpoint.cs to resolve that issue
you may have to change code in some other parts, just debug and it should be obvious.