0%

ASP.NET MVC 3

  使用Forms身份验证

身份验证流程

一、用户登录

  1、验证表单:ModelState.IsValid

  2、验证用户名和密码:通过查询数据库验证

  3、如果用户名和密码正确,则在客户端保存Cookie以保存用户登录状态:SetAuthCookie

    1):从数据库中查出用户名和一些必要的信息,并把额外信息保存到UserData中

    2):把用户名和UserData保存到 FormsAuthenticationTicket 票据中

    3):对票据进行加密 Encrypt

    4):将加密后的票据保存到Cookie发送到客户端

  4、跳转到登录前的页面

二、验证登录

1、在Global中注册PostAuthenticateRequest事件函数,用于解析客户端发过来的Cookie数据

    1):通过 HttpContext.Current.User.Identity 判断用户是否登录(FormsIdentity,IsAuthenticated,AuthenticationType)

    2):从HttpContext 的Request的Cookie中解析出Value,解密得到 FormsAuthenticationTicket  得到UserData

  2、角色验证

    在Action加入 Authorize特性,可以进行角色验证

      在 HttpContext.Current.User 的 IsInRole 方法进行角色认证(需要重写)

下面是代码,以上用到的所有验证的类都进行重载

一、首先是用户用户身份认证的 IPrincipal

  这里抽象出通用属性,定义两个 IPrincipal

复制代码

//通用的用户实体
public class MyFormsPrincipal<TUserData> : IPrincipal where TUserData : class, new()
{ //当前用户实例
    public IIdentity Identity { get; private set; } //用户数据
    public TUserData UserData { get; private set; } public MyFormsPrincipal(FormsAuthenticationTicket ticket, TUserData userData)
    { if (ticket == null) throw new ArgumentNullException("ticket"); if (userData == null) throw new ArgumentNullException("userData");

        Identity \= new FormsIdentity(ticket);
        UserData \= userData;
    } //角色验证
    public bool IsInRole(string role)
    { var userData = UserData as MyUserDataPrincipal; if (userData == null) throw new NotImplementedException(); return userData.IsInRole(role);
    } //用户名验证
    public bool IsInUser(string user)
    { var userData = UserData as MyUserDataPrincipal; if (userData == null) throw new NotImplementedException(); return userData.IsInUser(user);
    }
}

复制代码

  通用实体里面可以存放数据实体,并且把角色验证和用户验证放到了具体的数据实体里面

复制代码

//存放数据的用户实体
public class MyUserDataPrincipal : IPrincipal
{ //数据源
    private readonly MingshiEntities mingshiDb = new MingshiEntities(); public int UserId { get; set; } //这里可以定义其他一些属性
    public List<int\> RoleId { get; set; } //当使用Authorize特性时,会调用改方法验证角色 
    public bool IsInRole(string role)
    { //找出用户所有所属角色
        var userroles = mingshiDb.UserRole.Where(u => u.UserId == UserId).Select(u => u.Role.RoleName).ToList(); var roles = role.Split(new\[\] {','}, StringSplitOptions.RemoveEmptyEntries); return (from s in roles from userrole in userroles where s.Equals(userrole) select s).Any();
    } //验证用户信息
    public bool IsInUser(string user)
    { //找出用户所有所属角色
        var users = user.Split(new\[\] { ',' }, StringSplitOptions.RemoveEmptyEntries); return mingshiDb.User.Any(u => users.Contains(u.UserName));
    }

    
    \[ScriptIgnore\] //在序列化的时候忽略该属性
    public IIdentity Identity { get { throw new NotImplementedException(); } }
}

复制代码

二、用于验证和设置Cookie的 FormsAuthentication

复制代码

//身份验证类
public class MyFormsAuthentication<TUserData> where TUserData : class, new()
{ //Cookie保存是时间
    private const int CookieSaveDays = 14; //用户登录成功时设置Cookie
    public static void SetAuthCookie(string username, TUserData userData, bool rememberMe)
    { if (userData == null) throw new ArgumentNullException("userData"); var data = (new JavaScriptSerializer()).Serialize(userData); //创建ticket
        var ticket = new FormsAuthenticationTicket( 2, username, DateTime.Now, DateTime.Now.AddDays(CookieSaveDays), rememberMe, data); //加密ticket
        var cookieValue = FormsAuthentication.Encrypt(ticket); //创建Cookie
        var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, cookieValue)
        {
            HttpOnly \= true,
            Secure \= FormsAuthentication.RequireSSL,
            Domain \= FormsAuthentication.CookieDomain,
            Path \= FormsAuthentication.FormsCookiePath,
        }; if (rememberMe)
            cookie.Expires \= DateTime.Now.AddDays(CookieSaveDays); //写入Cookie

HttpContext.Current.Response.Cookies.Remove(cookie.Name);
HttpContext.Current.Response.Cookies.Add(cookie);
} //从Request中解析出Ticket,UserData
public static MyFormsPrincipal TryParsePrincipal(HttpRequest request)
{ if (request == null) throw new ArgumentNullException(“request”); // 1. 读登录Cookie
var cookie = request.Cookies[FormsAuthentication.FormsCookieName]; if (cookie == null || string.IsNullOrEmpty(cookie.Value)) return null; try { // 2. 解密Cookie值,获取FormsAuthenticationTicket对象
var ticket = FormsAuthentication.Decrypt(cookie.Value); if (ticket != null && !string.IsNullOrEmpty(ticket.UserData))
{ var userData = (new JavaScriptSerializer()).Deserialize(ticket.UserData); if (userData != null)
{ return new MyFormsPrincipal(ticket, userData);
}
} return null;
} catch { /* 有异常也不要抛出,防止攻击者试探。 */
return null;
}
}
}

复制代码

三、用于验证角色和用户名的Authorize特性

复制代码

//验证角色和用户名的类
public class MyAuthorizeAttribute : AuthorizeAttribute
{ protected override bool AuthorizeCore(System.Web.HttpContextBase httpContext)
    { var user = httpContext.User as MyFormsPrincipal<MyUserDataPrincipal>; if (user != null) return (user.IsInRole(Roles) || user.IsInUser(Users)); return false;
    } protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
    { //验证不通过,直接跳转到相应页面,注意:如果不使用以下跳转,则会继续执行Action方法
        filterContext.Result = new RedirectResult("http://www.baidu.com");
    }
}

复制代码

好了,四个类定义完成,接下来是使用

1、首先是登陆

复制代码

[HttpPost] public ActionResult LogOn(LogOnModel model, string returnUrl)
{ if (ModelState.IsValid)
{
          //通过数据库查询验证 var bll= new UserBll(); var userId = bll.Validate(model.UserName, model.Password, HttpContext.Request.UserHostAddress, HttpContext.Request.UserAgent); if (userId > 0)
{ //验证成功,用户名密码正确,构造用户数据(可以添加更多数据,这里只保存用户Id)
var userData = new MyUserDataPrincipal {UserId = userId}; //保存Cookie
MyFormsAuthentication.SetAuthCookie(model.UserName, userData, model.RememberMe); if (Url.IsLocalUrl(returnUrl) && returnUrl.Length > 1 && returnUrl.StartsWith(“/“) && !returnUrl.StartsWith(“//“) && !returnUrl.StartsWith(“/\\“))
{ return Redirect(returnUrl);
} else { return RedirectToAction(“Index”, “Home”);
}
} else {
ModelState.AddModelError(“”, “提供的用户名或密码不正确。”);
}
} // 如果我们进行到这一步时某个地方出错,则重新显示表单
return View(model);
}

复制代码

二、登陆完成后,是验证,验证之前首先要获得客户端的用户数据(从之前设置的Cookie中解析)

  在全局文件:Global.asax 中添加下面代码

复制代码

    protected void Application\_PostAuthenticateRequest(object sender, System.EventArgs e)
    { var formsIdentity = HttpContext.Current.User.Identity as FormsIdentity; if (formsIdentity != null && formsIdentity.IsAuthenticated && formsIdentity.AuthenticationType == "Forms")
        {
            HttpContext.Current.User \= MyFormsAuthentication<MyUserDataPrincipal>.TryParsePrincipal(HttpContext.Current.Request);
        }
    }

复制代码

 这样就从Request解析出了UserData,下面可以用于验证了

三、在需要验证角色的Action上添加 [MyAuthorize] 特性

    \[MyAuthorize(Roles = "User", Users = "bomo,toroto")\] public ActionResult About()
    { return View();
    }

  当用户访问该Action时,调用 MyAuthorize 的 AuthorizeCore 方法进行验证, 如果验证成功,则继续执行,如果验证失败,会调用 HandleUnauthorizedRequest方法做相应处理,在MyAuthorize 中可以获得这里定义的 Roles 和 Users 进行验证

在这篇文章中,我将继续ASP.NET Identity 之旅,这也是ASP.NET Identity 三部曲的最后一篇。在本文中,将为大家介绍ASP.NET Identity 的高级功能,它支持声明式并且还可以灵活的与ASP.NET MVC 授权结合使用,同时,它还支持使用第三方来实现身份验证。

关于ASP.NET Identity 的基础知识,请参考如下文章:

ASP.NET MVC 随想录——开始使用ASP.NET Identity,初级篇

ASP.NET MVC 随想录——探索ASP.NET Identity 身份验证和基于角色的授权,中级篇

本文的示例,你可以在此下载和预览:

点此进行预览

点此下载示例代码

走进声明的世界

在旧的用户管理系统,例如使用了ASP.NET Membership的应用程序,我们的应用程序被认为是获取用户所有信息的权威来源,所以本质上可以将应用程序视为封闭的系统,它包含了所有的用户信息。在上一篇文章中,我使用ASP.NET Identity 验证用户存储在数据库的凭据,并根据与这些凭据相关联的角色进行授权访问,所以本质上身份验证和授权所需要的用户信息来源于我们的应用程序。

ASP.NET Identity 还支持使用声明来和用户打交道,它效果很好,而且应用程序并不是用户信息的唯一来源,有可能来自外部,这比传统角色授权来的更为灵活和方便。

接下来我将为大家介绍ASP.NET Identity 是如何支持基于声明的授权(claims-based authorization)。

1.理解什么是声明

声明(Claims)其实就是用户相关的一条一条信息的描述,这些信息包括用户的身份(如Name、Email、Country等)和角色成员,而且,它描述了这些信息的类型、值以及发布声明的认证方等。我们可以使用声明来实现基于声明的授权。声明可以从外部系统获得,当然也可以从本地用户数据库获取。

对于ASP.NET MVC应用程序,通过自定义AuthorizeAttribute,声明能够被灵活的用来对指定的Action 方法授权访问,不像传统的使用角色授权那么单一,基于声明的授权更加丰富和灵活,它允许使用用户信息来驱动授权访问。

既然声明(Claim)是一条关于用户信息的描述,最简单的方式来阐述什么是声明就是通过具体的例子来展示,这比抽象概念的讲解来的更有用。所以,我在示例项目中添加了一个名为Claims 的 Controller,它的定义如下所示:

  1. public class ClaimsController : Controller

  2. {

  3. [Authorize]

  4. public ActionResult Index()

  5. {

  6. ClaimsIdentity claimsIdentity = HttpContext.User.Identity as ClaimsIdentity;

  7. if (claimsIdentity == null)

  8. {

  9. return View(“Error”, new string[] {“未找到声明”});

  10. }

  11. else

  12. {

  13. return View(claimsIdentity.Claims);

  14. }

  15. }

  16. }

在这个例子中可以看出ASP.NET Identity 已经很好的集成到ASP.NET 平台中,而HttpContext.User.Identity 属性返回一个 IIdentity 接口的实现,而当与ASP.NET Identity 结合使用时,返回的是ClaimsIdentity 对象。

ClaimsIdentity 类被定义在System.Security.Claims 名称空间下,它包含如下重要的成员:

Claims

返回用户包含的声明对象集合

AddClaim(claim)

为用户添加一个声明

AddClaims(claims)

为用户添加一系列声明

HasClaim(predicate)

判断是否包含声明,如果是,返回True

RemoveClaim(claim)

为用户移除声明

当然ClaimsIdentity 类还有更多的成员,但上述表描述的是在Web应用程序中使用频率很高的成员。在上述代码中,将HttpContext.User.Identity 转换为ClaimsIdentity 对象,并通过该对象的Claims 属性获取到用户相关的所有声明。

一个声明对象代表了用户的一条单独的信息数据,声明对象包含如下属性:

Issuer

返回提供声明的认证方名称

Subject

返回声明指向的ClaimIdentity 对象

Type

返回声明代表的信息类型

Value

返回声明代表的用户信息的值

有了对声明的基本概念,对上述代码的View进行修改,它呈现用户所有声明信息,相应的视图代码如下所示:

  1. @using System.Security.Claims

  2. @using Users.Infrastructure

  3. @model IEnumerable<Claim>

  4. @{

  5. ViewBag.Title = “Index”;

  6. }

  7. 声明

  8. </div>

  9. <tr>

  10. <th>Subject</th>

  11. <th>Issuer</th>

  12. <th>Type</th>

  13. <th>Value</th>

  14. </tr>

  15. @foreach (Claim claim in Model.OrderBy(x=>x.Type))

  16. {

  17. <tr>

  18. <td>@claim.Subject.Name</td>

  19. <td>@claim.Issuer</td>

  20. <td>@Html.ClaimType(claim.Type)</td>

  21. <td>@claim.Value</td>

  22. </tr>

  23. }

  24. </table>

  25. </div>

  26. Claim对象的Type属性返回URI Schema,这对于我们来说并不是特别有用,常见的被用来当作值的Schema定义在System.Security.Claims.ClaimType 类中,所以要使输出的内容可读性更强,我添加了一个HTML helper,它用来格式化Claim.Type 的值:

    1. public static MvcHtmlString ClaimType(this HtmlHelper html, string claimType)

    2. {

    3. FieldInfo[] fields = typeof(ClaimTypes).GetFields();

    4. foreach (FieldInfo field in fields)

    5. {

    6. if (field.GetValue(null).ToString() == claimType)

    7. {

    8. return new MvcHtmlString(field.Name);

    9. }

    10. }

    11. return new MvcHtmlString(string.Format(“{0}”,

    12. claimType.Split(‘/‘, ‘.’).Last()));

    13. }

    有了上述的基础设施代码后,我请求ClaimsController 下的Index Action时,显示用户关联的所有声明,如下所示:

    创建并使用声明

    有两个原因让我觉得声明很有趣。第一个原因是,应用程序能从多个来源获取声明,而不是仅仅依靠本地数据库来获取。在稍后,我会向你展示如何使用外部第三方系统来验证用户身份和创建声明,但此时我添加一个类,来模拟一个内部提供声明的系统,将它命名为LocationClaimsProvider,如下所示:

    1. public static class LocationClaimsProvider

    2. {

    3. public static IEnumerable GetClaims(ClaimsIdentity user)

    4. {

    5. List claims=new List();

    6. if (user.Name.ToLower()==”admin”)

    7. {

    8. claims.Add(CreateClaim(ClaimTypes.PostalCode, “DC 20500”));

    9. claims.Add(CreateClaim(ClaimTypes.StateOrProvince, “DC”));

    10. }

    11. else

    12. {

    13. claims.Add(CreateClaim(ClaimTypes.PostalCode, “NY 10036”));

    14. claims.Add(CreateClaim(ClaimTypes.StateOrProvince, “NY”));

    15. }

    16. return claims;

    17. }

    18. private static Claim CreateClaim(string type,string value)

    19. {

    20. return new Claim(type, value, ClaimValueTypes.String, “RemoteClaims”);

    21. }

    22. }

    上述代码中,GetClaims 方法接受一个参数为ClaimsIdentity 对象并为用户创建了PostalCode和StateOrProvince的声明。在这个类中,假设我模拟一个系统,如一个中央的人力资源数据库,那么这将是关于工作人员本地信息的权威来源。

    声明是在身份验证过程被添加到用户中,故在Account/Login Action对代码稍作修改:

    1. [HttpPost]

    2. [AllowAnonymous]

    3. [ValidateAntiForgeryToken]

    4. public async Task Login(LoginModel model,string returnUrl)

    5. {

    6. if (ModelState.IsValid)

    7. {

    8. AppUser user = await UserManager.FindAsync(model.Name, model.Password);

    9. if (user==null)

    10. {

    11. ModelState.AddModelError(“”,”无效的用户名或密码”);

    12. }

    13. else

    14. {

    15. var claimsIdentity =

    16. await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);

    17. claimsIdentity.AddClaims(LocationClaimsProvider.GetClaims(claimsIdentity));

    18. AuthManager.SignOut();

    19. AuthManager.SignIn(new AuthenticationProperties {IsPersistent = false}, claimsIdentity);

    20. return Redirect(returnUrl);

    21. }

    22. }

    23. ViewBag.returnUrl = returnUrl;

    24. return View(model);

    25. }

    修改完毕,运行应用程序,身份验证成功过后,浏览Claims/Index 地址,你就可以看到已经成功对用户添加声明了,如下截图所示:

    获取声明来自多个来源意味着我们的应用程序不会有重复数据并可以和外部数据集成。Claim 对象的Issuer 属性 告诉你这个声明的来源,这能帮助我们精确判断数据的来源。举个例子,从中央人力资源数据库获取的信息比从外部供应商邮件列表获取的信息会更准确。

    声明是有趣的第二个原因是你能用他们来管理用户访问,这比使用标准的角色控制来的更为灵活。在前一篇文章中,我创建了一个专门负责角色的管理RoleContoller,在RoleController里实现用户和角色的绑定,一旦用户被赋予了角色,则该成员将一直隶属于这个角色直到他被移除掉。这会有一个潜在的问题,在大公司工作时间很长的员工,当他们换部门时换工作时,如果旧的角色没被删除,那么可能会出现资料泄露的风险。

    考虑使用声明吧,如果把传统的角色控制视为静态的话,那么声明是动态的,我们可以在程序运行时动态创建声明。声明可以直接基于已知的用户信息来授权用户访问,这样确保当声明数据更改时授权也更改。

    最简单的是使用Role 声明来对Action 受限访问,这我们已经很熟悉了,因为ASP.NET Identity 已经很好的集成到了ASP.NET 平台中了,当使用ASP.NET Identity 时,HttpContext.User 返回的是ClaimsPrincipal 对象,它实现了IsInRole 方法并使用HasClaim来判断指定的角色声明是否存在,从而达到授权。

    接着刚才的话题,我们想让授权是动态的,是由用户信息(声明)驱动的,所以我创建了一个ClaimsRoles类,用来模拟生成声明,如下所示:

    1. public class ClaimsRoles

    2. {

    3. public static IEnumerable CreateRolesFromClaims(ClaimsIdentity user)

    4. {

    5. List claims = new List();

    6. if (user.HasClaim(x => x.Type == ClaimTypes.StateOrProvince

    7. && x.Issuer == “RemoteClaims” && x.Value == “北京”)

    8. && user.HasClaim(x => x.Type == ClaimTypes.Role

    9. && x.Value == “Employee”))

    10. {

    11. claims.Add(new Claim(ClaimTypes.Role, “BjStaff”));

    12. }

    13. return claims;

    14. }

    15. }

    初略看一下CreateRolesFromClaims方法中的代码,使用Lambda表达式检查用户是否有来自Issuer为RemoteClaims ,值为北京的StateOrProvince声明和值为Employee 的Role声明,如果用户都包含两者,新增一个值为BjStaff 的 Role 声明。最后在Login Action 时调用此方法,如下所示:

    1. [HttpPost]

    2. [AllowAnonymous]

    3. [ValidateAntiForgeryToken]

    4. public async Task Login(LoginModel model,string returnUrl)

    5. {

    6. if (ModelState.IsValid)

    7. {

    8. AppUser user = await UserManager.FindAsync(model.Name, model.Password);

    9. if (user==null)

    10. {

    11. ModelState.AddModelError(“”,”无效的用户名或密码”);

    12. }

    13. else

    14. {

    15. var claimsIdentity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);

    16. claimsIdentity.AddClaims(LocationClaimsProvider.GetClaims(claimsIdentity));

    17. claimsIdentity.AddClaims(ClaimsRoles.CreateRolesFromClaims(claimsIdentity));

    18. AuthManager.SignOut();

    19. AuthManager.SignIn(new AuthenticationProperties {IsPersistent = false}, claimsIdentity);

    20. return Redirect(returnUrl);

    21. }

    22. }

    23. ViewBag.returnUrl = returnUrl;

    24. return View(model);

    25. }

    现在就可以基于角色为BjStaff对OtherAction受限访问,如下所示:

    1. [Authorize(Roles = “BjStaff”)]

    2. public string OtherAction()

    3. {

    4. return “这是一个受保护的Action”;

    5. }

    当用户信息发生改变时,如若生成的声明不为BjStaff,那么他也就没权限访问OtherAction了,这完全是由用户信息所驱动,而非像传统的在RoleController中显示修改用户和角色的关系。

    基于声明的授权

    在前一个例子中证明了如何使用声明来授权,但是这有点不直接因为我基于声明来产生角色然后再基于新的角色来授权。一个更加直接和灵活的方法是通过创建一个自定义的授权过滤器特性来实现,如下展示:

    1. public class ClaimsAccessAttribute:AuthorizeAttribute

    2. {

    3. public string Issuer { get; set; }

    4. public string ClaimType { get; set; }

    5. public string Value { get; set; }

    6. protected override bool AuthorizeCore(HttpContextBase context)

    7. {

    8. return context.User.Identity.IsAuthenticated

    9. && context.User.Identity is ClaimsIdentity

    10. && ((ClaimsIdentity)context.User.Identity).HasClaim(x =>

    11. x.Issuer == Issuer && x.Type == ClaimType && x.Value == Value

    12. );

    13. }

    14. }

    ClaimsAccessAttribute 特性继承自AuthorizeAttribute,并Override了 AuthorizeCore 方法,里面的业务逻辑是当用户验证成功并且IIdentity的实现是ClaimsIdentity 对象,同时用户包含通过属性传入的声明,最后将此Attribute 放在AnOtherAction 前,如下所示:

    1. [ClaimsAccess(Issuer = “RemoteClaims”, ClaimType = ClaimTypes.PostalCode, Value = “200000”)]

    2. public string AnotherAction()

    3. {

    4. return “这也是一个受保护的Action”;

    5. }

    使用第三方来身份验证

    像ASP.NET Identity 这类基于声明的系统的一个好处是任何声明能从外部系统获取,这意味着其他应用程序能帮我们来身份验证。ASP.NET Identity 基于这个原则增加对第三方如Google、Microsoft、FaceBook身份验证的支持。

    使用第三方身份验证有许多好处:许多用户已经有一个第三方账户了,并且你也不想在这个应用程序管理你的凭据。用户也不想在每一个网站上注册账户并都记住密码。使用一个统一的账户会比较灵活。

    1.启用Google 账户身份验证

    ASP.NET Identity 发布了对第三方身份验证的支持,通过Nuget来安装:

    Install-Package Microsoft.Owin.Security.Google

    当Package 安装完成后,在OWIN Startup启动项中,添加对身份验证服务的支持:

    1. app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

    2. //http://www.asp.net/mvc/overview/security/create-an-aspnet-mvc-5-app-with-facebook-and-google-oauth2-and-openid-sign-on

    3. app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()

    4. {

    5. ClientId = “165066370005-6nhsp87llelff3tou91hhktg6eqgr0ke.apps.googleusercontent.com”,

    6. ClientSecret = “euWbCSUZujjQGKMqOyz0msbq”,

    7. });

    在View中,添加一个通过Google 登陆的按钮:

    1. @using (Html.BeginForm(“GoogleLogin”, “Account”))

    2. {

    3. <input type=“hidden” name=“returnUrl” value=“@ViewBag.returnUrl” />

    4. <button class=“btn btn-primary” type=“submit”>Google 账户登录 </button>

    5. }

    当点击按钮时,Post到Account/GoogleLogin :

    1. [HttpPost]

    2. [AllowAnonymous]

    3. public ActionResult GoogleLogin(string returnUrl)

    4. {

    5. var properties = new AuthenticationProperties

    6. {

    7. RedirectUri = Url.Action(“GoogleLoginCallback”,

    8. new { returnUrl = returnUrl })

    9. };

    10. HttpContext.GetOwinContext().Authentication.Challenge(properties, “Google”);

    11. return new HttpUnauthorizedResult();

    12. }

    GoogleLogin 方法创建了AuthenticationProperties 类型的对象,并制定RedirectUri为当前Controller下的GoogleLoginCallBack Action,接下来就是见证奇迹的时候,返回401 Unauthorize 然后OWIN 中间件重定向到Google 登陆页面,而不是默认的Account/Login。这意味着,当用户点击以Google登陆按钮后,浏览器重定向到Google 身份验证服务然后一旦身份验证通过,重定向到GoogleLoginCallBack:

    1. ///

    2. /// Google登陆成功后(即授权成功)回掉此Action

    3. ///

    4. ///

    5. ///

    6. [AllowAnonymous]

    7. public async Task GoogleLoginCallback(string returnUrl)

    8. {

    9. ExternalLoginInfo loginInfo = await AuthManager.GetExternalLoginInfoAsync();

    10. AppUser user = await UserManager.FindAsync(loginInfo.Login);

    11. if (user == null)

    12. {

    13. user = new AppUser

    14. {

    15. Email = loginInfo.Email,

    16. UserName = loginInfo.DefaultUserName,

    17. City = Cities.Shanghai,

    18. Country = Countries.China

    19. };

    20. IdentityResult result = await UserManager.CreateAsync(user);

    21. if (!result.Succeeded)

    22. {

    23. return View(“Error”, result.Errors);

    24. }

    25. result = await UserManager.AddLoginAsync(user.Id, loginInfo.Login);

    26. if (!result.Succeeded)

    27. {

    28. return View(“Error”, result.Errors);

    29. }

    30. }

    31. ClaimsIdentity ident = await UserManager.CreateIdentityAsync(user,

    32. DefaultAuthenticationTypes.ApplicationCookie);

    33. ident.AddClaims(loginInfo.ExternalIdentity.Claims);

    34. AuthManager.SignIn(new AuthenticationProperties

    35. {

    36. IsPersistent = false

    37. }, ident);

    38. return Redirect(returnUrl ?? “/“);

    39. }

    对上述代码中,通过AuthManager.GetExternalLoginInfoAsync 方法获取外部登陆详细信息,ExternalLoginInfo 类定义了如下属性:

    DefaultUserName

    返回用户名

    Email

    返回Email 地址

    ExternalIdentity

    返回代表用户的ClaimIdentity

    Login

    返回一个UserLoginInfo用来描述外部登陆

    接着使用定义在UserManager对象中的FindAsync方法,传入ExternalLoginInfo.Login 属性,来获取AppUser对象,如果返回的对象不存在,这意味这这是该用户第一次登录到我们的应用程序中,所以我创建了一个AppUser对象并填充了属性然后将其保存到数据库中。

    我同样也保存了用户登陆的详细信息以便下一次能找到。

    最后,创建ClaimsIdentity 对象并创建Cookie,让应用程序知道用户已经验证通过了。

    为了测试Google 身份验证,我们启动应用程序,当验证通过后,访问Claims/Index,得到如下声明:

    可以看到一些声明的认证发布者是Google,而且这些信息来自于第三方。

    小节

    在这篇文章中,我为大家介绍了ASP.NET Identity 支持的一些高级功能,并解释了Claim是如何运行以及怎样创建灵活的授权访问。在本文最后演示了如和通过Google来身份验证。

    在技术领域,我们往往会对一些晦涩难翻译的术语感到惶恐,甚至会排斥它,比如yield、Identity、Claim。
    在夜生人静时,泡一壶茶,拿上一本书,细细品读,或许会有别样的精彩正等在我们。

    熟悉IoC容器的都知道,在开发过程中,最郁闷的莫过于当你新增一个Service时,你需要对该Service进行注册,有的是使用代码注入,有的是XML配置注入,不管是哪种类型的注入,经常会出现开发人员忘记注入的情况,

    如果你的页面是直接发送请求的,那么会得到类似于如下页面的错误:

    image <- _<-

    如果该服务是用于AJAX请求实用的,那么你就悲剧了,因为页面只是没反应,只有查看错误日志了。

    于是我试着去想办法去避免每次的服务都需要注入,而是系统自动注入。

    image

    红色线条框住的地方就是自动注入的代码实现。很高兴Autofac提供一个RegisterAssemblyTypes方法。它回去扫描所有的dll并把每个类注册为它所实现的接口。。。。

    既然能够自动注入,那么接口和类的定义一定要有一定的规律。 从上面的代码可以看到baseType这个变量,它是IDependency接口的类型。

    IDependency接口如下:

    image

    其他任何的接口都需要继承这个接口,例如我们定义一个接口IModelCar:

    image

    IModelCar的实现类:

    image

    自动注入原理说明:

    首先我们去找到所有Dll,再去找到实现了IDependency接口的类,然后使用RegisterAssemblyTypes进行注入。

    在Controller中调用试试:

    image

    可以看到_carmodel解析后为ModelCar的实例。。 微笑

    Demo下载: https://github.com/nicholaspei/MvcApplication5

    随着软件的不断发展,出现了更多的身份验证使用场景,除了典型的服务器与客户端之间的身份验证外还有,如服务与服务之间的(如微服务架构)、服务器与多种客户端的(如PC、移动、Web等),甚至还有需要以服务的形式开放给第三方的,身份验证这一功能已经演化为一个服务,很多大型应用中都有自己的身份验证服务器甚至集群,所以普通的身份验证方式已经不能满足需求。

      在.Net领域中也有一些开源的身份验证服务器组件,如IdentityServer(http://identityserver.io/),但是这些组件对于一些规模较小的项目来说可能会感觉到比较庞大,增加了学习和维护成本,所以本章将对OAuth以及如何使用OAuth实现身份验证模式进行介绍。
      本章的主要内容有:

      ● OAuth2.0简介
      ● 在.Net中使用OAuth实现基于授权码模式的身份验证
      ● 实现基于Access Token的身份验证
      ● 加入Refresh Token支持
      ● 实现通过用户密码模式获取Access Token
      ● 实现客户端模式获取Access Token
      ● 关于.Net中OAuth相关令牌的加密说明

      注:本章内容源码下载:https://files.cnblogs.com/files/selimsong/OAuth2Demo.zip

    OAuth2.0简介

      在文章的开始的时候说过现代软件应用的身份验证场景越来越丰富,下图是现代应用程序的一个通信图,它描述了常见的“客户端”是如何与服务器提供的服务通信的。

      

      该图出自IdentityServer:https://identityserver4.readthedocs.io/en/release/intro/big_picture.html
      为了满足这些场景人们制定了一套标准协议,这个协议就是OAuth(Open Authorization,开放授权)协议,OAuth能够让第三方应用程序去访问受限制的HTTP服务。OAuth有两个版本分别是1.0和2.0,但是由于1.0版本过于复杂所以1.0版本被2.0版本替换了,并且两个版本是不兼容的。
      接下来就对OAuth2.0相关的概念进行介绍:

    1. OAuth2.0中的角色

      ● Resource Owner:资源拥有者,就是能够访问被限制资源的用户(注:这里的用户是个泛指,它既可以是真实用户也可以是服务程序)。
      ● Resource Server:资源宿主,能够接受和处理,使用访问令牌(access token)访问受保护资源的请求(如提供API的服务器)。
      ● Client:它泛指所有的第三方程序(无论是Web应用、桌面应用还是服务端应用),它通过资源拥有者以及它的授权来访问受保护的资源。
      ● Authorization Server:用来对授权成功的客户端发布令牌以及对令牌的验证授权。并对Client进行管理。

    2.OAuth2.0的协议流程

      

      A. 第三方程序向资源拥有者(用户)发送授权请求,这个过程既可以通过客户端直接向用户请求,也可以通过授权服务器作为中介来完成请求。(注:对于授权请求这个概念相当于用户登录,应用程序可以直接显示一个登录页面,也可以跳转到验证服务器的统一登录页面)
      B. 用户将授权相关信息“提交”给第三方程序,在OAuth中有4种不同的权限授予方式,每种方式需要的数据不同,如基于用户密码的授权方式就需要用户名和密码。
      C. 第三方程序将用户的授权信息提交到授权服务器,请求一个Access Token。
      D. 授权服务器验证完成用户的授权信息后,将Access Token发放到第三方程序。
      E. 第三方程序携带Access Token访问被保护的资源。
      F. 资源服务器验证Access Token有效后,将资源返回到第三方程序。

    3. OAuth中的授权模式(即获取Access Token的方式)

      ● Authorization Code(授权码模式):该模式的核心是客户端通过一个授权码来向授权服务器申请Access Token。是一种基于重定向的授权模式,授权服务器作为用户和第三方应用(Client)的中介,当用户访问第三方应用是,第三方应用跳转到授权服务器引导用户完成身份验证,生成Authorization Code并转交到第三方应用,以便于第三方应用根据这个授权码完成后续的Access Token获取。
      ● Implicit(简化模式):简化模式是一种简化的授权码模式,授权码模式在首次访问第三方应用时跳转到授权服务器进行身份验证返回授权码,而简化模式在跳转到授权服务器后直接返回Access Token,这种模式减少了获取Access Token的请求次数。
      ● Resource Owner Password Credentials(用户密码模式):通过资源拥有者(用户)的用户名和密码来直接获取Access Token的一种方法,这种方法要求第三方应用(Client)是高度可信任的,并且其它授权方式不可用的情况下使用。
      ● Client Credentials(客户端模式):该模式是通过第三方应用(Client)发送一个自己的凭证到授权服务器获得Access Token,这种模式的使用要求该Client已经被授权服务器管理并限制其对被保护资源的访问范围。另外这种模式下Client应该就是一个资源拥有者(用户),如微服务程序。

    4. Access Token & Refresh Token

      这个很好理解,第三方应用通过Access Token去获取受保护的资源,但是Access Token是存在有效期的,一旦过期就无法使用,为了避免Access Token过期后无法使用,所以加入了Refresh Token的概念,通过刷新的方式来完成Access Token的更新。

    5. Client的注册

      在OAuth2.0中,所有需要访问受限资源的程序都视为第三方应用(Client),为了保证这个Client是安全的、可信任的,所以OAuth需要对Client进行管理。参考:https://tools.ietf.org/html/rfc6749#section-2

    6. OAuth的终结点

      这里终结点代表的是HTTP资源,在OAuth授权过程中需要使用到一些终结点的支持,如Authorization code(授权码)的获取,以及Access Token的获取,终结点由授权服务器提供。参考:https://tools.ietf.org/html/rfc6749#section-3

    7. Access Token Type

      Access Token的类型是让Client根据具体类型来使用Access Token完成对受保护资源的请求。
      OAuth2.0中有两种类型分别是Bearer和Mac,它们体现方式如下:
       ● Bearer:

      

        ● Mac:

      

      参考:https://tools.ietf.org/html/rfc6750

    在.Net中使用OAuth实现基于授权码模式的身份验证

      OAuth2.0是一个开放标准,既然是标准那么就可以有实现,在.Net中微软基于Owin实现了OAuth2.0协议,下面就介绍如何在ASP.NET MVC程序中实现OAuth身份验证。
      注:本例基于ASP.NET MVC默认带身份验证模板完成。

    1. 组件安装

      通过NuGet安装Microsoft.Owin.Security.OAuth组件:

      

      注:从该组件的名称可以看出,.Net对OAuth的实现实际上是基于Owin的,所以很多内容均使用Owin中相关的身份验证概念,这些内容可参考本系列与身份验证的文章。

    2. 添加OAuth授权服务器

      根据上面OAuth的介绍可知,授权服务器是OAuth其中一个角色,该角色最主要的功能就是Access Token的发放以及授权,另外它还用于支持授权码模式的授权码发放以及Client的管理。
      在Startup类型的Configuration方法中加入以下代码,该代码是为Owin中间件添加一个授权服务器(注:该中间件是一个Owin的身份验证中间件可参考《ASP.NET没有魔法——ASP.NET Identity 的“多重”身份验证》)。

      

      其中OAuthAuthorizationServerOptions定义如下:

      

      上面的定义可以分为以下几类:
      ● 终结点地址:AuthorizeEndpointPath、TokenEndpointPath等,它定义了访问获取授权码以及获取Token的地址信息。
      ● Token提供器:AuthorizationCodeProvider、AccessTokenProvider、RefreshTokenProvider负责完成对应令牌的创建和处理功能。
      ● Token的“加密”与“解密”:该功能是OAuth与Owin身份验证的结合,通过AccessTokenFormat等ISecureDataFormat接口的实现可以将对应的Token转换成一个  AuthenticationTicket。可参考《ASP.NET没有魔法——ASP.NET Identity的加密与解密》文中TicketDataFormat的用法。
      ● OAuth授权服务:Provider是整个OAuth服务器的核心,它包含了终结点的处理与响应、OAuth中的4种Access Token授权方式和刷新令牌获取Access Token的方式以及请求、客户端的相关验证:

      

    3. 为授权服务器添加终结点

      上面介绍OAuth时介绍了终结点实际上就是用来获取授权码或者Access Token的,在.Net中使用Microsoft.Owin.Security.OAuth组件仅需要通过配置的形式就可以指定授权码及Token获取的终结点访问地址(注:把AllowInsecureHttp配置属性设为true,可以允许不安全的http来访问终结点,该配置仅用于开发环境):

      

      完成后就可以通过浏览器访问这两个地址:

      

      

      可以看到是可以访问,只不过是有错误的(注:请求地址的QueryString的参数参考文档)。

    4. Client的管理与验证

       Client在OAuth中指代了所有的第三方需要访问受限制资源的应用程序,授权服务器为了能够识别和验证Client所以需要完成Client的管理以及验证功能。(注:微软在Microsoft.Owin.Security.OAuth组件中仅仅提供了Client验证的接口,所以要自己实现Client数据的管理以及验证逻辑):

      1). 添加Client实体以及对应的仓储(本例以内存的方式实现仓储,实际使用中至少应该保存数据库):

      

      上图是Client最基础的属性(注:如果还需要对Client的访问范围进行限制,那么还应该加入一个Scope的列表,本例不再加入Scope的限制)。

      2). Client的仓储:

      

      3). 实现授权服务器对Client的验证:

      由于授权服务器对客户端验证的接口位于OAuthAuthorizationServerProvider类型中,所以首先要继承该类型,并重载相应的验证方法:

      

      上面代码做了以下几件事:
      ● 尝试从Http请求header或者请求body中获取Client信息,包含Id和密码。
      ● 如果没有Client的Id信息,那么直接判断为不通过验证,如果有Client的密码信息则保存到Owin上下文中,供后续处理使用。
      ● 使用获得的ClientId在Client仓储中查询,判断是否是一个合法的Client,如不是则判断为不通过验证。

      4). 验证完成后设置该Client的重定向Url(注:该方法仍旧是重载OAuthAuthorizationServerProvider类型中的方法):

      

    5. 添加授权码提供器

      授权码的生成是授权服务器终结点的一项功能,当使用授权码模式时,用户访问Client会被引导跳转到授权服务器完成身份验证(登录),随后又携带授权码跳转回Client,Client使用该授权码获取Access Token。在OAuth的.Net实现中,需要通过在配置中配置一个类型为IAuthenticationTokenProvider的令牌提供器,该提供器用于创建和解析令牌,这里的创建实际就是用户完成登录后授权码的生成以及授权码和用户登录身份信息的关联,而解析实际就是根据授权码获得对应用户身份信息并生成Access Token的过程。

      下面就通过实现IAuthenticationTokenProvider的方式实现一个自定义授权码提供器:

      

      从上面代码可以看出这个提供器的核心功能是以Guid的方式生成一个键值(授权码)保存了当前用户的信息,当解析时通过该键值(即授权码)获取用户身份信息。(注:AuthenticationTokenCreateContext对象用于对当前用户身份信息AuthenticationTicket对的的序列化和反序列化)

      完成后将该提供器配置到授权服务器中间件中:

      

    6. 为授权服务器添加用户授权提示页面

      当用户访问授权码终结点时理应让用户知道Client需要他的授权,为此在ASP.NET MVC程序中需要添加一个路由与授权码终结点地址匹配的Controller、Action以及View:

      1). Controller及Action(注:该Action需要通过身份验证,如果没有需要跳转到登录页面完成身份验证后才可访问):

      

      2). View:显示授权提示

      

    7. 运行程序

      1). 访问授权码终结点获取授权码:http://localhost:59273/oauth2/authorize?response\_type=code&client\_id=test1

      由于没有登录,所以先跳转到登录页面。

      

      完成登录后跳转回授权页面:

      

      点击授权按钮后,携带授权码跳转到test1这个client的重定向Url(注:此处test1这个Client设置的Url就是授权服务器本身,所以看上去没有做重定向)

      

      得到授权码后,携带授权码访问Access Token终结点获取Access Token(注:这里使用Chrome浏览器的Postman拓展来实现请求的模拟):

      

      注:上面响应信息中的access_token包含了加密后用户的身份信息,其加密过程可参考基于Cookie的用户信息加密过程。ASP.NET没有魔法——ASP.NET Identity的加密与解密

    实现基于Access Token的身份验证

      上面介绍了如何基于授权码模式获得Access Token,接下来将介绍如何使用Access Token来访问受限制的资源(注:本例中的资源服务器与授权服务器位于同一实例中,所以当资源服务器对access token解密时,能够保证与授权服务器用于生成access token所用密钥一致,能够正常解密,这里的Access Token和基于Cookie的身份验证中的身份验证Cookie性质是相同的,都是将用户的身份信息序列化后的加密字符串)

      1. 在Startup类中添加基于Bearer的OAuth身份验证中间件:

      

      2. 添加访问受限制的资源:

      

      3. 访问受限资源:

      未添加授权信息直接跳转到登录页面。

      

      添加Access Token后可正常访问资源:

      

    加入Refresh Token支持

      上面使用授权码模式生成的Access Token是存在过期时间的(实际上无论什么方式生成的Access Token都存在过期时间),Token过期后又不可能让用户再授权一次,所以需要使用Refresh Token来定期刷新Access Token,.Net中实现Refresh Token的方式与授权码类似,在生成Refresh Token的同时会关联用户的身份信息,后续可以使用这个Refresh Token来生成新的Access Token。

      1. 创建Refresh Token提供器(实现方式与授权码提供器基本一致):

      

       2. 为授权服务器配置Refresh Token提供器:

      

      3. 再次获取到授权码后,根据该授权码获取Access Token,返回信息中将携带Refresh Token:

      

      4. 根据Refresh Token刷新Access Token:

      

    实现通过用户密码模式获取Access Token

      上面介绍了授权码模式的实现方式,但这种方式的核心实际上是建立了一个授权码和用户信息的映射(包括刷新令牌方式也是建立了刷新令牌与用户信息的映射),后续的Access Token实际上是使用这个了用户信息生成的。换句话用户信息才是核心,.Net中用户信息的体现从底到高分别是:IIdentity->ClaimsIdentity-> AuthenticationTicket,关于用户的身份信息可参考:《ASP.NET没有魔法——ASP.NET Identity与授权》,在基于授权码的模式时通过在授权服务器的登录功能获得了用户信息,而基于用户名密码模式时没有这个跳转登录环节,所以需要直接通过用户名密码来获取用户信息,其实现如下重载了OAuthAuthorizationServerProvider类型的GrantResourceOwnerCredentials方法:

      

      该方法从Owin环境中获取Identity中的UserManager对象,通过UserManager来验证用户是否存在,如果存在则将使用用户信息来创建一个ClaimsIdentity对象(注:此处是省略的实现,正常实现可根据需求参考Cookie验证方式将Scope或者Role等信息也添加到Identity对象中)。另UserManager是通过以下代码添加到Owin上下文中的,它的Key值是”AspNet.Identity.Owin:” + typeof(ApplicationUserManager).AssemblyQualifiedName。

      

      使用用户名密码获取Access Token:

      

    实现客户端模式获取Access Token

      客户端模式和用户名密码模式是类似的,它是通过Client的Id以及密码来进行授权,使用的是Client相关的信息,它的实现方式如下,重载GrantClientCredentials方法,通过客户端验证后的id和密码信息来验证改Client是否合法,对于合法的Client为其创建Identity对象(注:此处可以根据实际需求在Identity中添加相应的属性):

      

      使用Client信息获取Access Token:

      

      以上就是.Net中对于OAuth的实现,另外.Net中没有提供简化模式的接口,但是提供了一个GrantCustomExtension,也就是说授权模式是可拓展的。

    关于.Net中OAuth相关令牌的加密说明

      本例中除了授权码以及刷新令牌是2个Guid连接外,访问令牌(包括所有授权模式生成的令牌)以及授权码对应的用户信息、刷新令牌对应的用户信息都是经过加密的,其加解密对象创建过程如下,具体内容可参考《ASP.NET没有魔法——ASP.NET Identity的加密与解密

      

    小结

      本章内容介绍了OAuth2.0协议相关的内容,并通过一个ASP.NET MVC程序基于微软的Microsoft.Owin.Security.OAuth组件实现了该协议中的大部分功能。使用OAuth来实现身份验证可以让我们的应用程序从Web拓展至任意的平台上运行,但这样的实现仍旧是存在一些问题的,在下一篇文章中将对这些问题进一步的讨论和介绍。

    PS.这一章内容比较多,如有问题可以在评论区留言,另外最近事情比较多,所以更新慢了,感谢大家的支持。

    参考:

      https://stackoverflow.com/questions/39909419/jwt-vs-oauth-authentication
      http://www.cnblogs.com/linianhui/p/oauth2-authorization.html
      http://www.c-sharpcorner.com/UploadFile/4b0136/openid-connect-availability-in-owin-security-components/
      https://docs.microsoft.com/en-us/aspnet/aspnet/overview/owin-and-katana/owin-oauth-20-authorization-server
      https://security.stackexchange.com/questions/94995/oauth-2-vs-openid-connect-to-secure-api
      http://bitoftech.net/2014/07/16/enable-oauth-refresh-tokens-angularjs-app-using-asp-net-web-api-2-owin/

     本文链接:http://www.cnblogs.com/selimsong/p/8037717.html 

    ASP.NET没有魔法——目录

    在前一篇博文中,我们通过以 OAuth 的 Client Credential Grant 授权方式(只验证调用客户端,不验证登录用户)拿到的 Access Token ,成功调用了与用户无关的 Web API。

    在这篇博文中,我们将以 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 变得更简单。

    OAuth(开放授权)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。

    OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的 2 小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth 让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。


    以上概念来自:https://zh.wikipedia.org/wiki/OAuth

    OAuth 是什么?为什么要使用 OAuth?上面的概念已经很明确了,这里就不详细说明了。

    阅读目录:

    • 运行流程和授权模式
    • 授权码模式(authorization code)
    • 简化模式(implicit grant type)
    • 密码模式(resource owner password credentials)
    • 客户端模式(Client Credentials Grant)

    开源地址:https://github.com/yuezhongxin/OAuth2.Demo

    1. 运行流程和授权模式

    关于 OAuth 2.0 的运行流程(来自 RFC 6749):

    这里我们模拟一个场景:用户听落网,但需要登录才能收藏期刊,然后用快捷登录方式,使用微博的账号和密码登录后,落网就可以访问到微博的账号信息等,并且在落网也已登录,最后用户就可以收藏期刊了。

    结合上面的场景,详细说下 OAuth 2.0 的运行流程:

    • (A) 用户登录落网,落网询求用户的登录授权(真实操作是用户在落网登录)。
    • (B) 用户同意登录授权(真实操作是用户打开了快捷登录,用户输入了微博的账号和密码)。
    • (C) 由落网跳转到微博的授权页面,并请求授权(微博账号和密码在这里需要)。
    • (D) 微博验证用户输入的账号和密码,如果成功,则将 access_token 返回给落网。
    • (E) 落网拿到返回的 access_token,请求微博。
    • (F) 微博验证落网提供的 access_token,如果成功,则将微博的账户信息返回给落网。

    图中的名词解释:

    • Client -> 落网
    • Resource Owner -> 用户
    • Authorization Server -> 微博授权服务
    • Resource Server -> 微博资源服务

    其实,我不是很理解 ABC 操作,我觉得 ABC 可以合成一个 C:落网打开微博的授权页面,用户输入微博的账号和密码,请求验证。

    OAuth 2.0 四种授权模式:

    • 授权码模式(authorization code)
    • 简化模式(implicit)
    • 密码模式(resource owner password credentials)
    • 客户端模式(client credentials)

    下面我们使用 ASP.NET WebApi OWIN,分别实现上面的四种授权模式。

    简单解释:落网提供一些授权凭证,从微博授权服务获取到 authorization_code,然后根据 authorization_code,再获取到 access_token,落网需要请求微博授权服务两次。

    第一次请求授权服务(获取 authorization_code),需要的参数:

    • grant_type:必选,授权模式,值为 “authorization_code”。
    • response_type:必选,授权类型,值固定为 “code”。
    • client_id:必选,客户端 ID。
    • redirect_uri:必选,重定向 URI,URL 中会包含 authorization_code。
    • scope:可选,申请的权限范围,比如微博授权服务值为 follow_app_official_microblog。
    • state:可选,客户端的当前状态,可以指定任意值,授权服务器会原封不动地返回这个值,比如微博授权服务值为 weibo。

    第二次请求授权服务(获取 access_token),需要的参数:

    • grant_type:必选,授权模式,值为 “authorization_code”。
    • code:必选,授权码,值为上面请求返回的 authorization_code。
    • redirect_uri:必选,重定向 URI,必须和上面请求的 redirect_uri 值一样。
    • client_id:必选,客户端 ID。

    第二次请求授权服务(获取 access_token),返回的参数:

    • access_token:访问令牌.
    • token_type:令牌类型,值一般为 “bearer”。
    • expires_in:过期时间,单位为秒。
    • refresh_token:更新令牌,用来获取下一次的访问令牌。
    • scope:权限范围。

    ASP.NET WebApi OWIN 需要安装的程序包:

    • Owin
    • Microsoft.Owin.Host.SystemWeb
    • Microsoft.Owin.Security.OAuth
    • Microsoft.Owin.Security.Cookies
    • Microsoft.AspNet.Identity.Owin

    在项目中创建 Startup.cs 文件,添加如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public partial class Startup
    {
    public void ConfigureAuth(IAppBuilder app)
    {
    var OAuthOptions = new OAuthAuthorizationServerOptions
    {
    AllowInsecureHttp = true,
    AuthenticationMode = AuthenticationMode.Active,
    TokenEndpointPath = new PathString("/token"),
    AuthorizeEndpointPath=new PathString("/authorize"),
    AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10),

    Provider = new OpenAuthorizationServerProvider(),
    AuthorizationCodeProvider = new OpenAuthorizationCodeProvider(),
    RefreshTokenProvider = new OpenRefreshTokenProvider()
    };
    app.UseOAuthBearerTokens(OAuthOptions);
    }
    }

    OpenAuthorizationServerProvider 示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
    {



    public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
    {
    string clientId;
    string clientSecret;
    if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
    {
    context.TryGetFormCredentials(out clientId, out clientSecret);
    }

    if (clientId != "xishuai")
    {
    context.SetError("invalid_client", "client is not valid");
    return;
    }
    context.Validated();
    }




    public override async Task AuthorizeEndpoint(OAuthAuthorizeEndpointContext context)
    {
    if (context.AuthorizeRequest.IsImplicitGrantType)
    {

    var identity = new ClaimsIdentity("Bearer");
    context.OwinContext.Authentication.SignIn(identity);
    context.RequestCompleted();
    }
    else if (context.AuthorizeRequest.IsAuthorizationCodeGrantType)
    {

    var redirectUri = context.Request.Query["redirect_uri"];
    var clientId = context.Request.Query["client_id"];
    var identity = new ClaimsIdentity(new GenericIdentity(
    clientId, OAuthDefaults.AuthenticationType));

    var authorizeCodeContext = new AuthenticationTokenCreateContext(
    context.OwinContext,
    context.Options.AuthorizationCodeFormat,
    new AuthenticationTicket(
    identity,
    new AuthenticationProperties(new Dictionary<string, string>
    {
    {"client_id", clientId},
    {"redirect_uri", redirectUri}
    })
    {
    IssuedUtc = DateTimeOffset.UtcNow,
    ExpiresUtc = DateTimeOffset.UtcNow.Add(context.Options.AuthorizationCodeExpireTimeSpan)
    }));

    await context.Options.AuthorizationCodeProvider.CreateAsync(authorizeCodeContext);
    context.Response.Redirect(redirectUri + "?code=" + Uri.EscapeDataString(authorizeCodeContext.Token));
    context.RequestCompleted();
    }
    }




    public override async Task ValidateAuthorizeRequest(OAuthValidateAuthorizeRequestContext context)
    {
    if (context.AuthorizeRequest.ClientId == "xishuai" &&
    (context.AuthorizeRequest.IsAuthorizationCodeGrantType || context.AuthorizeRequest.IsImplicitGrantType))
    {
    context.Validated();
    }
    else
    {
    context.Rejected();
    }
    }




    public override async Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context)
    {
    context.Validated(context.RedirectUri);
    }




    public override async Task ValidateTokenRequest(OAuthValidateTokenRequestContext context)
    {
    if (context.TokenRequest.IsAuthorizationCodeGrantType || context.TokenRequest.IsRefreshTokenGrantType)
    {
    context.Validated();
    }
    else
    {
    context.Rejected();
    }
    }
    }

    需要注意的是,ValidateClientAuthentication 并不需要对 clientSecret 进行验证,另外,AuthorizeEndpoint 只是生成 authorization_code,并没有生成 access_token,生成操作在 OpenAuthorizationCodeProvider 中的 Receive 方法。

    OpenAuthorizationCodeProvider 示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class OpenAuthorizationCodeProvider : AuthenticationTokenProvider
    {
    private readonly ConcurrentDictionary<string, string> _authenticationCodes = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);




    public override void Create(AuthenticationTokenCreateContext context)
    {
    context.SetToken(Guid.NewGuid().ToString("n") + Guid.NewGuid().ToString("n"));
    _authenticationCodes[context.Token] = context.SerializeTicket();
    }




    public override void Receive(AuthenticationTokenReceiveContext context)
    {
    string value;
    if (_authenticationCodes.TryRemove(context.Token, out value))
    {
    context.DeserializeTicket(value);
    }
    }
    }

    上面 Create 方法是 await context.Options.AuthorizationCodeProvider.CreateAsync(authorizeCodeContext); 的重载方法。

    OpenRefreshTokenProvider 示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class OpenRefreshTokenProvider : AuthenticationTokenProvider
    {
    private static ConcurrentDictionary<string, string> _refreshTokens = new ConcurrentDictionary<string, string>();




    public override void Create(AuthenticationTokenCreateContext context)
    {
    context.Ticket.Properties.IssuedUtc = DateTime.UtcNow;
    context.Ticket.Properties.ExpiresUtc = DateTime.UtcNow.AddDays(60);

    context.SetToken(Guid.NewGuid().ToString("n") + Guid.NewGuid().ToString("n"));
    _refreshTokens[context.Token] = context.SerializeTicket();
    }




    public override void Receive(AuthenticationTokenReceiveContext context)
    {
    string value;
    if (_refreshTokens.TryRemove(context.Token, out value))
    {
    context.DeserializeTicket(value);
    }
    }
    }

    refresh_token 的作用就是,在 access_token 过期的时候,不需要再通过一些凭证申请 access_token,而是直接通过 refresh_token 就可以重新申请 access_token。

    另外,需要一个 api 来接受 authorization_code(来自 redirect_uri 的回调跳转),实现代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class CodesController : ApiController
    {
    [HttpGet]
    [Route("api/authorization_code")]
    public HttpResponseMessage Get(string code)
    {
    return new HttpResponseMessage()
    {
    Content = new StringContent(code, Encoding.UTF8, "text/plain")
    };
    }
    }

    基本上面代码已经实现了,单元测试代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    public class OAuthClientTest
    {
    private const string HOST_ADDRESS = "http://localhost:8001";
    private IDisposable _webApp;
    private static HttpClient _httpClient;

    public OAuthClientTest()
    {
    _webApp = WebApp.Start<Startup>(HOST_ADDRESS);
    Console.WriteLine("Web API started!");
    _httpClient = new HttpClient();
    _httpClient.BaseAddress = new Uri(HOST_ADDRESS);
    Console.WriteLine("HttpClient started!");
    }

    private static async Task<TokenResponse> GetToken(string grantType, string refreshToken = null, string userName = null, string password = null, string authorizationCode = null)
    {
    var clientId = "xishuai";
    var clientSecret = "123";
    var parameters = new Dictionary<string, string>();
    parameters.Add("grant_type", grantType);

    if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
    {
    parameters.Add("username", userName);
    parameters.Add("password", password);
    }
    if (!string.IsNullOrEmpty(authorizationCode))
    {
    parameters.Add("code", authorizationCode);
    parameters.Add("redirect_uri", "http://localhost:8001/api/authorization_code");
    }
    if (!string.IsNullOrEmpty(refreshToken))
    {
    parameters.Add("refresh_token", refreshToken);
    }

    _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 != HttpStatusCode.OK)
    {
    Console.WriteLine(response.StatusCode);
    Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
    return null;
    }
    return await response.Content.ReadAsAsync<TokenResponse>();
    }

    private static async Task<string> GetAuthorizationCode()
    {
    var clientId = "xishuai";

    var response = await _httpClient.GetAsync($"/authorize?grant_type=authorization_code&response_type=code&client_id={clientId}&redirect_uri={HttpUtility.UrlEncode("http://localhost:8001/api/authorization_code")}");
    var authorizationCode = await response.Content.ReadAsStringAsync();
    if (response.StatusCode != HttpStatusCode.OK)
    {
    Console.WriteLine(response.StatusCode);
    Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
    return null;
    }
    return authorizationCode;
    }

    [Fact]
    public async Task OAuth_AuthorizationCode_Test()
    {
    var authorizationCode = GetAuthorizationCode().Result;
    var tokenResponse = GetToken("authorization_code", null, null, null, authorizationCode).Result;
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);

    var response = await _httpClient.GetAsync($"/api/values");
    if (response.StatusCode != HttpStatusCode.OK)
    {
    Console.WriteLine(response.StatusCode);
    Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
    }
    Console.WriteLine(await response.Content.ReadAsStringAsync());
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);

    Thread.Sleep(10000);

    var tokenResponseTwo = GetToken("refresh_token", tokenResponse.RefreshToken).Result;
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponseTwo.AccessToken);
    var responseTwo = await _httpClient.GetAsync($"/api/values");
    Assert.Equal(HttpStatusCode.OK, responseTwo.StatusCode);
    }
    }

    Startup 配置的 access_token 过期时间是 10s,线程休眠 10s,是为了测试 refresh_token。

    上面单元测试代码,执行成功,当然也可以用 Postman 模拟请求测试。

    3. 简化模式(implicit grant type)

    简单解释:授权码模式的简化版,省略 authorization_code,并且 access_token 以 URL 参数返回(比如 #token=xxxx)。

    请求授权服务(只有一次),需要的参数:

    • response_type:必选,授权类型,值固定为 “token”。
    • client_id:必选,客户端 ID。
    • redirect_uri:必选,重定向 URI,URL 中会包含 access_token。
    • scope:可选,申请的权限范围,比如微博授权服务值为 follow_app_official_microblog。
    • state:可选,客户端的当前状态,可以指定任意值,授权服务器会原封不动地返回这个值,比如微博授权服务值为 weibo。

    需要注意的是,简化模式请求参数并不需要 grant_type,并且可以用 http get 直接请求。

    Startup 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public partial class Startup
    {
    public void ConfigureAuth(IAppBuilder app)
    {
    var OAuthOptions = new OAuthAuthorizationServerOptions
    {
    AllowInsecureHttp = true,
    AuthenticationMode = AuthenticationMode.Active,
    TokenEndpointPath = new PathString("/token"),
    AuthorizeEndpointPath=new PathString("/authorize"),
    AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10),

    Provider = new OpenAuthorizationServerProvider(),
    RefreshTokenProvider = new OpenRefreshTokenProvider()
    };
    app.UseOAuthBearerTokens(OAuthOptions);
    }
    }

    OpenRefreshTokenProvider、OpenAuthorizationServerProvider 的代码就不贴了,和上面授权码模式一样,只不过在 OpenAuthorizationServerProvider 的 AuthorizeEndpoint 方法中有 IsImplicitGrantType 判断,示例代码:

    1
    2
    3
    var identity = new ClaimsIdentity("Bearer");
    context.OwinContext.Authentication.SignIn(identity);
    context.RequestCompleted();

    这段代码执行会直接回调 redirect_uri,并附上 access_token,接受示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [HttpGet]
    [Route("api/access_token")]
    public HttpResponseMessage GetToken()
    {
    var url = Request.RequestUri;
    return new HttpResponseMessage()
    {
    Content = new StringContent("", Encoding.UTF8, "text/plain")
    };
    }

    单元测试代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    [Fact]
    public async Task OAuth_Implicit_Test()
    {
    var clientId = "xishuai";

    var tokenResponse = await _httpClient.GetAsync($"/authorize?response_type=token&client_id={clientId}&redirect_uri={HttpUtility.UrlEncode("http://localhost:8001/api/access_token")}");

    var accessToken = "";
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

    var response = await _httpClient.GetAsync($"/api/values");
    if (response.StatusCode != HttpStatusCode.OK)
    {
    Console.WriteLine(response.StatusCode);
    Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
    }
    Console.WriteLine(await response.Content.ReadAsStringAsync());
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }

    回调 redirect_uri 中的 access_token 参数值,因为在 URL 的 # 后,后端不好获取到,所以这里的单元测试只是示例,并不能执行成功,建议使用 Poastman 进行测试。

    4. 密码模式(resource owner password credentials)

    简单解释:在一开始叙述的 OAuth 授权流程的时候,其实就是密码模式,落网发起授权请求,用户在微博的授权页面填写账号和密码,验证成功则返回 access_token,所以,在此过程中,用户填写的账号和密码,和落网没有半毛钱关系,不会存在账户信息被第三方窃取问题。

    请求授权服务(只有一次),需要的参数:

    • grant_type:必选,授权模式,值固定为 “password”。
    • username:必选,用户名。
    • password:必选,用户密码。
    • scope:可选,申请的权限范围,比如微博授权服务值为 follow_app_official_microblog。

    Startup 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public partial class Startup
    {
    public void ConfigureAuth(IAppBuilder app)
    {
    var OAuthOptions = new OAuthAuthorizationServerOptions
    {
    AllowInsecureHttp = true,
    AuthenticationMode = AuthenticationMode.Active,
    TokenEndpointPath = new PathString("/token"),
    AuthorizeEndpointPath=new PathString("/authorize"),
    AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10),

    Provider = new OpenAuthorizationServerProvider(),
    RefreshTokenProvider = new OpenRefreshTokenProvider()
    };
    app.UseOAuthBearerTokens(OAuthOptions);
    }
    }

    OpenAuthorizationServerProvider 示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
    {



    public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
    {
    string clientId;
    string clientSecret;
    if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
    {
    context.TryGetFormCredentials(out clientId, out clientSecret);
    }

    if (clientId != "xishuai")
    {
    context.SetError("invalid_client", "client is not valid");
    return;
    }
    context.Validated();
    }




    public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
    {
    if (string.IsNullOrEmpty(context.UserName))
    {
    context.SetError("invalid_username", "username is not valid");
    return;
    }
    if (string.IsNullOrEmpty(context.Password))
    {
    context.SetError("invalid_password", "password is not valid");
    return;
    }

    if (context.UserName != "xishuai" || context.Password != "123")
    {
    context.SetError("invalid_identity", "username or password is not valid");
    return;
    }

    var OAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType);
    OAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
    context.Validated(OAuthIdentity);
    }
    }

    GrantResourceOwnerCredentials 内部可以调用外部服务,以进行对用户账户信息的验证。

    单元测试代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    [Fact]
    public async Task OAuth_Password_Test()
    {
    var tokenResponse = GetToken("password", null, "xishuai", "123").Result;
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);

    var response = await _httpClient.GetAsync($"/api/values");
    if (response.StatusCode != HttpStatusCode.OK)
    {
    Console.WriteLine(response.StatusCode);
    Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
    }
    Console.WriteLine(await response.Content.ReadAsStringAsync());
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);

    Thread.Sleep(10000);

    var tokenResponseTwo = GetToken("refresh_token", tokenResponse.RefreshToken).Result;
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponseTwo.AccessToken);
    var responseTwo = await _httpClient.GetAsync($"/api/values");
    Assert.Equal(HttpStatusCode.OK, responseTwo.StatusCode);
    }

    5. 客户端模式(Client Credentials Grant)

    简单解释:顾名思义,客户端模式就是客户端直接向授权服务发起请求,和用户没什么关系,也就是说落网直接向微博提交授权请求,此类的请求不包含用户信息,一般用作应用程序直接的交互等。

    请求授权服务(只有一次),需要的参数:

    • grant_type:必选,授权模式,值固定为 “client_credentials”。
    • client_id:必选,客户端 ID。
    • client_secret:必选,客户端密码。
    • scope:可选,申请的权限范围,比如微博授权服务值为 follow_app_official_microblog。

    Startup 代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public partial class Startup
    {
    public void ConfigureAuth(IAppBuilder app)
    {
    var OAuthOptions = new OAuthAuthorizationServerOptions
    {
    AllowInsecureHttp = true,
    AuthenticationMode = AuthenticationMode.Active,
    TokenEndpointPath = new PathString("/token"),
    AuthorizeEndpointPath=new PathString("/authorize"),
    AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10),

    Provider = new OpenAuthorizationServerProvider(),
    RefreshTokenProvider = new OpenRefreshTokenProvider()
    };
    app.UseOAuthBearerTokens(OAuthOptions);
    }
    }

    OpenAuthorizationServerProvider 示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
    {



    public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
    {
    string clientId;
    string clientSecret;
    if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
    {
    context.TryGetFormCredentials(out clientId, out clientSecret);
    }

    if (clientId != "xishuai" || clientSecret != "123")
    {
    context.SetError("invalid_client", "client or clientSecret is not valid");
    return;
    }
    context.Validated();
    }




    public override async Task GrantClientCredentials(OAuthGrantClientCredentialsContext context)
    {
    var identity = new ClaimsIdentity(new GenericIdentity(
    context.ClientId, OAuthDefaults.AuthenticationType),
    context.Scope.Select(x => new Claim("urn:oauth:scope", x)));

    context.Validated(identity);
    }
    }

    和其他授权模式不同,客户端授权模式需要对 client_secret 进行验证(ValidateClientAuthentication)。

    单元测试代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    [Fact]
    public async Task OAuth_ClientCredentials_Test()
    {
    var tokenResponse = GetToken("client_credentials").Result;
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);

    var response = await _httpClient.GetAsync($"/api/values");
    if (response.StatusCode != HttpStatusCode.OK)
    {
    Console.WriteLine(response.StatusCode);
    Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
    }
    Console.WriteLine(await response.Content.ReadAsStringAsync());
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);

    Thread.Sleep(10000);

    var tokenResponseTwo = GetToken("refresh_token", tokenResponse.RefreshToken).Result;
    _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponseTwo.AccessToken);
    var responseTwo = await _httpClient.GetAsync($"/api/values");
    Assert.Equal(HttpStatusCode.OK, responseTwo.StatusCode);
    }

    除了上面四种授权模式之外,还有一种就是更新令牌(refresh token),单元测试代码中已经体现了,需要额外的两个参数:

    • grant_type:必选,授权模式,值固定为 “refresh_token”。
    • refresh_token:必选,授权返回的 refresh_token。

    最后,总结下四种授权模式的应用场景:

    • 授权码模式(authorization code):引入 authorization_code,可以增加系统的安全性,和客户端应用场景差不多,但一般用于 Server 端。
    • 简化模式(implicit):无需 Server 端的介入,前端可以直接完成,一般用于前端操作。
    • 密码模式(resource owner password credentials):和用户账户相关,一般用于第三方登录。
    • 客户端模式(client credentials):和用户无关,一般用于应用程序和 api 之间的交互场景,比如落网开放出 api,供第三方开发者进行调用数据等。

    开源地址:https://github.com/yuezhongxin/OAuth2.Demo

    参考资料:

ASP.NET Core 中基于策略的授权旨在分离授权与应用程序逻辑,它提供了灵活的策略定义模型,在一些权限固定的系统中,使用起来非常方便。但是,当要授权的资源无法预先确定,或需要将权限控制到每一个具体的操作当中时,基于策略的授权便不再适用,本章就来介绍一下如何进行动态的授权。

目录

  1. 基于资源的授权
  2. 基于权限的授权

基于资源的授权

有些场景下,授权需要依赖于要访问的资源,例如:每个资源通常会有一个创建者属性,我们只允许该资源的创建者才可以对其进行编辑,删除等操作,这就无法通过[Authorize]特性来指定授权了。因为授权过滤器会在我们的应用代码,以及MVC的模型绑定之前执行,无法确定所访问的资源。此时,我们需要使用基于资源的授权,下面就来演示一下具体是如何操作的。

定义资源Requirement

在基于资源的授权中,我们要判断的是用户是否具有针对该资源的某项操作,因此,我们先定义一个代表操作的Requirement

1
2
3
4
public class MyRequirement : IAuthorizationRequirement
{
public string Name { get; set; }
}

可以根据实际场景来定义需要的属性,在本示例中,只需要一个Name属性,用来表示针对资源的操作名称(如:增查改删等)。

然后,我们预定义一些常用的操作,方便业务中的调用:

