背景:
公司的权限认证模块比较老旧,想写一个支持多平台,同一个用户在不同平台拥有不同的角色的功能,本篇文章先基于jwt自定义基本的权限认证。
什么是JWT?
JWT是目前最流行的跨域身份验证解决方案。
JWT的官网地址:https://jwt.io/
通俗地来讲,JWT是能代表用户身份的令牌,可以使用JWT
令牌在api
接口中校验用户的身份以确认用户是否有访问api的权限。
JWT中包含了身份认证必须的参数以及用户自定义的参数,JWT可以使用秘密(使用HMAC
算法)或使用RSA
或ECDSA
的公钥/私钥对进行签名。
简单介绍一下JWT令牌结构
JSON Web Tokens
由 .
分隔的三个部分组成,它们是:
- Header 头
- Payload 有效载荷
- Signature 签名
JWT的详情:JWT详情
JWT安装
为了方便,本项目采用仓储模式,由Api,BizManagement,DataBase,Entity 4个模块组成,本次基于Rider开发,通过Nuget包管理器进行安装,如下所示:
项目模块组成:
依赖组件:
1. redis (StackExchange.Redis: 2.6.122) 查询全量数据的持久化,token的保存
2. Nlog(5.3.5) 日志记录到文件
3. Bcypt.Net-Next 用于将用户密码加密保存到数据库中
4. Mysql(Pomelo.EntityFrameworkCore.MySql: 7.0.0) 持久化Auth到数据库
5. AutoMapper(12.0.1) 实体映射
6. EFCore(7.0.13) ORM管理
代码部分(核心):
1.appsettings.json配置JWT的基本信息
"JWTTokenOptions": {
"Isuser": "org.huage.auth",
"Audience": "org.huage.auth",
"SecurityKey": "nadjhfgkadshgoihfkajhkjdhsfaidkuahfhdksjaghidshyaukfhdjks"
}
2.Entity模块定义配置信息实体
//注意这里的属性名称必须和配置信息的Key相同
public class JWTTokenOptions
{
public string Audience { get; set; }
public string Isuser { get; set; }
public string SecurityKey { get; set; }
}
3.依赖注入,Api模块定义一个ServiceExtension工具类,添加JwtAuthentication方法
public static void JwtAuthentication(this WebApplicationBuilder builder)
{
JWTTokenOptions tokenOptions = new JWTTokenOptions();//初始化
builder.Configuration.Bind("JWTTokenOptions", tokenOptions);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
//JWT有一些默认的属性,就是给鉴权时就可以筛选了
ValidateIssuer = true, //是否验证Issuer
ValidateAudience = true, //是否验证Audience
ValidateIssuerSigningKey = true, //是否验证SecurityKey
ValidAudience = tokenOptions.Audience,
ValidIssuer = tokenOptions.Isuser, //Issuer,这两项和前面签发jwt的设置一致
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenOptions.SecurityKey))
};
});
}
4.Program.cs中注入
builder.JwtAuthentication();
builder.Services.AddTransient<IJwtService,JwtService>();
//注册过滤器
builder.Services.AddControllers(o =>
{
o.Filters.Add<YesAttribute>();
});
5.写JWT服务,主要包含生成token,验证token两个方法
接口:IJwtService
public interface IJwtService
{
Task<string> GetToken(UserModel userModel);
Task<List<string>> AnalysisToken(string token);
Task<JwtSecurityTokenHandler> VerifyToken(string token);
}
实现类:JwtService
public class JwtService : IJwtService
{
private readonly IConfiguration _configuration;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IRedisManager _redisManager;
private readonly IUserManager _userManager;
private readonly ILogger<JwtService> _logger;
public JwtService(IConfiguration configuration, IHttpContextAccessor httpContextAccessor,
IRedisManager redisManager, IUserManager userManager, ILogger<JwtService> logger)
{
_configuration = configuration;
_httpContextAccessor = httpContextAccessor;
_redisManager = redisManager;
_userManager = userManager;
_logger = logger;
}
public async Task<string> GetToken(int organizationId, UserModel dto)
{
var jwtTokenOptions = _configuration.GetSection("JWTTokenOptions").Get<JWTTokenOptions>();
SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtTokenOptions.SecurityKey));
//查询用户的角色
var role = await _userManager.QueryRoleByUserIdAsync(organizationId, dto.Id);
var roleNames = role.RoleModels.Select(_ => _.Name).ToList();
var roleString = string.Join(",", roleNames);
//new Claim("Expiration", expiresAt.ToString())
IEnumerable<Claim> claims = new Claim[]
{
new Claim("UserName", dto.UserName),
new Claim("Role", roleString)
};
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
//expires: expiresAt, //60分钟有效期
var token = new JwtSecurityToken(
issuer: jwtTokenOptions.Isuser,
audience: jwtTokenOptions.Audience,
claims: claims,
notBefore: DateTime.Now, //立即生效 DateTime.Now.AddMilliseconds(30),//30s后有效
signingCredentials: creds);
string returnToken = new JwtSecurityTokenHandler().WriteToken(token);
//存到redis当中
var dataBase = await _redisManager.SwitchDataBase(1);
dataBase.StringSet(dto.Id.ToString(), returnToken, TimeSpan.FromMinutes(60));
return returnToken;
}
/// <summary>
/// 每次登录后刷新token有效时间。
/// </summary>
/// <param name="id"></param>
/// <param name="token"></param>
/// <returns></returns>
public async Task<bool> RefreshToken(string id)
{
var dataBase = await _redisManager.SwitchDataBase(1);
dataBase.KeyExpire(id, TimeSpan.FromMinutes(60));
return true;
}
/// <summary>
/// 解析token,拿到权限信息。
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task<List<string>> AnalysisToken(string token)
{
try
{
//校验token
var claimsPrincipal = await VerifyToken(token);
//拿到claims
var jwtSecurityToken = claimsPrincipal.ReadJwtToken(token);
//拿到权限列表
var roleString = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == "Role")?.Value;
var roleList = roleString!.Split(",").ToList();
return roleList;
}
catch (Exception e)
{
_logger.LogError($"AnalysisToken occur exception:{e.Message}");
throw;
}
}
/// <summary>
/// 校验token 的有效性
/// </summary>
/// <param name="token"></param>
public async Task<JwtSecurityTokenHandler> VerifyToken(string token)
{
var jwtTokenOptions = _configuration.GetSection("JWTTokenOptions").Get<JWTTokenOptions>();
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtTokenOptions!.SecurityKey));
//校验token ValidateLifetime = true,ClockSkew = TimeSpan.Zero //校验过期时间必须加此属性
var validateParameter = new TokenValidationParameters()
{
ValidateAudience = true,
ValidateIssuer = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtTokenOptions.Isuser,
ValidAudience = jwtTokenOptions.Audience,
IssuerSigningKey = securityKey,
};
try
{
var tokenHandler = new JwtSecurityTokenHandler();
//校验是否合法
tokenHandler.ValidateToken(token, validateParameter, out SecurityToken _);
return tokenHandler;
}
catch (Exception e)
{
_logger.LogError("Token invalid.");
throw;
}
}
}
6. 自定义特性Attribute和实现过滤器(注册过滤器在第四步一起)
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using org.huage.AuthManagement.BizManager.service;
namespace org.huage.AuthManagement.Api.Auth;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class YesAttribute : Attribute,IAuthorizationFilter
{
public string Roles { get; set; }
public YesAttribute()
{
}
//自定义权限认证
public async void OnAuthorization(AuthorizationFilterContext context)
{
//如果不携带这个参数则不校验
if (! context.ActionDescriptor.EndpointMetadata.Any(item => item is YesAttribute))
{
return;
}
//如果方法标有AllowAnonymousAttribute则跳过权限检查
if (context.ActionDescriptor.EndpointMetadata.Any(item => item is AllowAnonymousAttribute))
{
return;
}
//拿到token
string userToken = context.HttpContext.Request.Headers["token"].ToString(); //获取token
if (string.IsNullOrEmpty(userToken))
{
context.Result = new ContentResult() { StatusCode = 403, Content = "Headers no token,please check." }; //没有Cookie则跳转到登陆页面
return;
}
else
{
//拿到jwt服务来校验
var jwtService = context.HttpContext.RequestServices.GetService<IJwtService>();
//解析token,拿到role集合。
var roles =await jwtService!.AnalysisToken(userToken);
if (! roles.Any())
{
context.Result = new ContentResult() { StatusCode = 401, Content = "Please check the roles you owned." };
return;
}
//获取attribute的roles;
var methodInfo = ((ControllerActionDescriptor)context.ActionDescriptor).MethodInfo;
var attribute = methodInfo.GetCustomAttribute<YesAttribute>();
if (attribute ==null)
{
context.Result = new ContentResult() { StatusCode = 401, Content = "Please check the roles you owned." };
return;
}
var attributeRoles = attribute.Roles;
var a = attributeRoles.Split(",").ToHashSet();
var of = roles.ToHashSet().IsSupersetOf(a);
if (!of)
{
context.Result = new ContentResult() { StatusCode = 401, Content = "You don't have this role" };
}
}
}
}
7.测试,在AuthenticationController中定义Login()和Test()
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using org.huage.AuthManagement.Api.Auth;
using org.huage.AuthManagement.BizManager.Helper;
using org.huage.AuthManagement.BizManager.Redis;
using org.huage.AuthManagement.BizManager.service;
using org.huage.AuthManagement.Entity.Managers;
using org.huage.AuthManagement.Entity.Request;
using StackExchange.Redis;
namespace org.huage.AuthManagement.Api.Controllers;
[ApiController]
[Route("/[controller]/[action]")]
public class AuthenticationController : Controller
{
private readonly ILogger<AuthenticationController> _logger;
private readonly IJwtService _jwtService;
private readonly IUserManager _userManager;
private readonly IRedisManager _redisManager;
public AuthenticationController(IJwtService jwtService, IUserManager userManager, IRedisManager redisManager, ILogger<AuthenticationController> logger)
{
_jwtService = jwtService;
_userManager = userManager;
_redisManager = redisManager;
_logger = logger;
}
/// <summary>
/// 过去时间由redis控制,不再jwt里面设置,不好refresh 时间
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpPost]
[AllowAnonymous]
public async Task<string> Login(LoginRequest request)
{
if (string.IsNullOrEmpty(request.Phone) || string.IsNullOrEmpty(request.PassWord))
return "Account and Password is must.";
string userId = "";
IDatabase dataBase =null;
try
{
//查询数据库校验参数
var user = await _userManager.QueryUserByPhone(request.Phone);
userId = user.Id.ToString();
//密码加密
var realPwd = PwdBCrypt.Encryption(request.PassWord);
if (!user.Phone.Equals(request.Phone) || !user.PassWord.Equals(realPwd))
{
return "Account or Password wrong.";
}
//切库,token专门设立一个库
dataBase = await _redisManager.SwitchDataBase(1);
var x = await dataBase.StringGetAsync(userId);
var token=x.ToString();
if (string.IsNullOrEmpty(token))
{
//首次登录 ,生成token
token = await _jwtService.GetToken(request.OrganizationId,user);
}
else
{
//校验token
await _jwtService.VerifyToken(token);
//刷新过期时间
await _jwtService.RefreshToken(userId);
}
//将token 放到请求头
Response.Headers.Add(new KeyValuePair<string, StringValues>("Authorization",token));
return "Login success.";
}
catch (Exception e)
{
//删除token
await dataBase.KeyDeleteAsync(userId);
return "Login credentials have expired, please log in again";
}
}
/// <summary>
/// 加载页面的时候调用这个方法,然后判断是否有token.
/// </summary>
[HttpGet]
public async Task<bool> NoPwdLogin()
{
try
{
//无密码登录,解析token信息
string userToken = Request.Headers["token"].ToString();
await _jwtService.AnalysisToken(userToken);
return true;
}
catch (Exception e)
{
_logger.LogWarning("user token is valid,please login.");
return false;
}
}
/// <summary>
/// 退出登录
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
[HttpGet]
public async Task<IActionResult> Logout(int userId)
{
//删除redis中的token,页面跳转到登录页面
await _redisManager.HashDeleteFiled(RedisKeyGenerator.UserToken(), userId.ToString());
//页面跳转
return new RedirectResult("/index/login");
}
[HttpGet]
[Yes(Roles = "admin")]
public void Test()
{
Console.WriteLine("in");
}
}
先登录成功后,再调用Tes()方法,在请求头中携带上token参数
其余辅助代码:
1. 登录密码加密:
(注意这里存在的坑,自定义Salt会出现BCrypt.Net.SaltParseException: Invalid salt version,随意可以先BCrypt.Net.BCrypt.GenerateSalt(),拿到salt后设置常量。)
public static class PwdBCrypt
{
private const string PwdSalt = "$2a$11$jY8wPl8a0t6vaSQDz/Y1KO";
/// <summary>
/// 给密码加密
/// </summary>
/// <param name="pwd"></param>
/// <returns></returns>
public static string Encryption(string pwd)
{
return BCrypt.Net.BCrypt.HashPassword(pwd,PwdSalt);
}
}
本篇文章只是简单实现JWT加过滤器的基本权限认证,后续会扩展为基于组织,用户,角色,权限模式的认证模式。
Remark:源代码正在整理,后续贴上。