Issues implementing a custom MembershipService

Topics: Customizing Orchard
Nov 14, 2013 at 12:16 PM
I was recently tasked with changing an Orchard based site to use a custom Member table that Orchard should use to authenticate users. At first I thought this would be quite simple and just a matter of implementing a custom class against the IMembershipService interface, however things weren't that simple. I did get it working in the end, although some built in Orchard functionality doesn't work correctly. This wasn't ideal, however this functionality wasn't required for this project so we were lucky.

I'm creating this thread in order to try and find the cause of the problems I faced so solutions can be implemented which will help anyone else trying to do the same thing. I suspect the problems were due to a mixture of the following
  • Not using the latest version of Orchard (we're using 1.6.1 which was the latest version at the time)
  • A lack of understand of Orchard on my part, I'm fairly new to the Orchard scene
  • Due to the above, it's probably not been coded in the best way
  • Bugs in Orchard
However I think the biggest issue was with documentation, of lack thereof. If it wasn't for Codeplex/Stack Overflow posts then I probably wouldn't have got it working at all.

I'm not sure of the best way to proceed with this, perhaps listing all the problems I faced and going through them one by one?
Coordinator
Nov 16, 2013 at 7:04 AM
Well, if you could contribute some of what you've found in the form of a new documentation topic, that would be great.
Nov 24, 2013 at 11:10 AM
But I don't think what I've done is 'right', I wouldn't recommend others code it the same way. By that I mean I broke some user related Orchard functionality and had to duplicate quite a bit of code. I'd love to write some documentation if I could improve my solution
Developer
Nov 24, 2013 at 11:54 AM
Is there anyway we could see the code? Or a subset of us see the code and we could give you some pointers?
Nov 24, 2013 at 4:51 PM
I will have to check with work, it was developed for a client you see. Got deadlines coming up so please bare with me
Developer
Nov 24, 2013 at 6:41 PM
Edited Nov 24, 2013 at 6:46 PM
The big issue here is that you can't possibly totally decouple anything interacting with User content items from the default Orchard.Users implementation. The reason is content querying: if you want to query on users (and by that I mean filtering or ordering on the properties of UserPart) you need to specify the record(s), e.g. UserPartRecord. This is by design; I mean the only way you can query on properties of any content part is by referencing their records, i.e. their storage implementation detail. This means that there are several modules out there that, since they query User content items, are coupled with Orchard.Users because of the constraint of querying on UserPartRecord.

That in turn means that unless you can make sure that there is no module installed in your instance that queries Users like mentioned (what you can do, by removing or overriding everything that would do this; and I checked, there is no built-in module querying like this) you'll have to mirror user profiles locally not to break anything.

As an alternative to mirroring users locally you can roll out your completely own Orchard.Users counterpart by just implementing IMembershipService. This, however, will break anything relying on the <UserPart, UserPartRecord> duo (what can be an acceptable compromise for you of course). Be aware that still your implementations can break other modules that are just using the above service. The reason is that the interface type for users, IUser derives from IContent (content items are IContent objects). To play along with this you'd practically need to create content items for users on the fly (to enable straightforward extensibility, using User as the type name): these items don't need to be persisted but they should be proper content items. I think what you need here definitely is a proper ID for these items, i.e. make sure to set the ID of these on the fly content items that correspond to some real ID in your actual user database and make sure these IDs are unique among the local content items too. You'll need this because codes using IMembershipService commonly rely on identifying users by their ID, i.e. they use IUser.Id (like CommonPart).

To take this one step further things will get even more complex: User content items can have parts too naturally and modules may actually use this feature. Thus you'll have to take care that if anything attached a part to the User content type and tries to persist some data in the part you'll have to take care to save and rebuild this part's state. However you can rule out such cases by reviewing any third-party module dealing with users you use and not writing such modules yourself.

Be aware that Orchard.Roles depends on Orchard.Users. This means that you can only make use of Orchard.Roles if you don't swap out Orchard.Users with your own implementation but you mirror your user database. If you don't need the fine-grained permission handling that Orchard.Users provides and you can offload any permission checking to your external system then this is fine: however you'll need to implement IAuthorizationService too.
Jan 12, 2014 at 5:04 PM
Thanks for your reply piedone.

I must admit at this stage I don't fully understand everything you've said, I get the general idea of what you're getting at though. I appreciate the time you've taken to reply, it has helped me understand the complexities with what I was trying to achieve. I'm sure if I researched further into UserPart, UserPartRecord etc and how they are used throughout Orchard I'd be able to improve my implementation.

I would love to be able to take another look at it and work through this with you to get a good solution and document it, but unfortunately I don't think I'll be given this opportunity. The project was for a client and as it does what they need, there is no benefit for my company to let me look into this further as we can't charge the client for it. There might become a need for me to look into this again in the future, whether for the same client or a different one, if this happens I'll be sure to get in touch.

In the meantime, if it would help somebody write some documentation, I can see if I can share the code that I wrote.
Developer
Jan 12, 2014 at 6:00 PM
I think many out there would be interested in your implementation indeed.
Jan 12, 2014 at 7:39 PM
Will ask in work tomorrow if I'm allowed to post the code, not sure if we 'own' the code you see
Feb 26, 2014 at 6:40 PM
Ok I've been told I'm allowed to post the code.

I created a custom implementation of IAuthenticationService, IAuthorizationService, IMembershipService and IUser so there is quite a bit of code involved.

IAuthenticationService

[OrchardSuppressDependency("Orchard.Security.Providers.FormsAuthenticationService")]
public class CustomAuthenticationService : IAuthenticationService {
    private readonly IClock _clock;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IMembershipService _membershipService;
    private IUser _signedInUser;
    private bool _isAuthenticated = false;

    public CustomAuthenticationService(IClock clock, IHttpContextAccessor httpContextAccessor, IMembershipService membershipService)
    {
        _clock = clock;
        _httpContextAccessor = httpContextAccessor;
        _membershipService = membershipService;

        Logger = NullLogger.Instance;

        ExpirationTimeSpan = TimeSpan.FromDays(30);
    }

    public ILogger Logger { get; set; }

    public TimeSpan ExpirationTimeSpan { get; set; }

    public void SignIn(IUser user, bool createPersistentCookie) {
        var now = _clock.UtcNow.ToLocalTime();
        var userData = Convert.ToString(user.Id);

        var ticket = new FormsAuthenticationTicket(
            1 /*version*/,
            user.UserName,
            now,
            now.Add(ExpirationTimeSpan),
            createPersistentCookie,
            userData,
            FormsAuthentication.FormsCookiePath);

        var encryptedTicket = FormsAuthentication.Encrypt(ticket);

        var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket);
        cookie.HttpOnly = true;
        cookie.Secure = FormsAuthentication.RequireSSL;
        cookie.Path = FormsAuthentication.FormsCookiePath;
        if (FormsAuthentication.CookieDomain != null) {
            cookie.Domain = FormsAuthentication.CookieDomain;
        }

        if (createPersistentCookie) {
            cookie.Expires = ticket.Expiration;
        }

        var httpContext = _httpContextAccessor.Current();
        httpContext.Response.Cookies.Add(cookie);

        _isAuthenticated = true;
        _signedInUser = user;
    }

    public void SignOut() {
        _signedInUser = null;
        _isAuthenticated = false;
        FormsAuthentication.SignOut();
    }

    public void SetAuthenticatedUserForRequest(IUser user) {
        _signedInUser = user;
        _isAuthenticated = true;
    }

    public IUser GetAuthenticatedUser() {
        if (_signedInUser != null || _isAuthenticated)
            return _signedInUser;

        var httpContext = _httpContextAccessor.Current();
        if (httpContext == null || !httpContext.Request.IsAuthenticated || !(httpContext.User.Identity is FormsIdentity)) {
            return null;
        }

        var formsIdentity = (FormsIdentity) httpContext.User.Identity;
        var userData = formsIdentity.Ticket.UserData;
        int userId;
        if (!int.TryParse(userData, out userId)) {
            Logger.Fatal("User id not a parsable integer");
            return null;
        }

        _isAuthenticated = true;
        return _signedInUser = _membershipService.GetUser(formsIdentity.Name);
    }
}

IAuthorizationService

[OrchardSuppressDependency("Orchard.Roles.Services.RolesBasedAuthorizationService")]
public class CustomAuthorisationService : IAuthorizationService {
    private readonly IRoleService _roleService;
    private readonly IWorkContextAccessor _workContextAccessor;
    private readonly IAuthorizationServiceEventHandler _authorizationServiceEventHandler;
    private readonly IRepository<UserRolesPartRecord> _userRolesRepository;
    private static readonly string[] AnonymousRole = new[] { "Anonymous" };
    private static readonly string[] AuthenticatedRole = new[] { "Authenticated" };
    public CustomAuthorisationService(IAuthorizationServiceEventHandler authorizationServiceEventHandler, IWorkContextAccessor workContextAccessor, IRoleService roleService, IRepository<UserRolesPartRecord> userRolesRepository)
    {
        _roleService = roleService;
        _userRolesRepository = userRolesRepository;
        _workContextAccessor = workContextAccessor;
        _authorizationServiceEventHandler = authorizationServiceEventHandler;

        T = NullLocalizer.Instance;
        Logger = NullLogger.Instance;
    }

    public Localizer T { get; set; }
    public ILogger Logger { get; set; }

    public void CheckAccess(Permission permission, IUser user, IContent content)
    {
        if (!TryCheckAccess(permission, user, content))
        {
            throw new OrchardSecurityException(T("A security exception occurred in the content management system."))
            {
                PermissionName = permission.Name,
                User = user,
                Content = content
            };
        }
    }

