Click here to Skip to main content
15,860,861 members
Articles / Web Development / HTML

Membership Stores for ASP.NET

Rate me:
Please Sign up or sign in to vote.
4.93/5 (19 votes)
4 May 2014CPOL19 min read 76.2K   3.5K   74   17
Service based, multi-application, post ASP.NET 4.0 asynchronous custom membership stores for ASP.NET Identity 2.0 with a hierarchical role system.

Optional downloads:

Please extract all document items from the root directory of the package into the root directory of the data service site. However, if you do not wish to install the documents, they are also available here.

Note: the data service now run under .Net 4.5.1 and Asp.Net Mvc 5. The data store now support ASP.NET Identity 2.0. One should replace the old data service by the new one and, if not yet, ungrade his/her client applications to work under ASP.NET Identity 2.0.

If your IDE has nuget package manager properly installed, there is no need to download the package for referenced assemblies since they will be retrieved from nuget.org the first time a project is built. But if it is indeed necessary for you, please extract all items from the root directory of the package into the root directory of the entire solution (namely, the one where the solution files (*.sln) is placed).

Related Articles

Contents

Introduction

The user identification, authentication and authorization (UIAA) system supported by the new ASP.NET MVC 5 (and possibly a few future versions after it) is changed significantly compared to the ones discussed in the previous article: "Service Based Membership Providers for ASP.NET" published here. The new system can support more diverse, heterogeneous types of UIAA backends using loosely typed, third party verifiable, identity related property bags called claims. The roles of a user in a role based UIAA systems can be mapped to a kind of claim whose type is, obviously, Role.

Instead of using providers, the new system uses dependency injection to inject membership repository stores into corresponding controllers via the UserManager class under Microsoft.AspNet.Identity namespace.

The present article introduces two custom stores, namely UserStore and RoleStore that are connected to the Membership Data Service described in the previous article and an extension of UserManager, that can be injected into post .NET 4.0 ASP.NET web applications to support a multi-application UIAA.

Background

Microsoft released Visual Studio 2013 recently. Amongst others, it has improved support for asychronous programing styles. Maybe it's time to get into this new way of programming (see here for reasons).

For those who are new to the async/await language feature of post .NET 4.0 frameworks, they might think of using it in frontend programs that can help to improve user experiences related to increased UI responsiveness. The question as to why do we adopt asynchronous stores in a server environment may arise. At least I think that way at first. But after more thinking, I found that it can very useful since it is possible to actually realize some of my old ideas many years ago in the pre- .NET era that I failed to fully materialize or untilize them due partly to the complexity involved. The following provides a tentative explanation of the new async/await features following this line of thinking. It will be improved as more information is gathered or is made available in the future.

ASP.NET under post .NET 4.0 supports asynchronous operations in a way that keeps the simpler flow of sequential operations while doing them asynchronously underneath when encounter the newly introduced keywords async and await modifiers. Albeit the "traditional" sequential methods are still supported, asynchronous operations could be beneficial when they are applied to appropriate problems. For example, they can increase overall performance inside a busy IO dominated environment despite the fact that async/await could introduce considerable overheads, especially when they are used in a deeply nested calling stack.

Application software and IO (or any others) hardware run at different paces and orders, with the later being slower (nanoseconds vs. milliseconds) but is capable of processing to multiple requests during one cycle of its operation (e.g., traditional hard disks). A significant amount of works in a web server is doing IO operations. When a synchronous operation pattern is adopted in application software, most popular web-servers (like IIS, Apache) have to allocate threads or processes to post IO requests and subsequently to check for the hardware operations for completion status, one by one (for each core), hundreds and thousands of them in a relatively busy server. It creates significant loads of doing nothing tasks for the operating system (OS) to pull; while in doing so, it does not fully utilize the capability of hardware either. If asynchronous operation pattern is used, the application software could be so written that it will not wait for hardware operations to complete, but rather, it will return immediately to release the burden of the OS so it can handle other jobs, like posting more pending IO requests so that more data can be processed per hardware cycle. The application software will be notified of the results of corresponding hardware operation when they are available, later, which will then continue to do next steps, whatever they are.

However asynchronous operations are hard to manage in application software because usually the IO library dealing with async IO lies so deep in the calling stack (starting from the upper level calling context) that it is almost impossible to keep track of the call stack context information when asynchronous pattern is adopted, especially when dynamical recursive operations are involved, let along debugging or exception handling. It is most likely that the asynchronous version of an application software has to be so different from its synchronous counterpart that it and the IO libraries it depends upon have to be re-written completely. This is expensive both in production and in maintenance. The new asynchronous framework makes the otherwise messy, tangled, hard to manage and debug continuation pieces look like nothing complicated: let the compiler to do the dirty jobs, you only have to await!

Albeit the current version of the general purpose IO library of .NET may still be using threads or thread pool to handle asynchronous operations, the new language feature could allow Microsoft or third party developers to change or improve the underlying implementations without changing the calling contract so the upper layer applications do not have to be changed. So async based implementation is good for us to use since the membership query related computations are mostly done inside the server for the data service, the web-server as a client is more likely a proxy for backend data services that spend most of the time waiting for the data that are being sent to or received from network sockets to be completed.

Let's come back to the subject of membership management. The new IUserStore based interfaces and UserManager class of post ASP.NET 4.0 framework define the contracts that an ASP.NET application interacts with the membership data sources. The former is implemented and later is overridden in this article to use the Membership Data Service. These contracts define only async methods that can be invoked directory inside of MVC 5 controllers. The default form event handlers of the current version of ASP.NET WebForms applications are still non-async, the way ASP.NET handle it is by adding extension methods that convert the async calls into sync ones. Therefore they are still async based.

Compared to old default ones, the default membership data schema for ASP.NET MVC 5 is even simpler:
  1. It does not support multi-applications or is not application specific in its membership. But since the interface is flexible enough, the user stores introduce here can still be made to support multi-applications.
  2. The default Microsoft.AspNet.Identity.UserManager class does not support any mechanism to disable potential brutal force password guessing attempts or protect the data service from denial of service (DoS) attacks at the present. Fortunately, the class is not sealed. Most methods of the default Microsoft.AspNet.Identity.UserManager class can be overridden to define custom authentication logic in a derived class.

Therefore the previously published Membership Data Service can support the new framework without changes, except that the new framework allows the support of general user claims. The present role based membership management system will only support a sub-set of claim types related to standard user properties, like user identifier, name, email, etc., and user roles. Of couse a reader may find it possible to use the UserProfiles data set to store other types of more general claims, but the present article will not follow that path. This could be explored in the future.

The User Store

Unlike the old membership providers, maybe because it is still very new, the related documents are quite brief and inaccurate at present and there are very few sample implementations available on the web to study. It is found that sometimes a little guess work is required to proceed. Fortunately, the name of the methods involved in the interfaces are quite descriptive so that the process is not very hard.

User data model

User data model in ASP.NET 4.5 has to be derived from IUser. It is extended to IApplicationUser here:

C#
public interface IApplicationUser : Microsoft.AspNet.Identity.IUser
{
    string Email { get; set; }
    string AppMemberStatus { get; set; }
    string PasswordQuestion { get; set; }
    string PasswordAnswer { get; set; }
    ICollection<System.Security.Claims.Claim> Claims { get; }
    void UpdateInstance(User user);
}

to include more user properties supported by our data source. The data service already has a complete and fully documented data model for a user defined for us, its better not to re-invent the wheel by adding another un-necessary layer since these artifacts tend to create maintenance complexities without much to gain. So the following class is introduced to represent a user, which also implements IIdentity:

C#
public class ApplicationUser : User, IApplicationUser, IIdentity
{
    string IUser<string>.Id
    {
        get { return ID; }
    }
 
    string IUser<string>.UserName
    {
        get
        {
            return Username;
        }
        set
        {
            Username = value;
        }
    }
 
    string IIdentity.AuthenticationType
    {
        get { return DefaultAuthenticationTypes.ApplicationCookie; }
    }
 
    string IIdentity.Name
    {
        get { return Username; }
    }
 
    public bool IsAuthenticated
    {
        get;
        set;
    }
 
    public string AppMemberStatus
    {
        get;
        set;
    }
 
    public ICollection<Claim> Claims
    {
        get
        {
            if (_claims.Count == 0)
            {
                _claims.Add(UserStore<ApplicationUser>.CreateClaim(
      "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider", 
                    UserStore<ApplicationUser>.NameProviderId));
                _claims.Add(UserStore<ApplicationUser>.CreateClaim(
                    Microsoft.IdentityModel.Claims.ClaimTypes.NameIdentifier, 
                    ID));
                _claims.Add(UserStore<ApplicationUser>.CreateClaim(
                    Microsoft.IdentityModel.Claims.ClaimTypes.Name, 
                    Username));
                if (!string.IsNullOrEmpty(Email))
                {
                    _claims.Add(UserStore<ApplicationUser>.CreateClaim(
                        Microsoft.IdentityModel.Claims.ClaimTypes.Email, 
                        Email));
                }
            }
            return _claims;
        }
    }
    private List<Claim> _claims = new List<Claim>();
 
    public void UpdateInstance(User u)
    {
        IsPersisted = false;
        User.MergeChanges(u, this);
        IsPersisted = u.IsPersisted;
    }
}

The added Claims property is used to store user claims information that are supported by membership data service. Regardless of user, it initializes a claim, namely the not yet standarized claim named "http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider" that identifies the identity provider. For any particular user, it also initializes the standard Microsoft.IdentityModel.Claims.ClaimTypes.NameIdentifier that contains the value of a unique identifier of a user (the current Microsoft docs about this member is either wrong or misleading). These two claims are required ones to pass form anti-forgery validator of ASP.NET MVC 5. The next two claims are standard claim of type Microsoft.IdentityModel.Claims.ClaimTypes.Name and Microsoft.IdentityModel.Claims.ClaimTypes.Email. The standard claims of type Microsoft.IdentityModel.Claims.ClaimTypes.Role will be appended by the user store during user record retrieving (see the following).

The implementation

The default membership user store for ASP.NET implements the following interfaces:

IUserLoginStore<TUser>, IUserClaimStore<TUser>, IUserRoleStore<TUser>, IUserPasswordStore<TUser>, IUserSecurityStampStore<TUser>, IUserStore<TUser>, and IDisposable.

The following interfaces are not implemented by our user store

  • IUserLoginStore<TUser> contains methods that map users to login providers, i.e. Google, Facebook, Twitter, Microsoft. It is not implemented by our store at present.
  • IUserClaimStore<TUser> contains methods that manipulate general user specific claims. General claims can contain any identity related properties for a user, which is not handled by our Membership Data Service which already contains specific ones, enough to support a basic role based membership system. These claims related to role-based authentication are added to the user record during the user record retrieving without using additional claim store.
  • IUserSecurityStampStore<TUser> contains methods to manipulate user security stamps. It is not supported by the current system yet.

The following additional interfaces are implemented by our user store

  • IPasswordHasher for custom password hashing methods. It is implemented here so that the present system can be made compatible with the old membership providers.
  • IIdentityValidator<string> for validating a entity's string property, the password in particular. The implementation does nothing for the current version of the system.
Thanks to the new async/await feature, it's found that most of the methods implemented has a direct corresponding part in the old membership and role providers. One need only to copy the code from there and make them asynchronous by awaiting on the corresponding "Async" version of methods of the corresponding data service proxy class first, followed by a few obvious other changes needs to be done to adapt the copied code to the new method. For example, the method used to find a user given the user's identifier
C#
public async Task<TUser> FindByIdAsync(string userId)
{
    CallContext cctx = _cctx.CreateCopy();
    UserServiceProxy usvc = new UserServiceProxy();
    var u = await usvc.LoadEntityByKeyAsync(cctx, userId);
    if (u == null)
        return null;
    var user = new TUser();
    user.UpdateInstance(u);
    // New:
    // in addition, try to find all roles the user is in and add them
    // to its Claims property.
    //
    UserAppMemberSet membs = new UserAppMemberSet();
    UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
    var memb = await mbsvc.LoadEntityByKeyAsync(cctx, app.ID, user.Id);
    if (memb != null)
    {
        user.AppMemberStatus = memb.MemberStatus;
        if (memb.MemberStatus == membs.MemberStatusValues[0])
        {
            var roles = await GetRolesAsync(user);
            foreach (var r in roles)
            {
                user.Claims.Add(
                     CreateClaim
                     (
                       Microsoft.IdentityModel.Claims.ClaimTypes.Role, 
                       r
                     )
                );
            }
        }
    }
    return user;
}

The method returns a Task object which makes it awaitable. It also has a async modifier, which means that it can await on asynchronous awaitable calls. The body of the method await on asynchronous methods mbsvc.LoadEntityByKeyAsync(...) to load a user and GetRolesAsync(...) to get all the roles the user is in, explicitly or implicitly. The async method GetRolesAsync(...) is

C#
public async Task<System.Collections.Generic.IList<string>> GetRolesAsync(TUser user)
{
    var dic = await _getRolesAsync(user);
    return (from d in dic select d.Value).ToList();
}

It contains a line await on a async method _getRolesAsync(...) that await on some other async methods further, which will not be displayed here.

The User creation method is

C#
public async Task CreateAsync(TUser user)
{
    CallContext cctx = _cctx.CreateCopy();
    var _user = user as User;
    try
    {
        UserSet us = new UserSet();
        UserAppMemberSet ums = new UserAppMemberSet();
        UserServiceProxy usvc = new UserServiceProxy();
        User udata = null;
        List<User> lu = await usvc.LoadEntityByNatureAsync(cctx, 
                                                           user.UserName);
        if (lu == null || lu.Count == 0)
        {
            string id = user.Id;
            if (id != null && 
                     await usvc.LoadEntityByKeyAsync(cctx, id) != null)
                throw new Exception("Duplicate user ID found.");
            if (RequiresUniqueEmail)
            {
                var x = await GetUserNameByEmailAsync(_user.Email);
                if (!string.IsNullOrEmpty(x))
                    throw new Exception("User email exists.");
            }
            DateTime createDate = DateTime.UtcNow;
            if (id == null)
            {
                id = Guid.NewGuid().ToString();
            }
            else
            {
                Guid guid;
                if (!Guid.TryParse(id, out guid))
                    throw new Exception("Invalid user ID found.");
            }
            udata = new User();
            udata.IsPersisted = false;
            udata.ID = id;
            udata.Username = user.UserName;
            udata.Password = (user as User).Password;
            udata.PasswordFormat = "Hashed";
            udata.Email = _user.Email;
            udata.PasswordQuestion = _user.PasswordQuestion;
            udata.PasswordAnswer = _user.PasswordAnswer;
            udata.IsApproved = UserApprovedOnAddition;
            udata.CreateOn = createDate;
            udata.LastPasswordChangedDate = createDate;
            udata.FailedPasswordAttemptCount = 0;
            udata.FailedPasswordAttemptWindowStart = createDate;
            udata.FailedPasswordAnswerAttemptCount = 0;
            udata.FailedPasswordAnswerAttemptWindowStart = createDate;
            udata.Status = us.StatusValues[0];
            UserAppMember memb = new UserAppMember();
            memb.ApplicationID = app.ID;
            memb.UserID = udata.ID;
            memb.MemberStatus = ums.MemberStatusValues[0];
            memb.LastStatusChange = createDate;
            memb.LastActivityDate = createDate;
            memb.Comment = "";
            udata.ChangedUserAppMembers = new UserAppMember[] { memb };
            var v = await usvc.AddOrUpdateEntitiesAsync(cctx, 
                                                        us, 
                                                        new User[] { udata });
            if (v.ChangedEntities.Length == 1 &&  
                                 IsValidUpdate(v.ChangedEntities[0].OpStatus))
            {
                user.UpdateInstance(v.ChangedEntities[0].UpdatedItem);
                return;
            }
            throw new Exception("Add user failed!");
        }
        else if ((user as User).Password == lu[0].Password)
        {
            // case of an existing user trying to join an application
            DateTime createDate = DateTime.UtcNow;
            udata = lu[0];
            if (udata.Email != _user.Email)
            {
                udata.Email = _user.Email;
                udata.IsEmailModified = true;
           // no need to wait since it's already async on the server side.
                usvc.EnqueueNewOrUpdateEntitiesAsync(cctx, us, 
                                                     new User[] { udata });
            }
            UserAppMemberServiceProxy membsvc = new UserAppMemberServiceProxy();
            UserAppMember memb = await membsvc.LoadEntityByKeyAsync(cctx, 
                                                                    app.ID, 
                                                                    udata.ID);
            if (memb == null)
            {
                memb = new UserAppMember();
                memb.IsPersisted = false;
                memb.ApplicationID = app.ID;
                memb.UserID = udata.ID;
                memb.MemberStatus = ums.MemberStatusValues[0];
                memb.LastActivityDate = createDate;
                var v = membsvc.AddOrUpdateEntities(cctx, ums, 
                                    new UserAppMember[] { memb });
                if (v.ChangedEntities.Length == 1 && 
                            IsValidUpdate(v.ChangedEntities[0].OpStatus))
                {
                    user.UpdateInstance(udata);
                    return;
                }
                throw new Exception("Add user membership failed!");
            }
        }
        else
        {
            throw new Exception("User name exists!");
        }
    }
    catch (Exception e)
    {
        if (WriteExceptionsToEventLog)
        {
            WriteToEventLog(e, "CreateUser");
        }
        throw new Exception("error", e);
    }
    finally
    {
    }
}

Various parts of the above code is already explained in details in the previous article.

Finding whether or not a user is in a particular role in the hierarchic role system, which is copied from the role provider of the previous article, is implemented as

C#
public async Task<bool> IsInRoleAsync(TUser user, string role)
{
    CallContext cctx = _cctx.CreateCopy();
    try
    {
        UserServiceProxy usvc = new UserServiceProxy();
        var lu = await usvc.LoadEntityByNatureAsync(cctx, user.UserName);
        if (lu == null || lu.Count == 0)
            return false;
        User u = lu[0];
        Role r = await findRoleAsync(role);
        if (r == null)
            return false;
        UsersInRoleServiceProxy uisvc = new UsersInRoleServiceProxy();
        UsersInRole x = await uisvc.LoadEntityByKeyAsync(cctx, r.ID, u.ID);
        if (x != null)
            return true;
        else
        {
            RoleServiceProxy rsvc = new RoleServiceProxy();
            var ra = await rsvc.LoadEntityHierarchyRecursAsync(cctx, r, 0, -1);
            //for a given role, the users in it also include the ones in all 
            //its child roles, recursively (see above), in addition to its own ...
            List<string> uns = new List<string>();
            await _getUserInRoleAsync(cctx, ra, uns);
            return (from d in uns where d == user.UserName select d).Any();
        }
    }
    finally
    {
    }
}
 
private async Task _getUserInRoleAsync(CallContext cctx, 
                                       EntityAbs<Role> ra, 
                                       List<string> usersinrole)
{
    UserServiceProxy usvc = new UserServiceProxy();
    QueryExpresion qexpr = new QueryExpresion();
    qexpr.OrderTks = new List<QToken>(new QToken[]{ 
            new QToken { TkName = "Username" } 
        });
    qexpr.FilterTks = new List<QToken>(new QToken[]{
            new QToken { TkName = "UsersInRole." },
            new QToken { TkName = "RoleID" },
            new QToken { TkName = "==" },
            new QToken { TkName = "" + ra.DataBehind.ID + "" }
        });
    var users = await usvc.QueryDatabaseAsync(cctx, new UserSet(), qexpr);
    foreach (User u in users)
        usersinrole.Add(u.Username);
    if (ra.ChildEntities != null)
    {
        foreach (var c in ra.ChildEntities)
            await _getUserInRoleAsync(cctx, c, usersinrole);
    }
}

Note that the method async method _getUserInRoleAsync(...) can await on itself. It means that we are able do recursive invokations of asynchronous methods now. Go to here for further explanations of what code does.

The Role Store

Albeit the role store is implemented, it is not actually used at present.

Some of the methods that are in the role provider are moved into the user store above. The ones get left in the role store are relatively simple ones, for example

C#
public async Task DeleteAsync(TRole role)
{
    CallContext cctx = _cctx.CreateCopy();
    RoleServiceProxy rsvc = new RoleServiceProxy();
    try
    {
        string rolename = role.Name;
        var find = await findRoleAsync(rolename);
        Role r = find == null ? null : find.Item1;
        if (r != null)
        {
            if (!ThrowOnPopulatedRole)
                rsvc.DeleteEntities(cctx, new RoleSet(), new Role[] { r });
            else
            {
                var rus = await GetUsersInRoleAsync(rolename);
                if (rus == null || rus.Length == 0)
                    rsvc.DeleteEntities(cctx, new RoleSet(), new Role[] { r });
                else
                    throw new Exception("Cannot delete a populated role.");
            }
        }
    }
    catch (Exception e)
    {
        if (WriteExceptionsToEventLog)
        {
            WriteToEventLog(e, "DeleteRole");
        }
        throw new Exception("error", e);
    }
    finally
    {
    }
}

It will not be discussed in more details here. Interested a reader can go into the code and, again, find corresponding explanations of the code in our previous article.

The User Manager

Some methods inside the default Microsoft.AspNet.Identity.UserManager class need to be overridden in order to realize more sophisticated user authentication logic supported by the membership data service.

C#
public override async Task<TUser> FindAsync(string userName, 
                                            string password)
{
    if (HttpContext.Current != null)
    {
        string cacheKey = "userLoginState:" + userName;
        var error = 
           HttpContext.Current.Cache[cacheKey] as AuthFailedEventArg;
        if (error != null)
        {
            ErrorsHandler(userName, error);
            return null;
        }
    }
    CallContext cctx = _cctx.CreateCopy();
    UserServiceProxy usvc = new UserServiceProxy();
    UserSet us = new UserSet();
    var lu = await usvc.LoadEntityByNatureAsync(cctx, userName);
    if (lu == null || lu.Count == 0)
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.UnknownUser, 
            FailMessage = 
            "Your don't have an account in the present system, please register!" };
        ErrorsHandler(userName, err);
        return null;
    }
    var u = lu[0];
    if (!u.IsApproved)
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.ApprovalNeeded, 
            FailMessage = 
            "Your account is pending for approval, please wait!" };
        ErrorsHandler(userName, err);
        return null;
    }
    if (u.Status != us.StatusValues[0])
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.UserAccountBlocked, 
            FailMessage = string.Format(
"Your account is in the state of being [{0}], please contact an administrator!", 
            u.Status) };
        ErrorsHandler(userName, err);
        return null;
    }
    UserAppMemberSet membs = new UserAppMemberSet();
    UserAppMemberServiceProxy mbsvc = new UserAppMemberServiceProxy();
    var memb = await mbsvc.LoadEntityByKeyAsync(cctx, app.ID, u.ID);
    if (memb == null)
    {
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.MemberNotFound, 
            FailMessage = string.Format(
            "You are not currently a member of \"{0}\", please register", 
            string.IsNullOrEmpty(app.DisplayName) ? app.Name : app.DisplayName) };
        ErrorsHandler(userName, err);
        return null;
    }
    if (memb.MemberStatus != membs.MemberStatusValues[0])
    {
        if (memb.MemberStatus != membs.MemberStatusValues[3])
        {
            var err = new AuthFailedEventArg { 
                FailType = AuthFailedTypes.MembershipBlocked, 
                FailMessage = string.Format(
 "Your membership in \"{0}\" is in the state of being [{1}], please contact ... ", 
                string.IsNullOrEmpty(app.DisplayName) ? app.Name : app.DisplayName, 
                memb.MemberStatus) };
            ErrorsHandler(userName, err);
            return null;
        }
        else
        {
            var windowStart = 
                u.FailedPasswordAttemptWindowStart.HasValue ? 
                u.FailedPasswordAttemptWindowStart.Value : DateTime.MinValue;
            DateTime windowEnd = windowStart.AddSeconds(
                  (Store as UserStore<TUser>).PasswordAttemptWindow);
            if (DateTime.UtcNow <= windowEnd)
            {
                var err = new AuthFailedEventArg { 
                    FailType = AuthFailedTypes.MembershipFrozen, 
                    FailMessage = string.Format(
 "Maximum login attemps for \"{0}\" exceeded, please try again later!", 
                           string.IsNullOrEmpty(app.DisplayName) ? 
                                            app.Name : app.DisplayName) };
                ErrorsHandler(userName, err, false);
                return null;
            }
            else
            {
                memb.MemberStatus = membs.MemberStatusValues[0];
                memb.IsMemberStatusModified = true;
                memb.LastStatusChange = DateTime.UtcNow;
                memb.IsLastStatusChangeModified = true;
                await mbsvc.AddOrUpdateEntitiesAsync(cctx, membs, 
                                                     new UserAppMember[] { memb });
                var err = new AuthFailedEventArg { 
                    FailType = AuthFailedTypes.MembershipRecovered, 
                    FailMessage = 
"Your membership status is automatically restored, please try again in a few seconds!" };
                ErrorsHandler(userName, err, false);
                return null;
            }
        }
    }
    TUser user = new TUser();
    user.UpdateInstance(u);
    var found = await base.FindAsync(userName, password);
    if (found == null)
    {
        await (Store as UserStore<TUser>).UpdateFailureCountAsync(cctx, 
                                                                  user, 
                                                                  "password");
        var err = new AuthFailedEventArg { 
            FailType = AuthFailedTypes.InvalidCredential, 
            FailMessage = "Invalid username or password." };
        ErrorsHandler(userName, err, false);
    }
    else
    {
        u.LastLoginDate = DateTime.UtcNow;
        u.IsLastLoginDateModified = true;
        usvc.EnqueueNewOrUpdateEntities(cctx, new UserSet(), new User[] { u });
        memb.LastActivityDate = u.LastLoginDate;
        memb.IsLastActivityDateModified = true;
        mbsvc.EnqueueNewOrUpdateEntities(cctx, membs, new UserAppMember[] { memb });
        if (u.FailedPasswordAttemptCount != 0)
        {
            u.FailedPasswordAttemptCount = 0;
            usvc.EnqueueNewOrUpdateEntities(cctx, us, new User[] { u });
        }
    }
    return found;
}

In addition to successful user (password based) authentication cases, it handles various authentication failure scenarios categorized by

C#
public enum AuthFailedTypes
{
    Unknown,             // initial value, unknown
    UnknownUser,         // the user is not found
    InvalidCredential,   // the user's credential is not valid
    ApprovalNeeded,      // the user's account is not yet approved
    UserAccountBlocked,  // the user's account is currently being blocked from login
    MemberNotFound,      // the user is not a member of the current application
    MembershipBlocked,   // the user's membership login is currently blocked
    MembershipFrozen,    // the user's membership login is temporarily frozen
    MembershipRecovered, // the user's membership login is recovered
    ActionTip            // tip for the next actions to be performed
}

For each authentication failure of an existing member of the current application, the method calls

C#
await (Store as UserStore<TUser>).UpdateFailureCountAsync(cctx, user, "password");

which records the login failure time and count:

C#
public async Task UpdateFailureCountAsync(CallContext cctx, TUser user, string failureType)
{
    bool b = cctx.DirectDataAccess;
    cctx.DirectDataAccess = true;
    UserServiceProxy usvc = new UserServiceProxy();
    UserAppMemberServiceProxy umsvc = new UserAppMemberServiceProxy();
    try
    {
        User u = new User();
        u.IsPersisted = false;
        User.MergeChanges(user as User, u);
        u.IsPersisted = user.IsPersisted;
        DateTime windowStart = new DateTime();
        int failureCount = 0;
        if (failureType == "password")
        {
            failureCount = u.FailedPasswordAttemptCount.HasValue ? 
                           u.FailedPasswordAttemptCount.Value : 0;
            windowStart = u.FailedPasswordAttemptWindowStart.HasValue ? 
                          u.FailedPasswordAttemptWindowStart.Value : DateTime.MinValue;
        }
        else if (failureType == "passwordAnswer")
        {
            failureCount = u.FailedPasswordAnswerAttemptCount.HasValue ? 
                           u.FailedPasswordAnswerAttemptCount.Value : 0;
            windowStart = u.FailedPasswordAnswerAttemptWindowStart.HasValue ? 
                    u.FailedPasswordAnswerAttemptWindowStart.Value : DateTime.MinValue;
        }
        DateTime windowEnd = windowStart.AddSeconds(PasswordAttemptWindow);
        //repo.BeginRepoTransaction(cctx);
        if (failureCount == 0 || DateTime.UtcNow > windowEnd)
        {
            if (failureType == "password")
            {
                u.FailedPasswordAttemptCount = 1;
                u.IsFailedPasswordAttemptCountModified = true;
                u.FailedPasswordAttemptWindowStart = DateTime.UtcNow;
                u.IsFailedPasswordAttemptWindowStartModified = true;
            }
            else if (failureType == "passwordAnswer")
            {
                u.FailedPasswordAnswerAttemptCount = 1;
                u.IsFailedPasswordAnswerAttemptCountModified = true;
                u.FailedPasswordAnswerAttemptWindowStart = DateTime.UtcNow;
                u.IsFailedPasswordAnswerAttemptWindowStartModified = true;
            }
            await usvc.AddOrUpdateEntitiesAsync(cctx, new UserSet(), 
                                                      new User[] { u as User });
        }
        else
        {
            if (++failureCount >= MaxInvalidPasswordAttempts)
            {
                UserAppMemberSet us = new UserAppMemberSet();
                UserAppMember um = await umsvc.LoadEntityByKeyAsync(cctx, app.ID, u.ID);
                if (um != null)
                {
                    um.MemberStatus = us.MemberStatusValues[3];
                    um.IsMemberStatusModified = true;
                    um.LastStatusChange = DateTime.UtcNow;
                    um.IsLastStatusChangeModified = true;
                    await umsvc.AddOrUpdateEntitiesAsync(cctx, us, 
                                                           new UserAppMember[] { um });
                }
            }
            else
            {
                if (failureType == "password")
                {
                    u.FailedPasswordAttemptCount = failureCount;
                    u.IsFailedPasswordAttemptCountModified = true;
                    u.FailedPasswordAttemptWindowStart = DateTime.UtcNow;
                    u.IsFailedPasswordAttemptWindowStartModified = true;
                }
                else if (failureType == "passwordAnswer")
                {
                    u.FailedPasswordAnswerAttemptCount = failureCount;
                    u.IsFailedPasswordAnswerAttemptCountModified = true;
                    u.FailedPasswordAnswerAttemptWindowStart = DateTime.UtcNow;
                    u.IsFailedPasswordAnswerAttemptWindowStartModified = true;
                }
                await usvc.AddOrUpdateEntitiesAsync(cctx, new UserSet(), 
                                                         new User[] { u as User });
            }
        }
    }
    catch (Exception e)
    {
        if (WriteExceptionsToEventLog)
        {
            WriteToEventLog(e, "UpdateFailureCount");
        }
        throw new Exception("error", e);
    }
    finally
    {
        cctx.DirectDataAccess = b;
    }
}

