Asp-Net-Core学习笔记:身份认证入门

前言

过年前我又来更新了~

我就说了最近不是在偷懒吧,其实这段时间还是有积累一些东西的,不过还没去整理……

所以只能发以前没写完的一些笔记出来

就当做是温习一下啦

PS:之前说的红包封面我还没搞,得抓紧时间了

最近在准备搞一个我之前做的开源项目代码合集来做一期分享

两种常见的认证方式

先来看看两种常见的认证方式:基于token的认证和传统的session认证的区别。

session认证

我们知道,http协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session认证。

但是这种基于session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来。

弊端

Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

基于token的认证

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

流程上是这样的:

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并在每次请求时附送上这个token值
  • 服务端验证token值,并返回数据

这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *

OAuth2.0与OpenID

OAuth2.0OpenID Connect是标准验证框架

OAuth(Open Authorization,即开放授权)是一个用于代理授权的标准协议。它允许应用程序在不提供用户密码的情况下访问该用户的数据。

OpenID Connect 是在 OAuth2.0 协议之上的标识层。它拓展了 OAuth2.0,使得认证方式标准化。

OAuth 不会立即提供用户身份,而是会提供用于授权的访问令牌。OpenID Connect 使客户端能够通过认证来识别用户,其中,认证在授权服务端执行。它是这样实现的:在向授权服务端发起用户登录和授权告知的请求时,定义一个名叫openid的授权范围。在告知授权服务器需要使用 OpenID Connect 时,openid是必须存在的范围。

来看一看OpenID Connect的架构图,可以看到,JWT是作为它的底成实现支持。所以,对于了解JWT来说是必要的。

Asp-Net-Core学习笔记:身份认证入门

那么我们继续了解接下来的JWT。

JWT

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

对于我们常用的JWT,是采用了JWS的签名式加密方案。所以结构就是 "A.B.C"的样子,用Header来描述了签名加密所用的算法,该描述遵循了JWA,而使用Playload来包含咱们所需要的东西,在JWT里面,它们叫做JWT Claims Set,而JWT提出了很多内置的Claim规范,下面我们会看到。最后是Signature,这就是基于JWS所得到的内容。

JWT规范定义了七个可选的、已注册的声明(Claim),并允许将公共和私人声明包括在令牌中,这七个已登记的声明是:

Claim 描述
iss (Issuer) 确定了签发JWT的主体(发行者)。一般是STRING或者URI,比如"http://my.identityServer.com/5000"
sub (Subject) JWT所代表的主题。主题值必须限定为在发行者的上下文中是本地唯一的,或者是全局唯一的。所以你会在某些例子中看到它保存了用户的ID等。一般是STRING或者URI
aud (Audience) JWT的受众(该单词我也不知道该如何翻译比较合适)。一般是STRING或者URI,比如"http://my.clientiIp.com/5000"
exp (expire) JWT的过期时间
nbf (not-before) JWT的生效时间
iat ((issued-at) JWT的颁发时间
jti (expire) JWT的唯一标识符(JWT ID)

当然,仅仅靠这些值我们一般是无法处理完整业务逻辑的,比如我们往往需要将用户邮箱等信息放入Token中,所以我们可以在荷载中放入我们自定义的一些项,只要保证不要和内置的命名冲突就行啦。

具体要怎么写,下面有代码例子~

Bearer Token

BearerHTTP Authorization的类型规范,而JWT是一个数据结构的规范

HTTP 1.0中提出了Authorization: <type> <credentials>这样的格式。 如果Basic类型的验证是Authorization : Basic,那Bearer类型就是 Authorization : Bearer <token>

关于Bearer,它是伴随OAuth2.0所提出,该规范仅仅定义了Bearer Token的格式(也就是需要带上Bearer关键字在header中),并没有说过Token一定要使用JWT格式。

再捋一遍

前面介绍了这么多的概念之后,可能同学们已经有点晕晕的了,没事,接下来重新捋一遍

用户登录,首先要在客户端请求服务端的登录接口,把用户名和密码发给服务器;

然后服务器把用户名和密码拿去数据库里比对,如果正确的话,那就根据JWT标准生成一个JWT token返回给客户端;

客户端拿到了token,就能以Bearer token的形式将token放在HTTP请求头中,去请求那些需要登录才能访问的接口~

就是这么简单~

AspNetCore中的认证授权

在开始写代码之前,必须要了解一下AspNetCore中关于认证与授权的基础概念~

认证

身份认证处理程序是实现身份认证操作的核心类,身份认证处理程序派生自接口IAuthenticationHandler。该接口定义了以下三种操作:身份认证(AuthenticateAsync)、挑战(ChallengeAsync)和禁止(ForbidAsync)。其中,身份认证是主要的操作。

身份认证返回AuthenticateResult来表明该请求的身份认证是否成功,AuthenticateResult可以返回三种类型的结果:失败(Fail)、无结果(NoResult)和成功(Success)。如果验证成功,将会通过AuthenticateResult返回AuthenticationTicketAuthenticationTicket将会封装用户信息,以便于在后续的授权中使用。

挑战是指当前请求访问的资源要求身份认证,但是当前请求未通过身份认证,那么后续的授权阶段就需要通过指定的身份认证方案中的身份认证处理程序来提供挑战方法,以便发起挑战。如果没有指定身份认证方案,就会使用默认身份认证方案。举个例子来说,如果我们因为长时间没有操作而导致系统登录会话超时失效,那么再次对系统进行操作时,系统一般会将页面重定向到登录页面,这个重定向的操作就是一种挑战。

禁止是指已经通过身份认证的用户尝试访问其无权访问的资源时而进入授权阶段所要执行的操作。比如某站点的普通用户想要使用VIP用户的功能,如果该用户没有登录,那么本次请求就是匿名访问,这时授权阶段需要发起挑战操作;如果该用户已经登录,但是授权阶段发现该用户没有权限访问该资源,那么系统可能会返回HTTP 403状态码,这种返回HTTP 403状态码的操作就是一种禁止。

用户信息模型

身份认证通过后,身份认证处理程序会返回身份认证票根,即AuthenticationTicket

AuthenticationTicket是ASP.NET Core封装认证信息的类。

AuthenticationTicket又包含了ClaimsPrincipalClaimsPrincipal可以理解为用户主体,由一组ClaimIdentity组成。

ClaimsIdentity可以理解为身份证明,一个用户主体可以有多个身份证明,就好比身份证、驾驶证都可以代表唯一具体的人一样。

ClaimsIdentity包含了一组ClaimClaim就是好比身份证上的姓名、性别、籍贯等信息。一个用户通过身份认证后,就会用以上类来组织用户信息。后续的授权等其他中间件就可以使用这些信息来进行功能设计。

结构示例如下:

  • AuthenticationTicket (身份认证票根,其中封装了认证信息)
    • ClaimsPrincipal (用户主体)
      • ClaimIdentity (身份证明)
        • Claim
        • Claim
        • Claim
      • ClaimIdentity
      • ClaimIdentity

授权

授权(Authorization)决定了一个用户在系统里能干什么。对于ASP.NET Core应用来说,授权决定了一个用户能够访问哪些资源路径。授权与7.1节讲的身份认证是依赖和被依赖的关系,ASP.NETCore将身份认证与授权设计成了相对独立的两个功能模块,两个模块的职责分工非常明确,前者解决用户是谁的问题,后者解决用户能干什么的问题。从功能上来看,授权是基于身份认证的结果而做出的,从逻辑上来说,只有知道用户是谁才能确定用户能干什么。

ASP.NET Core提供了简单授权、基于角色的授权、基于策略的授权,多样的授权方式在通过简单的Attribute修饰就能满足大部分应用场景。授权中重要的两个Attribute就是AuthorizeAttributeAllowAnonymousAttribute,所有的授权配置都离不开这两个Attribute。同时,ASP.NET Core对授权方案的扩展也非常方便,在本节的最后会介绍如何自定义授权处理程序来实现自定义授权逻辑。

授权有这三种类型

  • 简单授权:只要登录就能访问,在Controller或者Action上加个[Authorize]就行
  • 基于角色的授权:特定角色能访问
  • 基于策略的授权:顾名思义

基于角色的授权

基于角色的授权简单来说就是一个资源必须要指定角色的用户才能够访问。基于角色的授权必须在Controller或Action上指定哪些角色可以访问该资源。

AuthorizeAttribute有一个公开的string类型的属性Roles。通过这个属性可以指定哪些角色可以访问特定资源。认证用户是否属于某个角色可以通过ClaimsPrincipal类的IsInRole方法进行验证,ASP.NET Core基于角色的授权就是通过这个方法来确定当前用户是否属于某个角色用户的。当前用户属于角色属性如何设置呢?很简单,ClaimsIdentity的属性RoleClaimType会告诉ASP.NET Core哪个Claim存储了用户的角色信息。

比如某个Controller需要管理员角色才能访问:

[Authorize(Roles="管理员")]

可以指定多个角色都可以访问,多个角色间用逗号分隔:

[Authorize(Roles="人力经理,财务")]

如果用多个[Authorize(Roles='SomeRole')]修饰ControllerAction,那么访问的用户必须是所有指定角色的成员,下面的例子必须同时是“销售”和“经理”才能访问

[Authorize(Roles="销售")]
[Authorize(Roles="经理")]

基于策略的授权

基于策略的授权是更灵活的授权方式,我们先来了解ASP.NET Core的授权模型。与身份认证相似,ASP.NET Core由授权处理程序、授权需求、授权方案、授权服务构成。其中,授权服务同身份认证服务一样,作为授权服务接口对外提供授权能力。授权方案是组织授权机制的概念,一个授权方案可以包含多个授权需求,只有满足了所有授权需求才算通过了授权方案,而授权需求可以关联多个授权处理程序,任意一个授权处理程序返回授权成功,则表示该授权方案下的授权需求被满足了。

ASP.NET Core提供了一个授权策略,实现了建造者模式,通过AuthorizationPolicyBuilder可以方便地构建AuthorizationPolicy。基于AuthorizationPolicyBuilder,可以方便地设置授权策略的授权需求。

services.AddAuthorization(config => {
    config.AddPolicy("RequireAdmin", builder => builder.RequireRole("管理员"));
});

除了AuthorizeAttribute上可以设置的角色外,还可以设置Claim需求。

该授权策略需要当前认证用户姓"赵":

services.AddAuthorization(config => {
    config.AddPolicy("RequireZhao", builder => builder.RequireClaim("姓", "赵"));
});

如果被授权的姓氏规则比较复杂,不利于枚举出来,那么推荐使用RequireAssertion来实现。比如上面的功能还可以用如下方式来实现:

services.AddAuthorization(config => {
    config.AddPolicy("RequireZhao", builder => builder.RequireAssertion(
        context => context.User.FindFirst("姓").Value=="赵"));
});

除此之外,还可以通过实现了IAuthorizationRequirement的授权需求来关联自定义的授权处理程序来实现更灵活的授权规则设计。

IdentityServer4

IdentityServer4是ASP.NET Core平台下的一个OAuth 2.0以及OpenID Connect的实现。它非常方便地提供了身份认证、授权以及第三方认证服务对接,并且支持自定义方式来满足开发者不同场景下各式各样的需求。IdentityServer4作为一个成熟的认证授权框架,是受到OpenID Connect官方认证的服务端实现。IdentityServer开源且免费,在重视知识产权的今天,我们可以放心地基于IdentityServer4搭建认证平台开发商业应用。

IdentityServer通过IdentityResource、ApiScope、ApiResource、Client这些概念来实现身份的认证和资源的权限控制。

IdentityResource是指用户ID、姓名、手机号等用户信息,比如OpenID Connect规范就定义了一组标准的IdentityResource。除此之外,我们也可以自定义IdentityResource,这些概念很像ASP.NETCore中身份认证的Claim,定义了程序能访问到的用户信息。

ApiScope可以认为是API的一种标签,而ApiResource就是对API在授权场景下的抽象。如果需要对客户端能否访问某个API进行控制,就要定义ApiScope和ApiResource。

Client通过Request Token限制了哪些应用可以访问对应的API资源。每个Client都会有一个唯一的Client ID,通过设置一个秘钥可以加强用户信息安全性,关键的是通过设置AllowedApiScopes,框架就可以控制这个Client可以访问哪些ApiResource(Resource是和Scope相关联的)。

开始编码!

OK,终于到了激动人心的写代码环节,书读百遍不如实践一次,开始吧!

首先根据JWT标准,我们需要先定义这几个信息:

  • Issuer:签发JWT的主体
  • Audience:JWT的受众
  • Key:用来加密的秘钥

本例子中我们写一个最简单的单站点登录认证,所以Audience可以写死在配置文件里。

定义配置类

为了方便的映射appsettings.json配置文件,我们定义一个类(误,是两个)

public class SecuritySettings {
    public Token Token { get; set; }
}
public class Token {
    public string Issuer { get; set; }
    public string Audience { get; set; }
    public string Key { get; set; }
}

然后注册服务

services.Configure<SecuritySettings>(configuration.GetSection(nameof(SecuritySettings)));

添加认证服务和中间件

添加认证服务

services.AddAuthentication(options => {
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
    .AddJwtBearer(options => {
        // 这里用到我们之前定义好的配置类
        var secSettings = configuration.GetSection(nameof(SecuritySettings)).Get<SecuritySettings>();
        // 设置jwt token的各种信息用于验证
        options.TokenValidationParameters = new TokenValidationParameters {
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuer = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = secSettings.Token.Issuer,
            ValidAudience = secSettings.Token.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secSettings.Token.Key)),
            ClockSkew = TimeSpan.Zero
        };
    });

添加中间件

app.UseEndpoints前面添加这三行代码

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

用户实体类

很简单,不多说了

public class LoginUser {
    public string Username { get; set; }
    public string Password { get; set; }
}

登录接口

在Controller里写一个用户登录接口

[HttpPost]
public ActionResult<LoginToken> Login(LoginUser loginUser) {
    var user = _authService.GetUser(loginUser.Username);
    if (user == null) return NotFound();

    var md5Pwd = loginUser.Password.MDString();
    if (md5Pwd != user.Password) return Unauthorized();

    return _authService.GenerateLoginToken(user);
}

这里面我封装了一个AuthService服务,专门用于处理跟用户认证有关的操作

其中的GetUser方法不用多介绍了,就是数据库读取操作而已。

我们主要看GenerateLoginToken这个方法。

生成token的关键代码

GenerateLoginToken方法的代码如下

public LoginToken GenerateLoginToken(User user) {
    // 构造JWT中的claims信息
    var claims = new List<Claim> {
        new("username", user.Name),
        new(JwtRegisteredClaimNames.Name, user.Id), // User.Identity.Name
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID
    };
    
    // 从配置文件里读取信息
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secSettings.Token.Key));
    var signCredential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var jwtToken = new JwtSecurityToken(
        issuer: _secSettings.Token.Issuer,		// 颁发者信息
        audience: _secSettings.Token.Audience,	// 接受者信息
        claims: claims,							// 要放进JWT中的claims信息
        expires: DateTime.Now.AddDays(7),		// 过期时间
        signingCredentials: signCredential);	// 签名

    // 最后返回一个 LoginToken 对象,其中包含JWT token和过期时间两个字段
    return new LoginToken {
        Token = new JwtSecurityTokenHandler().WriteToken(jwtToken),
        Expiration = TimeZoneInfo.ConvertTimeFromUtc(jwtToken.ValidTo, TimeZoneInfo.Local)
    };
}

这个代码的意义我都写在注释里面了,最后的LoginToken是我定义的一个类,代码很简单:

public class LoginToken {
    public string? Token { get; set; }
    public DateTime Expiration { get; set; }
}

效果

完成之后,访问登录接口,提交正确的用户名密码,就可以得到客户端要的JWT token,大概是下面这样的形式

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ5ZXpzIiwibmFtZSI6InllenMiLCJwaG9uZV9udW1iZXIiOiIxNTYwMjc3NzMwMCIsImV4cCI6MTY0MzMxMzc3OSwiaXNzIjoiZGVtb19pc3N1ZXIiLCJhdWQiOiJkZW1vX2F1ZGllbmNlIn0.7x8zfpcWWbCH6SwXOUnQKCfXRWsyUiWoB5jSxYSIq-Q",
  "expiration": "2022-01-28T04:02:59+08:00"
}

在需要登录的接口方法或者Controller类上加一个[Authorize]特性,就OK了

访问的时候如果不带上HTTP头Authorization : Bearer <token>,就会报401 Unauthorized错误。

大功告成!

SignalR中如何使用JWT Token?

接下来是一点扩展的东西

AspNetCore除了可以做WebApi这种基于HTTP的接口,还可以实现像websocket这样的实时通信,比如SignalR技术

那通过SignalR的请求能不能也加上身份验证呢?答案是肯定的

和controller一样,只需要在Hub类或者Hub类里面的方法加上[Authorize]特性,即可实现身份验证。

但是客户端访问的时候要怎么提交token呢?这可不是HTTP,没有header的

别急,来看看以下两种方法,都是要在前面添加服务那里配置。

首先确定要添加配置的地方:

services.AddAuthentication(...)
    .AddJwtBearer(options => {
        options.TokenValidationParameters = new TokenValidationParameters {...};
        options.Events = new JwtBearerEvents {
            // 等会要添加的配置代码放在这里...
        };
    });

官方文档的方法

OnMessageReceived = context => {
    var accessToken = context.Request.Query["access_token"];
    var path = context.HttpContext.Request.Path;
    // If the request is for our hub
    if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hub")) {
        // Read the token out of the query string
        context.Token = accessToken;
    }
    return Task.CompletedTask;
}

简书网友的方法

OnMessageReceived = context => {
    var accessToken = context.Request.Query["access_token"];
    if (!string.IsNullOrEmpty(accessToken) &&
        (context.HttpContext.WebSockets.IsWebSocketRequest || context.Request.Headers["Accept"] == "text/event-stream")){
        context.Token = context.Request.Query["access_token"];
    }
    return Task.CompletedTask;
}

点评一下,官方文档的方法有点硬编码,是根据请求的路径判断的,但如果我们的项目里不止一个hub,那就麻烦了,要多写点代码;

简书网友的方法是根据请求的方式来判断,我们知道SignalR和普通的HTTP请求是不一样的,所以感觉简书网友的这个方法更优雅一点~

客户端使用

差点把这个忘了

放上JavaScript代码~

let loginToken = "xxx"
let connection = new signalR.HubConnectionBuilder()
    .withUrl("/hub/hub_name", {accessTokenFactory: () => loginToken})
    .build()

在建立连接的时候,带上accessTokenFactory参数即可~

后记

呼~

终于搞定了

没想到这篇博客写了这么长这么久

授权与认证包括好多要学的东西,我目前也只是做了最基础的登录验证,还没有搞身份那些

所以这篇作为基础入门,接下来的博客会继续深入这方面,冲!

参考资料

上一篇:【RMAN】SPFILE的恢复方式有哪几种?


下一篇:如何查看Linux版本信息