📜 ⬆️ ⬇️

SSO for beauty and monster


In the picture - a silly beauty gives the user account of the monster.

In one country, there was an old ugly administration system for schools, written in Classic ASP. It was used by all teachers, students, as well as their parents. And one beautiful sunny day they decided to modernize it. Modern ASP.Net MVC 5 with a new design should replace the outdated technology.
However, overnight, it is not possible to rewrite all 6000 asp files, for some time the old and new systems should exist in parallel.
And now, six months later, the new, sparkling (although in some places, rust and patches still show up, because the deadlines), the system accepted the first users.
The next step was to apply single sign-on ( SSO ) technology to users so that everyone could move freely between the still silly beauty and the full-featured monster.
In addition, one of the largest learning management systems ( LMS ) wanted to have tight integration with our administration system, including SSO.


Problem statement and problems


So, as input we have: 2500 schools, 1.5 million active users. In the new system, each school has its own third-level domain, in the old - an arbitrary domain.
You need to create an account provider (IdP) and use it in both systems to ensure switching between them without having to log in.
The first problem is the multi-tenant application architecture. The school database is separated from the rest, respectively, there is no single list of users.
The second problem is the obsolete technology Classic ASP, for which it is difficult to find already existing solutions for the use of any IdP.
The third problem is that already existing IdPs are used as the account provider, with which you need to continue working. It turns out the chain: third-party IdP -> our IdP -> end user (Service Provider).
')

Selection of SSO technology


The task is not new and there are several ways to implement SSO. Most popular protocols:

OAuth in our case is not suitable, because in fact it is an authorization protocol, and not authentication. You can expand it, but it does not fit into other customer requirements.
SAML 2.0 is still the global standard, even though it is old. Some of the already existing IdPs mentioned above use this protocol to one degree or another. In addition, LMS understands SAML, but does not use OpenID. Therefore, it was decided to use SAML 2.0.
The next step was to select a specific SAML implementation. There are solutions out of the box and libraries, a list of which is easy to find on Wikipedia - SAML-based products and services . It was possible to use something ready and simple, like SimpleSAMLphp , but, firstly, it is php, with which there is little experience, and secondly, it needs to be separately hosted, maintained and monitored. Proshstivste implementation on .Net and not finding a solution out of the box, chose the library from ComponentSpace , against the rest looking more mature. In general, this decision was justified, although some unpleasant features of use were revealed, which will be discussed later.
Multi-tenant application architecture revealed another task - what should be IdP and SP in the case of each particular school? Two options:
  1. Each school is a separate IdP. Easy to implement, because all IdP users are stored in the same database. It is quite enough to provide SSO between the old and the new systems. It is very inconvenient for SSO with LMS or any other system - in fact, you need to register 2500 different IdPs in order to be able to enter any school.
  2. IdP is centralized, each school is a separate SP. The implementation is not so simple, users are smeared on 2500 bases. But integration with other systems is simplified and interesting opportunities appear in the future - to make a single login for all schools and for all roles. In this case, a large parent with children in different schools will have only one login, and not many, as now.

Having discussed the options with the customer, we stopped at the second.
For Classic ASP, we did not find any implementations, but we didn’t want to write ourselves either, and there was no time. I had to dwell on the fact that the new system will have a proxy that the old one will use. Not very nice, but it will work.
It remained to receive confirmation from the customer and the paid version of ComponentSpace SAML 2.0 for .Net. Now you can finally start writing code.

Authentication process


The problem of user base spreading has not been solved yet. Since, in essence, the application is one and all schools have one subdomain (say, newsystem.localhost), you can not select the IdP in a separate application, but enter it inside an existing one. It will be a pseudo-school with its third level domain "idp".
In fact, we will have 2 different user IDs, this with OWIN can be done as follows:
app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = "SSO.ApplicationCookie", CookieDomain = ".newsystem.localhost", ExpireTimeSpan = new TimeSpan(6, 0, 0), SlidingExpiration = true }); app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, LoginPath = new PathString("/Account/Login") }); 

The first cookie for an IdP user with a second level subdomain common to all schools. Shelf life - 6 hours with automatic renewal during use. The second is the usual cookie for the school user.
Then each school at the right time will act as IdP.

If the user is not yet authenticated at a particular school, “Account / Login” will create a SAML request and send it to “SsoService”. IdP processes this request and, if the user is not yet authenticated, sends it back to the school, but to a special “Account / IdPLogin” page. The school itself will process the login and password, authenticate the user, but not at home, but in IdP. IdP itself can also complete the process - “Sso / SsoCompete” responds to the request. Next, the school is already acting as an SP and processes the answer in its “Sso / AssertionConsumerService”.
There is a small snag - sometimes we don’t know in which school the user has a login and password. So it turns out, if the request came from a third-party system. In this case, you can provide the user to choose the school. It is inconvenient to choose from 2500 possible options, but you can optimize this process in several ways - remember the last selected school and let SP limit the list for selection by adding a custom SAML attribute to the request.
Another problem is what if the current IdP user is not in the selected school? Then we will show a special page with the ability for the current user to exit and initiate a new authentication.
Authentication process


school.newsystem.localhost is both SP (left) and IdP (right) simultaneously.
You can authenticate a user to Account / IdPLogin using your login and password for the school, or contact a third-party IdP.
Exit process diagram


Next come the implementation details, you can skip them if you don’t want to go deep into the details. But I still recommend reading the result at the end anyway.

Configuration


We start with the most difficult - IdP and SP settings. We decided that IdP will be one, but SP will be a lot. 2500 for the new system, 2500 for the old system and at least 1 more for the LMS. ComponentSpace allows you to load settings from a file and programmatically. So let's do both!
The configuration file is called saml.config, there we put the general settings:
 <?xml version="1.0"?> <SAMLConfiguration xmlns="urn:componentspace:SAML:2.0:configuration"> <!-- Identity provider configuration --> <IdentityProvider Name="urn:example:SAML:2.0:idp.newsystem.localhost" LocalCertificateSerialNumber="blabla"/> <!-- Service provider configuration --> <ServiceProvider Name="urn:example:SAML:2.0:idp.newsystem.localhost" AssertionConsumerServiceUrl="https://idp.newsystem.localhost/sso/idp/sp/AssertionConsumerService" LocalCertificateSerialNumber="blabla"/> <!-- Partner Identity providers configuration --> <PartnerIdentityProvider Name="urn:3rdPartyIdP" SignAuthnRequest="false" WantSAMLResponseSigned="true" WantAssertionSigned="false" WantAssertionEncrypted="false" SingleSignOnServiceUrl="http://localhost:50320/SAML/SSOService" SingleLogoutServiceUrl="http://localhost:50320/SAML/SLOService" PartnerCertificateSerialNumber="blabla"/> <!-- Service providers configurations --> <PartnerServiceProvider Name="urn:lms" WantAuthnRequestSigned="false" SignSAMLResponse="true" SignAssertion="false" EncryptAssertion="false" AssertionConsumerServiceUrl="https://lms.localhost/sso/sp/AssertionConsumerService.aspx" SingleLogoutServiceUrl="https://lms.localhost/sso/sp/SingleLogoutHandler.aspx" PartnerCertificateSerialNumber="blabla"/> </SAMLConfiguration> 

Here we register

The last list includes only third-party systems, we will register our schools programmatically. Certificates are needed to be able to sign and encrypt requests and responses. When new third-party systems appear, we simply add them to this file, and everything will work without changing the code.

Now at the start of the application you need to configure all other SP. There is one feature - our application at different times acts as both IdP, and as one of 5000 SP. Why 5000? Because the proxy for the old system is in fact a separate set of 2500 SP. ComponentSpace allows you to have an arbitrary number of configurations, which we will use.
First, load the configuration from the file:
 SAMLConfiguration.Load(); 

ComponentSpace will create the configuration itself with the predefined name “default”. Once created, it is current, so that it can be accessed through SAMLConfiguration.Current.
Then we create the IdP configuration, essentially copying everything from the current one:
 var identityProviderConfigurationId = SAMLConfiguration.Current.LocalIdentityProviderConfiguration.Name; var identityProviderConfiguration = new SAMLConfiguration { LocalIdentityProviderConfiguration = SAMLConfiguration.Current.LocalIdentityProviderConfiguration, PartnerServiceProviderConfigurations = SAMLConfiguration.Current.PartnerServiceProviderConfigurations, LocalServiceProviderConfiguration = SAMLConfiguration.Current.LocalServiceProviderConfiguration, PartnerIdentityProviderConfigurations = SAMLConfiguration.Current.PartnerIdentityProviderConfigurations, ReloadOnConfigurationChange = SAMLConfiguration.Current.ReloadOnConfigurationChange, CertificateManager = SAMLConfiguration.Current.CertificateManager, TraceLevel = SAMLConfiguration.Current.TraceLevel }; SAMLConfiguration.Configurations.Add(identityProviderConfigurationId, identityProviderConfiguration); 

For the rest, this will be PartnerIdentityProviderConfiguration:
 var partnerIdentityProviderConfigurations = new Dictionary<string, PartnerIdentityProviderConfiguration> { { identityProviderConfigurationId, new PartnerIdentityProviderConfiguration { Name = identityProviderConfigurationId, SignAuthnRequest = true, WantSAMLResponseSigned = false, WantAssertionSigned = false, WantAssertionEncrypted = false, SingleSignOnServiceUrl = string.Format("https://{0}/sso/ssoservice", identityProviderHost), SingleLogoutServiceUrl = string.Format("https://{0}/sso/sloidpservice", identityProviderHost), PartnerCertificateSerialNumber = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSerialNumber, PartnerCertificateFile = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateFile, PartnerCertificateSubject = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSubject, PartnerCertificateThumbprint = identityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateThumbprint } } }; 

We create, using school domains, SP configurations, and supplement IdP with our own SPs for the new system:
 var spConfigurationId = string.Format("urn:example:saml:2.0:{0}", domain); SAMLConfiguration.Configurations.Add(spConfigurationId, new SAMLConfiguration { LocalServiceProviderConfiguration = new LocalServiceProviderConfiguration { Name = spConfigurationId, AssertionConsumerServiceUrl = string.Format("https://{0}/sso/assertionconsumerservice", domain), LocalCertificateSerialNumber = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSerialNumber, LocalCertificateFile = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateFile, LocalCertificatePassword = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificatePassword, LocalCertificatePasswordKey = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificatePasswordKey, LocalCertificateSubject = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSubject, LocalCertificateThumbprint = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateThumbprint }, PartnerIdentityProviderConfigurations = partnerIdentityProviderConfigurations }); } identityProviderConfiguration .PartnerServiceProviderConfigurations .Add(spConfigurationId, new PartnerServiceProviderConfiguration { Name = spConfigurationId, WantAuthnRequestSigned = false, SignSAMLResponse = true, SignAssertion = false, EncryptAssertion = false, AssertionConsumerServiceUrl = string.Format("https://{0}/sso/assertionconsumerservice", domain), SingleLogoutServiceUrl = string.Format("https://{0}/sso/slospservice", domain), PartnerCertificateSerialNumber = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSerialNumber, PartnerCertificateFile = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateFile, PartnerCertificateSubject = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateSubject, PartnerCertificateThumbprint = IdentityProviderConfiguration.LocalIdentityProviderConfiguration.LocalCertificateThumbprint }); 


The same for proxy, the difference will be only in the ways:
  AssertionConsumerServiceUrl = string.Format("https://{0}/proxy/assertionconsumerservice", domain), SingleLogoutServiceUrl = string.Format("https://{0}/proxy/sloservice", domain), 

In the course of work, a problem was discovered - ComponentSpace needs a session to store its internal information, and our ASP.Net MVC distributed application does not use the session. Only for the sake of SSO to include it is not very correct, then someone will surely be tempted to put something else there, and went off, goodbye sessionless. There is a way out - use Redis as SessionStore, for example:
 public class SessionStore : AbstractSSOSessionStore { public override object Load(Type type) { var sessionObject = RedisSsoSessionComponent.Load(GetDatabaseSessionId(type)); return sessionObject != null && sessionObject.Length > 0 ? Deserialize(sessionObject) : null; } public override void Save(object ssoSession) { RedisSsoSessionComponent.Save(Serialize(ssoSession), GetDatabaseSessionId(ssoSession.GetType())); } public override string SessionID { get { CookieFacade.SsoSessionId; } } private string GetDatabaseSessionId(Type type) { return string.Format("{0}:{1}", SessionID, type.Name); } } 

Here RedisSsoSessionComponent is engaged in direct communication with Redis. There is a snag with the session ID, you can store it in cookies - in the CookieFacade we have the property SsoSessionId:
 string cookieName = "SsoSessionId"; var cookie = HttpContext.Current.Request.Cookies[cookieName]; if (cookie != null && !string.IsNullOrEmpty(cookie.Value)) { return cookie.Value; } cookie = HttpContext.Current.Response.Cookies[cookieName]; if (cookie != null && !string.IsNullOrEmpty(cookie.Value)) { return cookie.Value; } var sessionId = Guid.NewGuid().ToString(); cookie = new HttpCookie(cookieName, sessionId); HttpContext.Current.Response.Cookies.Remove(cookie.Name); HttpContext.Current.Response.AppendCookie(cookie); return sessionId; 

