.NET 6 多站点iframe嵌入场景下标签页级会话隔离实现方案
可行方案(基于.NET 6原生能力,无第三方依赖)
核心利用浏览器原生的sessionStorage实现标签页级上下文隔离,这个存储天然和单个标签页+同源绑定,同URL开多个标签页时存储完全独立,关闭标签页自动清空,刚好匹配标签页维度会话隔离的需求,不需要依赖已被移除的无Cookie会话方案。
整体实现逻辑
放弃把认证Claims存在全局共享Cookie的做法,改用「标签页唯一上下文ID+服务端缓存+前端自动透传」的模式:
- 首次加载iframe时,后端接收父页面POST过来的
AppCode,完成SSO校验后生成当前App专属的Claims,同时生成全局唯一的TabContextId(用Guid即可),将Claims、AppCode、用户ID存在服务端分布式缓存(内存缓存/Redis都可以,配置和原会话一致的滑动过期时间),缓存Key就是TabContextId - 首次返回的入口页HTML内联一段JS,把
TabContextId直接写入当前iframe的sessionStorage,这个ID只在当前标签页/iframe上下文内可见,其他标签页无法访问 - 前端全局拦截所有同域请求(Ajax/Fetch/表单提交),自动从
sessionStorage取出TabContextId放到自定义请求头X-Tab-Context-Id中,业务代码不需要手动传参 - 后端认证中间件不再从全局Cookie读Claims,改为从请求头取
TabContextId,查缓存拿到对应上下文后构造ClaimsPrincipal赋值给HttpContext.User,后续业务逻辑完全不需要改动
具体代码改造点
1. 后端改造
(1)调整Claim存储逻辑
把原来SetUtenteCorrente方法里写Cookie认证的逻辑,改成写缓存:
// 提前注入IDistributedCache,用内存缓存或者Redis都可以 private readonly IDistributedCache _cache; public async Task<string> SetUtenteCorrente(HttpContext context, string appCode) { var utente = GetUserIdFromRequestHeader(context); var utenteSPA = _UserDataServiceProvider.GetUtente(utente); // 构造当前App专属的Claims,不要和其他App的Claim混存 var claims = new List<Claim> { new Claim(ClaimTypes.Name, utenteSPA.Nominativo), new Claim(TipoClaim.matricola.ToString(), utenteSPA.Matricola), new Claim(TipoClaim.ufficio.ToString(), utenteSPA.Ufficio), new Claim(ClaimTypes.Role, utenteSPA.Ruolo), new Claim(TipoClaim.linguaPredefinita.ToString(), utenteSPA.LinguaDefault.ToString()), new Claim(TipoClaim.linguaCorrente.ToString(), utenteSPA.LinguaCorrente.ToString()), new Claim(TipoClaim.profiloPredefinito.ToString(), utenteSPA.ProfiloDefault), new Claim(TipoClaim.profiloCorrente.ToString(), utenteSPA.ProfiloCorrente), new Claim(TipoClaim.applicazioneAttiva.ToString(), appCode), }; // 生成标签页唯一上下文ID var tabContextId = Guid.NewGuid().ToString("N"); // 序列化上下文存入缓存,配置30分钟滑动过期 var cacheData = JsonSerializer.Serialize(new { UserId = utente, AppCode = appCode, Claims = claims }); var cacheOptions = new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30) }; await _cache.SetStringAsync($"TabContext_{tabContextId}", cacheData, cacheOptions); return tabContextId; }
(2)入口页Action处理首次POST请求
[HttpPost] public async Task<IActionResult> Index(string AppCode) { // 校验AppCode合法性 if (string.IsNullOrEmpty(AppCode)) return Redirect("/error/400"); // 生成当前标签页上下文ID var tabContextId = await userManager.SetUtenteCorrente(HttpContext, AppCode); // 把上下文ID传给视图,渲染内联脚本 ViewBag.TabContextId = tabContextId; return View(); }
(3)改造认证中间件
public async Task Invoke(HttpContext context) { var requestPath = context.Request.Path.Value; // 跳过错误页和入口页POST请求 if (requestPath.Contains("rror") || (requestPath == "/" && context.Request.Method == "POST")) { await _next(context); return; } try { // 从请求头取上下文ID if (!context.Request.Headers.TryGetValue("X-Tab-Context-Id", out var tabId) || string.IsNullOrEmpty(tabId)) { context.Response.StatusCode = 401; await context.Response.WriteAsync("Invalid context"); return; } // 查缓存拿上下文 var cacheData = await _cache.GetStringAsync($"TabContext_{tabId}"); if (string.IsNullOrEmpty(cacheData)) { context.Response.StatusCode = 401; await context.Response.WriteAsync("Context expired"); return; } var tabContext = JsonSerializer.Deserialize<TabContext>(cacheData); // 构造当前请求的ClaimsPrincipal var identity = new ClaimsIdentity(tabContext.Claims, CookieAuthenticationDefaults.AuthenticationScheme); context.User = new ClaimsPrincipal(identity); // 滑动续期 await _cache.RefreshAsync($"TabContext_{tabId}"); } catch (Exception ex) { _logger.Information("Authentication middleware error: {msg}", ex.InnerException?.Message ?? ex.Message); context.Response.StatusCode = 502; context.Response.Redirect($"{percorsoErrore}502"); return; } await _next(context); } // 上下文实体类 public class TabContext { public string UserId { get; set; } public string AppCode { get; set; } public List<Claim> Claims { get; set; } }
2. 前端改造
(1)入口页视图内联脚本写入sessionStorage
在入口页的head标签最前面加这段脚本,确保后续脚本执行前上下文已经写入:
<script> // 直接把后端生成的上下文ID写入当前标签页sessionStorage window.sessionStorage.setItem('tabContextId', '@ViewBag.TabContextId'); </script>
(2)全局拦截所有请求自动透传上下文ID
根据你用的请求库加全局拦截,不需要改单个业务接口:
// 拦截jQuery Ajax请求 if (window.jQuery) { $.ajaxSetup({ beforeSend: function(xhr) { const tabId = window.sessionStorage.getItem('tabContextId'); if (tabId) xhr.setRequestHeader('X-Tab-Context-Id', tabId); } }); } // 拦截fetch请求 const originalFetch = window.fetch; window.fetch = function(url, options = {}) { const tabId = window.sessionStorage.getItem('tabContextId'); options.headers = options.headers || {}; if (tabId) options.headers['X-Tab-Context-Id'] = tabId; // 拦截401响应,会话过期时跳错误页 return originalFetch.call(this, url, options).then(res => { if (res.status === 401) window.location.href = '/error/401'; return res; }); } // 拦截原生表单提交 document.addEventListener('submit', function(e) { const form = e.target; const tabId = window.sessionStorage.getItem('tabContextId'); if (tabId) { const input = document.createElement('input'); input.type = 'hidden'; input.name = 'X-Tab-Context-Id'; input.value = tabId; form.appendChild(input); } });
边界情况处理
- 页面刷新:同标签页刷新时
sessionStorage数据不会丢失,加载完成后拦截器会自动带上上下文ID,不需要重新走POST AppCode的流程 - 标签页关闭:
sessionStorage自动清空,服务端缓存到期后自动清理,无残留 - 跨iframe安全:如果你的iframe和父页面不同源,父页面无法访问iframe内的
sessionStorage,不会有上下文泄露风险 - 同标签页跳转:同域跳转时
sessionStorage数据保留,上下文自动续期,跨域跳转后sessionStorage自动隔离,不会串数据
内容的提问来源于stack exchange,提问作者gt.guybrush
相关产品推荐
相关产品推荐

