Finally a Federation STS sample using ASP.NET Forms Authentication!
I have recently worked on an advisory case on which the customer was looking for a STS sample using ASP.NET Forms authentication as base to provide the claims. The Internet may be vast, but it is like a desert when it comes to a single sample of a STS leveraging forms authentication. Before continuing, please notice that the sample is provided “AS IS”. It is not designed to work on production environment and I make no claims that it is a secure solution. It is just a proof-of-concept from which you can develop your own solution upon. There is at least an open source Identity Provider product that works with forms authentication, ThinkTecture Identity Server (https://www.thinktecture.com/) which seems to be production grade. This sample is just a web application that you can hook up to your solution in your test environment and have a federation server that can be used without a domain.
The base for this adaptation is the Federation Metadata sample in MSDN (https://code.msdn.microsoft.com/Federation-Metadata-34036040). The sample is specially interesting because it generates the federation metadata on the fly instead of using a static xml file. Assuming you are already familiar with this sample (requires .NET 4.5 and Visual Studio 2012+), I will only show what I added to integrate with forms authentication.
Download project HERE.
Changes in web.config
- I created a SQL membership database using aspnet_regsql.exe via wizard (https://msdn.microsoft.com/en-us/library/vstudio/ms229862(v=vs.100).aspx)
- Added database connection information in web.config, under <configuration> and membership info under configuration/system.web
- Configure to not enable anonymous user except for the federation metadata path
- Added configuration to use a http handler to return the federation metadata
- Removed the existing rewriting rule that would return the metadata from the WCF service (I had to remove to avoid problems with authentication as the federation metadata is supposed to be anonymously available while the service should not)
<?xml version="1.0"?>
<!--
This is an adaptation of the sample in msdn to include Forms Authentication Support
More information on this adaptation at https://blogs.msdn.com/rodneyviana
This adapted project can be download at https://rodneyviana.codeplex.com
-->
<configuration>
<connectionStrings>
<add name="ApplicationServices"
connectionString="data source=localhost\SQLEXPRESS;User Id=sa;Password=Pass@word1;Initial Catalog=aspnetdb;"
providerName="System.Data.SqlClient" />
</connectionStrings>
<location path="recover.aspx">
<system.web>
<authorization>
<allow users="*"/>
</authorization>
</system.web>
</location>
<location path="federationmetadata/2007-06/federationmetadata.xml">
<system.webServer>
<handlers>
<add name="XmlFederationHandler" verb="*" path="*.xml"
type="WSFederationSecurityTokenService.XmlFederationHandler,WSFederationSecurityTokenService"/>
</handlers>
</system.webServer>
<system.web>
<authorization>
<allow users="*"/>
</authorization>
</system.web>
</location>
<system.web>
<membership defaultProvider="AspNetSqlMembershipProvider">
<providers>
<clear/>
<add name="AspNetSqlMembershipProvider" type="System.Web.Security.SqlMembershipProvider"
connectionStringName="ApplicationServices"
enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false"
requiresUniqueEmail="false"
maxInvalidPasswordAttempts="100" minRequiredPasswordLength="6"
minRequiredNonalphanumericCharacters="0" passwordAttemptWindow="10"
applicationName="/" />
</providers>
</membership>
<roleManager enabled="true" defaultProvider="AspNetSqlRoleProvider">
<providers>
<clear />
<add connectionStringName="ApplicationServices" applicationName="/"
name="AspNetSqlRoleProvider" type="System.Web.Security.SqlRoleProvider" />
<add applicationName="/" name="AspNetWindowsTokenRoleProvider"
type="System.Web.Security.WindowsTokenRoleProvider" />
</providers>
</roleManager>
<authentication mode="Forms">
<forms loginUrl="login.aspx" />
</authentication>
<authorization>
<deny users="?"/>
</authorization>
<compilation debug="true" targetFramework="4.5" />
</system.web>
<system.serviceModel>
<services>
<service behaviorConfiguration="WSFederationSecurityTokenService.Service1Behavior"
name="WSFederationSecurityTokenService.WSFederationSecurityTokenService">
<endpoint address="" binding="webHttpBinding"
contract="WSFederationSecurityTokenService.IWSFederationSecurityTokenService">
<identity>
<dns value="localhost" />
</identity>
</endpoint>
<endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
</service>
</services>
<behaviors>
<serviceBehaviors>
<behavior name="WSFederationSecurityTokenService.Service1Behavior">
<!-- To avoid disclosing metadata information, set the values below to false before deployment -->
<serviceMetadata httpGetEnabled="true" httpsGetEnabled="true"/>
<serviceDebug includeExceptionDetailInFaults="false"/>
</behavior>
</serviceBehaviors>
</behaviors>
<serviceHostingEnvironment aspNetCompatibilityEnabled="true"
multipleSiteBindingsEnabled="true" />
</system.serviceModel>
<system.webServer>
<modules runAllManagedModulesForAllRequests="true"/>
<!--
To browse web app root directory during debugging, set the value below to true.
Set to false before deployment to avoid disclosing web app folder information.
-->
<directoryBrowse enabled="false"/>
<!-- Rewrite the usual federationmetadata.xml path to be rerouted to the service. -->
<!--<rewrite>
<rules>
<rule name="Rewrite federation metadata url">
<match url="(federationmetadata/2007-06/federationmetadata.xml)"/>
<action type="Rewrite" url="WSFederationSecurityTokenService.svc/{R:1}"/>
</rule>
</rules>
</rewrite>-->
</system.webServer>
</configuration>
Handler to return metadata
//----------------------------------------------------------------------------------------------
// Copyright 2012 Microsoft Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//----------------------------------------------------------------------------------------------
// For this database test:
// Username: jdoe
// Password: password1
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Web;
using System.Xml;
using System.Xml.Linq;
namespace WSFederationSecurityTokenService
{
public class XmlFederationHandler: IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
HttpRequest Request = context.Request;
HttpResponse Response = context.Response;
if (
Request.Path.ToLower().EndsWith("federationmetadata/2007-06/federationmetadata.xml")
)
{
Response.ContentType = "text/xml";
var stsConfiguration = new CustomSecurityTokenServiceConfiguration();
var fedMet = stsConfiguration.GetFederationMetadataReader();
Response.BinaryWrite(fedMet);
}
else
{
Response.Clear();
Response.StatusCode = 404;
Response.Status = "Not Found";
}
}
public bool IsReusable
{
get { return true; }
}
}
}
Changes in the Security Token Service (method Issue) to use the forms identity
public Stream Issue(string realm, string wctx, string wct, string wreply)
{
MemoryStream stream = new MemoryStream();
StreamWriter writer = new StreamWriter(stream, Encoding.UTF8);
string fullRequest = Constants.HttpLocalhost +
Constants.Port +
Constants.WSFedStsIssue +
string.Format("?wa=wsignin1.0&wtrealm={0}&wctx={1}&wct={2}&wreply={3}",
realm, HttpUtility.UrlEncode(wctx), wct, wreply);
SignInRequestMessage requestMessage =
(SignInRequestMessage)WSFederationMessage.CreateFromUri(new Uri(fullRequest));
ClaimsIdentity identity = new ClaimsIdentity(AuthenticationTypes.Federation);
IPrincipal user = HttpContext.Current.User;
if (user != null)
{
identity.AddClaim(new Claim(ClaimTypes.Name, user.Identity.Name));
}
else
{
throw new System.Security.SecurityException("There is no authenticated user");
}
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
SignInResponseMessage responseMessage =
FederatedPassiveSecurityTokenServiceOperations.ProcessSignInRequest(requestMessage,
principal, this.securityTokenService);
responseMessage.Write(writer);
writer.Flush();
stream.Position = 0;
WebOperationContext.Current.OutgoingResponse.ContentType =
"text/html";
return stream;
}
Changes in the Custom Security Token (adding Forms claims)
//----------------------------------------------------------------------------------------------
// Copyright 2012 Microsoft Corporation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//----------------------------------------------------------------------------------------------
using System.IdentityModel;
using System.IdentityModel.Configuration;
using System.IdentityModel.Protocols.WSTrust;
using System.Security.Claims;
using System.Web;
using System.Web.Security;
namespace WSFederationSecurityTokenService
{
internal class CustomSecurityTokenService : SecurityTokenService
{
public CustomSecurityTokenService(SecurityTokenServiceConfiguration configuration)
: base(configuration)
{
}
protected override Scope GetScope(ClaimsPrincipal principal, RequestSecurityToken request)
{
this.ValidateAppliesTo(request.AppliesTo);
Scope scope = new Scope(request.AppliesTo.Uri.OriginalString,
SecurityTokenServiceConfiguration.SigningCredentials);
scope.TokenEncryptionRequired = false;
scope.SymmetricKeyEncryptionRequired = false;
if (string.IsNullOrEmpty(request.ReplyTo))
{
scope.ReplyToAddress = scope.AppliesToAddress;
}
else
{
scope.ReplyToAddress = request.ReplyTo;
}
return scope;
}
protected override ClaimsIdentity GetOutputClaimsIdentity(ClaimsPrincipal principal,
RequestSecurityToken request, Scope scope)
{
RolePrincipal user = HttpContext.Current.User as RolePrincipal;
if (principal == null || user == null)
{
throw new InvalidRequestException("The caller's principal is null.");
}
var memberUser = Membership.GetUser();
ClaimsIdentity outputIdentity = new ClaimsIdentity();
outputIdentity.AddClaims(user.Claims);
outputIdentity.AddClaim(new Claim(ClaimTypes.Email, memberUser.Email));
outputIdentity.AddClaim(
new Claim(https://schemas.xmlsoap.org/claims/Logintime,
memberUser.LastLoginDate.ToUniversalTime().ToString())
);
return outputIdentity;
}
private void ValidateAppliesTo(EndpointReference appliesTo)
{
if (appliesTo == null)
{
throw new InvalidRequestException("The AppliesTo is null.");
}
}
}
}
When you start the project
Enter user jdoe and password password1
And you will get your claims: