Blogs

The One With

OData and OAuth - Part 4 – Managing Identities

     

We’re almost ready to implement an OAuth 1.0 Service provider. But first, let’s make sure our member accounts are properly password protected. Since we allow to sign in using different credentials into the same account, we need a place to store all those different identities.

public enum AuthenticationType : int {
    Principal = 0,
    Google = 1,
    Facebook = 2,
    Twitter = 3,
}

[Persistent("Identities")]
public class Identity : XPLiteObject {
    public Identity() : base() { }
    public Identity(Session session) : base(session) { }

    [Key(AutoGenerate = true)]
    public Guid ID { get; set; }
    
    [Indexed(Unique = true)]
    [Size(128)]
    public string Hash { get; set; }

    [Indexed(Unique = false)]
    public Nullable<Guid> Principal { get; set; }

    [Size(128)]
    public string Token { get; set; }
    [Size(128)]
    public string Secret { get; set; }

    public AuthenticationType AuthenticationType { get; set; }

    public DateTime Created { get; set; }
    public DateTime Modified { get; set; }
}

AuthenticationType

Specified the type of the identity.

Hash

A unique value given to an identity by the provider. We must make sure that this value is unique per provider/authentication type.

  • AuthenticationType.Principal: We use “Principal” + Email address
  • AuthenticationType.Google: We use “Google” + Email address. Google does not let users change their email address so this value is unique.
  • AuthenticationType.Facebook: We use “Facebook” + ID. Facebook does allow users to change their email address. If we were to hash “Facebook” + Email address, and the user changes the email at a later time, we will not be able to resolve the identity after the change. So ID is what we need.
  • AuthenticationType.Twitter: We use “Twitter” + ID. Twitter doesn’t even provide email addresses but provides a screen name and an ID. Screen names can be changed so we can’t use them either. So ID is what we need.

Note: Examine the FromTwitter, FromGoogle and FromFacebook methods in the AuthenticationService.cs

To compute the hash value we use MixedAuthenticationTicket.GetHashCode:

public string GetHashCode(string identitySecret) {    
    using (HMACSHA1 hash
                = new HMACSHA1(Encoding.ASCII.GetBytes(identitySecret))) {
        
        return Convert.ToBase64String(
            hash.ComputeHash(Encoding.ASCII.GetBytes(
                this.AuthenticationType.ToLowerInvariant() +
                this.Identity.ToLowerInvariant())));
    }

}

The “identitySecret” - a hash key, we’ll configure in Web.config.

    <appSettings>
       <add key="Identity_Private_Key" value="tR3+Ty81lMeYAr/Fid0kMTYa/WM=" />
    </appSettings>

Token & Secret

This is where we store the latest access token and token secret that were issued by the provider. And in the case of AuthenticationType.Principal, we store the hashed password and the password hash key (salt).

Principal

An ID of the Principal object to resolve to.

Putting it Together

The new sign up page:

[HttpPost]
public ActionResult Signup(Signup model, string returnUrl) {
     if (!ModelState.IsValid) {
         return View(model);
     }
     try {
         MembershipCreateStatus createStatus;         
         MixedAuthenticationTicket ticket = MembershipService.CreatePrincipal(
             model.Email, /* We use email as our primary identity */
             model.Email,
             model.FullName,
             model.Password,
             out createStatus);

         if (createStatus == MembershipCreateStatus.Success) {
             MixedAuthentication.SetAuthCookie(ticket, true, null);
             return RedirectToAction("", "home");
         }

         ModelState.AddModelError("", MembershipService.ErrorCodeToString(createStatus));
         return View(model);
     } catch (Exception e) {
         ModelState.AddModelError("", e);
         return View(model);
     }
}

Updated sign in page:

[HttpPost]
public ActionResult Signin(Signin model, string returnUrl) {
    if (!ModelState.IsValid) {
        return View(model);
    }
    try {                
        if (Impersonate(MembershipService.ResolvePrincipalIdentity(
                model.Email, 
                model.Password), true)) {

            return RedirectToAction("", "home");
        }

        ModelState.AddModelError("", "Email address or Password is incorrect.");
        return View(model);
    } catch (Exception e) {
        ModelState.AddModelError("", e);
        return View(model);
    }
}
public MixedAuthenticationTicket ResolvePrincipalIdentity(string identity, string password) {            
    MixedAuthenticationTicket ticket 
        = new MixedAuthenticationTicket("Principal", identity.ToLowerInvariant(), 
            String.Empty, 
            Guid.Empty,
            String.Empty);
    
    using (Session session = _Global.CreateSession()) {
        string identityCode 
            = ticket.GetHashCode(ConfigurationManager.AppSettings["Identity_Private_Key"]);

        Identity identityRecord = session.FindObject<Identity>(
            new BinaryOperator(
                    "Hash",
                    identityCode)
            );
        
        if (identityRecord == null) {
            return MixedAuthenticationTicket.Empty;
        }

        string encodedPassword = HMACSHA1(password, identityRecord.Secret);
        if (!encodedPassword.Equals(identityRecord.Token)) {
            return MixedAuthenticationTicket.Empty;
        }

        ticket.Principal = identityRecord.Principal;
        return ticket;

    }
}

For our own (Principal) authentication everything is straightforward and simple. For federated authentication it’s tricky. What should we do when someone signs in for example using Facebook for the first time? Should we auto create the identity and principal? Should we auto create the identity, redirect to the “confirm account” page and only than create the principal? Or should we keep the credentials in the cookie and only create persistent records when the user confirms the account?. My preference is toward option #2 for the following reasons:

  • Storing tokens (or any sensitive information) on the client side is not a good practice (even if we encrypt ticket cookie). And we need to keep track of the issued tokens so we have performs actions on user’s behalf.
  • The sign-in identity is unique (and will remain that way) and we’re going to persist it at some point anyway. So why not create it right away? (right after we acquired the token). You can put an expiration timestamp on auto created identities and GC them periodically.
  • Auto-creating principals without user’s confirmation is also not an option. What if one has an account already and wants to associate this *new* login with that *existing* account.

in other words:

public MixedAuthenticationTicket ResolveFederatedIdentity(
    MixedAuthenticationTicket ticket, IToken accessToken) {

    if (!ticket.IsAuthenticated) { return MixedAuthenticationTicket.Empty; }
   
    using (Session session = _Global.CreateSession()) {
        session.BeginTransaction();
        DateTime utcNow = DateTime.UtcNow;
        string identityCode 
            = ticket.GetHashCode(ConfigurationManager.AppSettings["Identity_Private_Key"]);
        Identity identityRecord = session.FindObject<Identity>(
            new BinaryOperator(
                    "Hash",
                    identityCode)
            );
        if (identityRecord != null) {
            if (identityRecord.Token == accessToken.Value
                            && identityRecord.Secret == accessToken.Secret) {
                ticket.Principal = identityRecord.Principal;
                return ticket;
            }
        }
        if (identityRecord == null) {
            identityRecord = new Identity(session);
            identityRecord.Created = utcNow;
            identityRecord.Hash = identityCode;
        }        
        identityRecord.Modified = utcNow;
        identityRecord.Token = accessToken.Value;
        identityRecord.Secret = accessToken.Secret;

        switch (ticket.AuthenticationType) {
            case "Google":
                identityRecord.AuthenticationType = AuthenticationType.Google;
                break;
            case "Facebook":
                identityRecord.AuthenticationType = AuthenticationType.Facebook;
                break;
            case "Twitter":
                identityRecord.AuthenticationType = AuthenticationType.Twitter;
                break;
            default:
                throw new ArgumentException("Authentication type is not supported.", "ticket");
        }
        identityRecord.Save();
        session.CommitTransaction();
        ticket.Principal = identityRecord.Principal;
        return ticket;
    }
}

Now we need to confirm the account and create the principal:

public MixedAuthenticationTicket ActivatePrincipal(
    MixedAuthenticationTicket ticket,
    out MembershipCreateStatus status) {

    status = MembershipCreateStatus.ProviderError;
    using (Session session = _Global.CreateSession()) {
        session.BeginTransaction();
        string identityCode 
            = ticket.GetHashCode(ConfigurationManager.AppSettings["Identity_Private_Key"]);        
        Identity identityRecord = session.FindObject<Identity>(
            new BinaryOperator(
                    "Hash",
                    identityCode)
            );
        if (identityRecord == null) {
            return MixedAuthenticationTicket.Empty;
        }

        /* Update or Create Principal */
        Principal principalRecord = null;
        if (identityRecord.Principal.HasValue 
                    && identityRecord.Principal.Value != Guid.Empty) {
            principalRecord = session.GetObjectByKey<Principal>(identityRecord.Principal.Value);
        }
        DateTime utcNow = DateTime.UtcNow;
        if (principalRecord == null) {
            principalRecord = new Principal(session);
            principalRecord.ID = Guid.NewGuid();
            principalRecord.Created = utcNow;
        }
        principalRecord.FullName = ticket.FullName;
        principalRecord.Email = ticket.Email;
        principalRecord.Modified = utcNow;
        /* Update Identity */
        identityRecord.Principal = principalRecord.ID;
        identityRecord.Modified = utcNow;
        /* Commit */
        principalRecord.Save();
        identityRecord.Save();
        session.CommitTransaction();
        status = MembershipCreateStatus.Success;
        ticket.Email = principalRecord.Email;
        ticket.Principal = principalRecord.ID;
        ticket.FullName = principalRecord.FullName;
        return ticket;
    }
}

What’s next

Download source code for Part 4

Cheers

Azret

Published Feb 28 2011, 09:00 PM by Azret Botash (DevExpress)
Filed under: , , , , ,
Technorati tags: OAuth, MVC, MySQL, ASP.NET, XPO, OData
Bookmark and Share

Comments

No Comments
More from DevExpress
Live Chat
Have a pre-sales question?
Need assistance with your evaluation?
We are here to help.
Chat is one of the many ways you can contact members of the DevExpress Team. We are available Monday-Friday between 8:30am and 5:00pm Pacific Time.
If you need additional product information, require pre-sales assistance, or want help with your order, write to us at info@devexpress.com or call us at
+1 (818) 844-3383.