ASP.NET Web API与Owin OAuth:调用与用户相关的Web API[转]
oauth验证客户端
那Access Token有什么用呢?在OAuth中对Resource Server(比如Web API)访问权限的验证都是基于Access Token。不管是什么样的客户端来调用,Resource Server总是铁面无私,只认Access Token。
在ASP.NET Web API中启用OAuth的Access Token验证非常简单,只需在相应的Controller或Action加上[Authorize]标记,比如:
[Authorize] public class ValuesController : ApiController { // GET api/values public IEnumerable<string> Get() { return new string[] { "value1", "value2" }; } }
加上[Authorize]之后,如果不使用Access Token,调用API时就会出现如下的错误:
{"Message":"Authorization has been denied for this request."}
这时你也许会问,为什么一加上[Authorize]就会有这个效果?原来的Forms验证怎么不起作用了?
原因是你在用Visual Studio创建ASP.NET Web API项目时,VS自动帮你添加了相应的代码,打开WebApiConfig.cs,你会看到下面这2行代码:
config.SuppressDefaultHostAuthentication(); config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));
就是这2行代码,改变了[Authorize]的作用。
在ASP.NET Web API中启用OAuth验证就这么简单(简单的背后是微软实现了基于OWIN的OAuth,实现源代码在Katana项目中)。
那在客户端如何使用Access Token调用Web API呢?
也很简单,只要在http请求头中加上Bearer:Token即可,客户端调用示例代码如下:
public class OAuthClientTest { private HttpClient _httpClient; public OAuthClientTest() { _httpClient = new HttpClient(); _httpClient.BaseAddress = new Uri("http://openapi.cnblogs.com"); } [Fact] public async Task Call_WebAPI_By_Access_Token() { var token = await GetAccessToken(); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); Console.WriteLine(await (await _httpClient.GetAsync("/api/values")).Content.ReadAsStringAsync()); } private async Task<string> GetAccessToken() { var parameters = new Dictionary<string, string>(); parameters.Add("client_id", "1234"); parameters.Add("client_secret", "5678"); parameters.Add("grant_type", "client_credentials"); var response = await _httpClient.PostAsync("/token", new FormUrlEncodedContent(parameters)); var responseValue = await response.Content.ReadAsStringAsync(); return JObject.Parse(responseValue)["access_token"].Value<string>(); } }
运行结果如下:
["value1","value2"]
搞定!
Oauth 验证用户名
在这篇博文中,我们将以 OAuth 的 Resource Owner Password Credentials Grant 的授权方式( grant_type=password )获取 Access Token,并以这个 Token 调用与用户相关的 Web API。
对应的应用场景是:为自家的网站开发手机 App(非第三方 App),只需用户在 App 上登录,无需用户对 App 所能访问的数据进行授权。
根据 OAuth 规范,客户端获取 Access Token 的请求方式如下:
POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded grant_type=password&username=johndoe&password=A3ddj3w
根据上面的请求方式,在 C# 中用 HttpClient 实现一个简单的客户端,代码如下:
public class OAuthClientTest { private HttpClient _httpClient; public OAuthClientTest() { _httpClient = new HttpClient(); _httpClient.BaseAddress = new Uri("http://openapi.cnblogs.com"); } [Fact] public async Task Get_Accesss_Token_By_Resource_Owner_Password_Credentials_Grant() { Console.WriteLine(await GetAccessToken()); } private async Task<string> GetAccessToken() { var clientId = "1234"; var clientSecret = "5678"; var parameters = new Dictionary<string, string>(); parameters.Add("grant_type", "password"); parameters.Add("username", "博客园团队"); parameters.Add("password", "cnblogs.com"); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(clientId + ":" + clientSecret)) ); var response = await _httpClient.PostAsync("/token", new FormUrlEncodedContent(parameters)); var responseValue = await response.Content.ReadAsStringAsync(); if (response.StatusCode == System.Net.HttpStatusCode.OK) { return JObject.Parse(responseValue)["access_token"].Value<string>(); } else { Console.WriteLine(responseValue); return string.Empty; } } }
(注:与之前相比,这里的 client_id/client_secret 改为了 Basic Authorization,以更好的遵循 OAuth 规范)
在服务端,基于 Owin OAuth, 针对 Resource Owner Password Credentials Grant 的授权方式,只需重载 OAuthAuthorizationServerProvider.GrantResourceOwnerCredentials() 方法即可。代码如下:
public class CNBlogsAuthorizationServerProvider : OAuthAuthorizationServerProvider { //... public override async Task GrantResourceOwnerCredentials( OAuthGrantResourceOwnerCredentialsContext context) { //调用后台的登录服务验证用户名与密码 var oAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType); oAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName)); var ticket = new AuthenticationTicket(oAuthIdentity, new AuthenticationProperties()); context.Validated(ticket); await base.GrantResourceOwnerCredentials(context); } }
完整的CNBlogsAuthorizationServerProvider实现代码如下(与之前相比,context.TryGetFormCredentials 改为了 context.TryGetBasicCredentials):
public class CNBlogsAuthorizationServerProvider : OAuthAuthorizationServerProvider { public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context) { string clientId; string clientSecret; context.TryGetBasicCredentials(out clientId, out clientSecret); if (clientId == "1234" && clientSecret == "5678") { context.Validated(clientId); } await base.ValidateClientAuthentication(context); } public override async Task GrantClientCredentials(OAuthGrantClientCredentialsContext context) { var oAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType); var ticket = new AuthenticationTicket(oAuthIdentity, new AuthenticationProperties()); context.Validated(ticket); await base.GrantClientCredentials(context); } public override async Task GrantResourceOwnerCredentials( OAuthGrantResourceOwnerCredentialsContext context) { //调用后台的登录服务验证用户名与密码 var oAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType); oAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName)); var ticket = new AuthenticationTicket(oAuthIdentity, new AuthenticationProperties()); context.Validated(ticket); await base.GrantResourceOwnerCredentials(context); } } CNBlogsAuthorizationServerProvider
这样,运行客户端程序就可以拿到 Access Token 了。
接下来,我们拿着以这种方式获取的 Access Token,就可以调用与用户相关的 Web API 了。
在服务端我们通过一个简单的 Web API 测试一下,代码如下:
public class UsersController : ApiController { [Authorize] public string GetCurrent() { return User.Identity.Name; //这里可以调用后台用户服务,获取用户相关数所,或者验证用户权限进行相应的操作 } }
然后,客户端用以 grant_type=password 方式拿到的 Access Token 调用这个Web API,客户端增加的代码如下:
[Fact] public async Task Call_WebAPI_By_Resource_Owner_Password_Credentials_Grant() { var token = await GetAccessToken(); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); Console.WriteLine(await (await _httpClient.GetAsync("/api/users/current")).Content.ReadAsStringAsync()); }
客户端运行结果如下:
"博客园团队"
调用成功!运行结果正是获取 Access Token 时所用的 username 。
结合 ASP.NET 现有的安全机制,借助 OWIN 的威力,Microsoft.Owin.Security.OAuth 的确让开发基于 OAuth 的 Web API 变得更简单。
Web API与OAuth:既生access token,何生refresh token
本以为搞定了 access token 就搞定了 Web API 的验证与授权问题,可是发现 OAuth 中还有一种 token,叫 refresh token。开始的时候很是纳闷,access token 已经能解决问题,为什么要搞定两套 token,refresh token 有啥用?在纳闷之下,发出了这样的感慨:既生 access token,何生 refresh token?
后来看了一些资料,有点明白了。refresh token 是专用于刷新 access token 的 token。
为什么要刷新 access token 呢?一是因为 access token 是有过期时间的,到了过期时间这个 access token 就失效,需要刷新;二是因为一个 access token 会关联一定的用户权限,如果用户授权更改了,这个 access token 需要被刷新以关联新的权限。
为什么要专门用一个 token 去更新 access token 呢?如果没有 refresh token,也可以刷新 access token,但每次刷新都要用户输入登录用户名与密码,多麻烦。有了 refresh token,可以减少这个麻烦,客户端直接用 refresh token 去更新 access token,无需用户进行额外的操作。
两个为什么也许没有解释清楚 refresh token 的用途,下面我们用示例代码在 ASP.NET Web API 与 OWIN OAuth 中实际体验一下,或许有更直观的认识。
(一)Refresh token 的生成、发放、保存
实现一个 RefreshTokenProvider ,比如 CNBlogsRefreshTokenProvider。
需要重载 Microsoft.Owin.Security.Infrastructure.AuthenticationTokenProvider 中的 Create() 与 Receive() 方法(或者直接实现 IAuthenticationTokenProvider 接口),示例代码如下:
public class CNBlogsRefreshTokenProvider : AuthenticationTokenProvider { private static ConcurrentDictionary<string, string> _refreshTokens = new ConcurrentDictionary<string, string>(); public override void Create(AuthenticationTokenCreateContext context) { string tokenValue = Guid.NewGuid().ToString("n"); context.Ticket.Properties.IssuedUtc = DateTime.UtcNow; context.Ticket.Properties.ExpiresUtc = DateTime.UtcNow.AddDays(60); _refreshTokens[tokenValue] = context.SerializeTicket(); context.SetToken(tokenValue); } public override void Receive(AuthenticationTokenReceiveContext context) { string value; if (_refreshTokens.TryRemove(context.Token, out value)) { context.DeserializeTicket(value); } } }
(注:后来采用的是重载CreateAsync()方法)
然后应用这个 CNBlogsRefreshTokenProvider:
public void ConfigureAuth(IAppBuilder app) { OAuthOptions = new OAuthAuthorizationServerOptions { TokenEndpointPath = new PathString("/token"), Provider = new CNBlogsAuthorizationServerProvider(), AccessTokenExpireTimeSpan = TimeSpan.FromDays(1), AllowInsecureHttp = true, RefreshTokenProvider = new CNBlogsRefreshTokenProvider() }; app.UseOAuthBearerTokens(OAuthOptions); }
(二)验证持有 refresh token 的客户端
重载 OAuthAuthorizationServerProvider.GrantRefreshToken() 方法,示例代码如下:
using Microsoft.Owin.Security.OAuth; namespace OpenAPI.Providers { public class CNBlogsAuthorizationServerProvider : OAuthAuthorizationServerProvider { public override async Task GrantRefreshToken(OAuthGrantRefreshTokenContext context) { var originalClient = context.Ticket.Properties.Dictionary["as:client_id"]; var currentClient = context.ClientId; if (originalClient != currentClient) { context.Rejected(); return; } var newId = new ClaimsIdentity(context.Ticket.Identity); newId.AddClaim(new Claim("newClaim", "refreshToken")); var newTicket = new AuthenticationTicket(newId, context.Ticket.Properties); context.Validated(newTicket); await base.GrantRefreshToken(context); } } }
为了验证client_id,需要在 GrantClientCredentials() 重载方法中保存client_id至context.Ticket:
namespace OpenAPI.Providers { public class CNBlogsAuthorizationServerProvider : OAuthAuthorizationServerProvider { public override async Task GrantClientCredentials(OAuthGrantClientCredentialsContext context) { var oAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType); var props = new AuthenticationProperties(new Dictionary<string, string> { { "as:client_id", context.ClientId } }); var ticket = new AuthenticationTicket(oAuthIdentity, props); context.Validated(ticket); } } }
只需实现上面这些代码,其他的都由 Microsoft.Owin.Security.OAuth 帮你代劳了。
(三)测试客户端获取 refresh token
客户端获取 access token 与 refresh token 是一起的,示例代码如下:
[Fact] public async Task GetAccessTokenTest() { var clientId = "[clientId]"; var clientSecret = "[clientSecret]"; var parameters = new Dictionary<string, string>(); parameters.Add("grant_type", "password"); parameters.Add("username", "[username]"); parameters.Add("password", "[password]"); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(clientId + ":" + clientSecret))); var response = await _httpClient.PostAsync("/token", new FormUrlEncodedContent(parameters)); var responseValue = await response.Content.ReadAsStringAsync(); Console.WriteLine(responseValue); }
运行结果:
{ "access_token": "D3VjxsFvr...", "token_type": "bearer", "expires_in": 86399, "refresh_token": "7f7edd15cba043c29d487235c2276eb1" }
成功拿到了 access token。
(四)测试客户端用 refresh token 刷新 access token
客户端测试代码如下:
public async Task GetAccessTokenByRefreshTokenTest() { var clientId = "[clientId]"; var clientSecret = "[clientSecret]"; var parameters = new Dictionary<string, string>(); parameters.Add("grant_type", "refresh_token"); parameters.Add("refresh_token", "7f7edd15cba043c29d487235c2276eb1"); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(clientId + ":" + clientSecret))); var response = await _httpClient.PostAsync("/token", new FormUrlEncodedContent(parameters)); var responseValue = await response.Content.ReadAsStringAsync(); Console.WriteLine(responseValue); }
注:这段客户端代码与前一步中客户端代码的主要区别是少了下面传递 resource owner 用户名与密码的代码,这就是 refresh token 的用途所在 —— 不需要用户名与密码就可以刷新 access token。
parameters.Add("username", "[username]"); parameters.Add("password", "[password]");
运行结果:
{ "access_token": "[new access token]", "token_type": "bearer", "expires_in": 86399, "refresh_token": "[new refresh token]" }
搞定!