There are plenty of articles available on creating web services, however it was my experience that some of the information was slightly out of date (using older tools/frameworks) and it was also my experience that I had to piece information from many sources together to get around bugs or implement all features. This article is an attempt to make a definitive guide for making a WCF Data Service in WCF using entity framework 6 and WCF Data Services 5.6. In my next article, I will show how to consume data from the service in a universal Windows platform app.
WCF Data Services (formerly known as "ADO.NET Data Services") is a component of the .NET Framework that enables you to create services that use the Open Data Protocol (OData) to expose and consume data over the Web or intranet by using the semantics of representational state transfer (REST). OData exposes data as resources that are addressable by URIs. Data is accessed and changed by using standard HTTP verbs of GET
, PUT
, POST
, and DELETE
. OData uses the entity-relationship conventions of the Entity Data Model to expose resources as sets of entities that are related by associations.
WCF Data Services uses the OData protocol for addressing and updating resources. In this way, you can access these services from any client that supports OData. OData enables you to request and write data to resources by using well-known transfer formats: Atom, a set of standards for exchanging and updating data as XML, and JavaScript Object Notation (JSON), a text-based data exchange format used extensively in AJAX application.
WCF Data Services can expose data that originates from various sources as OData feeds. Visual Studio tools make it easier for you to create an OData-based service by using an ADO.NET Entity Framework data model. You can also create OData feeds based on common language runtime (CLR) classes and even late-bound or un-typed data.
WCF Data Services also includes a set of client libraries, one for general .NET Framework client applications and another specifically for Silverlight-based applications. These client libraries provide an object-based programming model when you access an OData feed from environments such as the .NET Framework and Silverlight.
Some great articles on odata and WCF are:
Some principles on OData:
Some good information on authentication:
Information on filtering data (query interception):
Some workarounds for errors:
Information on stored procedure entities with multiple result sets:
- Go to File -> New Project.
- In the list of Installed Templates, select the Visual C# | WCF tree node and then select the WCF Service Application.
- Delete IService1.cs and Service1.svc from the resulting project.
- Add a new item to the project. In the list of Installed Templates, select the Visual C# | Data tree node and then select the ADO.NET Entity Data Model.
- For the purposes of this article, I am selecting to build my entity module from an existing database.
- Define your connection. Typically a cloud server.
- Save your connection to the web.config file.
- Specify entity version 6.0.
- Select the tables/view/procs to include in your entity model.
- Unzip the attached data service template WcfDataServiceItemTemplate.zip to your Visual Studio C# Item-Templates folder which is typically "{Documents}\Visual Studio 2017\Templates\ItemTemplates\Visual C#" (where {Documents} is your user profile documents folder.) Note the unzipped hiearchy should be a single level with the template files with in e.g. ...\ItemTemplates\Visual C#\WcfDataServiceItemTemplate\WebDataService.vstemplate. Now in visual studio, right click your project and choose to add a new item. Search the installed templates for WCF Data Service and add the new WCF Data Service 5.8.3 item to your project. If the template does not appear then restart visual studio.
- Edit the WcfDataService1.svc file Change the
<TODOReplaceWithYourEntitySetName>
to be the name of your entity model in my case futaTillHOEntities
Optionally:
- Set the access rules for your entity names. Use the star symbol to mean all entities.
- Set the
UseVerboseErrors
property in order to see proper feedback of errors.
using System.Data.Services.Providers;
namespace WcfService1
{
public class WcfDataService1 : EntityFrameworkDataService<FutaTillHOEntities>
{
public static void InitializeService(DataServiceConfiguration config)
{
config.SetEntitySetAccessRule("*", EntitySetRights.AllRead);
config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V3;
config.UseVerboseErrors = true;
}
}
}
- Create a new web app either from Visual Studio server explorer or your Azure portal. Then right click your project and click publish. In the publish screen, select Azure Web Apps.
-
Select your web app that you want to publish too. In my case, the name is UtilitiesDataService
.
- Click Next if settings are correct.
- Click Next if settings are correct.
- Click Publish.
- You should now be able to test your web-service using the URL of your web-app (visible in your azure portal) + the service name + the entity name
Example:
To retrieve a specific record by including the primary key in the URL:
If the key is composite, then use the following notation:
By default, the information will be serialized as an atom feed but you can include the format specifier to get data in json:
You can also include functions such as top/skip/expand and combine them using &
character:
Look at http://www.odata.org/ for more examples.
By default, the entityset
is setup in such a way to prevent updates on views. If your view is updatable, then perform the following steps:
- Right click the
model
element and select open with.
- Select the XML editor and click OK.
- Do a find for the text
<DefiningQuery>
. You will notice that this element and inner query is present for your views, but not for your tables.
- Remove the
DefiningQuery
element. In this screen, I’ve shown the element removed from the Behaviours
view, however you will need to remove it from all of the views.
- Strangely, you also need to change the text
store:Schema="dbo"
to be just Schema="dbo"
. Basically, if you look at how tables are defined, you can see the difference.
[Table Definition]
<EntitySet Name="Tenders" EntityType="Self.Tenders"
Schema="dbo" store:Type="Tables" />
[Original View Definition]
<EntitySet Name="Behaviours" EntityType="Self.Behaviours"
store:Type="Views" store:Schema="dbo"/>
[Corrected View Definition]
<EntitySet Name="Behaviours"
EntityType="Self.Behaviours" store:Type="Views" Schema="dbo"/>
- After editing and saving the XML, it is sometimes necessary to go back into the entity model designer and click the save button in the tool bar.
In order to setup basic authentication, you need two additional classes in your service. I have attached the entire source for these classes to the article.
The BasicAuthenticationModule
class sets up the event handling for an authentication request and forwards to the BasicAuthenticationProvider.Authenticate
method.
public class BasicAuthenticationModule : IHttpModule
{
public void Init(HttpApplication context)
{
context.AuthenticateRequest
+= new EventHandler(context_AuthenticateRequest);
}
void context_AuthenticateRequest(object sender, EventArgs e)
{
HttpApplication application = (HttpApplication)sender;
if (!BasicAuthenticationProvider.Authenticate(application.Context))
{
application.Context.Response.Status = "401 Unauthorized";
application.Context.Response.StatusCode = 401;
application.Context.Response.AddHeader("WWW-Authenticate", "Basic");
application.CompleteRequest();
}
}
public void Dispose() { }
}
Here is the BasicAuthenticationProvider
class. Please note that Basic Authentication in itself is not secure since the username and password are sent unencrypted. Therefore, basic authentication should only be allowed in an SSL environment. The code that enforces this condition has been commented out, in the code sample below, to allow testing.
public class BasicAuthenticationProvider
{
public static bool Authenticate(HttpContext context)
{
if (!HttpContext.Current.Request.Headers.AllKeys.Contains("Authorization"))
return false;
string authHeader = HttpContext.Current.Request.Headers["Authorization"];
IPrincipal principal;
if (TryGetPrincipal(authHeader, out principal))
{
HttpContext.Current.User = principal;
return true;
}
return false;
}
private static bool TryGetPrincipal(string authHeader, out IPrincipal principal)
{
var creds = ParseAuthHeader(authHeader);
if (creds != null && TryGetPrincipal(creds, out principal))
return true;
principal = null;
return false;
}
In basic authentication, the user name and password are stored in the header. This method extracts the username and password credentials into an array.
private static string[] ParseAuthHeader(string authHeader)
{
if (
authHeader == null ||
authHeader.Length == 0 ||
!authHeader.StartsWith("Basic")
) return null;
string base64Credentials = authHeader.Substring(6);
string[] credentials = Encoding.ASCII.GetString(
Convert.FromBase64String(base64Credentials)
).Split(new char[] { ':' });
if (credentials.Length != 2 ||
string.IsNullOrEmpty(credentials[0]) ||
string.IsNullOrEmpty(credentials[0])
) return null;
return credentials;
}
}
It is in this overload of TryGetPrincipal
that you must hook up your username/password data store. In the code sample below, I've used another entity model with a single stored procedure called aspnet_GetUserCredentials
to return multiple result sets containing the login information for the supplied username. The first result set returns the username and password details, the second contains the roles for the user and the third contains the role permissions. The supplied password (from the authentication header) is hashed and compared with the stored hash. If there is a match, then the principle object is initialized to a new container for the user credentials, roles and associated permissions.
private static bool TryGetPrincipal(string[] creds, out IPrincipal principal)
{
bool located = false;
principal = null;
var user = new User_SprocResult();
var roles = new List<Role_SprocResult>();
var permissions = new List<Permission_SprocResult>();
using (var dbContext = new AuthenticationEntities())
{
var result = dbContext.aspnet_GetUserCredentials("Utilities", creds[0]);
user = result.FirstOrDefault();
var result2 = result.GetNextResult<Role_SprocResult>();
roles.AddRange(result2);
permissions.AddRange(result2.GetNextResult<Permission_SprocResult>());
}
if (user != null)
{
byte[] bytes = Encoding.Unicode.GetBytes(creds[1]);
byte[] src = Convert.FromBase64String(user.PasswordSalt);
byte[] dst = new byte[src.Length + bytes.Length];
Buffer.BlockCopy(src, 0, dst, 0, src.Length);
Buffer.BlockCopy(bytes, 0, dst, src.Length, bytes.Length);
HashAlgorithm algorithm = HashAlgorithm.Create("SHA1");
byte[] inArray = algorithm.ComputeHash(dst);
if (string.Compare(Convert.ToBase64String(inArray), user.Password) == 0)
{
located = true;
principal = new CustomPrincipal(user.UserName,
roles.Select(r=>r.RoleName).ToArray(),
permissions.Select(r=>r.PermissionId).ToArray());
}
}
return located;
}
Here is the specialized container for the user principal.
public class CustomPrincipal : IPrincipal
{
string[] _roles;
string[] _permissions;
IIdentity _identity;
public CustomPrincipal(string name, string[] roles, string[] permissions)
{
this._roles = roles;
this._permissions = permissions;
this._identity = new GenericIdentity(name);
}
public IIdentity Identity
{
get { return _identity; }
}
public bool IsInRole(string role)
{
return _roles.Contains(role);
}
public bool HasPermission(string permission)
{
return _permissions.Contains(permission);
}
}
In order for your web service to actually implement the authentication module, you must add it to the modules node of your web.config.
<modules runAllManagedModulesForAllRequests="true">
<add name="BasicAuthentication" type="UtilitiesWcfService.BasicAuthenticationModule" />
<remove name="ApplicationInsightsWebTracking" />
<add name="ApplicationInsightsWebTracking"
type="Microsoft.ApplicationInsights.Web.ApplicationInsightsHttpModule,
Microsoft.AI.Web" preCondition="managedHandler" />
</modules>
Firstly you will need to setup some users in your tenant if you haven't already done so. Search for the Users blade in your azure portal.
Click on the add button and add a new user
Next you need to register your data service in your tenant. In your azure portal search for App Registrations.
Then click on the add button and create a new web api registration.
Make a note of the Sign-on URL and the generated application id GUID. You will need them in the dataservice. You will also need the Sign-on URL for any application that is consuming data from the WCF data service.
Back in the data service, install the following nuget.
PM> Install-Package System.IdentityModel.Protcols.OpenIdConnect
Then add a new OAuthProtectionModule
this class serves the same purpose as the BasicAuthenticationModule and that is to attach the AuthenicateRequest event. Inside the event handler the OAuthAuthenticationProvider
class is called to do the autentication or return an error response if applicable.
public class OAuthProtectionModule : IHttpModule
{
public void Init(HttpApplication context)
{
context.AuthenticateRequest += OnAuthenticateRequest;
}
void OnAuthenticateRequest(object sender, EventArgs args)
{
HttpApplication application = (HttpApplication)sender;
if (!OAuthAuthenticationProvider.Authenticate(
out int statusCode,
out string httpStatus,
out string wwwAuthenticateResponse))
{
application.Context.Response.Status = httpStatus;
application.Context.Response.StatusCode = statusCode;
if (!String.IsNullOrEmpty(wwwAuthenticateResponse))
{
application.Context.Response.AddHeader("WWW-Authenticate", wwwAuthenticateResponse);
}
application.CompleteRequest();
}
}
public void Dispose() { }
}
Here is the full definition of the OAuthAuthenticationProvider
class. It shares some similarlity with the BasicAuthenticationProvider in that is has an Authenticate
method which passes the authorization header to the TryGetPrinciple
method and assigns the resulting principle (if any) to the user context. Note that the bulk of this code comes from the following microsoft sample https://azure.microsoft.com/en-us/resources/samples/active-directory-dotnet-webapi-manual-jwt-validation/
With OAuth unlike basic authentication we are not looking for user credentials in the authorization header. The authorization header for oAuth2.0 should be the word "bearer" followed by a space followed by the base64url encoded access token. In the autenticate method I am stripping the word bearer out of the authorization header with a simply substring before passing to the TryGetPrinciple method.
Note that you need to fill in your tenant id e.g. mytenent.onmicrosoft.com, your dataservice application id GUID and your dataservice sign-on url.
public static class OAuthAuthenticationProvider
{
#region Fields
static string tenant = "%YourTenantName%";
static string authority = $"https://login.microsoftonline.com/{tenant}";
static ConfigurationManager<OpenIdConnectConfiguration> configurationManager =
new ConfigurationManager<OpenIdConnectConfiguration>($"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever());
static string[] audiences = { "%DataService Application ID%", "%DataService Sign-On URL%" };
static string scopeClaimType = "http://schemas.microsoft.com/identity/claims/scope";
static string wwwAuthenticate = $"Bearer realm=\"{audiences[1]}\", authorization_uri=\"{authority}\", resource_id=\"{audiences[1]}\"";
#endregion
public static bool Authenticate(out int statusCode, out string httpStatus, out string wwwAuthenticateResponse)
{
bool result = false;
if (!HttpContext.Current.Request.Headers.AllKeys.Contains("Authorization"))
{
httpStatus = "401 Unauthorized";
statusCode = (int)HttpStatusCode.Unauthorized;
wwwAuthenticateResponse = wwwAuthenticate;
}
else if (TryGetPrincipal(HttpContext.Current.Request.Headers["Authorization"].Substring(7),
out IPrincipal principal,
out statusCode,
out httpStatus,
out wwwAuthenticateResponse))
{
HttpContext.Current.User = principal;
Thread.CurrentPrincipal = principal;
result = true;
}
return result;
}
static bool TryGetPrincipal(string accessToken, out IPrincipal principal, out int statusCode, out string httpStatus, out string wwwAuthenticateResponse)
{
bool overallResult = false;
principal = null;
statusCode = 0;
httpStatus = null;
wwwAuthenticateResponse = null;
#if DEBUG
IdentityModelEventSource.ShowPII = true;
#endif
try
{
OpenIdConnectConfiguration config = GetConfigurationNonAsync();
var claimsPrincipal = new JwtSecurityTokenHandler().ValidateToken(accessToken,
new TokenValidationParameters
{
ValidateAudiences = audiences,
ValidIssuer = config.Issuer,
IssuerSigningKeys = config.SigningKeys
}
, out SecurityToken validatedToken); ;
if (claimsPrincipal.FindFirst(scopeClaimType) != null &&
claimsPrincipal.FindFirst(scopeClaimType).Value != "user_impersonation")
{
statusCode = (int)HttpStatusCode.Forbidden;
httpStatus = "403 Forbidden";
wwwAuthenticateResponse = wwwAuthenticate;
}
else
{
principal = claimsPrincipal;
overallResult = true;
}
}
catch (SecurityTokenException ex)
{
statusCode = (int)HttpStatusCode.Unauthorized;
httpStatus = "401 Unauthorized";
wwwAuthenticateResponse = wwwAuthenticate + $" error=\"invalid_token\", error_description=\"{ex.Message}\"";
}
catch (Exception)
{
statusCode = (int)HttpStatusCode.InternalServerError;
httpStatus = "500 InternalServerError";
}
return overallResult;
}
static string BuildWWWAuthenticateResponseHeader()
{
return $"Bearer authorization_uri =\"{authority}\", resource_id=\"{audiences[0]}";
}
static OpenIdConnectConfiguration GetConfigurationNonAsync()
{
OpenIdConnectConfiguration config = null;
Task.Run(async () =>
{
config = await configurationManager.GetConfigurationAsync();
}).Wait();
return config;
}
}
Finally you will want to change the web.config to redirect to the new autentication model.
<system.webServer>
<modules runAllManagedModulesForAllRequests="true">
<add name="OAuthProtectionModule" type="UtilitiesWcfService.OAuthProtectionModule" />
</modules>
AD Roles
To have roles appear in the access token and thus be able from the validated claims principle you need to first setup the roles themselves. In the app registrations blade in the azure portal, select your app and click on the manifest button.
Alter the roles field in the json maifest as desired. Here is an example spec for 3 roles. Note that the GUIDs can be anything; I used the following site https://guidgenerator.com/
{
"appRoles": [
{
"allowedMemberTypes": [
"User"
],
"displayName": "LocalUser",
"id": "04a25ad2-ebd2-46fc-b85a-646ef2c9c5c9",
"isEnabled": true,
"description": "Add or edit menus for a specific site.",
"value": "LocalUser"
},
{
"allowedMemberTypes": [
"User"
],
"displayName": "GroupUser",
"id": "434723c9-90ef-4320-bd68-cad24ca66c92",
"isEnabled": true,
"description": "Add or edit sites and site menus for a specific group",
"value": "GroupUser"
},
{
"allowedMemberTypes": [
"User"
],
"displayName": "Admin",
"id": "06579139-bd57-402c-b29f-b69532de2117",
"isEnabled": true,
"description": "Adit or edit groups, sites and site menus.",
"value": "Admin"
}
],
Next you need to assign users to a particular role. In the azure portal search for "Enterprise Applications"
Select your data service application and then click on Users and Groups
Click on the Add User button and asign users to roles
Now once you have a claims principle object you can use the IsInRole method to check role membership.
claimsPrincipal.IsInRole("GroupUser")
AD Groups
In order to have groups in the access token and thus be available from the validated claims principle firstly you need to create some groups and asign some users. In your azure portal search groups
Then create groups and asign users
In order for the groups to appear in the access token and resulting validated claims principle you must edit the manifest of your data service application again. Below the approles field find the "groupMembershipClaims" field and change the value to "SecurityGroup"
"groupMembershipClaims": "SecurityGroup",
Resolving group object Ids
Unfortunately what you get in the access token and the resulting claims principle is the group object id guid as opposed to the group name. In order to get the name of the group you must query the graph api.
Firstly you need to allow you dataservice the permission to query the graph api by itself. In the azure portal go to app registrations, select your application, go to settings and then required permissions. Open the Active directory API and enable the application permission. Directory.Read.All
Next go to the "Keys" blade which is directly below "Required Permissions". Create a new key, specifiy your own key name and desired expiry. Make a note of the key before it gets hidden.
Back in the data service you need to install the MSAL client nuget package
PM> Install-Package Microsoft.Identity.Client
Then in the OAuthAuthenticationProvider class declare an instance of the msal confidential client and also the secret key from the azure keys blade.
static ConfidentialClientApplication msaClient;
static string clientSecret = "%Secret Key%";
Then add the following methods. The AddGroupNameClaim will accept a claims principle and for each group object id claim will add the coresponding group_name claim by querying the graph api.
public static void AddGroupNameClaim(ClaimsPrincipal claimsPrincipal)
{
(claimsPrincipal.Identity as ClaimsIdentity).AddClaims(
claimsPrincipal.FindAll("groups").Select(r =>
new Claim("group_name", GetGroupNameByObjectId(r.Value))));
}
public static string GetGroupNameByObjectId(string objectId)
{
ActiveDirectoryClient activeDirectoryClient = new ActiveDirectoryClient(new Uri($"https://graph.windows.net/{tenant}"), async () =>
{
return await Task.Run(async () =>
{
if (msaClient == null)
{
msaClient = new ConfidentialClientApplication(
audiences[0],
authority,
audiences[1],
new Microsoft.Identity.Client.ClientCredential(clientSecret),
new Microsoft.Identity.Client.TokenCache(),
new Microsoft.Identity.Client.TokenCache());
}
var authResult = await msaClient.AcquireTokenForClientAsync(new string[] { "https://graph.windows.net/.default" });
return authResult.AccessToken;
});
});
IGroup group = null;
Task.Run(async () =>
{
group = await activeDirectoryClient.Groups.GetByObjectId(objectId).ExecuteAsync();
}).Wait();
return group?.DisplayName;
}
And finally change the Authenticate method to call the new method so the validated claims principle is updated with the group_name claim.
if (claimsPrincipal.FindFirst(scopeClaimType) != null &&
claimsPrincipal.FindFirst(scopeClaimType).Value != "user_impersonation")
{
statusCode = (int)HttpStatusCode.Forbidden;
httpStatus = "403 Forbidden";
wwwAuthenticateResponse = wwwAuthenticate;
}
else
{
AddGroupNameClaim(claimsPrincipal);
principal = claimsPrincipal;
overallResult = true;
}
So If subsequently working with the claims principle you can get the group name via the group_name claim.
claimsprinciple.FindFirst("group_name")?.Value
I mentioned that my stored procedure aspnet_GetUserCredentials
had multiple result sets. In order to achieve this, you must again edit the model that defines the stored procedure via the XML editor and make some changes.
In my case, my stored procedure accepted two input parameters (the application name and the username) and returned 3 result sets:
- The first result set contained fields for
username
, password
and passwordsalt
. - The second result set contained fields for
rolename
. - The third result set contained fields for
permissionId
.
Below are the modifications I needed.
<EntityContainer Name="AuthenticationEntities"
annotation:LazyLoadingEnabled="true" >
<FunctionImport Name="aspnet_GetUserCredentials">
<ReturnType Type="Collection(UtilitiesLightswitchModel.User_SprocResult)" />
<ReturnType Type="Collection(UtilitiesLightswitchModel.Role_SprocResult)" />
<ReturnType Type="Collection(UtilitiesLightswitchModel.Permission_SprocResult)" />
<Parameter Name="ApplicationName" Mode="In" Type="String" />
<Parameter Name="UserName"
Mode="In" Type="String" />
</FunctionImport>
</EntityContainer>
<ComplexType Name="User_SprocResult">
<Property Type="String" Name="UserName"
Nullable="false" MaxLength="256" />
<Property Type="String" Name="Password"
Nullable="false" MaxLength="128" />
<Property Type="String" Name="PasswordSalt"
Nullable="false" MaxLength="128" />
</ComplexType>
<ComplexType Name="Role_SprocResult">
<Property Type="String" Name="RoleName"
Nullable="false" MaxLength="256" />
</ComplexType>
<ComplexType Name="Permission_SprocResult">
<Property Type="String" Name="PermissionId"
Nullable="false" MaxLength="322" />
</ComplexType>
...
<FunctionImportMapping FunctionImportName="aspnet_GetUserCredentials"
FunctionName="UtilitiesLightswitchModel.Store.aspnet_GetUserCredentials">
<ResultMapping>
<ComplexTypeMapping TypeName="UtilitiesLightswitchModel.User_SprocResult">
<ScalarProperty Name="UserName" ColumnName="UserName" />
<ScalarProperty Name="Password" ColumnName="Password" />
<ScalarProperty Name="PasswordSalt" ColumnName="PasswordSalt" />
</ComplexTypeMapping>
</ResultMapping>
<ResultMapping>
<ComplexTypeMapping TypeName="UtilitiesLightswitchModel.Role_SprocResult">
<ScalarProperty Name="RoleName" ColumnName="RoleName" />
</ComplexTypeMapping>
</ResultMapping>
<ResultMapping>
<ComplexTypeMapping TypeName="UtilitiesLightswitchModel.Permission_SprocResult">
<ScalarProperty Name="PermissionId" ColumnName="PermissionId" />
</ComplexTypeMapping>
</ResultMapping>
</FunctionImportMapping>
You might want to Filter the entity based on user. Now that we have set the user context via the authentication module, we can now use the user context to filter the result set. You can filter entities by adding a query intercepter. This code below needs to be added to the main service class in my case WcfDataService1.svc. This example shows filtering the Groups
entity depending on what role the user belongs to.
[QueryInterceptor("Groups")]
public Expression<Func<Group, bool>> OnQueryGroups()
{
if (HttpContext.Current.User.IsInRole("GroupUser"))
{
return (Group e) => e.GroupID == HttpContext.Current.User.Identity.Name;
}
else if (HttpContext.Current.User.IsInRole("LocalUser"))
{
return (Group e) => e.Sites.Any(r => r.SiteID == HttpContext.Current.User.Identity.Name);
}
else
{
return (Group e) => true;
}
}
You may also want to base your data access rules on the user. To do that, we can add a ChangeInterceptor
. In the code below, the user requires the permission CanAddOrEditGroups
in order to make changes to the Groups
entity.
[ChangeInterceptor("Groups")]
public void OnChangeGroups(Group group, UpdateOperations operations)
{
var u = (BasicAuthenticationProvider.CustomPrincipal)HttpContext.Current.User;
if (!u.HasPermission("CanAddOrEditGroups"))
{
throw new DataServiceException(400, "You do not have permission to add or edit new groups.");
}
}
You might for whatever reason want to host the data service offline. It is possible to host the webservice in any .NET program such as a windows service or console application via the <font face="Courier New">WebServiceHost </font>
class.
1. Start by adding a new windows service or console application project to the existing solution.
2. Add the entity framework provider for OData via nugget command line which should also add the Microsoft.Data.Services APIs.
PM> Install-Package Microsoft.OData.EntityFrameworkProvider -Pre
3. Add an application configuration file to the project (if it doesn't already exist) and copy the configSections, system.serviceModel, connectionStrings, entityFramework
and runtime
configuration sections from the web.config file in the dataservice project to the app.config of the new project.
4. Within the system.serviceModel
section add the following element.
<bindings>
<webHttpBinding>
<binding>
<security mode="TransportCredentialOnly">
<transport clientCredentialType="Basic" />
</security>
</binding>
</webHttpBinding>
</bindings>
The full app.config should look something like this. Note in this example I've blanked the connection strings for both the data entity model and the authentication entity model but obviously in reality they would remain intact.
="1.0"="utf-8"
<configuration>
<configSections>
<section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
</configSections>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" />
</startup>
<system.serviceModel>
<behaviors>
<serviceBehaviors>
<behavior>
<serviceMetadata httpGetEnabled="true" httpsGetEnabled="true" />
<serviceDebug includeExceptionDetailInFaults="true" httpHelpPageEnabled="true" />
</behavior>
</serviceBehaviors>
</behaviors>
<protocolMapping>
<add binding="basicHttpsBinding" scheme="https" />
</protocolMapping>
<serviceHostingEnvironment aspNetCompatibilityEnabled="true" multipleSiteBindingsEnabled="true" />
<bindings>
<webHttpBinding>
<binding>
<security mode="TransportCredentialOnly">
<transport clientCredentialType="Basic" />
</security>
</binding>
</webHttpBinding>
</bindings>
</system.serviceModel>
<connectionStrings>
<add name="dfsdfsfsEntities" connectionString="" />
<add name="AuthenticationEntities" connectionString="" />
</connectionStrings>
<entityFramework>
<defaultConnectionFactory type="System.Data.Entity.Infrastructure.LocalDbConnectionFactory, EntityFramework">
<parameters>
<parameter value="mssqllocaldb" />
</parameters>
</defaultConnectionFactory>
<providers>
<provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
</providers>
</entityFramework>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Data.Services" publicKeyToken="31bf3856ad364e35" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-5.7.0.0" newVersion="5.7.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Data.Services.Client" publicKeyToken="31bf3856ad364e35" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-5.7.0.0" newVersion="5.7.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Data.Edm" publicKeyToken="31bf3856ad364e35" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-5.7.0.0" newVersion="5.7.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Spatial" publicKeyToken="31bf3856ad364e35" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-5.7.0.0" newVersion="5.7.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
4. Add a reference to your dataservice library i.e. the dll produced by the original data service project, to the new project.
5. Here is complete code for a console application.
using System;
using System.Linq;
using System.ServiceModel;
using System.ServiceModel.Web;
using System.ServiceModel.Channels;
using System.IdentityModel.Selectors;
using System.Configuration;
namespace WcfConsole
{
class Program
{
static WebServiceHost serviceHost = null;
static void Main(string[] args)
{
serviceHost = new WebServiceHost(typeof(MyWcfServiceNameSpace.MyWcfDataService), new Uri[] { new Uri("http://localhost:8195/MyDataService.svc") });
serviceHost.Authentication.ServiceAuthenticationManager = new MyAuthentication();
serviceHost.Credentials.UserNameAuthentication.UserNamePasswordValidationMode = System.ServiceModel.Security.UserNamePasswordValidationMode.Custom;
serviceHost.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator = new CustomUserNamePasswordValidator();
serviceHost.Open();
Console.ReadKey();
serviceHost.Close();
}
public class CustomUserNamePasswordValidator : UserNamePasswordValidator
{
public override void Validate(string userName, string password)
{
System.Security.Principal.IPrincipal principal;
if (MyWcfServiceNameSpace.BasicAuthenticationProvider.TryGetPrincipal(new string[] { userName, password }, out principal))
{
System.Threading.Thread.CurrentPrincipal = principal;
}
else
{
throw new FaultException("Unknown Username or Incorrect Password");
}
}
}
public class MyAuthentication : ServiceAuthenticationManager
{
public override System.Collections.ObjectModel.ReadOnlyCollection<System.IdentityModel.Policy.IAuthorizationPolicy> Authenticate(
System.Collections.ObjectModel.ReadOnlyCollection<System.IdentityModel.Policy.IAuthorizationPolicy> authPolicy,
Uri listenUri, ref Message message)
{
OperationContext.Current.IncomingMessageProperties.Add("Principal", System.Threading.Thread.CurrentPrincipal);
return authPolicy;
}
}
}
}
5. Here is complete code for a windows service
using System;
using System.Linq;
using System.ComponentModel;
using System.Diagnostics;
using System.ServiceProcess;
using System.ServiceModel;
using System.Configuration;
using System.Configuration.Install;
using System.ServiceModel.Web;
using System.ServiceModel.Channels;
using System.IdentityModel.Selectors;
namespace WcfWindowsService
{
public partial class Service1 : ServiceBase
{
WebServiceHost serviceHost = null;
public Service1()
{
InitializeComponent();
}
protected override void OnStart(string[] args)
{
serviceHost?.Close();
serviceHost = new WebServiceHost(typeof(MyWcfServiceNameSpace.MyWcfDataService), new Uri[] { new Uri("http://localhost:8195/MyDataService.svc") });
serviceHost.Authentication.ServiceAuthenticationManager = new MyAuthentication();
serviceHost.Credentials.UserNameAuthentication.UserNamePasswordValidationMode = System.ServiceModel.Security.UserNamePasswordValidationMode.Custom;
serviceHost.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator = new CustomUserNamePasswordValidator();
serviceHost.Open();
}
protected override void OnStop()
{
serviceHost?.Close();
serviceHost = null;
}
}
public class CustomUserNamePasswordValidator : UserNamePasswordValidator
{
public override void Validate(string userName, string password)
{
System.Security.Principal.IPrincipal principal;
if (MyWcfServiceNameSpace.BasicAuthenticationProvider.TryGetPrincipal(new string[] { userName, password }, out principal))
{
System.Threading.Thread.CurrentPrincipal = principal;
}
else
{
throw new FaultException("Unknown Username or Incorrect Password");
}
}
}
public class MyAuthentication : ServiceAuthenticationManager
{
public override System.Collections.ObjectModel.ReadOnlyCollection<System.IdentityModel.Policy.IAuthorizationPolicy> Authenticate(
System.Collections.ObjectModel.ReadOnlyCollection<System.IdentityModel.Policy.IAuthorizationPolicy> authPolicy,
Uri listenUri, ref Message message)
{
OperationContext.Current.IncomingMessageProperties.Add("Principal", System.Threading.Thread.CurrentPrincipal);
return authPolicy;
}
}
[RunInstaller(true)]
public class ProjectInstaller : Installer
{
private ServiceProcessInstaller process;
private ServiceInstaller service;
public ProjectInstaller()
{
process = new ServiceProcessInstaller();
process.Account = ServiceAccount.LocalSystem;
service = new ServiceInstaller();
service.ServiceName = "WcfWindowsService";
Installers.Add(process);
Installers.Add(service);
}
}
}
Note there is some new authentication hooks required when using the WebServiceHost
. The code is still leveraging the BasicAuthenticationProvider
class to validate the user and build the custom principle however this principal is now being retrieved in the Validate
method of the WebServiceHost UserNamePasswordValidator.
Also its now calling the TryGetPrincipal
method directly as opposed to the Authenicate
method. This is because the Authenicate
method of the <font face="Courier New">BasicAuthenticationProvider </font>
involves HttpContext
and when using the webservice host there is no HttpContext
.Additionally because is no HttpContext the security principal can't be stored in the HttpContext.Current.User property so I'm creating a new custom message property called Principal to store this object instead.
6. Finally any query or change interceptors need to be modified to access the principal from the appropriate context depending on the enviroment.
[QueryInterceptor("Groups")]
public Expression<Func<Group, bool>> OnQueryGroups()
{
var u = HttpContext.Current != null ?
HttpContext.Current.User :
(System.Security.Principal.IPrincipal)OperationContext.Current.IncomingMessageProperties["Principal"];
if (u.IsInRole("GroupUser"))
{
return (Group e) => e.GroupID == u.Identity.Name;
}
else if (u.IsInRole("LocalUser"))
{
return (Group e) => e.Sites.Any(r => r.SiteID == u.Identity.Name);
}
else
{
return (Group e) => true;
}
}
[ChangeInterceptor("Groups")]
public void OnChangeGroups(Group group, UpdateOperations operations)
{
var u = HttpContext.Current != null ?
(BasicAuthenticationProvider.CustomPrincipal)HttpContext.Current.User :
(BasicAuthenticationProvider.CustomPrincipal)OperationContext.Current.IncomingMessageProperties["Principal"];
if (!u.HasPermission("LightSwitchApplication:CanAddOrEditGroups"))
{
throw new DataServiceException(400, "You do not have permission to add or edit new groups.");
}
}
History
- 2016-03-25: Initial upload
- BasicAuthenticationProvider HttpContext
- 2018-05-09: OAuth Authentication