做后端接口的,总会走到身份验证这一步。以前我在Web API项目里一直用Session,后来前端从网页变成了小程序、App、SPA,Cookie和Session的问题越来越多,才下定决心切到基于Token的认证方案。这里更像是我在ASP.NET Web API 2项目里用Owin搭Token认证的实操笔记。从为什么不要继续用Session,到怎么写Startup、怎么签发Token、怎么保护API,再到我实际撞上的几个坑,全部摊开讲。适合刚接触Token认证,或者准备改造老接口认证方式的开发者。
1. 为什么要在Web API 2里用Owin做Token认证
1.1 前后端分离场景下,Session和Cookie真的不够用了
在Web API刚出来的那几年,不少项目只是在MVC里搭了几个接口给内部页面用,身份验证顺手就用了FormsAuth或者Session。但到了小程序和移动App时代,事情起了变化。Cookie不是每个客户端都能方便地存下来,跨域请求时Cookie策略也经常把人搞到头疼。Session虽然用起来简单,但分布式部署时要么做Session共享,要么让请求一直打到同一台服务器,这和我们想要的弹性扩缩容是矛盾的。Token认证把这些麻烦统一成一个字符串,客户端拿到凭证之后放到请求头里,服务端不再保留会话状态,只要校验Token本身就可以信任这个请求。
这句话对新手特别重要:之所以要换Token,不是因为它多时髦,而是因为无状态。接口本身不记得你,你每次来都带着令牌,令牌有效我就认你。这样一来,同一个Token可以在多个服务之间传递使用,一个服务签发,其他服务只要拿到共享密钥或一致的身份配置,也能完成校验。配合Owin中间件,这套逻辑可以被封装得非常干净,而不是在每一个Controller里写一遍重复验证。
1.2 Owin把认证逻辑变成了一条中间件管道
很多人一看到Owin就以为又是一套新框架,其实它只是一个接口约定。它把Web应用和服务器解耦,让请求像流水线一样经过一个个中间件,每个中间件可以处理请求、也可以把控制权交给下一个环节。认证逻辑放在这个管道里,比起在每个Controller里写判断自然要干净得多。ASP.NET Web API 2可以完全跑在Owin之上,我们不再依赖System.Web的请求管线,而是通过IAppBuilder注册各类中间件。
在Token认证这个需求里,我们一般会用到两个核心中间件:一个负责“发令牌”,也就是授权服务器;另一个负责“查令牌”,也就是Bearer认证中间件。前者处理用户在/token端点的登录请求,验证账号密码并颁发Token;后者拦截所有进入Web API的请求,检查Authorization头里的Token是否有效。两者配合之后,你的API方法基本上只需要一个[Authorize]标记,剩下的校验都交给管道去处理。
1.3 一次完整的Token认证流程长什么样
为了方便后面看代码,先把完整交互流程画在脑子里。客户端第一次访问时,大致走这几个步骤:
- 客户端把用户名和密码通过表单形式POST到
/token端点。 - 授权服务器从Owin管道中接收请求,调用你实现的
GrantResourceOwnerCredentials方法。 - 方法内部验证用户身份,验证通过后创建一个包含用户信息的ClaimsIdentity。
- 授权服务器根据身份生成一个access_token,并且把过期时间等信息打包进去,返回给客户端。
- 客户端保存Token,在每次请求API时在请求头里加上
Authorization: Bearer <token>。 - Bearer认证中间件读取这个头,解密或验签,解析出用户声明,写入当前请求的
HttpContext.User。 - 带有
[Authorize]的API发现用户身份有效,正常执行;如果没带Token或者Token无效,则直接返回401。
这一步最关键的设计是:第4步之后,服务端不需要保存任何会话记录。你只需要保证密钥不泄露,就能随时验证Token的真伪,这也是Token认证能够水平扩展的根本原因。相比Session要引入分布式缓存来支撑多实例部署,Token方案在架构上省掉了一整块状态同步逻辑。
2. 搭建项目并安装Owin相关中间件
2.1 项目模板选择与基础创建
我建议从ASP.NET Web API 2空项目开始,而不是选择带MVC或者带身份验证的模板。原因很简单,默认模板会带一堆AccountController和反伪造令牌相关的逻辑,反而干扰你理解Owin流程。如果你是在老项目上改造,也可以直接略过新建项目这一步,把下面要安装的包和Startup文件加到现有项目里。
这里要特别注意一个环境前提:整套方案运行在经典.NET Framework托管基础上,项目类型最好选择.NET Framework 4.7.2以上。虽然Owin理论上是跨宿主标准,但Web API 2的主流场景还是Windows加IIS,本文描述的就是这个最常用的组合。项目的输出类型不要搞成控制台应用,否则就算Owin能跑起来,你也没有IIS托管环境去承载Web API。
2.2 安装认证相关的NuGet程序包
打开包管理器控制台,依次执行下面三行:
Install-Package Microsoft.Owin.Host.SystemWeb Install-Package Microsoft.Owin.Security.OAuth Install-Package Microsoft.Owin.Cors第一个包让Owin中间件能够寄宿在IIS的SystemWeb环境下,也就是说不用自建服务器,传统站点的部署方式照样可用。第二个包是重头戏,里面既有OAuthAuthorizationServerMiddleware,也有Bearer认证中间件,前者拿来签发Token,后者拿来校验Token。第三个包用来解决跨域访问问题,这在前后端分离项目中几乎一定会遇到。
另外,如果你的项目之前没有引用Web API与Owin的桥接包,还要加一个:
Install-Package Microsoft.AspNet.WebApi.Owin这个包负责把Web API挂载到Owin管道里,否则你的API和Owin中间件是两条平行线,认证完也进不了控制器。装完这些包之后,项目里可能会自动生成带Startup字样的引导文件,如果没有就自己新建一个。后面所有认证配置都必须放在Startup类里完成,不要跑到Global.asax里做认证配置,那样就绕过了Owin管道。
2.3 编写Startup类并配置OAuth服务
先看一份最基础的Startup.cs,后面再往里面加业务逻辑:
[assembly: OwinStartup(typeof(YourNamespace.Startup))] namespace YourNamespace { public class Startup { public void Configuration(IAppBuilder app) { var config = new HttpConfiguration(); ConfigureOAuth(app); app.UseCors(CorsOptions.AllowAll); app.UseWebApi(config); } private void ConfigureOAuth(IAppBuilder app) { var oAuthServerOptions = new OAuthAuthorizationServerOptions { AllowInsecureHttp = true, TokenEndpointPath = new PathString("/token"), AccessTokenExpireTimeSpan = TimeSpan.FromHours(2), Provider = new SimpleAuthorizationServerProvider() }; app.UseOAuthAuthorizationServer(oAuthServerOptions); app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions()); } } }这里解释几个配置项。TokenEndpointPath指定了获取Token的URL,客户端就是要往这个地址POST账号密码。AccessTokenExpireTimeSpan决定Token多久失效,两小时是一个常见选择,具体要看你的业务场景。AllowInsecureHttp现在先设成true,方便本地调试,但生产环境必须改成false并开HTTPS,这个坑我后面还会专门讲。Provider指定授权服务器如何处理用户名密码验证,这就是下一章要写的核心类。
注意app.UseCors的位置。最佳实践是把它放在所有中间件的最前面,至少要在认证中间件之前。原因也很简单,跨域请求的预检请求是OPTIONS方法,它通常不会携带Authorization头,如果认证中间件先执行了,很可能把预检请求当成未授权请求直接拒绝,导致浏览器拿到401,真正的接口请求永远不会发出。关于顺序的问题,我后面会在避坑部分再展开。
3. 令牌签发与资源保护的核心实现
3.1 自定义Authorization Server Provider,先跑通最简单的版本
OAuthAuthorizationServerProvider是整个认证流程中的大脑,它不是抽象类就是一个虚方法很多的基类,我们只需要重写自己关心的方法。对于账号密码换Token的场景,最核心的方法是GrantResourceOwnerCredentials。我先给一个不依赖数据库的版本,方便你理解整个数据流向:
public class SimpleAuthorizationServerProvider : OAuthAuthorizationServerProvider { public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context) { var userName = context.UserName; var password = context.Password; if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password)) { context.SetError("invalid_grant", "用户名或密码不能为空"); return; } // 这里先写一个模拟用户,后续替换成真实的用户校验逻辑 if (userName != "demoUser" || password != "P@ssw0rd") { context.SetError("invalid_grant", "用户名或密码错误"); return; } var identity = new ClaimsIdentity(OAuthDefaults.AuthenticationType); identity.AddClaim(new Claim(ClaimTypes.Name, userName)); identity.AddClaim(new Claim(ClaimTypes.Role, "admin")); var props = new AuthenticationProperties(new Dictionary<string, string> { { "userName", userName } }); var ticket = new AuthenticationTicket(identity, props); context.Validated(ticket); } }有几个细节容易写错。ClaimsIdentity的认证类型参数必须传OAuthDefaults.AuthenticationType,不要随手写"Bearer"或者空字符串,否则后面API里取User.Identity.Name很可能拿到空值。context.SetError("invalid_grant", ...)返回的是标准的OAuth错误格式,客户端可以直接根据error字段提示用户。AuthenticationProperties里可以塞一些公开信息,比如用户名、自定义标识,这些内容会体现在Token的payload里,但要注意别往里面放敏感数据,因为Token本身不等于保险箱,该防泄漏的还是要防。
3.2 接上真实用户体系和角色声明
如果项目里已经用了ASP.NET Identity,完全可以不手写账号密码判断,而是让UserManager去数据库验证。代码可以改成这样:
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context) { var userManager = context.OwinContext.GetUserManager<ApplicationUserManager>(); var user = await userManager.FindAsync(context.UserName, context.Password); if (user == null) { context.SetError("invalid_grant", "用户名或密码错误"); return; } var identity = await userManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType); // 额外补充业务声明 identity.AddClaim(new Claim("TenantId", user.TenantId.ToString())); var ticket = new AuthenticationTicket(identity, new AuthenticationProperties()); context.Validated(ticket); }userManager.CreateIdentityAsync会自动把用户的Id、用户名、以及所有角色转成Claim,所以API端用[Authorize(Roles = "admin")]就能直接做粗粒度权限控制。额外的声明,比如租户ID、部门编码,可以通过identity.AddClaim补进去。这样在业务代码里可以随时通过User.FindFirst("TenantId")读取,省去每次查库的麻烦。
如果你不想用完整的Identity,也完全可以。只需要在GrantResourceOwnerCredentials里实现自己的账号校验逻辑,然后手工构造ClaimsIdentity即可。Owin管的是Token可信不可信,用户数据从哪里来完全由你决定。很多项目有自己的用户中心,走HTTP接口或数据库直查,最终都是把验证结果塞进ClaimsIdentity,再交还给context.Validated。
有一点需要提醒:用Identity时要确保ApplicationUserManager已经在Owin环境里注册,否则GetUserManager<ApplicationUserManager>()拿到的是空对象。注册方式通常是在Startup.Configuration里调用app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create),这样每个请求都会创建一个UserManager实例,用完自动释放。这个细节新手很容易漏掉。
3.3 用Authorize特性保护API,并读取Token里的身份信息
服务端配置到这里,API层已经变得非常简单。以Controller为例:
[Authorize] public class ValuesController : ApiController { public IHttpActionResult Get() { var userName = User.Identity.Name; var tenantId = User.FindFirst("TenantId")?.Value; return Ok(new { userName, tenantId, isAdmin = User.IsInRole("admin") }); } }[Authorize]一放上去,这个接口就成了受保护资源。请求进来后,Bearer中间件会先验证Token,验证不通过直接返回401,根本不会执行方法体。如果只想放行某几个动作,就把[Authorize]放到Action上而不是Controller上;如果要按角色控制,就写[Authorize(Roles = "admin,manager")]。这种声明式控制比在每个方法开头判断Session要优雅得多。
有些新手会发现,加了[Authorize]后匿名用户被拦截,但登录用户传进来之后User.Identity.Name有时候是null。排查思路很简单:确认签发Token时ClaimsIdentity的类型传的是OAuthDefaults.AuthenticationType,而且确实调用了context.Validated(ticket)。只要这两点没问题,名称、角色都能正确解析。
3.4 可选:把默认Token换成JWT,跨服务验证更省事
默认情况下,Owin授权服务器生成的Token是一串加密的不透明字符串,安全性没问题,但如果你想在多个服务之间共用同一个Token,不透明Token就比较难受——别的服务必须回到授权服务来验证,或者共享同一套解密配置。JWT(JSON Web Token)把身份信息直接编码在Token里,资源服务只需要依赖密钥就能验签,不需要在线调用授权服务。
在Owin里换JWT不算复杂。引入System.IdentityModel.Tokens.Jwt程序包后,给OAuthAuthorizationServerOptions设置一个自定义的AccessTokenFormat,使用JwtFormat和对应的签名密钥。不过不同版本的包API差异不小,建议按实际引用的版本来调整。这里我的个人建议是:如果只做一个单体API,默认的不透明Token已经够用;如果迟早要拆微服务,再提前切到JWT不迟。
4. 客户端接入与本地测试
4.1 用测试工具模拟获取Token的完整参数
配置完成后,第一步就是用工具验证Token接口通不通。打开Postman或者随便一个能发请求的客户端,创建一个POST请求,地址填http://localhost:xxxxx/token,注意不是/api/token,因为这个端点走的是Owin中间件,不在Web API路由里。请求体必须用application/x-www-form-urlencoded格式,不要用JSON。三个固定参数通常一个都不能少:
grant_type=passwordusername=demoUserpassword=P@ssw0rd
用curl命令就是下面这样:
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password&username=demoUser&password=P%40ssw0rd" \ http://localhost:52567/token一个正常的响应大概长这样:
{ "access_token": "一串很长很长的令牌字符串", "token_type": "bearer", "expires_in": 7199, "userName": "demoUser" }看到这个响应就说明授权服务器的Token签发流程已经通了。如果返回invalid_request,先检查是不是Content-Type用错了;如果返回invalid_grant,大概率是账户密码错误,或者是AllowInsecureHttp仍然为false但你却用了HTTP明文请求。
4.2 带Token访问受保护接口的正确姿势
拿到Token之后,请求受保护API时需要这样设置请求头:
curl -H "Authorization: Bearer <把这里替换成刚才的access_token>" \ http://localhost:52567/api/values关键点是Bearer后面必须有一个英文空格,然后再跟Token。Token两边不要加引号,不要有换行。如果你用Postman,可以直接在Authorization标签页里选择Bearer Token类型,把Token粘贴进去,工具会自动帮你拼好Header。有些客户端的拦截器喜欢往Header里额外加一个X-Access-Token之类的自定义字段,这完全没有必要,标准OAuth Bearer用法就是认Authorization头。
服务端验证通过后,API方法里通过User.Identity.Name拿到的用户名,和你发Token时放到ClaimsIdentity里的姓名是一致的。这时也能理解为什么说Token是无状态的:服务端从头到尾没有新建Session,只需要靠Token自带的签名和时间戳就能判断请求是否合法。
4.3 过期时间怎么定,以及刷新Token的思路
AccessTokenExpireTimeSpan设短一点更安全,但用户体验差;设太长又容易在Token泄漏后让别人长期利用。常见做法是2小时左右,再配合刷新Token机制解决续期问题。刷新Token的基本思路是:在RefreshTokenProvider里实现Create方法,在授权服务器允许RefreshTokenProvider的前提下,用户拿着一个长期有效的refresh_token去同一个/token端点,但把grant_type改成refresh_token,并带上refresh_token值,令牌端点验证通过后签发新的access_token。这样用户不需要重新输密码,又能继续访问接口。
在具体实现上,建议给刷新Token设置独立的过期时间,比如7天或30天,并且每次刷新时让旧的refresh_token作废。如果你不想引入额外的存储,可以先把刷新Token用类似AccessToken的方式加密生成,但要做黑名单管理,否则被吊销的用户还能通过旧刷新Token续期,这就麻烦了。这里不展开写完整代码,因为它涉及到业务存储,最合理的做法是先想清楚续期策略,再按策略实现。
4.4 移动端和SPA里的Token存储建议
Token拿到之后放哪里,也是一个很容易被忽略的问题。在SPA里,如果图方便直接放localStorage,一旦页面被注入XSS脚本,Token就能被偷走,等于账号直接沦陷。更稳妥的做法是把Token放在内存变量中,页面刷新后如果Token丢了,就跳一次静默登录用刷新Token换新Token。不过这个方案对用户体验要求较高,得兼顾刷新Token本身的安全存储。
移动端的处理会简单一些,可以用系统安全存储能力,比如Keychain或Keystore,至少比明文放在SharedPreferences里强得多。在实际项目里,我的习惯是:Web端尽量不用localStorage存长期凭证,移动端优先用安全存储,后台接口只认Authorization头,不依赖Cookie。
5. 实际操作中绕不开的坑
5.1 AllowInsecureHttp上线后还开着,等于身份凭证裸奔
这个坑我印象太深了。本地调试时为了方便,把AllowInsecureHttp = true,然后顺手就发布到测试环境,结果整个环境的Token都是通过明文HTTP传输的。只要有人在网络上做截获,账号密码换来的Token一眼就能看到,和把家门钥匙挂在门外没什么区别。所以代码里一定要留一个明显的上线检查项,发布生产前把AllowInsecureHttp改成false,并确保所有请求都走HTTPS。如果公司统一用网关做TLS终止,也要确认内网链路本身是可信的,否则还是建议在应用层强制HTTPS。
5.2 跨域预检请求被认证中间件挡下,前端一脸懵
前后端分离项目几乎都会遇到跨域。如果app.UseCors没有最先执行,浏览器发出的OPTIONS预检请求可能会先撞上Bearer认证中间件。预检请求一般不带Authorization头,认证中间件一看没有Token,直接给你返401,浏览器就会认为整个跨域请求不可用。这个问题的排查线索很明确:打开浏览器开发者工具,Network里看到请求Method是OPTIONS,状态码401,十有八九是顺序配置错了。确认UseCors在UseOAuthAuthorizationServer和UseOAuthBearerAuthentication之前,问题通常会自己消失。
5.3 Token请求头被粘贴工具加了一对引号
很多人在本地测试时,喜欢把Token直接复制到Postman里,结果服务端一直报“Authorization has been denied for this request”,查半天发现是复制的时候带了引号。Token解析要求非常严格,多一个字符都会导致解密失败。建议从响应JSON里选中access_token的值复制,不要在控制台里复制带引号的完整键值对。如果你自己写前端代码,也尽量别做“先存对象再JSON.stringify”的拼接逻辑,直接取response.data.access_token就好。
5.4 默认401响应体太空,前端没法给出友好提示
Owin Bearer中间件默认在验证失败时会返回一个空白的401状态码,前端只能识别“未授权”这个事实,但不知道具体原因。如果需要给用户更明确的提示,可以在OAuthBearerAuthenticationOptions.Provider里重写OnApplyChallenge方法,手动修改响应体和状态。不过要注意别回传过于详细的内部信息,防止攻击者利用错误内容探测接口结构。这里我更推荐的做法是:客户端先根据业务错误码统一映射提示文案,服务端只做状态码和标准OAuth错误字段的判断,层级清晰也安全。
5.5 Token被泄露后如何撤销
很多人以为Token无状态就无法撤销,其实有变通方案。对于不透明Token,你可以在内存或Redis里维护一个黑名单,把需要登出用户的Token标识加进去,Bearer中间件在验证Token时检查一下黑名单,命中就直接拒绝。JWT的撤销麻烦一些,因为它可以离线验签,所以通常靠缩短过期时间或者引入refresh_token吊销机制来兜底。我的建议是在设计初期就把“登出”场景考虑进去,不要等到上线被安全团队问起再补,那是比较被动的。
6. 常见问题与解决方案速查表
6.1 高频错误与排查对照表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| POST /token 返回 invalid_request | Content-Type不是application/x-www-form-urlencoded | 把请求体格式改成表单格式,不要用JSON |
| POST /token 返回 invalid_grant | 用户名或密码错误,或AllowInsecureHttp为false但用了HTTP | 核对账号密码;开发环境临时开启AllowInsecureHttp |
| 访问API返回401 | 没带Authorization头,或Token格式有误 | 加上Authorization: Bearer <token>头,检查引号和空格 |
| 跨域接口返回401,请求Method显示OPTIONS | Cors配置顺序不对 | 把app.UseCors(CorsOptions.AllowAll)放到所有Owin中间件前面 |
| Authorize生效但User.Identity.Name为空 | ClaimsIdentity认证类型传错 | 签发Token时使用OAuthDefaults.AuthenticationType |
| Token总是很快过期 | AccessTokenExpireTimeSpan设置太短 | 调大过期时间,或实现RefreshToken刷新机制 |
| 接口可以访问但角色不生效 | 角色Claims未加入身份 | 使用CreateIdentityAsync或手动AddClaim(ClaimTypes.Role, ...) |
这张表基本覆盖了从搭建到上线最容易碰到的问题。如果你在配置时遇到错误,先对照表格排查一遍,大多数情况都是顺序问题、格式问题和环境开关问题。
6.2 遇到问题先从哪几个方向查
我自己的排查顺序通常固定为三步:第一步看网络请求本身,用浏览器开发者工具或抓包工具确认地址、Method、Header三个要素是否正确,尤其是在跨域场景下先看OPTIONS请求的状态码。第二步看服务端日志,启动类有没有被加载、中间件注册顺序是不是符合预期,这一步能发现不少“代码没问题但环境不对”的怪问题。第三步再看Token内容,把Token拆开看过期时间和签发时间是否合理,如果是JWT可以找个解码工具看payload。
按这个顺序走,大部分问题都能在十分钟内定位。建议不要一开始就怀疑中间件本身,Owin这套方案已经很成熟,绝大多数报错都是使用姿势的问题,而不是框架Bug。
说实话,这套方案我前前后后在不同的项目里用了很多次。一开始也踩过不少坑,尤其是跨域顺序和Token过期时间的设计,后来慢慢摸索出一套自己的习惯:先从小接口开始改造,验证通了再逐步接管核心接口;上线前把AllowInsecureHttp和跨域顺序当成必须检查的静态清单。Token认证不是银弹,但配合Owin中间件在Web API 2里用,确实让代码结构清爽了不少。如果你正准备动工,建议先按本文把最小链路跑通,再按你自己的用户体系去替换Provider里的验证逻辑,这样最快也最稳。