zoukankan      html  css  js  c++  java
  • ASP.NET Core搭建多层网站架构【10-使用JWT进行授权验证】

    2020/01/31, ASP.NET Core 3.1, VS2019, Microsoft.AspNetCore.Authentication.JwtBearer 3.1.1

    摘要:基于ASP.NET Core 3.1 WebApi搭建后端多层网站架构【10-使用JWT进行授权验证】
    使用JWT给网站做授权验证

    文章目录

    此分支项目代码

    本章节介绍了使用JWT给网站做授权验证

    添加包引用

    MS.Component.Jwt类库中添加Microsoft.AspNetCore.Authentication.JwtBearer包引用:

    <ItemGroup>
      <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.1" />
    </ItemGroup>
    

    MS.Component.Jwt类库中引用MS.EntitiesMS.WebCore项目
    MS.Models类库中确保已引用MS.Component.Jwt项目

    添加jwt配置

    appsettings.json

    MS.WebApi应用程序的appsettings.json中增加JwtSetting节点:

    "JwtSetting": {
      "Issuer": "MS.WebHost",
      "Audience": "MS.Audience",
      "SecurityKey": "MS.WebHost SecurityKey", //more than 16 chars
      "LifeTime": 1440 //(minutes) token life time default:1440 m=1 day
    }
    
    • Issuer是颁发者
    • Audience是受众
    • SecurityKey是安全密钥,至少要16个字符
    • LifeTime是token的存活时间,这里指定了时间单位是分钟,注意JWT有自己默认的缓冲过期时间(五分钟)

    JwtSetting.cs

    MS.Component.Jwt类库中添加JwtSetting.cs类:

    namespace MS.Component.Jwt
    {
        public class JwtSetting
        {
            /// <summary>
            /// 颁发者
            /// </summary>
            public string Issuer { get; set; }
    
            /// <summary>
            /// 受众
            /// </summary>
            public string Audience { get; set; }
    
            /// <summary>
            /// 安全密钥
            /// </summary>
            public string SecurityKey { get; set; }
    
            /// <summary>
            /// 过期时间
            /// </summary>
            public double LifeTime { get; set; }
        }
    }
    

    可以使用选择性粘贴,将json直接粘贴为类

    添加UserClaim

    MS.Component.Jwt类库中新建UserClaim文件夹,在该文件夹中新建UserClaimType.csIClaimsAccessor.csClaimsAccessor.csUserData.cs类:

    UserClaimType.cs

    namespace MS.Component.Jwt.UserClaim
    {
        public static class UserClaimType
        {
            public const string Id = "http://schemas.microsoft.com/ws/2008/06/identity/claims/primarysid";
            public const string Account = "http://schemas.microsoft.com/ws/2008/06/identity/claims/serialnumber";
            public const string Name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";
            public const string Email = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
            public const string Phone = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/mobilephone";
            public const string RoleName = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
            public const string RoleDisplayName = "http://schemas.xmlsoap.org/ws/2009/09/identity/claims/actor";
        }
    }
    

    这个类是声明用户信息的
    里面的值都是从System.Security.Claims.ClaimTypes里挑选出来的值,也可以自行定义

    ClaimsAccessor.cs

    IClaimsAccessor接口:

    namespace MS.Component.Jwt.UserClaim
    {
        public interface IClaimsAccessor
        {
            string UserName { get; }
            long UserId { get; }
            string UserAccount { get; }
            string UserRole { get; }
            string UserRoleDisplayName { get; }
        }
    }
    

    ClaimsAccessor实现:

    using Microsoft.AspNetCore.Http;
    using System;
    using System.Linq;
    using System.Security.Claims;
    
    namespace MS.Component.Jwt.UserClaim
    {
        public class ClaimsAccessor : IClaimsAccessor
        {
            private readonly IHttpContextAccessor _httpContextAccessor;
    
            public ClaimsAccessor(IHttpContextAccessor httpContextAccessor)
            {
                _httpContextAccessor = httpContextAccessor;
            }
    
            public ClaimsPrincipal UserPrincipal
            {
                get
                {
                    ClaimsPrincipal user = _httpContextAccessor.HttpContext.User;
                    if (user.Identity.IsAuthenticated)
                    {
                        return user;
                    }
                    else
                    {
                        throw new Exception("用户未认证");
                    }
                }
            }
            public string UserName
            {
                get
                {
                    return UserPrincipal.Claims.First(x => x.Type == UserClaimType.Name).Value;
                }
            }
            public long UserId
            {
                get
                {
                    return long.Parse(UserPrincipal.Claims.First(x => x.Type == UserClaimType.Id).Value);
                }
    
            }
            public string UserAccount
            {
                get
                {
                    return UserPrincipal.Claims.First(x => x.Type == UserClaimType.Account).Value;
                }
            }
            public string UserRole
            {
                get
                {
                    return UserPrincipal.Claims.First(x => x.Type == UserClaimType.RoleName).Value;
                }
            }
            public string UserRoleDisplayName
            {
                get
                {
                    return UserPrincipal.Claims.First(x => x.Type == UserClaimType.RoleDisplayName).Value;
                }
            }
        }
    }
    

    定义用户信息访问接口,开发时通过获取IClaimsAccessor接口来获取登录用户的信息。

    UserData.cs

    namespace MS.Component.Jwt.UserClaim
    {
        public class UserData
        {
            public long Id { get; set; }
            public string Account { get; set; }
            public string Name { get; set; }
            public string Email { get; set; }
            public string Phone { get; set; }
            public string RoleName { get; set; }
            public string RoleDisplayName { get; set; }
    
            public string Token { get; set; } 
        }
    }
    

    定义用户数据类

    jwt服务

    MS.Component.Jwt类库中新建JwtService.cs类:

    using Microsoft.Extensions.Options;
    using Microsoft.IdentityModel.Tokens;
    using MS.Component.Jwt.UserClaim;
    using System;
    using System.IdentityModel.Tokens.Jwt;
    using System.Security.Claims;
    using System.Text;
    
    namespace MS.Component.Jwt
    {
        public class JwtService
        {
            private readonly JwtSetting _jwtSetting;
            private readonly TimeSpan _tokenLifeTime;
    
            public JwtService(IOptions<JwtSetting> options)
            {
                _jwtSetting = options.Value;
                _tokenLifeTime = TimeSpan.FromMinutes(options.Value.LifeTime);
            }
            /*
                 iss (issuer):签发人
                 exp (expiration time):过期时间
                 sub (subject):主题
                 aud (audience):受众
                 nbf (Not Before):生效时间
                 iat (Issued At):签发时间
                 jti (JWT ID):编号
                 */
    
            /// <summary>
            /// 生成身份信息
            /// </summary>
            /// <param name="userName">用户名</param>
            /// <param name="roleName">登录时的角色</param>
            /// <returns></returns>
            public Claim[] BuildClaims(UserData userData)
            {
                // 配置用户标识
                var userClaims = new Claim[]
                {
                    new Claim(UserClaimType.Id,userData.Id.ToString()),//id
                    new Claim(UserClaimType.Account,userData.Account),//account
                    new Claim(UserClaimType.Name,userData.Name),//name
                    new Claim(UserClaimType.RoleName,userData.RoleName),//rolename
                    new Claim(UserClaimType.RoleDisplayName,userData.RoleDisplayName),//roledisplayname
                    new Claim(JwtRegisteredClaimNames.Jti,userData.Id.ToString()),
                    new Claim(JwtRegisteredClaimNames.Iat, DateTime.Now.ToString()),
                    //new Claim(JwtRegisteredClaimNames.Iss,_jwtSetting.Issuer),
                    //new Claim(JwtRegisteredClaimNames.Aud,_jwtSetting.Audience),
                    //new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
                    //这个就是过期时间,可自定义,注意JWT有自己的缓冲过期时间
                    //new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.Add(_tokenLifeTime)).ToUnixTimeSeconds()}"),
                };
                return userClaims;
            }
    
            /// <summary>
            /// 生成jwt令牌
            /// </summary>
            /// <param name="claims">自定义的claim</param>
            /// <returns></returns>
            public string BuildToken(Claim[] claims)
            {
                var nowTime = DateTime.Now;
                var creds = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSetting.SecurityKey)), SecurityAlgorithms.HmacSha256);
                JwtSecurityToken tokenkey = new JwtSecurityToken(
                    issuer: _jwtSetting.Issuer,
                    audience: _jwtSetting.Audience,
                    claims: claims,
                    notBefore: nowTime,
                    expires: nowTime.Add(_tokenLifeTime),
                    signingCredentials: creds);
    
                return new JwtSecurityTokenHandler().WriteToken(tokenkey);
            }
        }
    }
    
    • 这个是jwt核心的生成token服务类,可以把它以单例的形式注册在ioc容器中
    • 调用的时候,先生成用户身份信息
    • 再将用户身份信息生成token,此时在JwtSecurityToken中定义了token的过期时间、颁发时间、加密方式等

    封装Ioc注册

    MS.Component.Jwt类库中新建JwtServiceExtensions.cs类:

    using MS.Component.Jwt.UserClaim;
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.AspNetCore.Http;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.IdentityModel.Tokens;
    using System;
    using System.Text;
    
    namespace MS.Component.Jwt
    {
        public static class JwtServiceExtensions
        {
            public static IServiceCollection AddJwtService(this IServiceCollection services, IConfiguration configuration)
            {
                //绑定appsetting中的jwtsetting
                services.Configure<JwtSetting>(configuration.GetSection(nameof(JwtSetting)));
    
                //注册jwtservice
                services.AddSingleton<JwtService>();
                //注册IHttpContextAccessor
                services.AddScoped<IHttpContextAccessor, HttpContextAccessor>();
                services.AddScoped<IClaimsAccessor, ClaimsAccessor>();
    
                var jwtConfig = configuration.GetSection("JwtSetting");
    
                services
                    .AddAuthentication(options =>
                    {
                        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                        options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
                    })
                    .AddJwtBearer(o =>
                    {
                        o.TokenValidationParameters = new TokenValidationParameters
                        {
                            ValidateIssuerSigningKey = true,
                            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig["SecurityKey"])),
    
                            ValidateIssuer = true,
                            ValidIssuer = jwtConfig["Issuer"],
    
                            ValidateAudience = true,
                            ValidAudience = jwtConfig["Audience"],
    
                            //总的Token有效时间 = JwtRegisteredClaimNames.Exp + ClockSkew ;
                            RequireExpirationTime = true,
                            ValidateLifetime = true,// 是否验证Token有效期,使用当前时间与Token的Claims中的NotBefore和Expires对比.同时启用ClockSkew 
                            ClockSkew = TimeSpan.Zero //注意这是缓冲过期时间,总的有效时间等于这个时间加上jwt的过期时间,如果不配置,默认是5分钟
    
                        };
                    });
                return services;
            }
        }
    }
    
    • 绑定appsetting中的jwtsetting
    • 以单例形式注册jwtservice
    • 注册IHttpContextAccessor和IClaimsAccessor为Scoped生命周期(网上很多文章都把IHttpContextAccessor的生命周期定义为单例,我不是很理解,我认为Scoped更好,如果有明白的小伙伴可以给我指点下)
    • IHttpContextAccessor是ASP.NET Core自带的接口,而IClaimsAccessor是我自己对IHttpContextAccessor的一个封装,所以这两个接口的注册生命周期保持了一致
    • 根据appsettings.json中的配置,启用jwt验证服务AddJwtBearer:
      • IssuerSigningKey定义了加密密钥,而ValidateIssuerSigningKey = true启用了密钥验证
      • ValidateIssuer、ValidIssuer和ValidateAudience、ValidAudience这两对同上
      • 注意token有效时间的计算方法,总的Token有效时间 = JwtRegisteredClaimNames.Exp + ClockSkew
      • 这里把ClockSkew缓冲时间改成了0,默认是5分钟(也就是去掉了缓冲时间)

    注册Jwt服务

    MS.WebApi应用程序的Startup.cs类中,ConfigureServices加上services.AddJwtService(Configuration);

    开启认证中间件

    MS.WebApi应用程序的Startup.cs类中,中间件配置加上app.UseAuthentication();以开启认证中间件:

    • 注意app.UseAuthentication()是认证中间件,而app.UseAuthorization()是授权中间件
    • 中间件的顺序不能随意调整!

    至此关于开启jwt授权验证、开启认证中间件、jwt服务注册都已完成

    1. 网站设定好JWT配置,例如颁发者、密钥、token的过期时间
    2. 用户输入账号密码进行登录,网站验证成功后调用JwtService生成并返回一个token给前端
    3. 用户在之后的请求中都会携带好这个token,而用户的信息就存在token中
    4. ASP.NET Core中有个IHttpContextAccessor接口,可以访问每次请求的上下文,从而可以让后端获取到当前请求的token中的用户信息
    5. 我这里对IHttpContextAccessor接口做了一个封装,叫IClaimsAccessor,所以可以直接通过IClaimsAccessor获取到用户信息
    6. 如果token过期、用户未登录,api接口调用会返回错误代码401未认证

    用户登录

    LoginViewModel.cs

    MS.Models类库中,在ViewModel文件夹下新建LoginViewModel.cs类:

    using AutoMapper;
    using Microsoft.EntityFrameworkCore;
    using MS.Common.Security;
    using MS.Component.Jwt.UserClaim;
    using MS.DbContexts;
    using MS.Entities;
    using MS.Entities.Core;
    using MS.UnitOfWork;
    using MS.WebCore;
    using MS.WebCore.Core;
    using System;
    using System.ComponentModel.DataAnnotations;
    using System.Threading.Tasks;
    
    namespace MS.Models.ViewModel
    {
        public class LoginViewModel
        {
            [Display(Name = "用户名")]
            [Required(ErrorMessage = "{0}必填")]
            [StringLength(16, ErrorMessage = "不能超过{0}个字符")]
            [RegularExpression(@"^[a-zA-Z0-9_]{4,16}$", ErrorMessage = "只能包含字符、数字和下划线")]
            public string Account { get; set; }
            [Display(Name = "密码")]
            [Required(ErrorMessage = "{0}必填")]
            public string Password { get; set; }
    
            public async Task<ExecuteResult<UserData>> LoginValidate(IUnitOfWork<MSDbContext> unitOfWork, IMapper mapper, SiteSetting siteSetting)
            {
                ExecuteResult<UserData> result = new ExecuteResult<UserData>();
                //将登录用户查出来
                var loginUserInDB = await unitOfWork.GetRepository<UserLogin>().FindAsync(Account);
    
                //用户不存在
                if (loginUserInDB is null)
                {
                    return result.SetFailMessage("用户不存在");
                }
    
                //用户被锁定
                if (loginUserInDB.IsLocked &&
                    loginUserInDB.LockedTime.HasValue &&
                    (DateTime.Now - loginUserInDB.LockedTime.Value).Minutes < siteSetting.LoginLockedTimeout)
                {
                    return result.SetFailMessage(string.Format("用户已被锁定,请{0}分钟后再试!", siteSetting.LoginLockedTimeout.ToString()));
                }
    
                //密码正确
                if (Crypto.VerifyHashedPassword(loginUserInDB.HashedPassword, Password))
                {
                    //密码正确后才加载用户信息、角色信息
                    var userInDB = await unitOfWork.GetRepository<User>().GetFirstOrDefaultAsync(
                        predicate: a => a.Id == loginUserInDB.UserId,
                        include: source => source
                         .Include(u => u.Role));
    
                    //如果用户已失效
                    if (userInDB.StatusCode != StatusCode.Enable)
                    {
                        return result.SetFailMessage("用户已失效,请联系管理员!");
                    }
    
                    //用户正常、密码正确,更新相应字段
                    loginUserInDB.IsLocked = false;
                    loginUserInDB.AccessFailedCount = 0;
                    loginUserInDB.LastLoginTime = DateTime.Now;
                    //提交到数据库
                    await unitOfWork.SaveChangesAsync();
    
                    //得到userdata
                    UserData userData = mapper.Map<UserData>(userInDB);
                    return result.SetData(userData);
                }
                //密码错误
                else
                {
                    loginUserInDB.AccessFailedCount++;//失败次数累加
                    result.SetFailMessage("用户名或密码错误!");
                    //超出失败次数限制
                    if (loginUserInDB.AccessFailedCount >= siteSetting.LoginFailedCountLimits)
                    {
                        loginUserInDB.IsLocked = true;
                        loginUserInDB.LockedTime = DateTime.Now;
                        result.SetFailMessage(string.Format("用户已被锁定,请{0}分钟后再试!", siteSetting.LoginLockedTimeout.ToString()));
                    }
                    //提交到数据库
                    await unitOfWork.SaveChangesAsync();
                    return result;
                }
            }
        }
    }
    

    在LoginViewModel中做了核心的登录验证,除了验证密码,还会校验用户密码错误次数,失败次数(LoginFailedCountLimits)过多会锁定账号,在指定时间(LoginLockedTimeout)后才能继续登录,这两个配置在SiteSetting中

    UserProfile.cs映射配置

    MS.Models类库中,在Automapper文件夹下新建UserProfile.cs类:

    using AutoMapper;
    using MS.Component.Jwt.UserClaim;
    using MS.Entities;
    
    namespace MS.Models.Automapper
    {
        public class UserProfile : Profile
        {
            public UserProfile()
            {
                CreateMap<User, UserData>()
                    .ForMember(a => a.Id, t => t.MapFrom(b => b.Id))
                    .ForMember(a => a.RoleName, t => t.MapFrom(b => b.Role.Name))
                    .ForMember(a => a.RoleDisplayName, t => t.MapFrom(b => b.Role.DisplayName))
                    ;
            }
        }
    }
    

    建立了User到UserData的映射配置

    账号服务

    MS.Services类库下新建Account文件夹,在该文件夹下新建IAccountService.csAccountService.cs类:
    IAccountService.cs:

    using MS.Component.Jwt.UserClaim;
    using MS.Models.ViewModel;
    using MS.WebCore.Core;
    using System.Threading.Tasks;
    
    namespace MS.Services
    {
        public interface IAccountService : IBaseService
        {
            Task<ExecuteResult<UserData>> Login(LoginViewModel viewModel);
        }
    }
    

    AccountService.cs:

    using AutoMapper;
    using Microsoft.Extensions.Options;
    using MS.Common.IDCode;
    using MS.Component.Jwt;
    using MS.Component.Jwt.UserClaim;
    using MS.DbContexts;
    using MS.Models.ViewModel;
    using MS.UnitOfWork;
    using MS.WebCore;
    using MS.WebCore.Core;
    using System.Threading.Tasks;
    
    namespace MS.Services
    {
        public class AccountService : BaseService, IAccountService
        {
            private readonly JwtService _jwtService;
            private readonly SiteSetting _siteSetting;
    
            public AccountService(JwtService jwtService, IOptions<SiteSetting> options, IUnitOfWork<MSDbContext> unitOfWork, IMapper mapper, IdWorker idWorker) : base(unitOfWork, mapper, idWorker)
            {
                _jwtService = jwtService;
                _siteSetting = options.Value;
            }
    
            public async Task<ExecuteResult<UserData>> Login(LoginViewModel viewModel)
            {
                var result = await viewModel.LoginValidate(_unitOfWork, _mapper, _siteSetting);
                if (result.IsSucceed)
                {
                    result.Result.Token = _jwtService.BuildToken(_jwtService.BuildClaims(result.Result));
                    return new ExecuteResult<UserData>(result.Result);
                }
                else
                {
                    return new ExecuteResult<UserData>(result.Message);
                }
            }
        }
    }
    
    • 目前就实现了Login逻辑,密码验证成功后,将用户信息交给JwtService生成token
    • 之后还有修改密码等行为,也都写在这个接口里

    登录接口

    MS.WebApi应用程序的Controllers文件夹下新建Base文件夹,在该文件夹下新建AuthorizeController.cs类:

    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    
    namespace MS.WebApi.Controllers
    {
        [Route("[controller]")]
        [Authorize]
        public class AuthorizeController : ControllerBase
        {
        }
    }
    
    • 注意命名空间依然是MS.WebApi.Controllers
    • AuthorizeController类上打上了[Authorize]特性,表示需要认证授权后才能访问

    Controllers文件夹下新建AccountController.cs类:

    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using MS.Component.Jwt.UserClaim;
    using MS.Models.ViewModel;
    using MS.Services;
    using MS.WebCore.Core;
    using System.Threading.Tasks;
    
    namespace MS.WebApi.Controllers
    {
        [Route("[controller]")]
        [ApiController]
        public class AccountController : AuthorizeController
        {
            private readonly IAccountService _accountService;
    
            public AccountController(IAccountService accountService)
            {
                _accountService = accountService;
            }
    
            [HttpPost]
            [AllowAnonymous]
            public async Task<ExecuteResult<UserData>> Login(LoginViewModel viewModel)
            {
                return await _accountService.Login(viewModel);
            }
        }
    }
    
    • 可以看到,AccountController已经继承了刚刚的AuthorizeController,所以AccountController内的资源也都要授权后才能访问
    • Login方法上打了[AllowAnonymous]特性,所以Login未授权也可以访问(用户登录的接口肯定不能有认证限制)

    将RoleController.cs的基类也修改为AuthorizeController:

    访问授权接口

    至此所有的授权验证已经完成了,启动项目,打开Postman,依旧是访问role接口,会提示401:

    在Postman的MSDemo中,新建一个Login请求localhost:5000/account,json参数为(这是种子数据中的默认超级管理员账号):

    {
    	"Account":"admin",
    	"Password":"admin"
    }
    

    点击发送,可以看到登录成功,返回了用户信息及token:

    我们复制这段token,右击MSDemo-Edit-Authorization-TYPE(Bearer Token)-把复制的token粘贴进去:

    此时,MSDemo里所有的接口请求时,都会带上这段token,就不需要每个请求单独添加一次token了

    也可以看到添加上token后,接口访问又请求成功了

    补全RoleService

    之前做角色增删改的时候,创建者和修改者都是临时代码,不是当前用户真实Id,这会儿登录做好了可以补全了:

    BaseService中添加公开类型的IClaimsAccessor成员,AccountService和RoleService的构造函数都要重构一下
    在RoleService中如下图获取和使用用户信息:

    项目完成后,如下图:

  • 相关阅读:
    JAVAWEB 一一框架整合(SSI : Spring+SpringMVC+ ibtis)
    接口一一默认方法
    内部类
    java抽象类的使用
    Node(十)之Mongoose配合Node路由实现邮箱注册登录(Post版)
    Node(九)之Node配合MongoDB实现简单的注册登录
    Node(八)之MongoDB简单应用
    JS案例:Ajax实现简单局域网聊天室
    JS瀑布流懒加载案例
    JS表格小案例
  • 原文地址:https://www.cnblogs.com/kasnti/p/12246220.html
Copyright © 2011-2022 走看看