mandag den 25. februar 2013

ASP.NET and/or MVC websites and Claims based Authentication

Implementing WIF on a website is pretty straight forward. There are ton’s of guide out there on how to do it …. By doing a bit of magic in web.config you can make any website support claims based authentication. ( even Exchange 2010 and Citrix  )

If you are a developer, implementing WIF on your website is just as easy. You install WIF SDK and run FedUtil.exe who will handle configuring your web.config for you …

But what if you want absolute control over the login process. You often end up, with a mix of stuff in web.config and some in code. So I decided to figure out, what it would take, to support getting a user signed on, without ever touching web.config.

You will need to handle 4 actions. Requesting login, Handle login, Handle sign-out and handling Cleanup. So lets start with requesting login. But before we can do that, we need to have a basic setup of WIF. WIF normally do most of it’s magic though 3-4 modules, that get’s its configuration though web.config, and is saved in a static (shared) variable you can access simply by typing

Dim fam As Microsoft.IdentityModel.Web.WSFederationAuthenticationModule = Microsoft.IdentityModel.Web.FederatedAuthentication.WSFederationAuthenticationModule

But we want to control. To make things simple, we function like this

Public Function GetAuthenticationModule() As WSFederationAuthenticationModule
Dim fam As New WSFederationAuthenticationModule
fam.ServiceConfiguration = New Microsoft.IdentityModel.Configuration.ServiceConfiguration()
' use this, to allow any realm
' fam.ServiceConfiguration.AudienceRestriction.AudienceMode = IdentityModel.Selectors.AudienceUriMode.Never
' Add known Audience's ( Realms )
' This would proberly come from a database or what ever
For Each Realm in my.settings.Realms
Dim AllowedUri = fam.ServiceConfiguration.AudienceRestriction.AllowedAudienceUris.Where(Function(x) x.AbsoluteUri = IDP.TargetRealm).FirstOrDefault()
If AllowedUri Is Nothing Then
fam.ServiceConfiguration.AudienceRestriction.AllowedAudienceUris.Add(New Uri(IDP.TargetRealm))
End If
Next
fam.ServiceConfiguration.IssuerNameRegistry = New CustomTrustedIssuerNameRegistry()

Dim certificate As System.Security.Cryptography.X509Certificates.X509Certificate2 = CustomSecurityTokenServiceConfiguration.Current.EncryptingCertificate
AttachCert(fam.ServiceConfiguration, certificate)
' If you later decide to let WIF set the identity and save a FedAuth cookie, use thise to allow it to work incase you got
' your website hosted in Azure. The reason is Azure's Loadbalencing are using true round robin and doesnt support Sticky sessions.
' So in order to make all websites able to read the cookie, you need to a "common" ground for reading the encrypted token
'Dim sessionTransforms As New List(Of CookieTransform)(New CookieTransform() {New DeflateCookieTransform(), New RsaEncryptionCookieTransform(certificate),
' New RsaSignatureCookieTransform(certificate)})
'Dim sessionHandler As New Microsoft.IdentityModel.Tokens.SessionSecurityTokenHandler(sessionTransforms.AsReadOnly())
'fam.ServiceConfiguration.SecurityTokenHandlers.AddOrReplace(sessionHandler)
Return fam
End Function

Private Function InlineAssignHelper(Of T)(ByRef target As T, value As T) As T
target = value
Return value
End Function
Sub AttachCert(configuration As Microsoft.IdentityModel.Configuration.ServiceConfiguration, certificate As System.Security.Cryptography.X509Certificates.X509Certificate2)
configuration.ServiceCertificate = certificate
Dim certificates = New List(Of System.IdentityModel.Tokens.SecurityToken)() From { _
New System.IdentityModel.Tokens.X509SecurityToken(Configuration.ServiceCertificate) _
}
Dim encryptedSecurityTokenHandler = TryCast((From handler In Configuration.SecurityTokenHandlers Where TypeOf handler Is Microsoft.IdentityModel.Tokens.EncryptedSecurityTokenHandler).First(), Microsoft.IdentityModel.Tokens.EncryptedSecurityTokenHandler)
configuration.ServiceTokenResolver = InlineAssignHelper(encryptedSecurityTokenHandler.Configuration.ServiceTokenResolver, System.IdentityModel.Selectors.SecurityTokenResolver.CreateDefaultSecurityTokenResolver(certificates.AsReadOnly(), False))
End Sub

We need to tell WIF the realm(s) we are running under right now, called Audience Uri.
We need to tell WIF what signing certificate's to allow tokens from, called Issuer Name Registry.
CustomTrustedIssuerNameRegistry is a class that will get “fed” the signing certificates when WIF is validating the tokens, and should return a name (any name) if the certificate was allowed. Code for that is further down


Perfect, so now we are ready to create a sign request ( what happens when a user clicks the “login” button or what ever)

Public Function CreateSignInRequestMessage(Issuer As String, Realm As String, Reply As String) As SignInRequestMessage
Dim fam = GetAuthenticationModule()
fam.Issuer = Issuer
fam.Realm = Realm
Dim signInRequest = New SignInRequestMessage(New Uri(fam.Issuer), fam.Realm) With { _
.AuthenticationType = fam.AuthenticationType, _
.Freshness = fam.Freshness, .Reply = Reply
}
Return signInRequest
End Function

So in the code for your “login button” you do something down the line of this


Dim STSLoginPage as string = "https://adfs.wingu.dk/adfs/ls/"
' This webpage, must be in the AllowedAudienceUris
Dim TargetRealm as string = "https://domain.com/MyAwsomeWebapp"
Dim returnURL as string = "https://domain.com/MyAwsomeWebapp/login.aspx"
' or if using MVC
Dim returnURL as string = "https://domain.com/MyAwsomeWebapp/login/signin/"

Dim signInRequest = WIFHelper.CreateSignInRequestMessage(STSLoginPage, TargetRealm, returnURL)
Dim redirURL As String = signInRequest.WriteQueryString()
Response.Redirect(redirURL)

That will kick the user over to the identity provider, and if all goes well, he will then return with a POST to the return URL, where we can now pickup he’s SAML token.
So on the login page (or MVC controller) its now time to read the token, we add a function to our module, than handles that part


Private Function GetSignInResponseMessage() As SignInResponseMessage
Dim ctx = System.Web.HttpContext.Current
Dim req = ctx.Request
Dim message As SignInResponseMessage = WSFederationMessage.CreateFromFormPost(req) 'as SignInResponseMessage
Return message
End Function

Public Function GetWIFPrincipal(ByRef Issuer As String) As Microsoft.IdentityModel.Claims.ClaimsPrincipal 'System.Security.Principal.IPrincipal
Dim fam = GetAuthenticationModule()
Dim message As SignInResponseMessage = GetSignInResponseMessage()
'Dim token As System.IdentityModel.Tokens.SamlSecurityToken = fam.GetSecurityToken(message)
Dim token = fam.GetSecurityToken(message)
If token IsNot Nothing Then
Dim claims As Microsoft.IdentityModel.Claims.ClaimsIdentityCollection = fam.ServiceConfiguration.SecurityTokenHandlers.ValidateToken(token)
Dim principal As System.Security.Principal.IPrincipal = New Microsoft.IdentityModel.Claims.ClaimsPrincipal(claims)
If TypeOf token Is System.IdentityModel.Tokens.SamlSecurityToken Then
Dim t As System.IdentityModel.Tokens.SamlSecurityToken = token
Issuer = t.Assertion.Issuer
ElseIf TypeOf token Is Microsoft.IdentityModel.Tokens.Saml2.Saml2SecurityToken Then
Dim t As Microsoft.IdentityModel.Tokens.Saml2.Saml2SecurityToken = token
Issuer = t.Assertion.Issuer.Value
Else
Throw New Exception("Failed resolving SecurityToken to a known type '" & token.GetType.Name & "'")
End If
Return principal
End If
Return Nothing
End Function

And in the login page/controller you, can now either set the current principal, or parse the claims and sign in the user with formsbased authentication or what ever makes you tick.

Dim Issuer As String = Nothing
Dim Principal As Microsoft.IdentityModel.Claims.ClaimsPrincipal = GetWIFPrincipal(Issuer)
If Principal Is Nothing Then Throw New Exception("Failed parsing sign message to new Principal")
Dim Identity As Microsoft.IdentityModel.Claims.ClaimsIdentity = Principal.Identity
If Identity Is Nothing Then Throw New Exception("Failed parsing sign message to new Principal")
Issuer = Issuer.ToLower

Lastly, to make everything work correctly, we need to make our “login” page support at least 3 of the WS-Federation commands ( I guess that makes “login” page a wrong name then ? ) . So to wrap it all up, you will get something like this

Dim action as string = Request.QueryString(WSFederationConstants.Parameters.Action)
Select Case action
Case WSFederationConstants.Actions.SignIn
' Send user to the identity provider
Dim STSLoginPage As String = "https://adfs.wingu.dk/adfs/ls/"
' This webpage, must be in the AllowedAudienceUris
Dim TargetRealm As String = "https://domain.com/MyAwsomeWebapp"
Dim returnURL As String = "https://domain.com/MyAwsomeWebapp/login.aspx"
' or if using MVC
returnURL = "https://domain.com/MyAwsomeWebapp/login/signin/"
Case WSFederationConstants.Actions.SignOut
Dim requestMessage As SignOutRequestMessage = Nothing
Try
' This will fail, if a "sharepoint" login message
requestMessage = WSFederationMessage.CreateFromUri(Request.Url)
Catch ex As Exception
' ignore
End Try
' Return user to front page
model.RedirectURL = Request.Url.Scheme & "://" & Request.Url.Host & Request.ApplicationPath
If Not requestMessage Is Nothing Then
If Not String.IsNullOrEmpty(requestMessage.Reply) Then
' unless they logged out from another site, then send them back there
model.RedirectURL = requestMessage.Reply
End If
End If
FormsAuthentication.SignOut()
Session.Abandon()

' clear authentication cookie
Dim cookie1 As HttpCookie = New HttpCookie(FormsAuthentication.FormsCookieName, "")
cookie1.Expires = DateTime.Now.AddYears(-1)
Response.Cookies.Add(cookie1)

' clear session cookie
Dim cookie2 As HttpCookie = New HttpCookie("ASP.NET_SessionId", "")
cookie2.Expires = DateTime.Now.AddYears(-1)
Response.Cookies.Add(cookie2)
Case WSFederationConstants.Actions.SignOutCleanup
' Do the same as above, but send an "ok" image back
End Select
And lastly, we need to create class that validates singing certificates. 
Imports Microsoft.IdentityModel.Tokens
Imports System.IdentityModel.Tokens

Public Class CustomTrustedIssuerNameRegistry
Inherits IssuerNameRegistry
'Inherits Microsoft.IdentityModel.Tokens.ConfigurationBasedIssuerNameRegistry

Public Sub New()
End Sub
'Public Sub New(customConfiguration As System.Xml.XmlNodeList)
' MyBase.New(customConfiguration)
'End Sub

Public Overrides Function GetWindowsIssuerName() As String
Return MyBase.GetWindowsIssuerName()
End Function

Public Overloads Overrides Function GetIssuerName(securityToken As IdentityModel.Tokens.SecurityToken) As String
Dim x509Token As X509SecurityToken = TryCast(securityToken, X509SecurityToken)
If x509Token IsNot Nothing Then

For Each Thumbprint in My.Settings.AllowedSigningThumbprints
If [String].Equals(x509Token.Certificate.Thumbprint, Thumbprint) Then
Return x509Token.Certificate.SubjectName.Name
End If
Next
End If
Throw New SecurityTokenException("Untrusted issuer.")
End Function

End Class

Ingen kommentarer:

Send en kommentar