What it does is to record the number of failure inside a time window spanned by the PasswordAttemptWindow (seconds) property. If the count exceed the number specified by the MaxInvalidPasswordAttempts property, the user's membership status will be set to a state of being Frozen (namely us.MemberStatusValues[3]). Further authentication attempts will be blocked within the said time window. After PasswordAttemptWindow seconds the user can try again. For those authentication issues that is not likely to be resolved by the user alone, a http cache is used to store the status so that the membership data service will not be invoked again within the time window spanned by PasswordAttemptWindow.

Using the User Store

Project modification

Here is a sequence of delete, add and modify edits that will change the default MVC 5 project so that the membership data service can be used.

Removed

  1. Remove References to EntityFramework, EntityFramework.SqlServer, Microsoft.AspNet.Identity.EntityFramework. Of course if your site do use the EntityFramework for other tasks, please ignore this.
  2. Remove References to Microsoft.Owin.Security.Facebook, <code>Microsoft.Owin.Security.OAuth, <code>Microsoft.Owin.Security.Google, Microsoft.Owin.Security.Twitter, Microsoft.Owin.Security.MicrosoftAccount.
  3. Remove <section name="entityFramework" ... /> related nodes under the <configSections> node from Web.config. Comment out or remove the "DefaultConnection" node since it is not currently used. Of course if your site do use the EntityFramework for other tasks, please ignore this.
  4. Remove <entityFramework> node under the root node from Web.config. Of course if your site do use the EntityFramework for other tasks, please ignore this.
  5. Delete or comment out app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); line in the Setup_Auth.cs file under App_Start sub-directory.
  6. IdentityModels.cs file under the Models sub-directory. Related data models are defined inside the project implementing the user store now.
  7. using Microsoft.AspNet.Identity.EntityFramework; line inside the Controllers\AccountController.cs file.
  8. Disassociate, ExternalLogin, ExternalLoginCallback, LinkLogin, LinkLoginCallback, ExternalLoginConfirmation, ExternalLoginFailure, RemoveAccountList, and Dispose methods and the XsrfKey property from the Controllers\AccountController.cs file. The ChallengeResult class inside of the AccountController class should also be removed.
  9. Remove the views: _ExternalLoginsListPartial.cshtm, _RemoveAccountPartial.cshtml, ExternalLoginConfirmation.cshtml and ExternalLoginFailure.cshtml under the Views\Account sub-directory.
  10. Remove the following block

    XML
    <div class="col-md-4">
        <section id="socialLoginForm">
            @Html.Partial("_ExternalLoginsListPartial",
                   new { Action = "ExternalLogin", ReturnUrl = ViewBag.ReturnUrl })
        </section>
    </div>
    

    from the Login.cshtml file. Remove the following block

    XML
    <section id="externalLogins">
        @Html.Action("RemoveAccountList")
        @Html.Partial("_ExternalLoginsListPartial",
                  new { Action = "LinkLogin", ReturnUrl = ViewBag.ReturnUrl })
    </section>
    

    from the Manage.cshtml file.

Added

  1. Add <add key="ApplicationName" value="YourApplicationName"/> node under the <appSettings> node of Web.config. Here the value "YourApplicationName" is the name chosen to be the unique name for the web application within the group of applications that uses the membership data service.

  2. Add the following keys nodes under the <appSettings> node of Web.config.

    XML
    <add key="WriteAuthExceptionsToEventLog" value="false" />
    <add key="RequiresUniqueUserEmail" value="true" />
    <add key="UserApprovedOnAddition" value="true" />
    <add key="ThrowOnDeletePopulatedRole" value="true"/>
    <add key="DeleteUserMembershipOnly" value="true"/>
    <add key="PasswordAttemptWindow" value="20" />
    <add key="MaxInvalidPasswordAttempts" value ="5" />
    <add key="UserStoreAutoCleanupRoles" value="true"/>
    

    They are used to control the behavior of the user store.

  3. Namespace reference using CryptoGateway.RDB.Data.AspNetMember; in the Setup_Auth.cs file under App_Start sub-directory.

  4. Add the following property

    C#
    internal static CallContext ClientContext
    {
        get;
        set;
    }
     
    internal static Application_ App
    {
        get;
        set;
    }

    to the <span lang="en-us"></span>Setup_Auth.cs file under App_Start sub-directory. ClientContext will serve as a single object that represents the web application's identity inside the remote data service. App is a data structure that identifies the kind of application served by the membership data service. They are initialized in the following code block added to the default ConfigureAuth method:

    C#
    public void ConfigureAuth(IAppBuilder app)
    {
        // Enable the application to use a cookie to store information 
        // for the signed in user
        app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Account/Login")
        });
        
        AspNetMemberServiceProxy svc = new AspNetMemberServiceProxy();
        if (ClientContext == null)
            ClientContext = svc.SignInService(new CallContext(), null);
        CallContext cctx = ClientContext.CreateCopy();
        // Get encryption and decryption key information from the configuration
        Configuration cfg = WebConfigurationManager.OpenWebConfiguration(
                    System.Web.Hosting.HostingEnvironment.ApplicationVirtualPath);
        var machineKey = (MachineKeySection)cfg.GetSection("system.web/machineKey");
        if (machineKey.ValidationKey.Contains("AutoGenerate"))
        {
            throw new Exception("Hashed or Encrypted passwords " +
                        "are not supported with auto-generated keys.");
        }
        string ApplicationName = ConfigurationManager.AppSettings["ApplicationName"];
        cctx.DirectDataAccess = true;
        Application_ServiceProxy apprepo = new Application_ServiceProxy();
        List<Application_> apps = apprepo.LoadEntityByNature(cctx, ApplicationName);
        if (apps == null || apps.Count == 0)
        {
            cctx.OverrideExisting = true;
            var tuple = apprepo.AddOrUpdateEntities(
                                   cctx, new Application_Set(), 
                                   new Application_[] { 
                                           new Application_ { Name = ApplicationName } 
                                   );
            App = tuple.ChangedEntities.Length == 1 &&
                                   IsValidUpdate(tuple.ChangedEntities[0].OpStatus) ? 
                                   tuple.ChangedEntities[0].UpdatedItem : null;
            cctx.OverrideExisting = false;
        }
        else
            App = apps[0];
    }
    
    public static bool IsValidUpdate(int status)
    {
        return (status & (int)EntityOpStatus.Added) > 0 || 
               (status & (int)EntityOpStatus.Updated) > 0 || 
               (status & (int)EntityOpStatus.NoOperation) > 0;
    }
  5. Install nuget package Microsoft.IdentityModel to the web application project. Add reference to System.ServiceModel assembly.

  6. Insert using Archymeta.Web.Security; line into the top of Controllers\AccountController.cs file.

  7. Add user.Email = model.Email; line after the user creation statement of the Register method.

  8. Add the following user e-mail input field to the Views\Account\Register.cshtml file

    XML
    <div class="form-group">
        @Html.LabelFor(m => m.Email, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.Email, new { @class = "form-control" })
        </div>
    </div>
    

Modified

  1. The Contollers\AccountControler.cs file should be modified. Change the parameterless constructor from

    C#
    public AccountController()
            : this(new UserManager<ApplicationUser>(
                          new UserStore<ApplicationUser>(       
                                    new ApplicationDbContext()
                              )
                       )
              )
    {
     
    }

    to

    C#
    public AccountController()
            : this(new UserManagerEx<ApplicationUser>(
                            new UserStore<ApplicationUser>(
                                  Startup.ClientContext, 
                                  Startup.App
                            ), 
                            Startup.ClientContext, 
                            Startup.App
                       )
              )
    {
        var manager = UserManager as UserManagerEx<ApplicationUser>;
        manager.ExternalErrorsHandler = err => ModelState.AddModelError(
                                                      err.FailType.ToString(), 
                                                      serr.FailMessage
                                               );
    }  
  2. In the same file, the method HasPassword is changed from

    C#
    private bool HasPassword()
    {
        var user = UserManager.FindById(User.Identity.GetUserId());
        if (user != null)
        {
            return user.PasswordHash != null;
        }
        return false;
    }   

    to

    C#
    private bool HasPassword()
    {
        var user = UserManager.FindById(User.Identity.GetUserId());
        if (user != null)
        {
            return user.Password != null;
        }
        return false;
    }   

    The mothed Register is changed. Change the line

    C#
    var user = new ApplicationUser() { UserName = model.UserName };

    to

    C#
    var user = new ApplicationUser() { Username = model.UserName };
  3. The Views\Account\Login.cshtml file is changed: change the line containing @Html.ValidationSummary(true) line into @Html.ValidationSummary() so that login errors can be displayed.

Updating packages

The javascript packages are not the latest ones. Use nuget to update jQuery related packages and also add the. The demo project includes the latest ones before Jan. 01, 2014. Also, it could be useful to add the knockoutjs package.

The ASP.NET MVC 5 is updated to ASP.NET MVC 5.1 when this article is created. The demo project is update to that version. The WebGrease is also updated to 1.6.0 for the demo project.

Using the demo project

The demo web application also contains Controllers\AdminController.cs and the corresponding empty View Views\Admin\Index.cshtml to demonstrate the role based authentication methods can still be used.

To demonstrate the hierarchic role system, the "Contact" page is authorized to users having the Administrators role and the "Admin" page is authorized to users having Administrators.System role. The pre-defined user sysadmin is in the Administrators.System role explicitly, sine the Administrators.System role is a child role of the Administrators role, sysadmin also has the Administrators role implicitly. The pre-defined demo-user-a is in the Administrators role. So the "Contact" page can be visited by sysadmin and demo-user-a. But the "Admin" page can only be visited by sysadmin alone.

Instead of following the above mentioned steps to modifying the default ASP.NET MVC 5 project, this almost empty project can in fact be used as a starting project.

Configuring the stores

One should setup the site as a client of the data service as well. The following is a set of basic settings

XML
<system.serviceModel>
   <bindings>
      <basicHttpBinding>
         <binding name="basicHttpBinding_DataService" 
                        allowCookies="true" maxBufferSize="6553600"
                        maxBufferPoolSize="5242880" 
                        maxReceivedMessageSize="6553600" >
            <security mode="None" />
         </binding>
      </basicHttpBinding>
   </bindings>
   <behaviors>
      <endpointBehaviors>
         <behavior name="ImpersonateEndpointBehavior">
            <clientCredentials>
               <windows allowedImpersonationLevel="Delegation" allowNtlm="true" />
            </clientCredentials>
         </behavior>
      </endpointBehaviors>
   </behaviors>
   <client>
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/AspNetMemberDatabase2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IAspNetMemberService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/Application_Set2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IApplication_Service2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/RoleSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IRoleService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserAppMemberSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserAppMemberService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserProfileSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserProfileService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserProfileTypeSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserProfileTypeService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UserSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUserService2" />
      <endpoint name="HTTP" 
              address="http://_domain_/Services/DataService/AspNetMember/UsersInRoleSet2.svc" 
              binding="basicHttpBinding" 
              bindingConfiguration="basicHttpBinding_DataService" 
              contract="CryptoGateway.RDB.Data.AspNetMember.IUsersInRoleService2" />
   </client>
</system.serviceModel>

Here "_domain_" inside the address attribute of each endpoint node represents the domain name or IP address (plus port number if not 80), one should change them to a proper one pointing to the server on which data service is hosting.

The <connectionStrings> section of the Web.config file contains a name="DefaultConnection" add node, it initially points to a ASP.NET created membership database. Your can remove the node if you have no further use of it or change the content of it to point to other database that the site is going to use.

The Test Project

The test requires some configuration before it can be run. There is the app.config file inside the test project in which the data service end points are not fully initialized. The parameter __servicedomain__ should be replaced by the domain name (and port if not 80) where the data service is hosted.

The included solution for test projects can be used either to test the async membership stores and manager, but also be used as a source of information that provides an alternative view to study the implementation and related assumptions. It also demonstrate how the data service can be used on the client side. This is because they cover far more details than the one contained in the demo web application.

There are about 15K users in the sample file User.xml inside the root directory of the test project, about 1.5% of them are randomly selected for each test. Since there are quite a number of test users involved, the speed of the tests will notably depend on the network speed from the test computer to the computer where the data service is hosted.

Note: do not test against an instance of the data service where multiple agents, including other test agents, might be accessing it at the same time. This is required not because the service can not handle CRUD operations by multiple users but because the test codes reset (not locked) the state of the data source constantly so the results for other agents and the present test agent are unpredictable if out of sequence resets by other agents are going on.

Setup the Data Service

If the data service had already been installed when testing or using the membership providers given in the previous article here, please ignore this section. Otherwise read on.

Extract the files from the Member Data Service package to a folder, configure a website for it (it is a ASP.NET MVC 4 web application). Enable the HTTP activation of WCF inside your system. That's basically it.

If you need to persist your changes, at least the "App_Data\AspNetMember\Data" sub-directory under the service site need to have proper permission. It should allow user IIS_IUSRS the Write permission.

Warning: This is a demonstration version of the system for evaluation purposes. It's based on a transient serialization method. Please do not used it for long time data persistence or for supporting production systems.

History

  • V1.0.0. Initial publication.
  • V1.0.1. Improved handling of edge cases in the test project and the consistency of exception treatment.
  • V1.5.0. The data service now run under .NET 4.5.1 and ASP.NET MVC 5 with many features improved and new features, like support of SignalR or WCF based entity change events subscription ports, etc., added. The user store now supports ASP.NET Identity 2.0.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Founder CryptoGateway Software Inc.
Canada Canada


His interests in software research and development include security, domain specific meta-language development technologies and tools, meta-language driven code generation, generation of non-invasive and virtual SQL database for "un-structured" data (sqlization), automated relational data service production technologies, file system sqlization technologies and products, structured multi-lingual documentation tools and technologies, sqlization of user's personal data, like emails, etc..


He was a physicist before year 2000. He studied theoretical physics (the hidden symmetry between the past and future, quantum field theories, mirror universe, cosmological dark energies etc.) in which he think to had made fundamental breakthroughs[^] but the community is either not ready for it or is actively rejecting/ignoring it Smile | :) .



It struck me on Apr. 11, 2023 that the said theory above can even generate General Relativity naturally after a recent discussion in the Insider News group that triggers a rethinking of the subject on my side. The first stage of the work is completed in Sept. 2023, it is and will be continue to be published online

  • On Vacuum
  • Quantum and Gravity







    Most recent software system to share:



    • V-NET[^] - Full stack virtualization management system including networking, storage, virtual machines and containers, with an emphasis on networking ... to be released.

Comments and Discussions

 
QuestionGood Article Pin
agbenaza9-Jun-14 3:20
agbenaza9-Jun-14 3:20 
QuestionPossible to consume from inside another WCF-service? Pin
ValleTrim8-May-14 0:58
ValleTrim8-May-14 0:58 
AnswerRe: Possible to consume from inside another WCF-service? Pin
Shuqian Ying8-May-14 12:36
Shuqian Ying8-May-14 12:36 
QuestionApplicable Pin
keistic27-Feb-14 13:18
keistic27-Feb-14 13:18 
AnswerRe: Applicable Pin
Shuqian Ying27-Feb-14 13:51
Shuqian Ying27-Feb-14 13:51 
Questiondownloading the data service site not functional Pin
tutor17-Feb-14 8:40
tutor17-Feb-14 8:40 
AnswerRe: downloading the data service site not functional Pin
Shuqian Ying17-Feb-14 10:01
Shuqian Ying17-Feb-14 10:01 
GeneralRe: downloading the data service site not functional Pin
Ken-domainagents.com19-Feb-14 9:48
Ken-domainagents.com19-Feb-14 9:48 
QuestionGreat Pin
J. Wijaya29-Jan-14 16:27
J. Wijaya29-Jan-14 16:27 
AnswerRe: Great Pin
Shuqian Ying6-Feb-14 9:30
Shuqian Ying6-Feb-14 9:30 
QuestionGreat article..thanks for this :) Pin
Member 1055650928-Jan-14 16:14
Member 1055650928-Jan-14 16:14 
AnswerRe: Great article..thanks for this :) Pin
Shuqian Ying28-Jan-14 16:17
Shuqian Ying28-Jan-14 16:17 
QuestionLink error Pin
casper.kinsun27-Jan-14 21:03
casper.kinsun27-Jan-14 21:03 
AnswerRe: Link error Pin
Shuqian Ying27-Jan-14 21:46
Shuqian Ying27-Jan-14 21:46 
QuestionVery good article! Pin
Volynsky Alex27-Jan-14 8:04
professionalVolynsky Alex27-Jan-14 8:04 
AnswerRe: Very good article! Pin
Shuqian Ying27-Jan-14 21:48
Shuqian Ying27-Jan-14 21:48 
GeneralRe: Very good article! Pin
Volynsky Alex28-Jan-14 6:03
professionalVolynsky Alex28-Jan-14 6:03 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.