1
2
3
4
5
6
7
public static class Operations
{
public static MyRequirement Create = new MyRequirement { Name = "Create" };
public static MyRequirement Read = new MyRequirement { Name = "Read" };
public static MyRequirement Update = new MyRequirement { Name = "Update" };
public static MyRequirement Delete = new MyRequirement { Name = "Delete" };
}

上面定义的 MyRequirement 虽然很简单,但是非常通用,因此,在 ASP.NET Core 中也内置了一个OperationAuthorizationRequirement

1
2
3
4
public class OperationAuthorizationRequirement : IAuthorizationRequirement
{
public string Name { get; set; }
}

在实际应用中,我们可以直接使用OperationAuthorizationRequirement,而不需要再自定义 _Requirement_,而在这里只是为了方便理解,后续也继续使用 MyRequirement 来演示。

实现资源授权Handler

每一个 Requirement 都需要有一个对应的 _Handler_,来完成授权逻辑,可以直接让 Requirement 实现IAuthorizationHandler接口,也可以单独定义授权Handler,在这里使用后者。

在本示例中,我们是根据资源的创建者来判断用户是否具有操作权限,因此,我们定义一个资源创建者的接口,而不是直接依赖于具体的资源:

1
2
3
4
public interface IDocument
{
string Creator { get; set; }
}

然后实现我们的授权Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class DocumentAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, IDocument>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, OperationAuthorizationRequirement requirement, IDocument resource)
{

if (context.User.IsInRole("admin"))
{
context.Succeed(requirement);
}
else
{

if (requirement == Operations.Create || requirement == Operations.Read)
{
context.Succeed(requirement);
}
else
{

if (context.User.Identity.Name == resource.Creator)
{
context.Succeed(requirement);
}
else
{
context.Fail();
}
}
}
return Task.CompletedTask;
}
}

在前面章节的《自定义策略》示例中,我们继承的是AuthorizationHandler<NameAuthorizationRequirement>,而这里继承了AuthorizationHandler<OperationAuthorizationRequirement, Document>,很明显,比之前的多了resource参数,以便用来实现基于资源的授权。

如上,我们并没有验证用户是否已登录,以及context.User是否为空等。这是因为在 ASP.NET Core 的默认授权中,已经对这些进行了判断,我们只需要在要授权的控制器上添加[Authorize]特性即可,无需重复性的工作。

最后,不要忘了,还需要将DocumentAuthorizationHandler注册到DI系统中:

1
services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();

现在就可以在我们的应用代码中调用IAuthorizationService来完成授权了,不过在此之前,我们再来回顾一下IAuthorizationService接口:

1
2
3
4
5
6
public interface IAuthorizationService
{
Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements);

Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName);
}

在《上一章》中,我们提到,使用[Authorize]设置授权时,其AuthorizationHandlerContext中的resource字段被设置为空,现在,我们将要授权的资源传进去即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[Authorize]
public class DocumentsController : Controller
{
public async Task<ActionResult> Details(int? id)
{
var document = _docStore.Find(id.Value);
if (document == null)
{
return NotFound();
}
if ((await _authorizationService.AuthorizeAsync(User, document, Operations.Read)).Succeeded)
{
return View(document);
}
else
{
return new ForbidResult();
}
}

public async Task<IActionResult> Edit(int? id)
{
var document = _docStore.Find(id.Value);
if (document == null)
{
return NotFound();
}
if ((await _authorizationService.AuthorizeAsync(User, document, Operations.Update)).Succeeded)
{
return View(document);
}
else
{
return new ForbidReuslt();
}
}
}

如上,在授权失败时,我们返回了ForbidResult,建议不要返回ChallengeResult,因为我们要明确的告诉用户是无权访问,而不是未登录。

基于资源的权限非常简单,但是每次都要在应用代码中显示调用IAuthorizationService,显然比较繁琐,我们也可以使用AOP模式,或者使用EF Core拦截器来实现,将授权验证与业务代码分离。

基于权限的授权

在一个通用的用户权限管理系统中,通常每一个Action都代表一种权限,用户拥有哪些权限也是可以动态分配的。本小节就来介绍一下在 ASP.NET Core 中,如何实现一个简单权限管理系统。

定义权限项

首先,我们要确定我们的系统分为哪些权限项,这通常是由业务所决定的,并且是预先确定的,我们可以硬编码在代码中,方便统一调用:

1
2
3
4
5
6
7
8
public static class Permissions
{
public const string User = "User";
public const string UserCreate = "User.Create";
public const string UserRead = "User.Read";
public const string UserUpdate = "User.Update";
public const string UserDelete = "User.Delete";
}

如上,我们简单定义了“创建用户”,“查询用户”,“更新用户”,“删除用户”四个权限。通常会对权限项进行分组,构成一个树形结构,这样在展示和配置权限时,都会方便很多。在这里,使用.来表示层级进行分组,其中User权限项包含所有以User.开头的权限。

定义权限Requirement

与基于资源的授权类似,我们同样需要定义一个权限_Requirement_:

1
2
3
4
5
6
7
8
9
public class PermissionAuthorizationRequirement : IAuthorizationRequirement
{
public PermissionAuthorizationRequirement(string name)
{
Name = name;
}

public string Name { get; set; }
}

使用Name属性来表示权限的名称,与上面Permissions的常量对应。

实现权限授权Handler

然后实现与上面定义的 Requirement 对应的授权Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionAuthorizationRequirement>
{
private readonly UserStore _userStore;

public PermissionAuthorizationHandler(UserStore userStore)
{
_userStore = userStore;
}

protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionAuthorizationRequirement requirement)
{
if (context.User != null)
{
if (context.User.IsInRole("admin"))
{
context.Succeed(requirement);
}
else
{
var userIdClaim = context.User.FindFirst(_ => _.Type == ClaimTypes.NameIdentifier);
if (userIdClaim != null)
{
if (_userStore.CheckPermission(int.Parse(userIdClaim.Value), requirement.Name))
{
context.Succeed(requirement);
}
}
}
}
return Task.CompletedTask;
}
}

如上,把admin角色设置为内部固定角色,直接跳过授权检查。其他角色则从Claims中取出用户Id,然后调用CheckPermission完成授权。

权限检查的具体逻辑就属于业务层面的了,通常会从数据库中查找用的的权限列表进行验证,这里就不在多说,简单模拟了一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class UserStore
{
private static List<User> _users = new List<User>() {
new User { Id=1, Name="admin", Password="111111", Role="admin", Email="admin@gmail.com", PhoneNumber="18800000000"},
new User { Id=2, Name="alice", Password="111111", Role="user", Email="alice@gmail.com", PhoneNumber="18800000001", Permissions = new List<UserPermission> {
new UserPermission { UserId = 1, PermissionName = Permissions.User },
new UserPermission { UserId = 1, PermissionName = Permissions.Role }
}
},
new User { Id=3, Name="bob", Password="111111", Role = "user", Email="bob@gmail.com", PhoneNumber="18800000002", Permissions = new List<UserPermission> {
new UserPermission { UserId = 2, PermissionName = Permissions.UserRead },
new UserPermission { UserId = 2, PermissionName = Permissions.RoleRead }
}
},
};

public bool CheckPermission(int userId, string permissionName)
{
var user = Find(userId);
if (user == null) return false;
return user.Permissions.Any(p => permissionName.StartsWith(p.PermissionName));
}
}

最后,与上面示例一样,将Handler注册到DI系统中:

1
services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();

使用策略授权

那么,怎么在应用代码中使用基于权限的授权呢?

最为简单的,我们可以直接借助于 ASP.NET Core 的授权策略来实现基于权限的授权,因为此时并不需要资源。

1
2
3
4
5
6
7
services.AddAuthorization(options =>
{
options.AddPolicy(Permissions.UserCreate, policy => policy.AddRequirements(new PermissionAuthorizationRequirement(Permissions.UserCreate)));
options.AddPolicy(Permissions.UserRead, policy => policy.AddRequirements(new PermissionAuthorizationRequirement(Permissions.UserRead)));
options.AddPolicy(Permissions.UserUpdate, policy => policy.AddRequirements(new PermissionAuthorizationRequirement(Permissions.UserUpdate)));
options.AddPolicy(Permissions.UserDelete, policy => policy.AddRequirements(new PermissionAuthorizationRequirement(Permissions.UserDelete)));
});

如上,针对每一个权限项都定义一个对应的授权策略,然后,就可以在控制器中直接使用[Authorize]来完成授权:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[Authorize]
public class UserController : Controller
{
[Authorize(Policy = Permissions.UserRead)]
public ActionResult Index()
{
}

[Authorize(Policy = Permissions.UserRead)]
public ActionResult Details(int? id)
{
}

[Authorize(Policy = Permissions.UserCreate)]
public ActionResult Create()
{
return View();
}

[Authorize(Policy = Permissions.UserCreate)]
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create([Bind("Title")] User user)
{
}
}

当然,我们也可以像基于资源的授权那样,在应用代码中调用IAuthorizationService完成授权,这样做的好处是无需定义策略,但是,显然一个一个来定义策略太过于繁琐。

还有一种更好方式,就是使用MVC过滤器来完成对IAuthorizationService的调用,下面就来演示一下。

自定义授权过滤器

我们可以参考上一章中介绍的《AuthorizeFilter》来自定义一个权限过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class PermissionFilter : Attribute, IAsyncAuthorizationFilter
{
public PermissionFilter(string name)
{
Name = name;
}

public string Name { get; set; }

public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var authorizationService = context.HttpContext.RequestServices.GetRequiredService<IAuthorizationService>();
var authorizationResult = await authorizationService.AuthorizeAsync(context.HttpContext.User, null, new PermissionAuthorizationRequirement(Name));
if (!authorizationResult.Succeeded)
{
context.Result = new ForbidResult();
}
}
}

上面的实现非常简单,我们接受一个name参数,代表权限的名称,然后将权限名称转化为PermissionAuthorizationRequirement,最后直接调用 authorizationService 来完成授权。

接下来,我们就可以直接在控制器中使用PermissionFilter过滤器来完成基于权限的授权了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[Authorize]
public class UserController : Controller
{
[PermissionFilter(Permissions.UserRead)]
public ActionResult Index()
{
return View(_userStore.GetAll());
}

[PermissionFilter(Permissions.UserCreate)]
public ActionResult Create()
{
}

[PermissionFilter(Permissions.UserCreate)]
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create([Bind("Title")] User user)
{
}

[PermissionFilter(Permissions.UserUpdate)]
public IActionResult Edit(int? id)
{
}

[PermissionFilter(Permissions.UserUpdate)]
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Edit(int id, [Bind("Id,Title")] User user)
{
}
}

在视图中使用授权

通常,在前端页面当中,我们也需要根据用户的权限来判断是否显示“添加”,“删除”等按钮,而不是让用户点击“添加”,再提示用户没有权限,这在 ASP.NET Core 中实现起来也非常简单。

我们可以直接在Razor视图中注入IAuthorizationService来检查用户权限:

1
2
3
4
5
6
7
8
@inject IAuthorizationService AuthorizationService

@if ((await AuthorizationService.AuthorizeAsync(User, AuthorizationSample.Authorization.Permissions.UserCreate)).Succeeded)
{
<p>
<a asp-action="Create">创建</a>
</p>
}

不过,上面的代码是通过策略名称来授权的,如果我们使用了上面创建的授权过滤器,而没有定义授权策略的话,需要使用如下方式来实现:

1
2
3
4
5
6
7
8
@inject IAuthorizationService AuthorizationService

@if ((await AuthorizationService.AuthorizeAsync(User, new PermissionAuthorizationRequirement(AuthorizationSample.Authorization.Permissions.UserCreate))).Succeeded)
{
<p>
<a asp-action="Create">创建</a>
</p>
}

我们也可以定义一个AuthorizationService的扩展方法,实现通过权限名称进行授权,这里就不再多说。

我们不能因为隐藏了操作按钮,就不在后端进行授权验证了,就像JS的验证一样,前端的验证就为了提升用户的体验,后端的验证在任何时候都是必不可少的。

总结

在大多数场景下,我们只需要使用授权策略就可以应对,而在授权策略不能满足我们的需求时,由于 ASP.NET Core 提供了一个统一的 IAuthorizationService 授权接口,这就使我们扩展起来也非常方便。ASP.NET Core 的授权部分到这来也就介绍完了,总的来说,要比ASP.NET 4.x的时候,简单,灵活很多,可见 ASP.NET Core 不仅仅是为了跨平台,而是为了适应现代应用程序的开发方式而做出的全新的设计,我们也应该用全新的思维去学习.NET Core,踏上时代的浪潮。

本文示例代码地址:https://github.com/RainingNight/AspNetCoreSample/tree/master/src/Functional/Authorization/AuthorizationSample

ASP.NET Core中使用GraphQL - 目录


在之前的几章中,我们的GraphQL查询是没有优化过的。下面我们以CustomerType中的orders查询为例

CustomerType.cs
1
2
3
4
5
6
Field<ListGraphType<OrderType>, IEnumerable<Order>>()  
.Name("Orders")
.ResolveAsync(ctx =>
{
return dataStore.GetOrdersAsync();
});

在这个查询中,我们获取了某个顾客中所有的订单, 这里如果你只是获取一些标量字段,那很简单。

但是如果需要获取一些关联属性呢?例如查询系统中的所有订单,在订单信息中附带顾客信息。

OrderType
1
2
3
4
5
6
7
8
9
10
11
public OrderType(IDataStore dataStore, IDataLoaderContextAccessor accessor)  
{
Field(o => o.Tag);
Field(o => o.CreatedAt);
Field<CustomerType, Customer>()
.Name("Customer")
.ResolveAsync(ctx =>
{
return dataStore.GetCustomerByIdAsync(ctx.Source.CustomerId);
});
}

这里当获取customer信息的时候,系统会另外初始化一个请求,以便从数据仓储中查询订单相关的顾客信息。

如果你了解dotnet cli, 你可以针对以下查询,在控制台输出所有的EF查询日志

1
2
3
4
5
6
7
8
9
10
{
orders{
tag
createdAt
customer{
name
billingAddress
}
}
}

查询结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"data": {
"orders": [
{
"tag": "XPS 13",
"createdAt": "2018-11-11",
"customer": {
"name": "Lamond Lu",
"billingAddress": "Test Address"
}
},
{
"tag": "XPS 15",
"createdAt": "2018-11-11",
"customer": {
"name": "Lamond Lu",
"billingAddress": "Test Address"
}
}
]
}
}

产生日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (16ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [o].[OrderId], [o].[CreatedAt], [o].[CustomerId], [o].[CustomerId1], [o].[Tag]
FROM [Orders] AS [o]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (6ms) [Parameters=[@__get_Item_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']

SELECT TOP(1) [e].[CustomerId], [e].[BillingAddress], [e].[Name]
FROM [Customers] AS [e]
WHERE [e].[CustomerId] = @__get_Item_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (5ms) [Parameters=[@__get_Item_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']

SELECT TOP(1) [e].[CustomerId], [e].[BillingAddress], [e].[Name]
FROM [Customers] AS [e]
WHERE [e].[CustomerId] = @__get_Item_0
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[2]
Request finished in 864.2749ms 200

从日志上我们很清楚的看到,这个查询使用了3个查询语句,第一个语句查询所有的订单信息,第二个和第三个请求分别查询了2个订单的顾客信息。这里可以想象如果这里有N的订单,就会产生N+1个查询语句,这是非常不效率的。正常情况下我们其实可以通过2条语句就完成上述的查询,后面查询单个顾客信息其实可以整合成一条语句。

为了实现这个效果,我们就需要介绍一下GraphQL中的DataLoader

DataLoaderGraphQL中的一个重要功能,它为GraphtQL查询提供了批处理和缓存的功能。

为了使用DataLoader, 我们首先需要在Startup.cs中注册2个新服务IDataLoaderContextAccessorDataLoaderDocumentListener

Startup.cs
1
2
services.AddSingleton<IDataLoaderContextAccessor, DataLoaderContextAccessor>();  
services.AddSingleton<DataLoaderDocumentListener>();

如果你的某个GraphQL类型需要DataLoader, 你就可以在其构造函数中注入一个IDataLoaderContextAccessor接口对象。

但是为了使用DataLoader, 我们还需要将它添加到我们的中间件中。

GraphQLMiddleware.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public async Task InvokeAsync(HttpContext httpContext, ISchema schema, IServiceProvider serviceProvider)  
{
....
....

var result = await _executor.ExecuteAsync(doc =>
{
....
....
doc.Listeners.Add(serviceProvider .GetRequiredService<DataLoaderDocumentListener>());
}).ConfigureAwait(false);

....
....
}

下一步,我们需要为我们的仓储类,添加一个新方法,这个方法可以根据顾客的id列表,返回所有的顾客信息。

DataStore.cs
1
2
3
4
5
6
7
8
public async Task<Dictionary<int, Customer>> GetCustomersByIdAsync(
IEnumerable<int> customerIds,
CancellationToken token)
{
return await _context.Customers
.Where(i => customerIds.Contains(i.CustomerId))
.ToDictionaryAsync(x => x.CustomerId);
}

然后我们修改OrderType

OrderType
1
2
3
4
5
6
7
Field<CustomerType, Customer>()  
.Name("Customer")
.ResolveAsync(ctx =>
{
var customersLoader = accessor.Context.GetOrAddBatchLoader<int, Customer>("GetCustomersById", dataStore.GetCustomersByIdAsync);
return customersLoader.LoadAsync(ctx.Source.CustomerId);
});

完成以上修改之后,我们重新运行项目, 使用相同的query, 结果如下,查询语句的数量变成了2个,效率大大提高

1
2
3
4
5
6
7
8
9
10
11
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
Entity Framework Core 2.1.4-rtm-31024 initialized 'ApplicationDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: None
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (19ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [o].[OrderId], [o].[CreatedAt], [o].[CustomerId], [o].[CustomerId1], [o].[Tag]
FROM [Orders] AS [o]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [i].[CustomerId], [i].[BillingAddress], [i].[Name]
FROM [Customers] AS [i]
WHERE [i].[CustomerId] IN (1)

DataLoader背后的原理

GetOrAddBatchLoader方法会等到所有查询的顾客id列表准备好之后才会执行,它会一次性把所有查询id的顾客信息都收集起来。 这种技术就叫做批处理,使用了这种技术之后,无论有多少个关联的顾客信息,系统都只会发出一次请求来获取所有数据。

本文源代码: https://github.com/lamondlu/GraphQL_Blogs/tree/master/Part%20X

你是否已经厌倦了REST风格的API? 让我们来聊一下GraphQL。 GraphQL提供了一种声明式的方式从服务器拉取数据。你可以从GraphQL官网中了解到GraphQL的所有优点。在这一系列博客中,我将展示如何在ASP.NET Core中集成GraphQL, 并使用GraphQL作为你的API查询语言。

使用GraphQL的声明式查询,你可以自定义API返回的属性列表。这与REST API中每个API只返回固定字段不同。

为了在C#中使用GraphQL, GraphQL社区中提供了一个开源组件graphql-dotnet。本系列博客中我们都将使用这个组件。

首先我们创建一个空的ASP.NET Core App

1
dotnet new web 

然后我们添加对graphql-dotnet库的引用

1
dotnet add package GraphQL

下面我们来创建一个query类, 我们将它命名为HelloWorldQuerygraphql-dotnet中,查询类都需要继承ObjectGraphType类,所以HelloWorldQuery的代码如下

1
2
3
4
5
6
7
8
9
10
11
using GraphQL.Types;
public class HelloWorldQuery : ObjectGraphType
{
public HelloWorldQuery()
{
Field<StringGraphType>(
name: "hello",
resolve: context => "world"
);
}
}

这里你可能注意到我们使用了一个泛型方法Field,并传递了一个GraphQL的字符串类型StringGraphType来定义了一个hello字段, resolve 参数是一个Func委托,在其中定义了如何返回当前字段的值,这里我们是直接返回了一个字符串hello。

查询类中的返回字段都是定义在查询类的构造函数中的

现在我们一个有了一个查询类,下一步我们需要使用这个查询类构建一个结构(schema)。

Startup.cs文件的Configure方法中,使用以下代码替换原有代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var schema = new Schema {
Query = new HelloWorldQuery()
};

app.Run(async (context) =>
{
var result = await new DocumentExecuter()
.ExecuteAsync(doc =>
{
doc.Schema = schema;
doc.Query = @"
query {
hello
}
";
}).ConfigureAwait(false);

var json = new DocumentWriter(indent: true)
.Write(result)
await context.Response.WriteAsync(json);
});
  • DocumentExecuter 类的ExecuteAsync方法中我们定义Action委托,并通过这个委托设置了一个ExecutionOptions对象。这个对象初始化了我们定义的结构(schema), 并执行了我们定义的查询字符串。
  • doc.Query定义了一个查询字符串
  • 最终查询执行的结果会通过DocumentWriter类实例的Write被转换成一个JSON字符串

下面我们来运行一下这个程序

1
dotnet run

你将在浏览器中看到以下结果

1
2
3
4
5
{
"data": {
"hello": "world"
}
}

从以上的例子中,你会发现使用GraphQL并不像想象中那么难。下面我们可以在HelloWorldQuery类的构造函数中再添加一个字段howdy, 并指定这个字段会返回一个字符串universe

1
2
3
4
Field<StringGraphType>(
name: "howdy",
resolve: context => "universe"
);

然后我们继续修改Startup类中的Configure方法, 修改我们之前定义的query

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var schema = new Schema { 
Query = new HelloWorldQuery()
};

app.Run(async (context) =>
{
var result = await new DocumentExecuter()
.ExecuteAsync(doc =>
{
doc.Schema = schema;
doc.Query = @"
query {
hello
howdy
}
";
}).ConfigureAwait(false);

var json = new DocumentWriter(indent: true)
.Write(result);
await context.Response.WriteAsync(json);
});

重新启动项目后,结果如下

1
2
3
4
5
6
{
"data": {
"hello": "world",
"howdy": "universe"
}
}

本篇我们只是接触了GraphQL的一些皮毛,你可能会对GraphQL声明式行为有很多问题,没有关系,后续博客中,我们慢慢解开GraphQL的面纱。下一篇我们将介绍如何创建一个中间件(Middleware)

本篇源代码:https://github.com/lamondlu/GraphQL_Blogs

ASP.NET Core中使用GraphQL - 目录


在前面几篇中,我们已经介绍了如何使用GraphQL中的query字段获取数据。那么如何使用GraphQL进行数据的添加,删除,修改操作呢?这里我们需要引入GraphQL中的mutation

我们继续编写新代码之前,我们需要先整理一下当前的项目代码。这里我们将HelloWorldQuery类改名为InventoryQuery类, 并将HelloWorldSchema类改名为InventorySchema。然后我们将hellohowdy两个字段移除掉。

在GraphQL中, 一个Mutation类型也是继承自ObjectGraphType类。在以下代码中,createItem字段在服务器端创建了一个货物并返回了它的内容。

InventoryMutation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class InventoryMutation : ObjectGraphType  
{
public InventoryMutation(IDataStore dataStore)
{
Field<ItemType>(
"createItem",
arguments: new QueryArguments(
new QueryArgument<NonNullGraphType<ItemInputType>> { Name = "item" }
),
resolve: context =>
{
var item = context.GetArgument<Item>("item");
return dataStore.AddItem(item);
});
}
}

以上代码中我们引入了一个新的ItemInputType类作为查询参数。在第五章中,我们已经创建过一个标量类型的参数。但是针对复杂类型,我们使用不同的方式。因此,这里我们创建了一个新的类ItemInputType。其代码如下:

ItemInputType
1
2
3
4
5
6
7
8
9
10
public class ItemInputType : InputObjectGraphType  
{
public ItemInputType()
{
Name = "ItemInput";
Field<NonNullGraphType<StringGraphType>>("barcode");
Field<NonNullGraphType<StringGraphType>>("title");
Field<NonNullGraphType<DecimalGraphType>>("sellingPrice");
}
}

为了将新的货物记录添加到数据库,我们还需要修改IDataStore接口,添加一个AddItem的方法,并在DataStore类中实现它。

IDataStore
1
2
3
4
5
6
public interface IDataStore
{
IEnumerable<Item> GetItems();
Item GetItemByBarcode(string barcode);
Task<Item> AddItem(Item item);
}
DataStore
1
2
3
4
5
6
public async Task<Item> AddItem(Item item)  
{
var addedItem = await _context.Items.AddAsync(item);
await _context.SaveChangesAsync();
return addedItem.Entity;
}

这里请注意AddItem的方法签名,在添加完成之后,我们将添加成功的货物记录返回了。因此我们可以查询新添加对象的内嵌字段

Just like in queries, if the mutation field returns an object type, you can ask for nested fields. This can be useful for fetching the new state of an object after an update. - GraphQl Org.

和查询一样,如果mutation字段返回一个对象类型,你就可以查询它的内嵌字段。这对于获取一个更新后对象的新状态非常有用。

在我们运行程序之前,我们还如要在控制反转容器中注册ItemInputTypeInventoryMutation

Startup
1
2
services.AddScoped<ItemInputType>();  
services.AddScoped<InventoryMutation>();

最后我们需要在InventorySchema的构造函数中,注入InventoryMutation

InventorySchame
1
2
3
4
5
6
7
8
public class InventorySchema : Schema  
{
public InventorySchema(InventoryQuery query, InventoryMutation mutation)
{
Query = query;
Mutation = mutation;
}
}

现在你可以运行程序了,这里我们运行如下的mutation

1
2
3
4
5
6
mutation {  
createItem(item: {title: "GPU", barcode: "112", sellingPrice: 100}) {
title
barcode
}
}

这段代码的意思是,我们将调用createItemmutation, 将item保存到数据库,并会返回新增item的titlebarcode属性。

当然你也可以把添加的item对象放到Query Variables窗口中, 得到的结果是一样的

本文源代码: https://github.com/lamondlu/GraphQL_Blogs/tree/master/Part%20VII