一、什么是OAuth
OAuth是一個關於授權(Authorization)的開放網絡標准,目前的版本是2.0版。注意是Authorization(授權),而不是Authentication(認證)。用來做Authentication(認證)的標准叫做openid connect,我們將在以后的文章中進行介紹。
二、名詞定義
理解OAuth中的專業術語能夠幫助你理解其流程模式,OAuth中常用的名詞術語有4個,為了便於理解這些術語,我們先假設一個很常見的授權場景:
你訪問了一個日志網站(third party application),你(client)覺得這個網站很不錯,准備以后就要在這個網站上寫日志了,所以你准備把QQ空間(Resource owner)里面的日志都導入進來。此日志網站想要導入你在QQ空間中的日志需要知道你的QQ用戶名和密碼才行,為了安全期間你不會把你的QQ用戶名和密碼直接輸入在日志網站中,所以日志網站幫你導航到了QQ認證界面(Authorization Server),當你輸入完用戶名和密碼后,QQ認證服務器返回給日志網站一個token, 該日志網站憑借此token來訪問你在QQ空間中的日志。
- third party application 第三方的應用,想要的到Resource owner的授權
- client 代表用戶
- Resource owner 資源擁有者,在這里代表QQ
- Authorization server 認證服務,這里代表QQ認證服務,Resource owner和Authorization server可以是不同的服務器,也可以是同一個服務器。
三、OAuth2.0中的四種模式
OAuth定義了四種模式,覆蓋了所有的授權應用場景:
- 授權碼模式(authorization code)
- 簡化模式(implicit)
- 密碼模式(resource owner password credentials)
- 客戶端模式(client credentials)
前面我們假設的場景可以用前兩種模式來實現,不同之處在於:
當日志網站(third party application)有服務端,使用模式1;
當日志網站(third party application)沒有服務端,例如純的js+html頁面需要采用模式2;
本文主描述利用OAuth2.0實現自己的WebApi認證服務,前兩種模式使用場景不符合我們的需求。
四、選擇合適的OAuth模式打造自己的webApi認證服務
場景:你自己實現了一套webApi,想供自己的客戶端調用,又想做認證。
這種場景下你應該選擇模式3或者4,特別是當你的的客戶端是js+html應該選擇3,當你的客戶端是移動端(ios應用之類)可以選擇3,也可以選擇4。
密碼模式(resource owner password credentials)的流程:
這種模式的流程非常簡單:
- 用戶向客戶端(third party application)提供用戶名和密碼。
- 客戶端將用戶名和密碼發給認證服務器(Authorization server),向后者請求令牌(token)。
- 認證服務器確認無誤后,向客戶端提供訪問令牌。
- 客戶端持令牌(token)訪問資源。
此時third party application代表我們自己的客戶端,Authorization server和Resource owner代表我們自己的webApi服務。我們在日志網站的場景中提到:用戶不能直接為日志網站(third party application)提供QQ(resource owner)的用戶名和密碼。而此時third party application、authorization server、resource owner都是一家人,Resource owner對third party application足夠信任,所以我們才能采取這種模式來實現。
五、使用owin來實現密碼模式
owin集成了OAuth2.0的實現,所以在webapi中使用owin來打造authorization無疑是最簡單最方便的方案。
- 新建webApi項目
- 安裝Nuget package:
Microsoft.AspNet.WebApi.Owin
Microsoft.Owin.Host.SystemWeb
- 增加owin的入口類:Startup.cs
在項目中新建一個類,命名為Startup.cs,這個類將作為owin的啟動入口,添加下面的代碼
[assembly: OwinStartup(typeof(OAuthPractice.ProtectedApi.Startup))] namespace OAuthPractice.ProtectedApi { public class Startup { public void Configuration(IAppBuilder app) { var config = new HttpConfiguration(); WebApiConfig.Register(config); app.UseWebApi(config); } } }
另外修改WebApiConfig.Register(HttpConfiguration config)方法:
public static class WebApiConfig { public static void Register(HttpConfiguration config) { config.MapHttpAttributeRoutes(); config.Routes.MapHttpRoute( name: "DefaultApi", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional } ); var jsonFormatter = config.Formatters.OfType<JsonMediaTypeFormatter>().First(); jsonFormatter.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); } }
最后兩句話將會使用CamelCase命名法序列化webApi的返回結果。
3.使用ASP.NET Identity 實現一個簡單的用戶認證功能,以便我們生成用戶名和密碼
安裝nuget package:
Microsoft.AspNet.Identity.Owin
Microsoft.AspNet.Identity.EntityFramework
4.新建一個Auth的文件夾,並添加AuthContext類:
public class AuthContext : IdentityDbContext<IdentityUser> { public AuthContext():base("AuthContext") { } }
同時在web.config中添加connectionString:
<connectionStrings> <add name="AuthContext" connectionString="Data Source=.;Initial Catalog=OAuthPractice;Integrated Security=SSPI;" providerName="System.Data.SqlClient" /> </connectionStrings>
5.增加一個Entities文件夾並添加UserModel類:
public class UserModel { [Required] [Display(Name = "UserModel name")] public string UserName { get; set; } [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } [DataType(DataType.Password)] [Display(Name = "Confirm password")] [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] public string ConfirmPassword { get; set; } }
6.在Auth文件夾下添加AuthRepository類,增加用戶注冊和查找功能:
public class AuthRepository : IDisposable { private AuthContext _ctx; private UserManager<IdentityUser> _userManager; public AuthRepository() { _ctx = new AuthContext(); _userManager = new UserManager<IdentityUser>(new UserStore<IdentityUser>(_ctx)); } public async Task<IdentityResult> RegisterUser(UserModel userModel) { IdentityUser user = new IdentityUser { UserName = userModel.UserName }; var result = await _userManager.CreateAsync(user, userModel.Password); return result; } public async Task<IdentityUser> FindUser(string userName, string password) { IdentityUser user = await _userManager.FindAsync(userName, password); return user; } public void Dispose() { _ctx.Dispose(); _userManager.Dispose(); } }
7、增加AccountController
[RoutePrefix("api/Account")] public class AccountController : ApiController { private readonly AuthRepository _authRepository = null; public AccountController() { _authRepository = new AuthRepository(); } // POST api/Account/Register [AllowAnonymous] [Route("Register")] public async Task<IHttpActionResult> Register(UserModel userModel) { if (!ModelState.IsValid) { return BadRequest(ModelState); } IdentityResult result = await _authRepository.RegisterUser(userModel); IHttpActionResult errorResult = GetErrorResult(result); if (errorResult != null) { return errorResult; } return Ok(); } protected override void Dispose(bool disposing) { if (disposing) { _authRepository.Dispose(); } base.Dispose(disposing); } private IHttpActionResult GetErrorResult(IdentityResult result) { if (result == null) { return InternalServerError(); } if (!result.Succeeded) { if (result.Errors != null) { foreach (string error in result.Errors) { ModelState.AddModelError("", error); } } if (ModelState.IsValid) { // No ModelState errors are available to send, so just return an empty BadRequest. return BadRequest(); } return BadRequest(ModelState); } return null; } }
Register方法打上了AllowAnonymous標簽,意味着調用這個api無需任何授權。
8.增加一個OrderControll,添加一個受保護的api用來做實驗
在Models文件夾下增加Order類:
public class Order { public int OrderID { get; set; } public string CustomerName { get; set; } public string ShipperCity { get; set; } public Boolean IsShipped { get; set; } public static List<Order> CreateOrders() { List<Order> OrderList = new List<Order> { new Order {OrderID = 10248, CustomerName = "Taiseer Joudeh", ShipperCity = "Amman", IsShipped = true }, new Order {OrderID = 10249, CustomerName = "Ahmad Hasan", ShipperCity = "Dubai", IsShipped = false}, new Order {OrderID = 10250,CustomerName = "Tamer Yaser", ShipperCity = "Jeddah", IsShipped = false }, new Order {OrderID = 10251,CustomerName = "Lina Majed", ShipperCity = "Abu Dhabi", IsShipped = false}, new Order {OrderID = 10252,CustomerName = "Yasmeen Rami", ShipperCity = "Kuwait", IsShipped = true} }; return OrderList; } }
增加OrderController類:
[RoutePrefix("api/Orders")] public class OrdersController : ApiController { [Authorize] [Route("")] public List<Order> Get() { return Order.CreateOrders(); } }
我們在Get()方法上加了Authorize標簽,所以此api在沒有授權的情況下將返回401 Unauthorize。使用postman發個請求試試:
9. 增加OAuth認證
public class Startup { public void Configuration(IAppBuilder app) { var config = new HttpConfiguration(); WebApiConfig.Register(config); ConfigureOAuth(app); //這一行代碼必須放在ConfiureOAuth(app)之后 app.UseWebApi(config); } public void ConfigureOAuth(IAppBuilder app) { OAuthAuthorizationServerOptions OAuthServerOptions = new OAuthAuthorizationServerOptions() { AllowInsecureHttp = true, TokenEndpointPath = new PathString("/token"), AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(30), Provider = new SimpleAuthorizationServerProvider() }; // Token Generation app.UseOAuthAuthorizationServer(OAuthServerOptions); app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions()); }
ConfigureOAuth(IAppBuilder app)方法開啟了OAuth服務。簡單說一下OAuthAuthorizationServerOptions中各參數的含義:
AllowInsecureHttp:允許客戶端使用http協議請求;
TokenEndpointPath:token請求的地址,即http://localhost:端口號/token;
AccessTokenExpireTimeSpan :token過期時間;
Provider :提供具體的認證策略;
SimpleAuthorizationServerProvider的代碼如下:
public class SimpleAuthorizationServerProvider : OAuthAuthorizationServerProvider { public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context) { context.Validated(); return Task.FromResult<object>(null); } public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context) { using (AuthRepository _repo = new AuthRepository()) { IdentityUser user = await _repo.FindUser(context.UserName, context.Password); if (user == null) { context.SetError("invalid_grant", "The user name or password is incorrect."); return; } } var identity = new ClaimsIdentity(context.Options.AuthenticationType); identity.AddClaim(new Claim(ClaimTypes.Name, context.UserName)); identity.AddClaim(new Claim(ClaimTypes.Role, "user")); identity.AddClaim(new Claim("sub", context.UserName)); var props = new AuthenticationProperties(new Dictionary<string, string> { { "as:client_id", context.ClientId ?? string.Empty }, { "userName", context.UserName } }); var ticket = new AuthenticationTicket(identity, props); context.Validated(ticket); } public override Task TokenEndpoint(OAuthTokenEndpointContext context) { foreach (KeyValuePair<string, string> property in context.Properties.Dictionary) { context.AdditionalResponseParameters.Add(property.Key, property.Value); } return Task.FromResult<object>(null); } }
ValidateClientAuthentication方法用來對third party application 認證,具體的做法是為third party application頒發appKey和appSecrect,在本例中我們省略了頒發appKey和appSecrect的環節,我們認為所有的third party application都是合法的,context.Validated(); 表示所有允許此third party application請求。
GrantResourceOwnerCredentials方法則是resource owner password credentials模式的重點,由於客戶端發送了用戶的用戶名和密碼,所以我們在這里驗證用戶名和密碼是否正確,后面的代碼采用了ClaimsIdentity認證方式,其實我們可以把他當作一個NameValueCollection看待。最后context.Validated(ticket); 表明認證通過。
只有這兩個方法同時認證通過才會頒發token。
TokenEndpoint方法將會把Context中的屬性加入到token中。
10、注冊用戶
使用postman發送注冊用戶的請求(http://{url}/api/account/register)服務器返回200,說明注冊成功。
11、向服務器請求token
resource owner password credentials模式需要body包含3個參數:
grant_type-必須為password
username-用戶名
password-用戶密碼
12、使用token訪問受保護的api
在Header中加入:Authorization – bearer {{token}},此token就是上一步得到的token。
此時客戶端在30分鍾內使用該token即可訪問受保護的資源。30分鍾這個設置來自AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(30),你可以自定義token過期時間。
六、刷新token
當token過期后,OAuth2.0提供了token刷新機制:
public void ConfigureOAuth(IAppBuilder app) { OAuthAuthorizationServerOptions OAuthServerOptions = new OAuthAuthorizationServerOptions() { AllowInsecureHttp = true, TokenEndpointPath = new PathString("/token"), AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10), Provider = new SimpleAuthorizationServerProvider(), //refresh token provider RefreshTokenProvider = new SimpleRefreshTokenProvider() }; // Token Generation app.UseOAuthAuthorizationServer(OAuthServerOptions); app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions()); }
1、添加新的RefreshTokenProvider
public class SimpleRefreshTokenProvider : IAuthenticationTokenProvider { public async Task CreateAsync(AuthenticationTokenCreateContext context) { var refreshTokenId = Guid.NewGuid().ToString("n"); using (AuthRepository _repo = new AuthRepository()) { var token = new RefreshToken() { Id = refreshTokenId.GetHash(), Subject = context.Ticket.Identity.Name, IssuedUtc = DateTime.UtcNow, ExpiresUtc = DateTime.UtcNow.AddMinutes(30) }; context.Ticket.Properties.IssuedUtc = token.IssuedUtc; context.Ticket.Properties.ExpiresUtc = token.ExpiresUtc; token.ProtectedTicket = context.SerializeTicket(); var result = await _repo.AddRefreshToken(token); if (result) { context.SetToken(refreshTokenId); } } } public async Task ReceiveAsync(AuthenticationTokenReceiveContext context) { string hashedTokenId = context.Token.GetHash(); using (AuthRepository _repo = new AuthRepository()) { var refreshToken = await _repo.FindRefreshToken(hashedTokenId); if (refreshToken != null) { //Get protectedTicket from refreshToken class context.DeserializeTicket(refreshToken.ProtectedTicket); var result = await _repo.RemoveRefreshToken(hashedTokenId); } } } public void Create(AuthenticationTokenCreateContext context) { throw new NotImplementedException(); } public void Receive(AuthenticationTokenReceiveContext context) { throw new NotImplementedException(); } }
我們實現了其中兩個異步方法,對兩個同步方法不做實現。其中CreateAsync用來生成RefreshToken值,生成后需要持久化在數據庫中,客戶端需要拿RefreshToken來請求刷新token,此時ReceiveAsync方法將拿客戶的RefreshToken和數據庫中RefreshToken做對比,驗證成功后刪除此refreshToken。
2、重新請求token
可以看到這次請求不但得到了token,還得到了refresh_token
3、當token過期后,憑借上次得到的refresh_token重新獲取token
此次請求又得到了新的refresh_token,每次refresh_token只能用一次,因為在方法ReceiveAsync中我們一旦拿到refresh_token就刪除了記錄。
七、總結
此文重點介紹了OAuth2.0中resource owner password credentials模式的使用,此模式可以實現資源服務為自己的客戶端授權。另外文章中也提到模式4-client credentials也可以實現這種場景,但用來給有服務端的客戶端使用-區別於純html+js客戶端。原因在於模式4-client credentials使用appKey+appSecrect來驗證客戶端,如果沒有服務端的話appSecrect將暴露在js中。
同樣的道理:模式1-授權碼模式(authorization code)和模式2-簡化模式(implicit)的區別也在於模式2-簡化模式(implicit)用在無服務端的場景下,請求頭中不用帶appSecrect。
在webApi中使用owin來實現OAuth2.0是最簡單的解決方案,另外一個方案是使用DotNetOpenOauth,這個方案的實現稍顯復雜,可用的文檔也較少,源碼中帶有幾個例子我也沒有直接跑起來,最后無奈之下幾乎讀完了整個源碼才理解。
八、客戶端的實現
我們將采用jquery和angular兩種js框架來調用本文實現的服務端。下一篇將實現此功能,另外還要給我們的服務端加上CORS(同源策略)支持。
所有的代碼都同步更新在 https://git.oschina.net/richieyangs/OAuthPractice.git
使用OAuth打造webapi認證服務供自己的客戶端使用(二)
參考:
http://www.asp.net/aspnet/overview/owin-and-katana/owin-oauth-20-authorization-server
http://www.asp.net/web-api/overview/security/individual-accounts-in-web-api
http://bitoftech.net/2014/06/01/token-based-authentication-asp-net-web-api-2-owin-asp-net-identity/