When using UserName instead of the default Windows authentication, the idea of using Custom username validator can only do the authentication part, while the authorization always needs more info besides the username, usually it’s a list of roles. We could create a CustomAuthorizationPolicy : IAuthorizationPolicy, then fill out the evalute method something like this:
<behavior name="customAuthPolicy">
<serviceMetadata httpGetEnabled="true"/>
<serviceDebug includeExceptionDetailInFaults="true"/>
<serviceAuthorization principalPermissionMode ="Custom" >
<authorizationPolicies>
<add policyType="Premotion.Services.CustomAuthorizationPolicy, App_Code" />
</authorizationPolicies>
</serviceAuthorization>
<serviceCredentials>
<serviceCertificate findValue="MyServerCert" x509FindType="FindBySubjectName" storeLocation="LocalMachine" storeName="My" />
<userNameAuthentication userNamePasswordValidationMode="Custom"
customUserNamePasswordValidatorType="Premotion.Services.UsernameValidator, App_Code" />
</serviceCredentials>
</behavior>
public class CustomAuthorizationPolicy : IAuthorizationPolicy
{
public string Id
{
get { return new Guid().ToString(); }
}
public bool Evaluate(EvaluationContext evaluationContext, ref object state)
{
// get the authenticated client identity
IIdentity client = GetClientIdentity(evaluationContext);
List<string> roles = new List<string>();
if (client.Name == "frank") roles.Add("Admins");
evaluationContext.Properties["Principal"] = new CustomPrincipal(client, roles.ToArray());
return true;
}
public ClaimSet Issuer
{
get { return ClaimSet.System; }
}
private IIdentity GetClientIdentity(EvaluationContext evaluationContext)
{
object obj;
if (!evaluationContext.Properties.TryGetValue("Identities", out obj))
throw new Exception("No Identity found");
IList<IIdentity> identities = obj as IList<IIdentity>;
if (obj == null || identities.Count <= 0)
throw new Exception("No Identity found");
return identities[0];
}
}
Not only the relection code to get identity from evalution context, to create our own CustomPriciple and CustomIdentiy class is also very annoying, even it’s simple, but, can we make it simpler. What we need just a list of roles based on the username.
/// <summary>
/// Make sure to generate the test certificate
/// C:\Program Files\Microsoft SDKs\Windows\v6.0A\Bin
/// makecert.exe -sr LocalMachine -ss My -a sha1 -n CN=MyServerCert -sky exchange –pe
/// </summary>
[TestFixture]
public class UsernameWcfSpecs
{
private ServiceHost _host;
private const string _url = "http://localhost:9000/TestUsernameAuthentication";
private void SetupWcfHostTakingWindowsLogin()
{
_host = new ServiceHost(typeof (TestService));
var binding = new WSHttpBinding();
binding.Security.Message.ClientCredentialType = MessageCredentialType.Windows; // default
_host.AddServiceEndpoint(typeof (ITestService), binding, _url);
_host.Open();
Console.WriteLine("wcf service started.");
}
private void SetupWcfHostTakingUsernameButValidateItAgainstWindows()
{
_host = new ServiceHost(typeof (TestService));
var binding = new WSHttpBinding();
binding.Security.Mode = SecurityMode.Message; // default
binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName; // default is windows
// protect message sent from client and certificate host
_host.Credentials.ServiceCertificate.SetCertificate(StoreLocation.LocalMachine, StoreName.My,
X509FindType.FindBySubjectName, "MyServerCert");
_host.Credentials.UserNameAuthentication.UserNamePasswordValidationMode =
UserNamePasswordValidationMode.Windows; // default
_host.AddServiceEndpoint(typeof (ITestService), binding, _url);
_host.Open();
Console.WriteLine("wcf service started.");
}
private void SetupWcfHostTakingUsernameAndDoCustomValidation()
{
_host = new ServiceHost(typeof (TestService));
var binding = new WSHttpBinding();
binding.Security.Mode = SecurityMode.Message; // default
binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName; // default is windows
// protect message sent from client and certificate host
_host.Credentials.ServiceCertificate.SetCertificate(StoreLocation.LocalMachine, StoreName.My,
X509FindType.FindBySubjectName, "MyServerCert");
_host.Credentials.UserNameAuthentication.UserNamePasswordValidationMode =
UserNamePasswordValidationMode.Custom; // default is windows
_host.Authorization.PrincipalPermissionMode = PrincipalPermissionMode.None; // default is UseWindowGroups
_host.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator =
new CustomUsernameValidator(new MyActiveDirectoryService());
_host.AddServiceEndpoint(typeof (ITestService), binding, _url);
_host.Open();
Console.WriteLine("wcf service started.");
}
private void SetupWcfHostUsingUsernameAuthCustomValidationWithAuthorization()
{
_host = new ServiceHost(typeof (TestService));
var binding = new WSHttpBinding();
binding.Security.Mode = SecurityMode.Message; // default
binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName; // default is windows
// protect message sent from client and certificate host
_host.Credentials.ServiceCertificate.SetCertificate(StoreLocation.LocalMachine, StoreName.My,
X509FindType.FindBySubjectName, "MyServerCert");
_host.Credentials.UserNameAuthentication.UserNamePasswordValidationMode =
UserNamePasswordValidationMode.Custom; // default is windows
var policies = new List<IAuthorizationPolicy>();
policies.Add(new CustomAuthorizationPolicy(new MyActiveDirectoryService()));
_host.Authorization.ExternalAuthorizationPolicies = policies.AsReadOnly();
_host.Authorization.PrincipalPermissionMode = PrincipalPermissionMode.Custom; // default is UseWindowGroups
_host.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator =
new CustomUsernameValidator(new MyActiveDirectoryService());
_host.AddServiceEndpoint(typeof (ITestService), binding, _url);
_host.Open();
Console.WriteLine("wcf service started.");
}
private ChannelFactory<ITestService> _channelFactory;
private bool _success;
[TearDown]
public void CleanUpChannelFacotry()
{
if (_success)
{
_channelFactory.Close();
}
else
{
_channelFactory.Abort();
}
_host.Close();
Console.WriteLine("Wcf host stopped.");
}
[Test]
public void should_connect_to_host_taking_windows_auth()
{
SetupWcfHostTakingWindowsLogin();
var binding = new WSHttpBinding();
_channelFactory = new ChannelFactory<ITestService>(
binding,
new EndpointAddress(_url));
ITestService proxy = _channelFactory.CreateChannel();
proxy.Ping();
_success = true;
}
[Test]
public void should_connect_to_host_taking_username_auth_windows_login()
{
SetupWcfHostTakingUsernameButValidateItAgainstWindows();
var binding = new WSHttpBinding();
binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName;
binding.Security.Mode = SecurityMode.Message;
// usually certificate should be same as the domain name of service host, so set identity at client to bypass this check.
_channelFactory = new ChannelFactory<ITestService>(
binding,
new EndpointAddress(new Uri(_url), EndpointIdentity.CreateDnsIdentity("MyServerCert")));
// Ignore certificate, call could be redirected to a malicious service (through client address resolving)
_channelFactory.Credentials.ServiceCertificate.Authentication.CertificateValidationMode =
X509CertificateValidationMode.None;
_channelFactory.Credentials.UserName.UserName = "guest";
_channelFactory.Credentials.UserName.Password = "hello";
ITestService proxy = _channelFactory.CreateChannel();
proxy.Ping();
_success = true;
}
[Test]
public void should_connect_to_host_takeing_username_validation()
{
SetupWcfHostTakingUsernameAndDoCutomValidation();
var binding = new WSHttpBinding();
binding.Security.Mode = SecurityMode.Message;
binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName;
// usually certificate should be same as the domain name of service host, so set identity at client to bypass this check.
_channelFactory = new ChannelFactory<ITestService>(
binding,
new EndpointAddress(new Uri(_url), EndpointIdentity.CreateDnsIdentity("MyServerCert")));
// Ignore certificate, call could be redirected to a malicious service (through client address resolving)
_channelFactory.Credentials.ServiceCertificate.Authentication.CertificateValidationMode =
X509CertificateValidationMode.None;
_channelFactory.Credentials.UserName.UserName = "guest";
_channelFactory.Credentials.UserName.Password = "hello";
ITestService proxy = _channelFactory.CreateChannel();
proxy.Ping();
bool noAccess = false;
try
{
proxy.CmisUsersOnly();
}
catch (SecurityAccessDeniedException)
{
noAccess = true;
}
Assert.That(noAccess);
_success = true;
}
[Test]
public void should_connect_to_host_takeing_username_validation_with_authorization()
{
SetupWcfHostUsingUsernameAuthCustomValidationWithAuthorization();
var binding = new WSHttpBinding();
binding.Security.Mode = SecurityMode.Message;
binding.Security.Message.ClientCredentialType = MessageCredentialType.UserName;
// usually certificate should be same as the domain name of service host, so set identity at client to bypass this check.
_channelFactory = new ChannelFactory<ITestService>(
binding,
new EndpointAddress(new Uri(_url), EndpointIdentity.CreateDnsIdentity("MyServerCert")));
// Ignore certificate, call could be redirected to a malicious service (through client address resolving)
_channelFactory.Credentials.ServiceCertificate.Authentication.CertificateValidationMode =
X509CertificateValidationMode.None;
_channelFactory.Credentials.UserName.UserName = "guest";
_channelFactory.Credentials.UserName.Password = "hello";
ITestService proxy = _channelFactory.CreateChannel();
proxy.Ping();
proxy.CmisUsersOnly();
_success = true;
}
AspSqlMembership
In fact, MS provide a default Role-based AspSqlMembership Provider for WCF, with a pre-defined database and whole set of membership provider / role manager classes. Fortunately, We can easily override the classes to let WCF to go against our own credential store.
<behavior name="AglcSqlMembership">
<serviceMetadata httpGetEnabled="true"/>
<serviceAuthorization principalPermissionMode ="UseAspNetRoles" />
<serviceCredentials>
<serviceCertificate findValue="MyServerCert" x509FindType="FindBySubjectName" storeLocation="LocalMachine" storeName="My" />
<userNameAuthentication userNamePasswordValidationMode="MembershipProvider" />
</serviceCredentials>
</behavior>
...
<system.web>
<membership defaultProvider ="MySqlMembershipProvider" >
<providers>
<add name ="MySqlMembershipProvider"
type ="Premotion.Services.MySqlMembershipProvider"
connectionStringName ="WcfUsers"
/>
</providers>
</membership>
<roleManager enabled="true" defaultProvider ="MySqlRoleManager">
<providers>
<add name ="MySqlRoleManager"
type ="Premotion.Services.MySqlRoleManager"
connectionStringName ="WcfUsers"
/>
</providers>
</roleManager>
</system.web>
<connectionStrings>
<add name="WcfUsers" connectionString ="data souce=.\SQLEXPRESS; Integrated Security=SSPI; Initial Catalog=WcfUsers" />
</connectionStrings>
public class MySqlMembershipProvider : SqlMembershipProvider
{
public override bool ValidateUser(string username, string password)
{
// check if the user is not test
if (username == "test1" && password == "test1") return true;
return false;
}
public class MySqlRoleManager : SqlRoleProvider
{
public override bool IsUserInRole(string username, string roleName)
{
if (username == "test1" && roleName == "Admin") return true;
return false;
}
}
}
public class CoreService: ICoreServiceContract
{
[PrincipalPermission(SecurityAction.Demand, Role="Admin")]
public void AdminsOnly()
{
}
}
public string Hello()
{
OperationContext context = OperationContext.Current;
MessageProperties messageProperties = context.IncomingMessageProperties;
RemoteEndpointMessageProperty endpointProperty =
messageProperties[RemoteEndpointMessageProperty.Name] as RemoteEndpointMessageProperty;
return string.Format("Hello {0}! Your IP address is {1} and your port is {2}",
ServiceSecurityContext.Current.PrimaryIdentity.Name, endpointProperty.Address, endpointProperty.Port);
// If it's Windows authorization
// return "How are you ! " + Thread.CurrentPrincipal.Identity.Name;
}
I had to add a connectionStringName in those xml definition part, even I’m not using it at all.
Some interesting differences,
| security mode |
UserNamePassword Validation Mode |
Authorization PrincipalPermissionMode |
Authentication Type |
principle type |
|
| Windows |
|
|
Kerberos |
WindowsPrinciple |
|
| UserName |
Windows |
anyone but Custom |
NTLM |
WindowsPrinciple |
|
| UserName |
Custom (must apply CutomAuthorziationPolicy, otherwise the Thread.CurrentPrincipal is not authenticated while ServiceSecurityContext is ) |
Custom |
CustomUsernameValidator |
|
|
| UserName |
MembershipProvider |
UseAspNetRoles |
MembershipProviderValidator |
GenricPrinciple |
|
Updates: The WsHttpBinding using Windows auth which sendng Kerberos auth will fail on Vista client, the error message is “The message or signature supplied for verification has been altered”. Microsoft admit it’s a bug.