Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ktownsend-personal/f5097181c7fda9189be143b5feca18ac to your computer and use it in GitHub Desktop.
Save ktownsend-personal/f5097181c7fda9189be143b5feca18ac to your computer and use it in GitHub Desktop.
How to synchronize classicASP session with ASP.NET MVC 5

How to synchronize classicASP session with ASP.NET MVC 5

This is related to a gist I wrote for doing this with ASP.NET Core 8, which was a refactor based on this code. I'm just dumping the old code here for reference. Read that gist for full description of the strategy and challenges I had to overcome.

This older version is using ActionFilter on the MVC 5 side of things to coordinate sync and puts a class holding the data and helper methods in the controller's ViewBag with a handy extension method to access it. The new refactor for ASP.NET Core 8 is using a scoped service class you can inject where you need it.

The classicASP side is virtually the same in both versions aside from a few tweaks I did in the newer version to drop an unnecessary HTTP verb and improve sync of removed variables.

If you end up using any of this, my only ask is that you link to this gist and give me some credit in your code for the next developer to discover... and of course tell me about how it saved your life in the gist comments :P

Deploying the code

Basically, you will want a subfolder on your website to hold the classic ASP related files. I used /internal to hold session.asp, JSON.js and a custom web.config with the extra HTTP verbs defined for the classic ASP ISAPI handler.

Add the other files in your MVC 5 project and adjust the namespaces. You will need to edit the fetch URL path in SessionSync.cs to match where you put session.asp around line 41.

  • /Global.asax
  • /App_Start/FilterConfig.cs
  • /Extensions/ControllerExtensions.cs
  • /Extensions/RequestExtensions.cs
  • /ClassicASP/SessionSync.cs
  • /ClassicASP/SessionInfo.cs

Two codes, one website

I'm not getting detailed on it here, but I started with an existing classic ASP website and created an application folder in IIS for the MVC5 website. I added some rewrite rules in web.config to hide the application folder from the URL path for a more seamless experience. I remember having some challenges getting it to work, but it was long enough ago that I can't really explain it more than that. You're on your own on getting the two sites to play nice together :P

If you're looking to get ASP.NET Core 8 and classic ASP working together, I have another gist about how I got that to work. In that case I actually got them to work in the same folder without creating an IIS application folder.

Using session variables:

In a Razor View access the session state object in the ViewBag: @ViewBag.ClassicASP["somevariable"]

In a controller you can use an extension method to get a strongly typed reference: this.ClassicASP().MemberName, or you can get a dynamic reference from the ViewBag: ViewBag.ClassicASP.MemberName or ViewBag.ClassicASP["member_name"].

If you update any values, you can push them back to classic ASP by setting ClassicASP.SaveMode to SyncMode.Merge or SyncMode.Replace. You can also use SyncMode.Clear or SyncMode.Abandon to perform those actions on the classic ASP session. These 4 actions are not immediate; they will occur at the end of the request processing.

using SCRCNational.Interim.ClassicASP;
using System.Web.Mvc;
namespace SCRCNational.Interim.Extensions {
public static class ControllerExtensions
{
public static SessionInfo ClassicASP(this Controller controller) =>
controller.ViewBag.ClassicASP;
}
}
using System.Web.Mvc;
namespace yourProject {
public class FilterConfig {
public static void RegisterGlobalFilters( GlobalFilterCollection filters ) {
filters.Add( new ClassicASP.SessionSyncFilter() );
}
}
}
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
namespace yourProject {
public class MvcApplication : System.Web.HttpApplication {
protected void Application_Start() {
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters( GlobalFilters.Filters );
RouteConfig.RegisterRoutes( RouteTable.Routes );
BundleConfig.RegisterBundles( BundleTable.Bundles );
}
}
}
using System;
using System.Net;
using System.Web;
namespace yourProject.Extensions {
public static class RequestExtensions {
public static WebClient LocalWebClient( this HttpRequestBase request ) => new LocalWebClient( IPAddress.Parse( request.ServerVariables["LOCAL_ADDR"] ) );
}
/// <summary>
/// This subclass gives us a way to specify the origin IP address.
/// The need for this arises on a hosting service with many IP addresses assigned, and the
/// one used by default may not be the same one assigned to the website executing this code.
/// That situation was a problem for my side-by-side classic-ASP session state sync because
/// it was trying to compare its ServerVariables["LOCAL_ADDR"] to ["REMOTE_ADDR"] to ensure
/// the caller was the same website. An alternative approach would be authentication rather
/// than IP comparison, but IP comparison is easier.
/// </summary>
public class LocalWebClient : WebClient {
public IPAddress OriginIP { get; private set; }
public LocalWebClient( IPAddress originIP ) => OriginIP = originIP;
protected override WebRequest GetWebRequest( Uri address ) {
WebRequest request = (WebRequest)base.GetWebRequest(address);
((HttpWebRequest)request).ServicePoint.BindIPEndPointDelegate += ( servicePoint, remoteEndPoint, retryCount ) => new IPEndPoint( OriginIP, 0 );
return request;
}
}
}
<%' you don't need these, but I am showing them for completeness because there is a comment later that mentions them %>
<!-- #include virtual="/wwwroot/includes/asp-preamble.asp" -->
<!-- #include virtual="/wwwroot/includes/environment.asp" -->
<!-- #include virtual="/wwwroot/includes/maintenance.asp" -->
<%
'we have to call the javascript from inside < % % > tags because of the script execution order:
' 1. <script runat="server" language="not default language">
' 2. < % % >
' 3. <script runat="server" language="same as default language">
' If the javascript were to run first, the session variables set by the includes won't have happened yet!
run
%>
<script language="javascript" runat="server" src='json2.js'></script>
<script language="javascript" runat="server">
/*
Author: Keith Townsend, Feb 7, 2021
This page is for ASP.NET on same server to read and update the classic ASP session state while we have them running side by side.
Inspiration for this technique from: https://searchwindevelopment.techtarget.com/tip/Share-session-state-between-ASP-and-ASPNET-apps
NOTE: It is critical that the REMOTE_ADDR vs. LOCAL_ADDR comparison works as intended to ensure only the server can call this page.
NOTE: I had to update IIS to allow the custom MERGE, REPLACE, CLEAR and ABANDON request methods:
=> Open IIS => Open Handler Mappings => Select "ASPClassic" => Request Restrictions => Verbs => select "All verbs", or add the ones you want to the list of accepted verbs.
=> Alternatively, you can configure this in web.config (<configuration> => <system.webServer> => <handlers>) with a <remove name="ASPClassic"> and an <add> to put it back in.
=> If that doesn't work, try also removing WebDav because I saw mention that can interfere (PUT and DELETE were mentioned).
NOTE: In JScript, the Request.ServerVariables collection returns an object with Count and Item properties (Item is default property, but that only works in VBScript)
We need json2.js because server-side JScript is very old and doesn't have the JSON object built-in like modern browsers do.
NOTE: If using JSON.parse() in VBScript, be aware that arrays are JScript arrays and you need dot syntax to access them (e.g., parsedObj.[0]).
SOURCE: https://github.com/douglascrockford/JSON-js/blob/master/json2.js
*/
function run(){
// IMPORTANT: it is critical that the remote and local address is the same to avoid external hacking
if(Request.ServerVariables("REMOTE_ADDR").item === Request.ServerVariables("LOCAL_ADDR").item){
switch(Request.ServerVariables("REQUEST_METHOD").item.toUpperCase()){
case "GET":
//provide session data to caller
//NOTE: Session property is reflected back with changes; other properties, like Debug, are safe to send anything you want without contaminating the session.
var wrapper = {
// ServerVariables: CollectionToObject(Request.ServerVariables, function(i){return i.Item}), //"default" property to get value from a Request.ServerVariables object is .Item
Session: CollectionToObject(Session.Contents), //no default property to worry about on session items
SessionId: Session.SessionId
}
Response.Clear();
Response.ContentType = "application/json";
Response.Write(JSON.stringify(wrapper, null, 2));
break;
case "MERGE":
//merge JSON with existing session data
SetSessionFromBodyJSON();
break;
case "REPLACE":
//replace existing session data with JSON
Session.Contents.RemoveAll();
SetSessionFromBodyJSON();
break;
case "CLEAR":
//clear session, but keep it alive
Session.Contents.RemoveAll();
break;
case "ABANDON":
//kill session
Session.Contents.RemoveAll(); //this should be redundant, but it doesn't hurt
Session.Abandon();
break;
}
}
}
//enumerating COM collections is a PITA, so I made a function to push the items to an object
function CollectionToObject(collection, mapper, target){
var map = target ? target : {}; //fresh object if no target given to mutate
for(var objEnum = new Enumerator(collection); !objEnum.atEnd(); objEnum.moveNext()){
var key = objEnum.item();
var val = collection(key);
//try to handle VBScript arrays
if (typeof(val) === 'unknown')
try {
val = (new VBArray(val)).toArray();
} catch(e) {
val = "{unknown type}";
}
//mapper callback gives caller a way to access a property to get the value if needed (looking at you Request.ServerVariables("key").Item)
var mapped = typeof mapper === "function" ? mapper(val) : val;
if(mapped !== undefined) map[key] = mapped;
}
return map;
}
function SetSessionFromBodyJSON(){
var data = BodyJSONAsObject();
if(!data) return;
for(key in data)
Session.Contents(key) = data[key];
}
function BodyJSONAsObject(){
if(Request.TotalBytes > 0){
var lngBytesCount = Request.TotalBytes;
if(lngBytesCount > 100000) return null; //simple sanity check for length; come back to this if having truncation issues
var data = BytesToStr(Request.BinaryRead(lngBytesCount));
return JSON.parse(data);
}
}
//used for getting string from request body; found here: https://stackoverflow.com/a/9777124
function BytesToStr(bytes){
var stream = Server.CreateObject("Adodb.Stream")
stream.type = 1 //adTypeBinary
stream.open
stream.write(bytes)
stream.position = 0
stream.type = 2 //adTypeText
stream.charset = "utf-8"
var sOut = stream.readtext()
stream.close
return sOut
}
</script>
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
namespace yourProject.ClassicASP
{
public enum SyncMode
{
ReadOnly,
Merge,
Replace,
Clear,
Abandon
}
public class SessionInfo
{
public SyncMode SaveMode { get; set; } = SyncMode.ReadOnly;
public long? SessionId { get; set; }
public Dictionary<string, object> Session { get; } = new Dictionary<string, object>(StringComparer.InvariantCultureIgnoreCase);
[JsonExtensionData]
public Dictionary<string, object> Extra { get; } = new Dictionary<string, object>(StringComparer.InvariantCultureIgnoreCase);
//elevate the Session methods & accessors for convenience
//NOTE: ipmlementing IDictionary<> or inheriting Dictionary<> breaks our serialization behavior, so if you need Linq methods use the child Session property
public object this[string key]
{
get => Session.ContainsKey(key) ? ((IDictionary<string, object>)Session)[key] : null;
set => ((IDictionary<string, object>)Session)[key] = value;
}
[JsonIgnore]
public ICollection<string> Keys => Session.Keys;
[JsonIgnore]
public ICollection<object> Values => Session.Values;
public int Count => Session.Count;
public void Add(string key, object value) => Session.Add(key, value);
public void Clear() => Session.Clear();
public bool ContainsKey(string key) => Session.ContainsKey(key);
public Dictionary<string, object>.Enumerator GetEnumerator() => Session.GetEnumerator();
public bool Remove(string key) => Session.Remove(key);
public bool TryGetValue(string key, out object value) => Session.TryGetValue(key, out value);
//some helpers so I don't have to remember the key names
public int? MemberId => TryGetValue("member_id", out var id) ? unchecked((int?)(long?)id) : null;
public string MemberNumber => TryGetValue("member_number", out var memno) ? (string)memno : "";
public bool IsLoggedIn => this.ContainsKey("member_number") && !string.IsNullOrEmpty((string)this["member_number"]);
public string MemberName => TryGetValue("member_name", out var name) ? (string)name : "";
public string MemberEmail => TryGetValue("member_email", out var email) ? (string)email : "";
public string MemberTitle => TryGetValue("member_title", out var title) ? (string)title : "";
public string[] Roles => this.TryGetValue("assigned_roles", out var roles) ? ((JArray)roles).ToObject<string[]>().Select(s=>s.ToLowerInvariant()).Append("any").ToArray() : Array.Empty<string>();
public bool HasRole(string role) => this.Roles.Contains(role.ToLowerInvariant());
public bool HasRoles(params string[] requiredRoles) => this.Roles.Intersect(requiredRoles.Select(r => r.ToLowerInvariant())).Any();
}
}
/*
Note, SessionInfo type is whatever you need for the JSON structure expected from session.asp
Note, extension method context.Request.LocalWebClient() is found in RequestExtensions.cs file in this gist.
I only needed it to solve a shared hosting IP address problem I had in my production environment. If you
don't need it just use a normal WebRequest instance.
*/
using Newtonsoft.Json;
using yourProject.Extensions;
using System;
using System.Linq;
using System.Net;
using System.Text;
using System.Web;
using System.Web.Mvc;
namespace yourProject.ClassicASP {
public class SessionSyncFilter : ActionFilterAttribute {
public override void OnActionExecuting( ActionExecutingContext filterContext ) {
base.OnActionExecuting( filterContext );
//fetch classic ASP session state from the ASP engine running on same website
string json = null;
SyncClassicASP( filterContext.HttpContext, w => json = w.DownloadString( "" ) );
var data = JsonConvert.DeserializeObject<SessionInfo>(json);
filterContext.Controller.ViewBag.ClassicASP = data;
}
public override void OnResultExecuted( ResultExecutedContext filterContext ) {
base.OnResultExecuted( filterContext );
//push changed state back to classic ASP
var classicASP = filterContext.Controller.ViewBag.ClassicASP as SessionInfo;
if (classicASP == null || classicASP.SaveMode == SyncMode.ReadOnly) return; //nothing to push
var json = JsonConvert.SerializeObject(classicASP.Session);
var method = classicASP.SaveMode.ToString().ToUpper();
SyncClassicASP( filterContext.HttpContext, w => w.UploadString( "", method, json ) );
}
private void SyncClassicASP( HttpContextBase context, Action<WebClient> action ) {
//figure out the classic ASP session cookies (there can be multiple, so send them all)
var aspcookies = context.Request.Cookies.AllKeys.Where(c => c.StartsWith("ASPSESSION"));
var cookie = aspcookies.Count() > 0 ? string.Join("; ", aspcookies.Select(c => $"{c}={context.Request.Cookies[c].Value}").ToArray()) : null;
//build URL for the request (same server)
var uri = context.Request.Url;
var host = uri.Host == "companion7" ? "nat-dev.driftershideout.com" : uri.Host; // special handling for local-dev reverse-proxy because rewritten host hangs the whole application pool!
var url = $"{uri.Scheme}://{host}:{uri.Port}/internal/session.asp";
//hack to not fail on localhost SSL cert: https://stackoverflow.com/a/14580179
//if (uri.Host.ToLower() == "localhost") ServicePointManager.ServerCertificateValidationCallback += (o, c, ch, er) => true;
//BETTER: copy the localhost certificate to your trusted root store: https://stackoverflow.com/a/32788265
//BEST: as above, but export localhost cert from personal certificates, without private key, then import that into trusted root certificates
//prepare WebClient with common settings
var localIP = IPAddress.Parse(context.Request.ServerVariables["LOCAL_ADDR"]);
using (var webClient = context.Request.LocalWebClient()) {
//prepare common settings
webClient.Headers[HttpRequestHeader.ContentType] = "application/json";
if (cookie != null) webClient.Headers.Add( HttpRequestHeader.Cookie, cookie );
webClient.Encoding = Encoding.UTF8;
webClient.BaseAddress = url;
//trying to get auth working...so far no luck
//var credCache = new CredentialCache();
//credCache.Add(new Uri(url), "ntlm", new NetworkCredential("internal", "xKgg19H"));
//webClient.Credentials = credCache;
//caller can do what they want with it
action( webClient );
//forward any new cookies to our response to "keep" the new session if one was created during the request
webClient.ResponseHeaders?.GetValues( "Set-Cookie" )?.ToList().ForEach( c => context.Response.AddHeader( "Set-Cookie", c ) );
//example Set-Cookie for a classic ASP session
//ASPSESSIONIDQEARCSAS=EBBBPDCBHBPENBBOLMCEHOOB; secure; path=/
//cookies can also have expires=xxx; max-age=xxx; and domain=xxx;
//NOTE: we get a separate Set-Cookie header for each cookie being set
}
}
}
}
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<handlers>
<!--
Adding custom verbs requires removing and re-adding the IsapiModule.
This web.config is directly in the same folder as session.asp so we can target the custom HTTP verbs to just this folder.
-->
<remove name="ASPClassic" />
<add name="ASPClassic" path="*.asp" verb="GET,MERGE,REPLACE,CLEAR,ABANDON" modules="IsapiModule" scriptProcessor="%windir%\system32\inetsrv\asp.dll" resourceType="File" requireAccess="Script" />
</handlers>
</system.webServer>
</configuration>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment