ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#单点登录示例拆解:认证中心+票据实现一次登录多系统

C#单点登录示例拆解:认证中心+票据实现一次登录多系统

简介:这是一份基于C#实现单点登录(SSO)的示例程序,面向.NET开发人员,主要解决多个子站点之间统一身份认证与登录状态共享的问题。压缩包共173个文件,包含64个.cs核心逻辑源码、16个.aspx页面、.config配置文件、wsdl/disco服务描述、DLL引用库以及示例数据库(mdf/ldf)等,整体大小约2.07MB,目录分区清晰,用户登录、令牌服务、受保护页面等均有对应模块,便于按需查阅。资源围绕中央认证服务(CAS)展开,完整演示了利用ASP.NET Identity实现用户管理、JWT令牌生成与验证、子站点令牌信任、HTTP重定向与Cookie会话保持等SSO关键环节,并考虑了通信加密、令牌有效期及防重放等安全细节。目前已有494人学习下载。通过研读源码、配置及数据库脚本,可快速理解从CAS服务端到受保护子站点的完整交互流程,同时掌握项目分层、接口抽象和依赖注入等设计手法。项目中还展示了如何通过配置管理子站点地址,以及为令牌验证编写测试用例的方法,适合用来改造现有系统或搭建轻量级统一身份认证平台。

1. C#单点登录示例程序拆解:一次登录,全部系统放行的核心思路

做C#单点登录示例程序拆解前,先给一个反直觉的结论:SSO(单点登录)真正共享的不是账号密码,而是“登录状态”这个东西。很多刚接触的开发者以为让多个系统共用一张用户表就算单点登录,结果登录态仍然各自独立——在A系统登录后进B系统又得输一次密码。我拆过一套内网C#多系统场景,主站、报表、后台三个Web应用,用户每天要在每个系统各登录一次,体验极差,管理员也抱怨账号改密后各系统不同步。这个C# SSO示例解决的就是这件事:由认证中心统一校验身份,登录一次,生成一张全局票据,其他系统凭票据换自己的本地会话。适合刚接手企业门户、内部管理系统,或者想把认证跳转完整链路搞清楚再动手写代码的C#开发者。

2. 先拆原理:认证中心与客户端的分工 + 三种形态对比

2.1 认证中心是核心:登录状态统一收敛

先讲清楚SSO为什么能成立。业务系统各自登录、各自建会话,那叫“多次登录”,不叫单点登录。单点登录的核心思维是:把“校验用户名密码”这个动作收敛到一个独立站点,其余系统只消费认证结果,不重复验证账号密码。

这里最关键的一个架构决策是:认证中心不直接给客户端系统发“用户密码”,而是发一张临时凭证,客户端系统拿凭证换自己的本地会话。身份验证只发生一次,后续系统之间传递的是凭证和会话引用,不是敏感明文。

一个完整的跳转流程是这样:

  1. 浏览器访问客户端应用A的受保护资源,客户端发现本地没有登录态,响应302跳转到认证中心登录页,带上client_id、redirect_uri、state。
  2. 认证中心检查自己的会话,发现未登录,展示登录页;用户输入账号密码,认证中心验证通过,创建全局会话。
  3. 认证中心拼接一个回跳地址:redirect_uri?ticket=xxx&state=yyy,浏览器302跳回客户端A。
  4. 客户端A收到ticket,后端调用认证中心的校验接口,验证ticket有效后,建立本地登录会话,返回受保护资源。
  5. 用户再访问客户端B,同样没有本地会话,但认证中心已经有了全局会话,所以不再让用户登录,而是直接生成新ticket回跳,客户端B校验后建立自己的会话。

这就是网上经常说的“认证中心是唯一信任锚点”。理解了这个链路,你在看示例代码的时候就不会迷路:客户端系统里几乎不会出现“验证密码”的代码,验证密码只出现在认证中心项目里。

2.2 三种实现形态对比:同域Cookie、跨域跳转、Token/OIDC

同样是SSO,落地的技术形态差别很大。常见的有三种:

形态实现方式适用场景复杂度典型安全边界
同域Cookie共享认证中心和所有子系统共用父域,登录Cookie写在统一域名下,各系统直接读同域名下的几个Web应用低任何子系统被注入脚本,Cookie都会泄露,信任边界太宽
跨域跳转+票据认证中心独立域名,子系统各自校验票据并换取会话不同域名/端口的内部系统,传统Web应用中票据需要防篡改、防重放、回跳地址需要白名单
Token/OIDC认证中心签发JWT或OAuth2授权码,客户端验签或按协议换token前后端分离、移动端、开放平台高密钥管理、token有效期、scope最小化是重点

我拆的这个示例程序采用的是第二行“跨域跳转+票据”。原因很直接:它最贴近企业内部多系统场景,既不像同域Cookie那样过度信任,也不引入JWT的密钥分发负担;代码量适中,正好能把SSO原理讲透。如果你要接的是现代前端项目,后面提到的跳转思路仍然成立,只是把“ticket”换成“授权码”,把“Cookie”换成“Access Token”。

2.3 C#侧选型:为什么要把“认证中心”做成独立ASP.NET Core项目

示例程序里一般会有三个项目:认证中心(AuthCenter)、客户端A(ClientA)、客户端B(ClientB)。客户端项目不引用认证中心的类库,也不共享数据库,只通过HTTP重定向和校验接口交互。这样做的目的,是防止开发者图省事直接把认证逻辑拷贝进每个业务系统——一旦那样做了,后续改密码策略、加二次验证,你就得每个系统都改一遍,SSO的意义就没了。

在C#生态里,常见做法是认证中心基于ASP.NET Core MVC + Cookie认证写一个精简版,客户端基于ASP.NET Core的认证中间件做拦截。更重的方案可以上IdentityServer4、OpenIddict这一类框架,但对于学习SSO链路,先把精简实现看明白,再套框架,否则会像看黑匣子:登录请求发出去,中间件帮你跳转、帮你验票,但你不知道中间件替你做了什么,出了问题只能抓瞎。

2.4 会话和票据的区别:两个易混词

很多人在示例代码里把“Session”和“Ticket”混着说,排障时就出问题。我的理解是这样:会话(Session)是服务端保存在内存、Redis或数据库里的状态,它不经过浏览器URL传递,只通过Cookie里的一个SessionId索引;票据(Ticket)是认证中心生成的一段临时凭证,它要经过浏览器URL传递,所以必须短命、一次性、并且只能被认证中心校验。

会话是“长期身份”,票据是“临时通行证”。认证中心登录成功建立会话,客户端登录成功建立自己的会话,票据只在“认证中心 -> 客户端”这一段路上出现。你把这两者分清后,看代码里的“存储”和“过期时间”设置就不会一头雾水。

3. 把示例跑起来:解决方案结构、数据库表与关键配置

3.1 解决方案结构与项目职责

拿到示例程序后,第一件事是先看解决方案目录。一个典型的C# SSO示例,结构大概是这样的:

项目/目录职责关键文件
AuthCenter认证中心,负责登录页面、票据发放、票据校验接口Controllers/AccountController.cs、Views/Login.cshtml
ClientA客户端A,演示受保护页面Controllers/HomeController.cs、Startup.cs
ClientB客户端B,验证一次登录多点可用Controllers/HomeController.cs、Startup.cs
Shared公共票据模型和常量定义TicketConstants.cs

客户端项目之间、客户端与认证中心之间没有直接的项目引用。各自启动在独立端口上,通过HTTP通信,这模拟了真实生产环境里的跨站点跳转。如果你打开解决方案发现只有一个Web项目,那多半是示例被简化成了“认证中心内置客户端”,看原理可以,但复现完整跳转就差点意思。

3.2 数据库表结构与模型定义

认证中心的核心数据是用户账号和票据。常见的示例会给出两张表:SsoUser和SsoTicket。SsoUser存用户名、密码哈希、显示名;SsoTicket存票据ID、用户ID、过期时间、来源回跳地址、是否使用过。示例为了演示方便,有时也直接用内存字典,但生产环境别这么干,内存数据一经重启就全部消失。

下面这组是典型的Entity Framework Core实体定义:

public class SsoUser { public int Id { get; set; } public string UserName { get; set; } public string PasswordHash { get; set; } public string DisplayName { get; set; } } public class SsoTicket { public string Id { get; set; } // 票据ID,直接用GUID字符串 public int UserId { get; set; } // 关联SsoUser public DateTime ExpiresAt { get; set; } // 过期时间,建议UTC public bool Used { get; set; } // 是否已被消费,防重放 public string IssueIp { get; set; } // 记录签发IP,便于排障 }

字段含义拆开说:PasswordHash不能存明文,示例里多半用PBKDF2或类似算法做哈希;Used这个字段是关键,很多初学者会忘记在“消费票据”后把它置为true,结果一张票据能反复登录所有客户端,这是最典型的安全漏洞。IssueIp不是必须的,但用它做审计日志时很有用。

3.3 关键配置:appsettings.json参数详解

示例程序给你跑通,主要就是改两个配置文件。认证中心的appsettings.json长这样:

{ "ConnectionStrings": { "AuthDB": "Server=localhost;Database=SsoAuthCenter;User Id=sa;Password=your_password;TrustServerCertificate=True" }, "SsoSettings": { "CookieName": "AuthCenter.Session", "CookieDomain": ".example.local", "TicketExpireMinutes": 5, "AllowedClientUrls": [ "http://clienta.example.local:5001/login-callback", "http://clientb.example.local:5002/login-callback" ] }, "Logging": { "LogLevel": { "Default": "Information" } } }

参数说明如下:

  • CookieName:认证中心全局会话的Cookie名。名字要起得和业务系统自己的登录Cookie区分开,避免相互覆盖。
  • CookieDomain:如果采用“跨域跳转+票据”方案,这个Domain通常不需要配成父域,按认证中心自己的域名写即可。假如只用localhost多端口调试,这里留空。
  • TicketExpireMinutes:临时票据有效期。设5分钟是给网络慢、调试留余量;生产环境我一般用2分钟,越短越安全。
  • AllowedClientUrls:回跳地址白名单。这是整个配置里最重要的项,只允许信任的客户端回跳地址出现在这个列表里,防止开放重定向被利用。

客户端项目的appsettings.json则要配AuthCenterUrl(认证中心地址)和LocalCookieName(本地登录态Cookie名)。这两个值一个决定了往哪跳,一个决定了自己怎么看“是否已登录”。注意不要图方便把认证中心地址直接写在Controller里,否则后面换环境部署要改一堆文件。

3.4 启动与初始化步骤:从建库到三个站点并行

我第一次跑这套示例时踩了“只启动了认证中心”的坑,在ClientA上访问,页面一直跳转,因为端口根本没起来。正确做法是先建库,再按顺序把所有站点都拉起来。

# 假设示例已经还原好依赖,先建数据库 dotnet ef database update -p AuthCenter -s AuthCenter # 分别启动认证中心、客户端A、客户端B,每个站点独立端口 dotnet run --project AuthCenter --urls http://localhost:5000 dotnet run --project ClientA --urls http://localhost:5001 dotnet run --project ClientB --urls http://localhost:5002

命令含义拆开说:dotnet ef database update是把迁移脚本应用到目标数据库,生产环境需要把连接字符串改成真实数据库地址;--urls指定站点监听端口,不同客户端必须用不同端口,否则Session和Cookie会互相覆盖,表现出的现象就是“登录态错乱”。三个命令行窗口分别跑,不要试图放到同一个控制台后台运行,因为ASP.NET Core站点默认是前台阻塞的,Ctrl+C可以快速停掉某个站点做测试。

启动之后,用未登录状态访问ClientA的受保护页面,浏览器地址栏会发生两次跳转:

  1. http://localhost:5001->http://localhost:5000/Account/Login?redirect_uri=...
  2. 输入账号后 ->http://localhost:5001/login-callback?ticket=...&state=...

如果这两次跳转中间卡住,多半是配置里的端口、域名或回调地址没对齐,后面避坑章会专门列排查方法。

4. 核心代码链路:登录回调与票据校验

4.1 认证中心的登录Action:只做验证,不做业务

认证中心的登录接口,承担的是“校验密码 + 创建全局会话 + 发放临时票据”。一个精简实现如下:

[HttpPost] public async Task<IActionResult> Login(LoginViewModel model) { // 1. 校验用户名密码,这是认证中心的核心职责 var user = await _userRepo.FindByNameAsync(model.UserName); if (user == null || !_userRepo.VerifyPassword(user, model.Password)) { return View(model); // 返回登录页并显示错误 } // 2. 创建认证中心全局会话 var sessionId = Guid.NewGuid().ToString("N"); await _sessionStore.SaveAsync(sessionId, user.Id, TimeSpan.FromHours(8)); Response.Cookies.Append("AuthCenter.Session", sessionId, new CookieOptions { HttpOnly = true, Expires = DateTimeOffset.Now.AddHours(8) }); // 3. 生成一次性票据,票据不是“用户信息”,只是换取会话的凭证 var ticket = new SsoTicket { Id = Guid.NewGuid().ToString("N"), UserId = user.Id, ExpiresAt = DateTime.UtcNow.AddMinutes(5), Used = false }; await _ticketRepo.CreateAsync(ticket); // 4. 回跳客户端,带上票据和state var state = _antiForgeryService.GenerateState(); return Redirect($"{model.RedirectUri}?ticket={ticket.Id}&state={state}"); }

逻辑说明:第1步是唯一校验密码的地方,客户端项目里不会出现VerifyPassword。第2步是给认证中心自己的会话“打个标记”,这样用户再访问ClientB时,认证中心发现已有AuthCenter.Session,就不需要重新输密码。第3步生成的票据不包含密码或token,只是一串随机Id,真正的用户信息由客户端再调用接口换取,避免票据在URL里被截获后直接泄露用户身份。第4步的state必须和发起时生成的state一致,这是防CSRF和防跳转伪造的关键。

参数你要注意:RedirectUri不能直接接收前端传过来的任意值,必须在进入登录页前就用服务端配置的AllowedClientUrls做白名单校验。示例放在Login的GET入口里。

4.2 客户端如何拦截未登录请求:拦截器只做跳转

客户端不能把“认证中心地址”硬编码进每个Controller。常规做法是在一个认证中间件或全局ActionFilter里统一判断。下面的代码是ClientA里的SsoRedirectMiddleware:

public async Task Invoke(HttpContext context) { var path = context.Request.Path; // 回调接口不拦截,否则会形成死循环 if (path.StartsWithSegments("/login-callback")) { await _next(context); return; } // 判断本地会话是否已存在 var localSession = context.Request.Cookies["ClientA.Session"]; if (string.IsNullOrEmpty(localSession)) { var returnUrl = Uri.EscapeDataString( $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}"); var loginUrl = _authCenterUrl + "/Account/Login?" + $"redirect_uri={returnUrl}&client_id=clienta&state=xyz"; context.Response.Redirect(loginUrl); return; } await _next(context); }

逻辑说明:中间件把“是否已登录”的判断收敛到一个位置,任何受保护请求进来,先看本地Cookie,没有再统一跳认证中心。/login-callback路径放行是必须的,否则认证中心回跳的请求又被中间件截住,直接形成“跳出去又跳回来”的循环。

state参数在这里先写死是“xyz”,生产环境应该是每次跳转前生成、存在服务端(或带签名),回跳时校验。很多示例为了演示简洁会忽略它,但正式项目必须补。

4.3 票据校验与会话回写:一次性使用,拒绝重放

客户端收到ticket之后,不能直接信任,要调认证中心校验接口。示例里通常有两种做法:同步调用认证中心的ValidateTicket接口,或客户端直接读共享票据表。生产中我更倾向接口方式,因为客户端不会碰到认证中心的数据库。

[HttpGet("/login-callback")] public async Task<IActionResult> Callback(string ticket, string state) { // 1. 校验state,防止跨站请求伪造 if (!_stateStore.Validate("clienta", state)) { return BadRequest("state mismatch"); } // 2. 调用认证中心接口换用户信息 var userInfo = await _authCenterClient.ValidateTicketAsync(ticket); if (userInfo == null) { return Redirect("/home/denied"); } // 3. 创建本地会话 var localSessionId = Guid.NewGuid().ToString("N"); Response.Cookies.Append("ClientA.Session", localSessionId, new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.Now.AddHours(8) }); await _localSessionStore.SaveAsync(localSessionId, userInfo.UserId); return Redirect(userInfo.ReturnUrl ?? "/home/index"); }

校验接口ValidateTicketAsync内部遵循的规则是:票据在数据库里存在、未超过ExpiresAt、且Used=false,查到后立即把Used置为true。这一步非常关键,它保证了一张票据只能换一次会话。如果不做一次性消费,攻击者截获了一次跳转URL,就能反复用同一张票据登录所有客户端。在这里,票据有效期短、一次性使用、state明确,是三重防线。

4.4 认证中心校验接口的返回结构

接着定义校验接口的返回格式,这个格式会被所有客户端复用。我的建议是返回一个最小化的JSON:

{ "success": true, "userId": 1001, "userName": "zhangsan", "displayName": "张三", "returnUrl": "/home/index" }

字段说明:success表示票据校验是否通过;userId和userName是客户端建立本地会话需要的最小信息;displayName用于界面展示;returnUrl是客户端登录前想去的原始地址,由认证中心在签发ticket时原样带回来。不要把密码哈希、手机号、邮箱等敏感字段塞进这个返回值,客户端只需要“这个用户是谁”,不需要知道更多。返回结构里没有角色权限信息,角色应该由客户端自己的数据库管理,不要在认证中心里做“全家桶”。

5. 避坑:常见SSO集成问题

这一章都是我在实际环境里踩过、也帮别人排查过的坑。每一条都按“现象 → 原因 → 解决”记录。

5.1 跳转死循环:登录成功后又被弹回登录页

现象:输入账号密码后,认证中心已经显示登录成功,但浏览器反复跳到客户端、再跳回认证中心,页面一直刷新不停。

原因:最常见的有两种。一是认证中心判断“已登录”的Cookie写入失败,比如AuthCenter.Session设置了Secure=true,而测试环境用的是HTTP,浏览器直接拒绝写入;二是客户端回调接口没有放行,回调请求又被重定向到认证中心。

解决:先在浏览器开发者工具里看两个关键响应头:认证中心的Set-Cookie是否出现,客户端/login-callback的响应是200还是302。如果是Set-Cookie没有生效,把CookieOptions.Secure改成false(生产环境用HTTPS时保留true);如果是回调被拦截,在中间件路径判断里把/login-callback明确放行。

5.2 state校验总是失败:用户的登录流程时好时坏

现象:部分用户登录失败,日志里出现“state mismatch”,但重试一次又好了。

原因:state的生成和校验不在同一个存储容器里。常见做法是把state存Session,但客户端站点重启后Session清空,或者访问ClientA生成的state,在回调时又被识别为另一个会话。原因也可能来自浏览器同时开多个标签页,两次跳转互相覆盖state。

解决:不要把state存在内存Session里。改成带签名的state,即把{clientId}:{随机数}:{时间戳}用密钥做签名;也可以把state放进分布式缓存。回调时只验签名,不依赖服务端Session状态。这样既防止CSRF,又不怕服务器重启。

5.3 票据过期导致偶发登录失败

现象:用户在登录页停留太久,输入完密码提示票据过期或无效。

原因:票据有效期设得太长,安全风险高;设得太短,用户输密码慢了就过期。我在示例里默认把TicketExpireMinutes设为5分钟,但某些慢网络环境里,登录页静态资源加载都在3秒以上,往返几跳之后就接近边缘时间。此外,认证中心和客户端服务器时间不同步,也会放大这个问题。

解决:把票据有效期设为2分钟,校验端留30秒时钟偏移容忍;生产环境一定要做时间同步。如果业务上必须允许用户长时间停留登录页,可以把“创建票据”的时机后移,等到用户点击登录、服务端确认密码正确后再生成新票据回跳,而不是打开登录页时就生成。

5.4 Cookie作用域与回调域名不一致:登录成功但本地会话没建立

现象:认证中心登录页正常,回跳URL带了ticket,客户端也校验通过,但跳回原页面后依旧显示未登录。

原因:本地会话Cookie的作用域不对。比如客户端A部署在clienta.example.local,但代码里CookieOptions.Domain误写成了.example.local,或者反过来只写了localhost,而浏览器实际访问的是127.0.0.1,Cookie不会下发。

解决:把客户端本地会话Cookie的Domain留空,让Cookie跟随当前请求的域名;调试多域名时用hosts文件做映射,保持访问域名和Cookie域名一致。打开浏览器开发者工具的“Application -> Cookies”看本地Session是否存在,一目了然。

5.5 回跳地址白名单总是匹配不上

现象:客户端传过来的redirect_uri校验不通过,但明明已经把地址写进AllowedClientUrls了。

原因:URL规范化差异。常见有末尾斜杠不一样、http和https混用、参数顺序被重排、Uri.EscapeDataString导致大小写编码变化。有时候是负载均衡器在中间改写Host头,服务端拿到的Request.Host和用户实际访问的域名不一致。

解决:白名单校验前把URI规范化,去掉末尾斜杠、统一Scheme、只取出{scheme}://{host}{path}部分做比较,不参与query比较。调试时把实际传入的redirect_uri原样存日志,与白名单对比,一眼能看出差异。

6. 进阶:把示例改成分布式会话,并验证单点登出

6.1 从内存Session改成Redis共享

示例程序为了方便单机演示,把会话和票据存储都放在内存里。一旦要部署多实例认证中心,问题就来了:第一次请求落在实例A,登录会话写在实例A内存里;下一次请求被负载均衡分到实例B,实例B查不到会话,又把用户踢回登录页。破解办法是把会话存储外移到Redis。

// 认证中心项目里增加分布式缓存支持 builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "server:6379,password=your_redis_pwd,abortConnect=false"; options.InstanceName = "SsoAuthCenter:"; }); // 原先的内存会话存储换成IDistributedCache public class RedisSessionStore : ISessionStore { private readonly IDistributedCache _cache; public async Task SaveAsync(string sessionId, string userId, TimeSpan expire) { await _cache.SetStringAsync(sessionId, userId, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expire }); } public async Task<string> GetUserIdAsync(string sessionId) { return await _cache.GetStringAsync(sessionId); } }

参数说明:abortConnect=false表示Redis暂时不可用时不抛异常强制断连,而是让命令排队重试,避免认证中心一启动就崩。InstanceName加前缀,是为了在同一个Redis实例里区分不同应用的数据,避免key冲突。

换掉内存存储后,还有一个隐藏问题:票据表的Used标记也必须走Redis或数据库。我的建议是票据走数据库,理由是一张票据只使用一次,天然适合数据库事务;会话走Redis,因为要高频读写。两者别混在一个存储里,否则排障时你分不清到底是会话丢了还是票据重复消费。

6.2 单点登出的实现思路与补偿

登录难做,登出更难做。所谓单点登出,是用户在认证中心点了退出,所有客户端的本地会话一起作废。常见做法有三种:

做法思路优点缺点
反向通知认证中心登录退出后,拿着客户端注册的登出地址逐个通知会话立即失效,可控性强客户端必须保证在线可通知,通知失败要补偿
前端集中跳转登出页依次iframe或fetch访问各客户端登出接口实现简单浏览器第三方Cookie限制会影响跨域登出
只清认证中心客户端会话等自然过期最简单不是真正意义的单点登出,用户换系统仍能进

我通常生产环境用第一种,同时把“通知失败的客户端”放进重试队列。示例程序里面,你可以在认证中心的Logout里遍历已经登录过的客户端列表,逐个调用/logout-callback。注意通知接口要做到“幂等”,重复调用也不能报错,因为重试队列可能对一个客户端发多次请求。

6.3 验证清单:拿到示例后照这个表过一遍

单点登录功能不是“能登录”就算完,建议按下面这个清单验收:

场景操作预期结果
首次登录访问ClientA受保护页跳转认证中心,输入密码后回到ClientA
二次免登已登录状态直接访问ClientB不出现登录页,直接进入ClientB
票据重放从浏览器历史里复制ticket再次请求提示票据无效或已被使用
会话过期认证中心会话过期后访问客户端重新跳转登录页
单点登出在认证中心点退出后访问所有客户端各客户端都回到未登录状态

每一个场景我都实际跑过。最容易被验收忽视的是“票据重放”这一行,很多示例代码如果漏了Used=true这一笔更新,重放请求会稳稳通过,安全问题就这么出现了。

说一个我自己的习惯:从那以后,我每次接SSO需求,第一件事不是写代码,而是先画一遍完整的跳转路径图,标出哪一步写Cookie、哪一步校验票据、哪一步可能循环;确认redirect_uri白名单和Cookie作用域,再动工。这套路帮我少踩了至少一半认证相关的坑,也希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进