If the desired cookie was not found, create a new identifier.
It remains to connect our samopinny storage:
 SAMLConfiguration.SSOSessionStore = new SessionStore(); 

The configuration is finally complete.

Necessary infrastructure


In the process, you need to distinguish which configuration to use for each request. You can do this by changing SAMLConfiguration.ConfigurationID on the fly.
Create attributes that will mark the necessary actions in the controllers:
 public class SamlIdentityProviderAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext filterContext) { if (string.IsNullOrWhiteSpace(SAMLConfiguration.ConfigurationID) || !SAMLConfiguration.ConfigurationID.Equals(SamlConfig.IdentityProviderConfigurationId, StringComparison.InvariantCultureIgnoreCase)) { SAMLConfiguration.ConfigurationID = SamlConfig.IdentityProviderConfigurationId; } base.OnActionExecuting(filterContext); } } public class SamlServiceProviderAttribute : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext filterContext) { string spConfigurationId = string.Format("urn:example:saml:2.0:{0}", domain); if (string.IsNullOrWhiteSpace(SAMLConfiguration.ConfigurationID) || !SAMLConfiguration.ConfigurationID.Equals(spConfigurationId, StringComparison.InvariantCultureIgnoreCase)) { SAMLConfiguration.ConfigurationID = spConfigurationId; } base.OnActionExecuting(filterContext); } } 


IdP logic


All IdP actions can be added to a separate controller - SamlIdentityProviderController.
Ssoservice
 //    SAMLIdentityProvider.ReceiveSSO(Request, out partnerSp); //      ,   Low level API HTTPRedirectBinding.ReceiveRequest(HttpContext.Request, out authnRequestElement, out relayState, out signatureAlgorithm, out signature); domains = _ssoComponent.GetSchoolDomains(authnRequestElement); //          if (HttpContext.User.Identity.IsAuthenticated) { return RedirectToAction(MVC.SamlIdentityProvider.SsoComplete()); } //    IdPLogin  ,       return Redirect(GetDomainLoginUrl(domain)); //      ,      return RedirectToAction(MVC.SamlIdentityProvider.SchoolSelect()); 

SsoComplete
 //  SAML    var attributes = new Dictionary<string, string> { { Saml2Helper.Attributes.UserRoleKey, userIdentity.UserRole.ToString() }, { Saml2Helper.Attributes.UserFirstNameKey, userIdentity.FirstName }, { Saml2Helper.Attributes.UserLastNameKey, userIdentity.LastName } }; //    SAMLIdentityProvider.SendSSO(Response, userIdentity.UserIdentifier, attributes); 

Sloservice
 //    SAMLIdentityProvider.ReceiveSLO(Request, Response, out isRequest, out hasCompleted, out logoutReason, out partnerServiceProvider); //     IdP HttpContext.GetOwinContext().Authentication.SignOut(); //    SP,      SP SAMLIdentityProvider.SendSLO(Response, null); //      IdP SAMLServiceProvider.SendSLO(Response, null); 

SchoolSelect
 //        IdPLogin   

Initso
 //   SSO   IdP SAMLServiceProvider.InitiateSSO(Response, null, partnerIdP); 

AssertionConsumerService
 //     IdP SAMLServiceProvider.ReceiveSSO(Request, out isInResponseTo, out partnerIdP, out userName, out attributes, out relayState); //     var user = await userManager.FindAsync(new UserLoginInfo(“IdP”, userId)); //       IdP    ,   SP await _nativeLoginProcessor.SignInAsync(user); return Redirect(MVC.SamlIdentityProvider.SsoComplete()); //      “  ” 

IdpSpSloService
 //     IdP   SAMLServiceProvider.ReceiveSLO(Request, out isRequest, out logoutReason, out partnerIdP); //       HttpContext.GetOwinContext().Authentication.SignOut("SSO.ApplicationCookie"); //     SP SAMLIdentityProvider.InitiateSLO(Response, null); //     SP    SAMLIdentityProvider.SendSLO(Response, null); 


AccountController will have a special action associated with IdP.
IdPLogin
 //          ,     IdP await _nativeLoginProcessor.SignInAsync(user); //      return RedirectToAction(MVC.SamlIdentityProvider.SsoComplete()); //     IdP   SSO return RedirectToAction(MVC.SamlIdentityProvider.InitSso(partnerIdP)); 



SP logic


The main SP actions will be in SamlServiceProviderController.
AssertionConsumerService
 //    IdP SAMLServiceProvider.ReceiveSSO(Request, out isInResponseTo, out partnerIdP, out userName, out attributes, out relayState); //     var user = await _samlServiceProviderComponent.FindUserAsync(attributes); //            ,     relayState await _nativeLoginProcessor.LocalSignInAsync(user); return RedirectToLocal(relayState); //            return RedirectToAction(MVC.SamlServiceProvider.LogOut()); 

Sloservice
 //    SAMLServiceProvider.ReceiveSLO(Request, out isRequest, out logoutReason, out partnerIdP); //      HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ApplicationCookie); //   ,     IdP SAMLServiceProvider.SendSLO(Response, null); //    ,      return RedirectToAction(MVC.Account.Login()); 

Logout
 //      “        ,  ,       ” 


In addition, you need to change the SP-related actions in AccountController.
Login
 //    IdP SAMLServiceProvider.InitiateSSO(Response, returnUrl, SamlConfig.IdentityProviderConfigurationId); 

Logoff
 //      HttpContext.GetOwinContext().Authentication.SignOut(DefaultAuthenticationTypes.ApplicationCookie) //    IdP SAMLServiceProvider.InitiateSLO(Response, null); 



Proxy logic for the old system


Another controller is ProxyController.
Login - called by the old system when a user tries to enter it
 //  SAMLServiceProvider.InitiateSSO(Response, relayState, SamlConfig.IdentityProviderConfigurationId); 

AssertionConsumerService
 //    IdP SAMLServiceProvider.ReceiveSSO(Request, out isInResponseTo, out partnerIdP, out userName, out attributes, out relayState); //     var user = await _samlServiceProviderComponent.FindUserAsync(attributes); //        ,      ,   string timeStamp = DateTime.UtcNow.ToString("yyyyMMddHHmm"); string queryParams = string.Format("userId={0}&userLoginProvider={1}Ă—tamp={2}&auth={3}", userId, userLoginProviderKey, timeStamp, MD5Helper.ComputeHash(string.Format("{0}{1}{2}{3}", userId, userLoginProviderKey, timeStamp, "Secret"))); //            return RedirectToAction(MVC.Proxy.LogOut()); 

InitiateSlo
 //    SAMLServiceProvider.InitiateSLO(Response, null); 

Sloservice
 //    SAMLServiceProvider.ReceiveSLO(Request, out isRequest, out logoutReason, out partnerIdP); //       ,        ProcessSlo 

ProcessSlo
 //    SAMLServiceProvider.SendSLO(HttpContext.Response, null); 

Logout
 //      “        ,  ,       ” 


This is only the top of the process, inside there are still a lot of little things that are too long and uninteresting to write.

Result and conclusions


Switching to SSO is difficult and slow. The owners of the old system were very skeptical and cautious about the idea itself and its implementation. The transition took place gradually, in packs of 100–200 schools per week.
The third pack started having problems with the speed of response from IdP. It turned out that only one web server was allocated for it, although we asked to allocate at least 3 and control the number of requests, adding more servers if necessary. The customer understood the error and corrected the situation. Now 6 servers are serviced by 1.5 million users who are in the habit of logging in in the morning almost simultaneously.
Next in 1000 schools, everything died suddenly. Logs showed that Redis is not responding. Here it must be mentioned that the customer himself volunteered to provide us with the necessary infrastructure and even bought Redis Labs Enterprise Cluster (RLEC). And for some reason they set up a cluster not just for the requested 100 GB, but only by 5. When the data became more of this restriction, the RLEC just got up ... No warning, no crowding out of old data, just stopped responding to any requests. Redis Labs technical support was very weak and slow - it took them a week to find the cause, they later called their recommendations erroneous, and in general had to re-create the entire cluster several times. Now the cluster works without failures, but the sediment remains unpleasant.
Libraries from ComponentSpace can be successfully used, although there are inconveniences in their structure - almost everything is static and not thread safe. We had to carefully check everything ourselves, dig into the source code and, if necessary, wrap it up with our own thread-safe components. This is more true for the High level API, which was not enough, in some places they used Low level. In addition, the classes stored in the session are internal. Therefore, for example, finding out the name of SP, whose request we are currently processing, is not so easy, despite the fact that we have access to SSOSessionStore. I had to get the necessary data by reflection.
In general, everything works perfectly, there is a huge potential for improvement and optimization, which is currently being gradually implemented.

Source: https://habr.com/ru/post/280806/


All Articles