2

I was tasked with adding logging via external service (using SAML 2.0) to an MVC app (.Net 4.5) that uses SimpleMembership. To be honest I'm not even sure where to start. From what I found on the internet there are few points to the problem. Most of the materials I found dealt with communication with the SAML identity provider (frequently written from scratch). However before I can reach that point I need to make sure I can actually integrate it with the SimpleMembership which we are using.

I suspect for starters I would need something like SAMLWebSecurity (akin to OAuthWebSecurity which we also use). I have found no such thing* on the internet which makes me believe it does not exist (though I wouldn't mind being wrong here). This makes me believe I would have to write it myself, but can I do that without have to write my own membership provider?

*I'm not sure what would be a correct way to call this static class.

Nerdroid
  • 13,398
  • 5
  • 58
  • 69
jahu
  • 5,427
  • 3
  • 37
  • 64

2 Answers2

2

After discussing it with a colleague I think I figured out the course of actions. Both OAuthWebSecurity and WebSecurity appear to be a part of SimpleMembership, so what I wrote in the question would indicate I want to write a custom membership or reverse engineer SimpleMembership to copy OAuthWebSecurity (which doesn't sound like a fun activity to have).

My best bet here is hijacking the OAuthWebSecurity, by writing a custom client (one which implements the IAuthenticationClient interface). Normally one registers various OAuth clients using OAuthWebSecurity's built in methods (like RegisterFacebookClient). But it is also possible to register those clients using OAuthWebSecurity.RegisterClient which accepts IAuthenticationClient. This way I should be able to add this SAML login without writing a custom membership provider and keep using SimpleMembership.


I managed to do this. Thankfully the identity provider wasn't extremely complicated so all I had to do was redirect to a certain address (I didn't even need to request assertion). After a successful login, the IDP "redirects" the user using POST to my site with the base64 encoded SAMLResponse attached. So all I had to do was to parse and validate the response. I placed the code for this in my custom client (implementing IAuthenticationClient interface).

public class mySAMLClient : IAuthenticationClient
{
    // I store the IDP certificate in App_Data
    // This can by actually skipped. See VerifyAuthentication for more details
    private static X509Certificate2 certificate = null;
    private X509Certificate2 Certificate
    {
        get
        {
            if (certificate == null)
            {
                certificate = new X509Certificate2(Path.Combine(HttpContext.Current.ApplicationInstance.Server.MapPath("~/App_Data"), "idp.cer"));
            }
            return certificate;
        }
    }

    private string providerName;
    public string ProviderName
    {
        get
        {
            return providerName;
        }
    }

    public mySAMLClient()
    {
        // This probably should be provided as a parameter for the constructor, but in my case this is enough
        providerName = "mySAML";
    }

    public void RequestAuthentication(HttpContextBase context, Uri returnUrl)
    {
        // Normally you would need to request assertion here, but in my case redirecting to certain address was enough
        context.Response.Redirect("IDP login address");
    }

    public AuthenticationResult VerifyAuthentication(HttpContextBase context)
    {
        // For one reason or another I had to redirect my SAML callback (POST) to my OAUTH callback (GET)
        // Since I needed to retain the POST data, I temporarily copied it to session
        var response = context.Session["SAMLResponse"].ToString();
        context.Session.Remove("SAMLResponse");
        if (response == null)
        {
            throw new Exception("Missing SAML response!");
        }
        // Decode the response
        response = Encoding.UTF8.GetString(Convert.FromBase64String(response));


        // Parse the response
        var assertion = new XmlDocument { PreserveWhitespace = true };
        assertion.LoadXml(response);

        //Validating signature based on: http://stackoverflow.com/a/6139044
        // adding namespaces
        var ns = new XmlNamespaceManager(assertion.NameTable);
        ns.AddNamespace("samlp", @"urn:oasis:names:tc:SAML:2.0:protocol");
        ns.AddNamespace("saml", @"urn:oasis:names:tc:SAML:2.0:assertion");
        ns.AddNamespace("ds", @"http://www.w3.org/2000/09/xmldsig#");

        // extracting necessary nodes
        var responseNode = assertion.SelectSingleNode("/samlp:Response", ns);
        var assertionNode = responseNode.SelectSingleNode("saml:Assertion", ns);
        var signNode = responseNode.SelectSingleNode("ds:Signature", ns);

        // loading the signature node
        var signedXml = new SignedXml(assertion.DocumentElement);
        signedXml.LoadXml(signNode as XmlElement);


        // You can extract the certificate from the response, but then you would have to check if the issuer is correct
        // Here we only check if the signature is valid. Since I have a copy of the certificate, I know who the issuer is
        // So if the signature is valid I then it was sent from the right place (probably).
        //var certificateNode = signNode.SelectSingleNode(".//ds:X509Certificate", ns);
        //var Certificate = new X509Certificate2(System.Text.Encoding.UTF8.GetBytes(certificateNode.InnerText));

        // checking signature
        bool isSigned = signedXml.CheckSignature(Certificate, true);
        if (!isSigned)
        {
            throw new Exception("Certificate and signature mismatch!");
        }

        // If you extracted the signature, you would check the issuer here

        // Here is the validation of the response
        // Some of this might be unnecessary in your case, or might not be enough (especially if you plan to use SAML for more than just SSO)
        var statusNode = responseNode.SelectSingleNode("samlp:Status/samlp:StatusCode", ns);
        if (statusNode.Attributes["Value"].Value != "urn:oasis:names:tc:SAML:2.0:status:Success")
        {
            throw new Exception("Incorrect status code!");
        }
        var conditionsNode = assertionNode.SelectSingleNode("saml:Conditions", ns);
        var audienceNode = conditionsNode.SelectSingleNode("//saml:Audience", ns);
        if (audienceNode.InnerText != "Name of your app on the IDP")
        {
            throw new Exception("Incorrect audience!");
        }
        var startDate = XmlConvert.ToDateTime(conditionsNode.Attributes["NotBefore"].Value, XmlDateTimeSerializationMode.Utc);
        var endDate = XmlConvert.ToDateTime(conditionsNode.Attributes["NotOnOrAfter"].Value, XmlDateTimeSerializationMode.Utc);
        if (DateTime.UtcNow < startDate || DateTime.UtcNow > endDate)
        {
            throw new Exception("Conditions are not met!");
        }
        var fields = new Dictionary<string, string>();
        var userId = assertionNode.SelectSingleNode("//saml:NameID", ns).InnerText;
        var userName = assertionNode.SelectSingleNode("//saml:Attribute[@Name=\"urn:oid:1.2.840.113549.1.9.1\"]/saml:AttributeValue", ns).InnerText;
        // you can also extract some of the other fields in similar fashion
        var result = new AuthenticationResult(true, ProviderName, userId, userName, fields);
        return result;


    }
}

Then I just registered my client in App_Start\AuthConfig.cs using OAuthWebSecurity.RegisterClient and then I could reuse my existing external login code (which was originally made for OAUTH). For various reasons my SAML callback was a different action than my OAUTH callback. The code for this action was more or less this:

[AllowAnonymous]
public ActionResult Saml(string returnUrl)
{
    Session["SAMLResponse"] = Request.Form["SAMLResponse"];
    return Redirect(Url.Action("ExternalLoginCallback") + "?__provider__=mySAML");
}

Additionally OAuthWebSecurity.VerifyAuthentication didn't work with my client too well, so I had to conditionally run my own verification in the OAUTH callback.

AuthenticationResult result = null;
if (Request.QueryString["__provider__"] == "mySAML")
{
    result = new mySAMLClient().VerifyAuthentication(HttpContext);
}
else
{
    // use OAuthWebSecurity.VerifyAuthentication
}

This probably all looks very weird and might differ greatly in case of your IDP, but thanks to this I was able to reuse most of the existing code for handling external accounts.

jahu
  • 5,427
  • 3
  • 37
  • 64
2

I'd recommend that you upgrade to ASP.NET Identity and the OWIN Based authentication middleware. Then you can use Kentor.AuthServices middleware that works with ASP.NET Identity (except that the XSRF-guard has to be commented out until bug #127 has been resolved).

You could also use the SAML classes from Kentor.AuthServices if you have to stick with SimpleMembership, so that you don't have to implement SAML from scratch.

Disclaimer: I'm the author of Kentor.AuthServices, but since it's open source, I'm not making money on people using it.

Anders Abel
  • 67,989
  • 17
  • 150
  • 217
  • Thanks for the suggestion. I was already looking at your code for that very purpose (which is not having to write everything from scratch). – jahu Dec 13 '14 at 22:03