    public bool TryCheckAccess(Permission permission, IUser user, IContent content)
    {
        var context = new CheckAccessContext { Permission = permission, User = user, Content = content };
        _authorizationServiceEventHandler.Checking(context);

        for (var adjustmentLimiter = 0; adjustmentLimiter != 3; ++adjustmentLimiter)
        {
            if (!context.Granted && context.User != null)
            {
                if (!String.IsNullOrEmpty(_workContextAccessor.GetContext().CurrentSite.SuperUser) &&
                        String.Equals(context.User.UserName, _workContextAccessor.GetContext().CurrentSite.SuperUser, StringComparison.Ordinal))
                {
                    context.Granted = true;
                }
            }

            if (!context.Granted)
            {

                // determine which set of permissions would satisfy the access check
                var grantingNames = PermissionNames(context.Permission, Enumerable.Empty<string>()).Distinct().ToArray();

                // determine what set of roles should be examined by the access check
                IEnumerable<string> rolesToExamine;
                    
                if (context.User == null)
                {
                    rolesToExamine = AnonymousRole;
                }

                else 
                {
                    IEnumerable<UserRolesPartRecord> userRoles = _userRolesRepository.Fetch(userRole => userRole.UserId == context.User.Id);
                    if (userRoles.Any()) {
                        // the current user is not null and has roles, so get his roles and add "Authenticated" to it
                        rolesToExamine = userRoles.Select(userRole => userRole.Role.Name);

                        // when it is a simulated anonymous user in the admin
                        if (!rolesToExamine.Contains(AnonymousRole[0])) {
                            rolesToExamine = rolesToExamine.Concat(AuthenticatedRole);
                        }
                    }
                    else
                    {
                        // the user is not null and has no specific role, then it's just "Authenticated"
                        rolesToExamine = AuthenticatedRole;
                    }

                }

                foreach (var role in rolesToExamine)
                {
                    foreach (var permissionName in _roleService.GetPermissionsForRoleByName(role))
                    {
                        string possessedName = permissionName;
                        if (grantingNames.Any(grantingName => String.Equals(possessedName, grantingName, StringComparison.OrdinalIgnoreCase)))
                        {
                            context.Granted = true;
                        }

                        if (context.Granted)
                            break;
                    }

                    if (context.Granted)
                        break;
                }
            }

            context.Adjusted = false;
            _authorizationServiceEventHandler.Adjust(context);
            if (!context.Adjusted)
                break;
        }

        _authorizationServiceEventHandler.Complete(context);

        return context.Granted;
    }

    private static IEnumerable<string> PermissionNames(Permission permission, IEnumerable<string> stack)
    {
        // the given name is tested
        yield return permission.Name;

        // iterate implied permissions to grant, it present
        if (permission.ImpliedBy != null && permission.ImpliedBy.Any())
        {
            foreach (var impliedBy in permission.ImpliedBy)
            {
                // avoid potential recursion
                if (stack.Contains(impliedBy.Name))
                    continue;

                // otherwise accumulate the implied permission names recursively
                foreach (var impliedName in PermissionNames(impliedBy, stack.Concat(new[] { permission.Name })))
                {
                    yield return impliedName;
                }
            }
        }

        yield return StandardPermissions.SiteOwner.Name;
    }
}
Feb 26, 2014 at 6:42 PM

IMembershipService

[OrchardSuppressDependency("Orchard.Users.Services.MembershipService")]
public class CustomMembershipService : IMembershipService 
{
    private IMemberService _memberService
    {
        get { return HttpContext.Current.Request.RequestContext.GetWorkContext().Resolve<IMemberService>(); }
    }

    private readonly IContentManager _contentManager;

    public CustomMembershipService(IContentManager contentManager)
    {
        _contentManager = contentManager;
    }

    public MembershipSettings GetSettings() {
        throw new System.NotImplementedException();
    }

    public IUser CreateUser(CreateUserParams createUserParams) {
        throw new System.NotImplementedException();
    }

    public IUser GetUser(string username) {
        MemberDetail member = _memberService.GetMember(username);
        return member != null ? CreateCustomUserFromMemberDetail(member) : null;
    }

    public IUser ValidateUser(string userNameOrEmail, string password) {
        string hashedPassword = AuthenticationService.HashPassword(password);
        MemberDetail member = _memberService.Authenticate(userNameOrEmail, hashedPassword);
        return member != null && member.Enabled ? CreateCustomUserFromMemberDetail(member) : null;
    }

    public void SetPassword(IUser user, string password) {
        throw new System.NotImplementedException();
    }

    private CustomUser CreateCustomUserFromMemberDetail(MemberDetail member)
    {
        return new CustomUser(_contentManager, member.Id, member.EMail);
    }
}

IUser

[OrchardSuppressDependency("Orchard.Users.Models.UserPart")]
public class CustomUser : ContentPart<UserPartRecord>, IUser
{
    public CustomUser(IContentManager contentManager, int id, string email)
    {
        Id = id;
        UserName = email;
        Email = email;

        ContentItem = new ContentItem {ContentManager = contentManager};
        var part = new UserPart {Record = new UserPartRecord(), RegistrationStatus = UserStatus.Approved, EmailStatus = UserStatus.Approved, UserName = UserName, Email = Email};
        ContentItem.Weld(part);
    }

    public ContentItem ContentItem { get; private set; }

    public int Id { get; private set; }
    public string UserName { get; private set; }
    public string Email { get; private set; }
}