资讯详情

资讯详情

ASP.NET Web API 2基于Owin的Token认证实战指南

做后端接口的总会走到身份验证这一步。以前我在Web API项目里一直用Session后来前端从网页变成了小程序、App、SPACookie和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 ! Pssw0rd) { 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 Dictionarystring, 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.GetUserManagerApplicationUserManager(); 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环境里注册否则GetUserManagerApplicationUserManager()拿到的是空对象。注册方式通常是在Startup.Configuration里调用app.CreatePerOwinContextApplicationUserManager(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就比较难受——别的服务必须回到授权服务来验证或者共享同一套解密配置。JWTJSON 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_typepasswordusernamedemoUserpasswordPssw0rd用curl命令就是下面这样curl -X POST -H Content-Type: application/x-www-form-urlencoded \ -d grant_typepasswordusernamedemoUserpasswordP%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_requestContent-Type不是application/x-www-form-urlencoded把请求体格式改成表单格式不要用JSONPOST /token 返回 invalid_grant用户名或密码错误或AllowInsecureHttp为false但用了HTTP核对账号密码开发环境临时开启AllowInsecureHttp访问API返回401没带Authorization头或Token格式有误加上Authorization: Bearer token头检查引号和空格跨域接口返回401请求Method显示OPTIONSCors配置顺序不对把app.UseCors(CorsOptions.AllowAll)放到所有Owin中间件前面Authorize生效但User.Identity.Name为空ClaimsIdentity认证类型传错签发Token时使用OAuthDefaults.AuthenticationTypeToken总是很快过期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里的验证逻辑这样最快也最稳。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →