This is the second in a series of articles discussing the implementation of an SSO application for Google Apps. The first .NET Google SSO Part 1 of 2 began the discussion by laying out the steps needed to handle the initial Authentication Request made by Google. This final piece will complete the package by illustrating the generation of the Authentication Response.
Google will expect the Authentication Response to come back as a form posted to the AssertionConsumerServiceURL provided by Google in the inital request. The form needs to have at least two elements on it, a <textarea> named SAMLResponse and an <input> named RelayState. The RelayState element is the url to redirect the user to at Google once the response has been sent. This is usually the same as the RelayState that Google provided to you via the RelayState url parameter.
Building the SAMLResponse is a little bit more involved. The SAMLResponse will contain the xml body of the AuthnResponse, which has the following format:
<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response ID="RESPONSE_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315" />
<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
<Reference URI="">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
<DigestValue>DIGEST_VALUE</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>SIGNATURE_VALUE</SignatureValue>
<KeyInfo>
<KeyValue>
<RSAKeyValue>
<Modulus>RSA_KEY_MODULUS</Modulus>
<Exponent>RSA_KEY_EXPONENT</Exponent>
</RSAKeyValue>
</KeyValue>
</KeyInfo>
</Signature>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
<Assertion ID="ASSERTION_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
<Issuer>https://www.opensaml.org/IDP</Issuer>
<Subject>
<NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress">
USERNAME_STRING
</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData Recipient="ACS_URL" />
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="NOT_BEFORE" NotOnOrAfter="NOT_ON_OR_AFTER">
</Conditions>
<AuthnStatement AuthnInstant="AUTHN_INSTANT">
<AuthnContext>
<AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:Password
</AuthnContextClassRef>
</AuthnContext>
</AuthnStatement>
</Assertion>
</samlp:Response> |
Where the items in red will need to be replaced with actual values before sending the response to Google. Here are some specs for those items:
Attribute | Description | Examples |
ASSERTION_ID | A 160-bit string made up of randomly generated lower case alpha characters from a through p | dfpccklacbmnmioodokleambcplpmfghahihdmna |
ISSUE_INSTANT | Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ | 2000-01-01T23:01:01Z
2008-10-02T05:55:23Z |
AUTHN_INSTANT | Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ | 2000-01-01T23:01:01Z
2008-10-02T05:55:23Z |
NOT_BEFORE | This defines the beginning timeframe for which the authentication is valid. Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ | 2000-01-01T23:01:01Z
2008-10-02T05:55:23Z |
NOT_ON_OR_AFTER | This defines the ending timeframe for which the authentication is valid. Should be the current date and time in the format:
yyyy-mm-ddThh:mm:ssZ | 2000-01-01T23:01:01Z
2008-10-02T05:55:23Z |
ACS_URL | The URL to send the authentication result | https://www.google.com/a/yourCompany.com/acs |
USERNAME_STRING | The username of the person validated | jsmith
jon.smith |
DIGEST_VALUE | The value of the digest of the Reference URI calculated using the algorithm listed in the DigestMethod | Qo9gKjwBgNtt6P07aIZmIPXP+uQ= |
SIGNATURE_VALUE | The value of the digest of the SignedInfo Element calculated using the algorithm listed in the SignatureMethod | 55khyrdDp0SgPmlu5dH49CAfASW2psDLhgjaB+Yl06pfxLJWnIctb7pX0K3k/vhNU8sUMY6Ps582CS6+YUPJps45U0i
VDK/+PPZvFREOwDR54XSzXAPd7GZyk+jBdQZv6D/g5IgccIGiw3Zi9Qpfe64iewSFoRukvVUD0yGOfszA= |
RSA_KEY_MODULUS | For use in the RSA encryption algorithm | K88ntfSyp1JU9Nu1KJwk+KKOuunT9G1RLwJXm6WrDb2klVGXLNKcP72lu5AlyGgYoZXO2bY2d610LE7kGol3Hu
PKfOMLoKqO/m1etjBBhQnuc2aVL1vJjQiLV/KsCsgwocNIoGOF01ndlgJiazFUFf6IwFltNg/A1pz9I05kkj0= |
RSA_KEY_EXPONENT | For use in the RSA encryption algorithm | AQAB |
Here are the basic steps we need to take to create the AuthnResponse:
- Authenticate the user
- Generate an xmlDocument object using the AuthnResponse Template
- Fill in the AuthnResponse parameters
- Create a new signedXml object based on the xmlDocument
- Submit an html form with the InnerXml of the signedXml object and the RelayState
If we continue with the project created using the first blog post, we currently have one item on our form, a label called lblError. You will need to add two text boxes, txtUser and txtPwd to allow the user to enter their credentials and a button, btnSubmit. Format the form to your liking, and then add some code to authenticate the user, this can be done against a directory, a database, etc. This example will authenticate a user against ADAM.
// Class level vars
string googleUserName = "";
///<summary>
/// Authenticate user and return the result
///</summary>
protected bool AuthenticateUser()
{
// User parameters from the form
bool authenticated = false;
// User parameters from the form
string user = Request.Params["txtUser"];
string password = Request.Params["txtPwd"];
// Variables
SearchResult srPerson = null;
DirectorySearcher dSearch = null;
DirectoryEntry de = null;
DirectoryEntry deAdmin = null;
string dn = "";
// Clear out our class level variable, googleUserName
googleUserName = "";
try
{
if (!string.IsNullOrEmpty(user) && !string.IsNullOrEmpty(password))
{
// All required params were not entered, throw an error
// could also use some field validators to avoid this
throw new Exception("Logon failure: unknown user name or bad password");
}
// Get the users dn based on the user name used the form, so
// that we can use it later with our bind test
dSearch = new DirectorySearcher();
dSearch.SearchScope = SearchScope.Subtree;
dSearch.PropertiesToLoad.Add("distinguishedName");
dSearch.SearchRoot = new DirectoryEntry("LDAP://YourServer:389/C=YourRootPath", "AdamAdminUser", "AdamAdminPswd",
AuthenticationTypes.Secure | AuthenticationTypes.ServerBind);
dSearch.Filter = "(userName=" + user + ")";
srPerson = dSearch.FindOne();
dn = srPerson == null ? null : srPerson.Properties["distinguishedName"][0].ToString();
if (!string.IsNullOrEmpty(dn))
{
// Get the directory entry, using the users credentials to bind
de = new DirectoryEntry("LDAP://YourServer:389/C=YourRootPath", dn, password,
AuthenticationTypes.ServerBind | AuthenticationTypes.FastBind);
// Get the native object to force authentication, an error will be throw here
// if the wrong credentials were given, this will be captured below and the user will be notified
object bind = de.NativeObject;
// They have authenticated okay, we are now assuming that their google user name
// is different than their ADAM login name, we need to get it for use later
// we cant use the above object to get the data because we had to issue our connection
// using the fast bind option, get a new handle on the object using
// admin credentials and the secure auth type
deAdmin = new DirectoryEntry("LDAP://YourServer:389/" + dn, "AdamAdminUser", "AdamAdminPswd",
AuthenticationTypes.ServerBind | AuthenticationTypes.Secure);
deAdmin.RefreshCache(new string[] { "googleUserName" });
// If googleUserName is an email address, we just want what comes before the @ symbol
googleUserName = deAdmin.Properties.Contains("googleUserName") ?
deAdmin.Properties["googleUserName"][0].ToString() : "";
googleUserName = googleUserName.IndexOf("@") > 0 ?
googleUserName.Substring(0, googleUserName.IndexOf("@")) : googleUserName;
// We didn't get what we expected throw an error
if (googleUserName == "")
{
throw new Exception("Logon failure: unable to retrieve your Google username");
}
else
{
authenticated = true;
}
}
else
{
// We cant continue without a dn
throw new Exception("Logon failure: unknown user name or bad password");
}
}
catch(Exception ex)
{
// Let user know there was a problem
lblError.Text = ex.Message;
}
finally
{
// Clean up
if (dSearch!= null)
{
dSearch.Dispose();
}
if (de != null)
{
de.Dispose();
}
if (deAdmin != null)
{
deAdmin.Dispose();
}
}
} |
Now that our user is authenticated and we have their Google UserName, the next step is to generate an xmlDocument object using the AuthnResponse template. The template that we need is similar to the AuthnResponse shown above, with one very important difference; we don't want the <Signature> element or its children. We will be adding this using the signedXml object later. Create an xml file named AuthnResponseTemplate in your project directory. Copy in the AuthnResponse xml below and save the document.
<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response ID="RESPONSE_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</samlp:Status>
<Assertion ID="ASSERTION_ID" IssueInstant="ISSUE_INSTANT" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
<Issuer>https://www.opensaml.org/IDP</Issuer>
<Subject>
<NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress">
USERNAME_STRING
</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData Recipient="ACS_URL" />
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="NOT_BEFORE" NotOnOrAfter="NOT_ON_OR_AFTER">
</Conditions>
<AuthnStatement AuthnInstant="AUTHN_INSTANT">
<AuthnContext>
<AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:Password
</AuthnContextClassRef>
</AuthnContext>
</AuthnStatement>
</Assertion>
</samlp:Response> |
We will now add code to consume this new file and replace the elements in red with proper values:
///<summary>
/// Create our xml SAML Response base on the template
///</summary>
private string CreateSamlResponse()
{
// Grab our response template
string filepath = Request.PhysicalApplicationPath + ("AuthnResponseTemplate.xml");
XmlDocument doc = new XmlDocument();
doc.PreserveWhitespace = true;
doc.Load(filepath);
string samlResponse = doc.InnerXml;
// Get our IDs and make sure that they arent the same (timing)
string responseID = CreateID();
string assertionID = CreateID();
while (responseID == assertionID)
{
assertionID = CreateID();
}
// Fill out out our template
samlResponse = samlResponse.Replace("USERNAME_STRING", googleUserName);
samlResponse = samlResponse.Replace("RESPONSE_ID", responseID);
samlResponse = samlResponse.Replace("ISSUE_INSTANT", GetDateAndTime(0, 0));
samlResponse = samlResponse.Replace("AUTHN_INSTANT", GetDateAndTime(0, 1));
samlResponse = samlResponse.Replace("NOT_BEFORE", GetRequestAttributes(requestXmlString, "IssueInstant"));
samlResponse = samlResponse.Replace("NOT_ON_OR_AFTER", GetDateAndTime(1, 0););
samlResponse = samlResponse.Replace("ASSERTION_ID", assertionID);
samlResponse = samlResponse.Replace("ACS_URL", GetRequestAttributes(requestXmlString, "AssertionConsumerServiceURL"));
return samlResponse;
}
///<summary>
/// Generate a random set of alpha characters
/// appropriate for our SAML IDs
///</summary>
public static string CreateID()
{
Random random = new Random();
char[] charMapping = {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p'};
byte[] bytes = new byte[20]; // 160 bits
random.NextBytes(bytes);
char[] chars = new char[40];
for (int i = 0; i < bytes.Length; i++)
{
int left = (bytes[i] >> 4) & 0x0f;
int right = bytes[i] & 0x0f;
chars[i * 2] = charMapping[left];
chars[i * 2 + 1] = charMapping[right];
}
string id = new string(chars);
return id;
}
///<summary>
/// Create a datetime string in the appropriate SAML format
///</summary>
public static string GetDateAndTime(int days, int minutes)
{
DateTime dt = DateTime.Now;
if (days > 0)
{
dt = dt.AddDays(days);
}
if (minutes > 0)
{
dt = dt.AddMinutes(minutes);
}
return dt.ToString("yyyy-MM-dd") + 'T' + dt.ToString("HH:mm:ss") + 'Z';
}
///<summary>
/// Get the specified attribute value from the SAMLRequest
///</summary>
private string GetRequestAttributes(string xmlString, string attribute)
{
XmlDocument doc = new XmlDocument();
doc.LoadXml(xmlString);
if (doc != null)
{
return doc.DocumentElement.GetAttribute(attribute).ToString();
}
else
{
throw new Exception("Error parsing AuthnRequest XML: Null document");
}
} |
Next, we will be adding the xml signature. Be sure to update the file path to the x509 cert used for signing the xml. This certificate must contain the private key:
///<summary>
/// Sign the AuthnResponse xml adding the Signature element
///</summary>
private string SignResponse(string xmlDocString)
{
// Get our pfx cert w/private and public keys
// we have the document on the file system, but you could also
// get it from the certificate store
// this is the same cert (with public keys only) that was provided
// to Google during the SSO set-up
X509Certificate2 x509 = new X509Certificate2();
x509.Import("C:\FilePathToGoogleCert\Cert.pfx");
// Create a new RSA signing key
RSACryptoServiceProvider rsaKey = new RSACryptoServiceProvider();
rsaKey = (RSACryptoServiceProvider) x509.PrivateKey;
// Load our xml into a doc
XmlDocument doc = new XmlDocument();
doc.PreserveWhitespace = true;
doc.LoadXml(xmlDocString);
// Sign the XML document.
// Create a SignedXml object.
SignedXml signedXml = new SignedXml(doc);
// Add the key to the SignedXml document.
signedXml.SigningKey = rsaKey;
// Add KeyInfo
KeyInfo keyInfo = new KeyInfo();
keyInfo.AddClause(new RSAKeyValue(rsaKey));
signedXml.KeyInfo = keyInfo;
// Create a reference to be signed.
Reference reference = new Reference();
reference.Uri = "";
// Add an enveloped transformation to the reference.
XmlDsigEnvelopedSignatureTransform env = new XmlDsigEnvelopedSignatureTransform();
reference.AddTransform(env);
// Add the reference to the SignedXml object.
signedXml.AddReference(reference);
// Compute the signature.
signedXml.ComputeSignature();
// Get the XML representation of the signature and save
// it to an XmlElement object.
XmlElement xmlDigitalSignature = signedXml.GetXml();
// Append the element to the XML document.
doc.DocumentElement.InsertBefore(doc.ImportNode(xmlDigitalSignature, true), doc.DocumentElement.FirstChild);
return doc.InnerXml.Trim();
} |
Finally, we will bring it all together in the submit button on_click event handler and generate the html form with the SAMLResponse and RelayState and send it on to Google:
///<summary>
/// Handle the submit button click event, authenticate the user,
/// then create the response and submit it to Google
///</summary>
protected void btnSubmit_Click(object sender, System.EventArgs e)
{
// Authenticate user
if (AuthenticateUser())
{
// Get our SAMLResponse and sign it
string samlResponse = CreateSamlResponse();
string signedResponse = SignResponse(samlResponse);
string formName = "authnResponse";
string acsUrl = GetRequestAttributes(requestXmlString, "AssertionConsumerServiceURL");
// Write the http response in such a way that the html document
// will submit a form with our data on it to the requestor
System.Web.HttpContext.Current.Response.Clear();
System.Web.HttpContext.Current.Response.Write("<html><head/>");
System.Web.HttpContext.Current.Response.Write(string.Format("<body onload='document.{0}.submit()'>",
formName));
System.Web.HttpContext.Current.Response.Write(string.Format("<form name='{0}' method='post' action='{1}'>",
formName, acsUrl));
System.Web.HttpContext.Current.Response.Write(string.Format("<textarea name=\"{0}\" style='visibility:hidden'>{1}</textarea>",
"SAMLResponse", signedResponse));
System.Web.HttpContext.Current.Response.Write(string.Format("<input name=\"{0}\" type=\"hidden\" value=\"{1}\">",
"RelayState", Request.Params["RelayState"]));
System.Web.HttpContext.Current.Response.Write("</form>");
System.Web.HttpContext.Current.Response.Write("</body></html>");
System.Web.HttpContext.Current.Response.End();
}
} |
That should do it! The user will then be allowed into their Google account.
5 comments:
its good.
Work from home
Does this still work?
James, I can only assume so. However, I am no longer at the client that we did this work for. While its possible they could have made changes to this since I left, I havent heard of any.
Thanks for the code, made for an excellent base to work off.
BTW code works.
just couldn’t leave your web site before suggesting that I really loved the standard information a person provide for your visitors? Is gonna be again ceaselessly to check up on new posts car key replacement london
Post a Comment