In claim based scenarios, sometimes we might need to
authenticate against CRM contacts. CRM system might be acting more as backend
or internal system. We can have a web application or might be a Sharepoint
application which wants to authenticate end users against CRM entities. I used
this in some application, and I find it interesting and useful to share.
Let’s try to understand the
scenario:
Figure 1: Sharepoint system is authenticated against CRM
using ADFS 2 and custom STS.
Look into the roles of various
components involved here:
Client: Any PC
where we want to access a SharePoint application. Client accesses the SharePoint
URL. User enters the credentials in the 3rd step.
SharePoint Application:
This application is configured as relying party in ADFS. It will request claims
from ADFS.
ADFS: Active
Directory Federation Service is Optional here. If we need to authenticate against
AD and/or we need to federate among various claim/authentication providers then
we would need this, otherwise we don’t. We can just use a custom STS with web
application configured as relying party.
Custom STS (Security Token Service): A custom STS based on ASP.Net web application or WCF. It
is configured as another authentication provider in ADFS. It creates required
claims after successful authentication in federation scenario using ADFS.
CRM System: Custom STS accesses CRM using organization service to fetch contact details and authenticate against the details entered by user.
Now we can now go thru the code to achieve this in step by step manner. I am going to create a WCF based STS which can be accessed as service from any exiting STS (Active federation). This can be done using ASP.Net STS with a login page (Passive federation).
CRM System: Custom STS accesses CRM using organization service to fetch contact details and authenticate against the details entered by user.
Now we can now go thru the code to achieve this in step by step manner. I am going to create a WCF based STS which can be accessed as service from any exiting STS (Active federation). This can be done using ASP.Net STS with a login page (Passive federation).
Steps to follow (I might give a miss to detailed explanation
of claim fundamentals, rather I would like to focus more on custom STS and how
to use it to authenticate against CRM contacts:-
Environment: Visual Studio 2010, CRM
Dlls, IIS.
1. Create WCF STS project using Visual
Studio 2010:
Go
to new website and then select following-
It
will create a startup project with basic code for WCF based STS.
Default
Project structure:
It basically generates following:
Some important classes generated:
CustomSecurityTokenService: WCF
service for STS (The custom STS class)
public class CustomSecurityTokenService : SecurityTokenService { // TODO: Set enableAppliesToValidation to true to enable only the RP Url's specified in the ActiveClaimsAwareApps array to get a token from this STS static bool enableAppliesToValidation = false; // TODO: Add relying party Url's that will be allowed to get token from this STS static readonly string[] ActiveClaimsAwareApps = { /*"https://localhost/ActiveClaimsAwareWebApp"*/ }; ////// Creates an instance of CustomSecurityTokenService. /// /// The SecurityTokenServiceConfiguration. public CustomSecurityTokenService( SecurityTokenServiceConfiguration configuration ) : base( configuration ) { } ////// Validates appliesTo and throws an exception if the appliesTo is null or contains an unexpected address. /// /// The AppliesTo value that came in the RST. ///If 'appliesTo' parameter is null. ///If 'appliesTo' is not valid. void ValidateAppliesTo( EndpointAddress appliesTo ) { if ( appliesTo == null ) { throw new ArgumentNullException( "appliesTo" ); } // TODO: Enable AppliesTo validation for allowed relying party Urls by setting enableAppliesToValidation to true. By default it is false. if ( enableAppliesToValidation ) { bool validAppliesTo = false; foreach ( string rpUrl in ActiveClaimsAwareApps ) { if ( appliesTo.Uri.Equals( new Uri( rpUrl ) ) ) { validAppliesTo = true; break; } } if ( !validAppliesTo ) { throw new InvalidRequestException( String.Format( "The 'appliesTo' address '{0}' is not valid.", appliesTo.Uri.OriginalString ) ); } } } ////// This method returns the configuration for the token issuance request. The configuration /// is represented by the Scope class. In our case, we are only capable of issuing a token for a /// single RP identity represented by the EncryptingCertificateName. /// /// The caller's principal. /// The incoming RST. ///The scope information to be used for the token issuance. protected override Scope GetScope( IClaimsPrincipal principal, RequestSecurityToken request ) { ValidateAppliesTo( request.AppliesTo ); // // Note: The signing certificate used by default has a Distinguished name of "CN=STSTestCert", // and is located in the Personal certificate store of the Local Computer. Before going into production, // ensure that you change this certificate to a valid CA-issued certificate as appropriate. // Scope scope = new Scope( request.AppliesTo.Uri.OriginalString, SecurityTokenServiceConfiguration.SigningCredentials ); string encryptingCertificateName = WebConfigurationManager.AppSettings[ "EncryptingCertificateName" ]; if ( !string.IsNullOrEmpty( encryptingCertificateName ) ) { // Important note on setting the encrypting credentials. // In a production deployment, you would need to select a certificate that is specific to the RP that is requesting the token. // You can examine the 'request' to obtain information to determine the certificate to use. scope.EncryptingCredentials = new X509EncryptingCredentials( CertificateUtil.GetCertificate( StoreName.My, StoreLocation.LocalMachine, encryptingCertificateName ) ); } else { // If there is no encryption certificate specified, the STS will not perform encryption. // This will succeed for tokens that are created without keys (BearerTokens) or asymmetric keys. Symmetric keys are // required to be 'wrapped' and the STS will throw. scope.TokenEncryptionRequired = false; // Symmetric keys are required to be 'wrapped' or the STS will throw, uncomment the code below to turn off proof key encryption. // Turning off proof key encryption is not secure and should not be used in a deployment scenario. // scope.SymmetricKeyEncryptionRequired = false; } return scope; } ////// This method returns the claims to be issued in the token. /// /// The caller's principal. /// The incoming RST, can be used to obtain addtional information. /// The scope information corresponding to this request./// ///If 'principal' parameter is null. ///The outgoing claimsIdentity to be included in the issued token. protected override IClaimsIdentity GetOutputClaimsIdentity( IClaimsPrincipal principal, RequestSecurityToken request, Scope scope ) { if ( null == principal ) { throw new ArgumentNullException( "principal" ); } ClaimsIdentity outputIdentity = new ClaimsIdentity(); // Issue custom claims. // TODO: Change the claims below to issue custom claims required by your application. // Update the application's configuration file too to reflect new claims requirement. outputIdentity.Claims.Add( new Claim( System.IdentityModel.Claims.ClaimTypes.Name, principal.Identity.Name ) ); outputIdentity.Claims.Add( new Claim( ClaimTypes.Role, "Manager" ) ); return outputIdentity; } }
CustomSecurityTokenServiceConfiguration: We can use this class to override
some of the default configurations. Later in the article, I will override the
default user name token handler behavior.
////// A custom SecurityTokenServiceConfiguration implementation. /// public class CustomSecurityTokenServiceConfiguration : SecurityTokenServiceConfiguration { ////// CustomSecurityTokenServiceConfiguration constructor. /// public CustomSecurityTokenServiceConfiguration() : base( WebConfigurationManager.AppSettings[Common.IssuerName], new X509SigningCredentials( CertificateUtil.GetCertificate( StoreName.My, StoreLocation.LocalMachine, WebConfigurationManager.AppSettings[Common.SigningCertificateName] ) ) ) { this.SecurityTokenService = typeof( CustomSecurityTokenService ); } }
CertificateUtil: A kind of helper class to access
certificates from local store.
FederationMetadata.xml: Default federation metadata definitions which are exposed
by this custom STS. It has all the necessary information such as issuer name,
certificate info, endpoint address or exposed claims.
Web.config:
It plays a major role in setting up this STS correctly. We will discuss this in
detail in 2nd step.
2. Modify default web.config: It should be modified as following –
<?xml version="1.0" encoding="UTF-8"?> <configuration> <configSections> <section name="microsoft.identityModel" type="Microsoft.IdentityModel.Configuration.MicrosoftIdentityModelSection, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"/> </configSections> <appSettings> <add key="IssuerName" value="ActiveSTSForCRM"/> <add key="SigningCertificateName" value="CN=DEV01.corp.contoso.com"/> <add key="EncryptingCertificateName" value="CN=DEV01.corp.contoso.com"/> </appSettings> <location path="FederationMetadata"> <system.web> <authorization> <allow users="*"/> </authorization> </system.web> </location> <system.web> <compilation debug="true" targetFramework="4.0"> <assemblies> <add assembly="Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" /> </assemblies> </compilation> <authentication mode="None"/> <pages> <controls> <add tagPrefix="asp" namespace="System.Web.UI" assembly="System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" /> </controls> </pages> </system.web> <system.web.extensions> <scripting> <webServices> </webServices> </scripting> </system.web.extensions> <system.serviceModel> <services> <service name="Microsoft.IdentityModel.Protocols.WSTrust.WSTrustServiceContract" behaviorConfiguration="ServiceBehavior"> <endpoint address="IWSTrust13" binding="ws2007HttpBinding" contract="Microsoft.IdentityModel.Protocols.WSTrust.IWSTrust13SyncContract" bindingConfiguration="ws2007HttpBindingConfiguration"/> <host> <baseAddresses> <add baseAddress="https://DEV01.corp.contoso.com/AAMCustomSTS/Service.svc"/> <add baseAddress="http:// devcrm01.contoso.com/dev /XRMServices/2011/Organization.svc"/> </baseAddresses> </host> <endpoint address="mex" binding="ws2007HttpBinding" contract="IMetadataExchange" bindingConfiguration="ws2007HttpBindingConfiguration"/> </service> </services> <bindings> <ws2007HttpBinding> <binding name="ws2007HttpBindingConfiguration"> <security mode="TransportWithMessageCredential"> <transport clientCredentialType="None"/> <message clientCredentialType="UserName" establishSecurityContext="false"/> </security> </binding> </ws2007HttpBinding> </bindings> <behaviors> <serviceBehaviors> <behavior name="ServiceBehavior"> <!-- To avoid disclosing metadata information, set the value below to false and remove the metadata endpoint above before deployment --> <serviceMetadata httpGetEnabled="true"/> <!-- To receive exception details in faults for debugging purposes, set the value below to true. Set to false before deployment to avoid disclosing exception information --> <serviceDebug includeExceptionDetailInFaults="false"/> <serviceCredentials> <serviceCertificate findValue="DEV01.corp.contoso.com" storeLocation="LocalMachine" storeName="My" x509FindType="FindBySubjectName"/> </serviceCredentials> </behavior> </serviceBehaviors> </behaviors> <diagnostics> <messageLogging logEntireMessage="true" logMessagesAtServiceLevel="true" logMessagesAtTransportLevel="true" logMalformedMessages="true" maxMessagesToLog="50000" maxSizeOfMessageToLog="20000"/> </diagnostics> </system.serviceModel> <microsoft.identityModel> <service name="Microsoft.IdentityModel.Protocols.WSTrust.WSTrustServiceContract"> <audienceUris> <add value="https://DEV01.corp.contoso.com/AuthUsingCRMClientSite/"/> </audienceUris> <issuerNameRegistry type="Microsoft.IdentityModel.Tokens.ConfigurationBasedIssuerNameRegistry, Microsoft.IdentityModel, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"> <trustedIssuers> <add thumbprint="eaa8f8bd2063d55ac083a0907e52b74d8cdb9d07" name="https://DEV01.corp.contoso.com/AuthUsingCRM/Service.svc/IWSTrust13"/> </trustedIssuers> </issuerNameRegistry> <serviceCertificate> <certificateReference findValue="DEV01.corp.contoso.com" storeLocation="LocalMachine" storeName="My" x509FindType="FindBySubjectName"/> </serviceCertificate> </service> </microsoft.identityModel> </configuration>
We need to make following changes:
2.1. AppSettings:
- Mention token
issuer name for this STS.
- Token signing
certificate: Create a self-signed certificate in IIS and mention the name here.
- Encrypting
Certificate name: You can use separate certificate. Or use the same as signing
certificate.
<appSettings>
<add key="IssuerName" value="ActiveSTSForCRM"/>
<add key="SigningCertificateName"
value="CN=DEV01.corp.contoso.com"/>
<add key="EncryptingCertificateName" value="CN=DEV01.corp.contoso.com"/>
</appSettings>
2.2. Add a new config
section for microsoft.identityModel:
Define section as
added in above sample config.
Mention
fully qualified name of the system (in place of Localhost). Replace DEV01.corp.contoso.com accordingly and the
certificate name or thumbprint.
2.3. Audience URI: This
is the relying party for this STS. In some later step, I will add a test web
client to consume this STS. The URI of this application should be mentioned as
audient URI on STS web config.
2.4. Specify service,
binding, and service behavior appropriately. Use your domain name.
3. Add a claim aware web site to test
the custom STS created above:
Select new website:
With claim aware web site, we will
have most of the required assemblies automatically added into solution.
4. Add logic to actively consume WCF
based STS in login.aspx.cs:
WSTrustChannelFactory: We will
actively consume the WCF STS end point using WSTurst channel factory class as
shown in the below method.
protected void btnSubmit_Click( object sender, EventArgs e ) { // Note: Add code to validate user name, password. This code is for illustrative purpose only. // Do not use it in production environment. //FormsAuthentication.RedirectFromLoginPage( txtUserName.Text, false ); GetToken(); } private static void GetToken() { WSTrustChannelFactory factory = null; try { factory = new WSTrustChannelFactory( new MIB.UserNameWSTrustBinding(SecurityMode.TransportWithMessageCredential), new EndpointAddress("https://dev01.corp.contoso.com/AuthUsingCRM/Service.svc/IWSTrust13")); //IWSTrust13 factory.TrustVersion = SSS.TrustVersion.WSTrust13; factory.Credentials.SupportInteractive = false; factory.Credentials.UserName.UserName = "domain\\username"; factory.Credentials.UserName.Password = "password"; factory.Credentials.ServiceCertificate.Authentication.CertificateValidationMode = SSS.X509CertificateValidationMode.None; factory.Credentials.ServiceCertificate.Authentication.RevocationMode = X509RevocationMode.NoCheck; factory.Credentials.ClientCertificate.Certificate = CertificateUtil.GetCertificate(StoreName.My, StoreLocation.LocalMachine, WebConfigurationManager.AppSettings["SigningCertificateName"]); RequestSecurityToken rst = new RequestSecurityToken(); rst.RequestType = RequestTypes.Issue; rst.AppliesTo = new EndpointAddress("https:// dev01.corp.contoso.com /AuthUsingCRMClientSite/"); rst.KeyType = KeyTypes.Bearer; IWSTrustChannelContract channel = factory.CreateChannel(); SecurityToken secToken = channel.Issue(rst); } finally { if (factory.State != CommunicationState.Closing) { factory.Close(); factory.Abort(); } } }
User Name / Password:
Set the window’s user account name and password. By default, for username
authentication, WindowsUserNameSecurityTokenHandler is used by STS configuration.
5. Now Set the client web site as
startup project and debug it.
Set a breakpoint
at following line in GetToken() method in login.aspx.cs: SecurityToken secToken = channel.Issue(rst);
Once it executes, it will return a
valid encrypted secToken. So, great job,
our STS is working. Now we can modify it to authenticate against CRM.
6. Add custom security handler to
authenticate against CRM:
We
will add 2 classes to App_Code of STS project:
CRMService: A wrapper around OrganizationService of
CRM / XRM.
public class CRMService { private OrganizationService _Service = null; public CRMService() { var connection = new CrmConnection("CRMConn"); _Service = new OrganizationService(connection); } public OrganizationService Service { get { return _Service; } set { _Service = value; } } public Guid CreateEntity(Entity entity) { return Service.Create(entity); } public Entity RetreiveEntity(Guid id, string entityName) { return Service.Retrieve(entityName, id, new ColumnSet(true)); } public EntityCollection RetreiveMultipleRecords(string fetchXml) { return Service.RetrieveMultiple(new FetchExpression(fetchXml)); } public void UpdateEntity(Entity entity) { Service.Update(entity); } }
For this to work, we need to add following Xrm assemblies to
Bin folder:
Microsoft.Xrm.Sdk.dll,
microsoft.crm.sdk.proxy.dll, and microsoft.xrm.client.dll
7. CRMUserNameSecurityTokenHandler: A custom user name security token handler which will override the default WindowsUserNameSecurityTokenHandler from STS configuration. Here we need to override ValidateToken() method and access CRM services using CRMService wrapper to authenticate user.
{ public class CRMUserNameSecurityTokenHandler : Microsoft.IdentityModel.Tokens.UserNameSecurityTokenHandler public CRMUserNameSecurityTokenHandler() { } public override bool CanValidateToken { get { return true; } } public override Microsoft.IdentityModel.Claims.ClaimsIdentityCollection ValidateToken(SecurityToken token) { System.Diagnostics.Debugger.Launch(); if (token == null) { throw new ArgumentNullException("token"); } UserNameSecurityToken userNameToken = token as UserNameSecurityToken; if (userNameToken == null) { throw new SecurityTokenException("Invalid token"); } IClaimsIdentity identity = new ClaimsIdentity(); EntityCollection contacts = AuthenticateUser(userNameToken.UserName, userNameToken.Password); if (contacts != null && contacts.Entities.First() != null) { Entity contact = contacts.Entities.First(); identity.Claims.Add(new Claim(Microsoft.IdentityModel.Claims.ClaimTypes.Authentication, "true")); identity.Claims.Add(new Claim(Microsoft.IdentityModel.Claims.ClaimTypes.Upn, userNameToken.UserName)); string name = contact.Attributes["lastname"] != null ? contact.Attributes["lastname"].ToString() + ", " : "" + contact.Attributes["middlename"] != null ? contact.Attributes["middlename"].ToString() + " " : "" + contact.Attributes["firstname"] != null ? contact.Attributes["firstname"].ToString() : ""; identity.Claims.Add(new Claim(Microsoft.IdentityModel.Claims.ClaimTypes.Name, name)); identity.Claims.Add(new Claim(Microsoft.IdentityModel.Claims.ClaimTypes.Email, contact.Attributes["emailaddress1"] != null ? contact.Attributes["emailaddress1"].ToString() : userNameToken.UserName)); identity.Claims.Add(new Claim(Microsoft.IdentityModel.Claims.ClaimTypes.PrimarySid, contact.Attributes["contactid"] != null ? contact.Attributes["contactid"].ToString() : "InvalidContactID")); } return new ClaimsIdentityCollection(new IClaimsIdentity[] { identity }); } private EntityCollection AuthenticateUser(string userName, string password) { //Authentication against CRM CRMService svc = new CRMService(); var fetchXML = @"<fetch version='1.0' output-format='xml-platform' mapping='logical' distinct='false'> <entity name='contact'> <all-attributes/> <filter type='and'> <condition attribute='neu_username' operator='eq' value='" + userName + @"' /> </filter> </entity> </fetch>"; EntityCollection contacts = svc.RetreiveMultipleRecords(fetchXML); if (contacts.Entities.Count > 0) { if (password.ToLower() == contacts[0]["new_password_field"].ToString().ToLower()) return contacts; } return null; } }
8. Override default user name security
token handler with this custom one:
public class CustomSecurityTokenServiceConfiguration : SecurityTokenServiceConfiguration { ////// CustomSecurityTokenServiceConfiguration constructor. /// public CustomSecurityTokenServiceConfiguration() : base( WebConfigurationManager.AppSettings[Common.IssuerName], new X509SigningCredentials( CertificateUtil.GetCertificate( StoreName.My, StoreLocation.LocalMachine, WebConfigurationManager.AppSettings[Common.SigningCertificateName] ) ) ) { var removeWinUNHdl = this.SecurityTokenHandlerCollectionManager.SecurityTokenHandlerCollections.ToList()[0].First(x => x is Microsoft.IdentityModel.Tokens.WindowsUserNameSecurityTokenHandler); this.SecurityTokenHandlerCollectionManager.SecurityTokenHandlerCollections.ToList()[0].Remove(removeWinUNHdl); this.SecurityTokenHandlerCollectionManager.SecurityTokenHandlerCollections.ToList()[0].Add(new CRMUserNameSecurityTokenHandler()); this.SecurityTokenService = typeof( CustomSecurityTokenService ); } }
This can be done in web.config under Microsoft.IdentityModel
section, but it was not working in my case.
9. Set CRM connection string in
web.config with correct User name and password.
<add name="CRMConn" connectionString="Url=http://devcrm01.contoso.com/Dev/;
Username=domain\user_name; Password=password;"/>
10. Set CRM user name and password in
GetToken() method of test client web site and Debug the application. If all
these settings are set correctly, custom token handler will be called and it
will authenticate against CRM. Finally it will return the claims which we have configured
in custom token handler.
Source Code
I have developed this sample application and attached here with
this article. The source code contains two websites (STS and test client) which
has to be deployed correctly in IIS with all the configuration and certificate
needed (as explained in this article). The source code is not perfect in all
sense as its primary focus is to demo authentication using CRM.
Source code (Shared on GitHub): https://github.com/manoj-kumar1/auth-against-crm-using-STS
To run this application, you would need Visual Studio 2010.
Conclusion
In this article, I have demonstrated a technique to consume CRM services using custom STS for authenticating end user. This can be setup as Active or Passive federation with ADFS in advance scenarios as also explained in the beginning of this discussion.
Please get in touch
with me if you have any questions or you think otherwise @
manoj.kumar[at]neudesic.com.
No comments:
Post a Comment