用NetCore + ReactJS 实现一个前后端分离的网站 (4) 用户登录与授权
1. 前言
这几天学了一些前端的知识,用Ant Design Pro
的脚手架搭建了一个前端项目->这里。
登录界面是现成的,所以回到后端来完成相应的API。
2. 登录与授权
2.1. 首先利用EFCore的Migration功能创建数据表,并添加种子数据。
User.cs
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
namespace NovelTogether.Core.Model.Models
{
public class User
{
[Key]
public int ID { get; set; }
[Required]
[Column(TypeName = "varchar")]
[MaxLength(128)]
[JsonPropertyName("username")]
public string? UserName { get; set; }
[Required]
public string? Password { get; set; }
[Required]
public string? PasswordSalt { get; set; }
[Required]
[Column(TypeName = "nvarchar")]
[MaxLength(128)]
public string? NickName { get; set; }
[Column(TypeName = "nvarchar")]
[MaxLength(128)]
public string? FirstName { get; set; }
[Column(TypeName = "nvarchar")]
[MaxLength(128)]
public string? LastName { get; set; }
public string? IDNumber { get; set; }
public string? Email { get; set; }
public string? PhoneNumner { get; set; }
public string? Address { get; set; }
[Required]
public DateTime Birthday { get; set; }
[Required]
public DateTime CreatedDate { get; set; }
public DateTime? ModifiedTime { get; set; }
}
}
ModelBuilderExtension.cs
public static void AddUserSeed(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
new User() { ID = 1, UserName = "SuperAdmin", Password = string.Empty, PasswordSalt = string.Empty, NickName = "我是超级管理员", Birthday = new DateTime(1970, 1, 1), CreatedDate = DateTime.Now },
new User() { ID = 2, UserName = "Wright", Password = "ddcddb33bd354212e2c2404fbf079a84", PasswordSalt = "qwe123!@#", NickName = "我是普通管理员", Birthday = new DateTime(1970, 1, 1), CreatedDate = DateTime.Now }
);
}
2.2. 依葫芦画瓢添加IUserService, UserService
,为后面的API提供服务。
2.3. 用户身份验证和Jwt Token授权。
先说一下登录以及API授权访问的原理:
- 前端对用户输入的密码和从后端获取的
密码盐
一起用MD5
加密,然后传到后端验证。
为什么要加盐:因为常见的密码的MD5值是固定的,数据库要是泄露了,很容易被别人猜出来,所以数据的密码是MD5(密码+盐)。
为什么不在后端生成随机数保护密码的传输:前后端分离,所有信息都在网络上传输,随机数也不安全,只有用Https
来保护数据,所以不需要加额外的数据安全措施。
- 后端密码验证通过之后,生成一个Jwt Token,并在Payload中存放一个userid通过SetCookie的方式在前端写入
HttpOnly
的Cookie,确保这个Cookie前端JS的无法访问和修改
的。 - 前端后续的API请求会自动带上这个Cookie,后端接收到请求后,通过中间件取出Jwt Token,验证通过后取出其中存放的userid放入
上下文
中。 - 所有的API就可以从上下文中得到当前用户的id。
下面是相应的代码:
2.3.1. 在ViewModels目录下创建登录接口用的request以及response的实体类,以及一般response的实体类。
LoginModel.cs
using System.Text.Json.Serialization;
namespace NovelTogether.Core.Model.ViewModels
{
public class LoginModel
{
[JsonPropertyName("username")]
public string? UserName { get; set; }
public string? Password { get; set; }
public bool AutoLogin { get; set; }
}
}
LoginResultModel.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NovelTogether.Core.Model.ViewModels
{
public class LoginResultModel
{
public string? Status { get; set; }
public string? Type { get; set; }
public string? CurrentAuthority { get;set; }
public LoginResultModel Success(LoginType loginType)
{
Status = "success";
Type = loginType.ToString().ToLower();
return this;
}
public LoginResultModel Error(LoginType loginType)
{
Status = "error";
Type = loginType.ToString().ToLower();
return this;
}
}
public enum LoginType
{
Account,
Mobile
}
}
ResponseModel.cs
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NovelTogether.Core.Model.ViewModels
{
public class ResponseModel<TEntity> where TEntity : class
{
public bool Success { get; set; }
public TEntity Data { get; set; }
public int ErrorCode { get; set; }
public string ErrorMessage { get; set; }
public ErrorShowType ErrorShowType { get; set; }
public ResponseModel<TEntity> Ok(TEntity entity)
{
Success = true;
Data = entity;
return this;
}
public ResponseModel<TEntity> Failed(int statusCode)
{
Success = false;
ErrorCode = statusCode;
ErrorMessage = "error";
return this;
}
public ResponseModel<TEntity> Failed(int statusCode, TEntity entity)
{
Success = false;
ErrorCode = statusCode;
ErrorMessage = JsonSerializer.Serialize(entity);
return this;
}
}
public enum ErrorShowType
{
SILENT = 0,
WARN_MESSAGE = 1,
ERROR_MESSAGE = 2,
NOTIFICATION = 3,
REDIRECT = 9,
}
}
2.3.2. 在API层创建文件夹Utils,添加一些帮助实体。
AuthToken.cs
namespace NovelTogether.Core.API.Utils
{
public class AuthToken
{
public string? Token { get; set; }
public string? RefreshToken { get; set; }
}
}
Consts.cs
namespace NovelTogether.Core.API.Utils
{
public class Consts
{
public static readonly string TOKEN_NAME = "auth-token";
public static readonly string REFRESH_TOKEN_NAME = "auth-refresh-token";
public static readonly string USER_ID = "USER_ID";
public static readonly string UNIQUE_ID = "UNIQUE_ID";
public static readonly string CACHE_KEY = "CACHE_KEY";
public static readonly string CACHE_DURATION_SECONDS = "CACHE_DURATION_SECONDS";
}
}
JwtOption.cs
namespace NovelTogether.Core.API.Utils
{
public class JwtOption
{
public string? Secret { get; set; }
public string? Issuer { get; set; }
public string? Audience { get; set; }
}
}
2.3.3. 在API层中创建工具类JwtHelper.cs,用来生成Jwt Token。
JwtHelper.cs
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace NovelTogether.Core.API.Helpers.Jwt
{
public class JwtHelper
{
public static string CreateToken(string secret, string issuer, string audience, int expiredHours, List<Claim> claims)
{
//秘钥
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
//生成秘钥
var key = new SymmetricSecurityKey(secretBytes);
//生成数字签名的签名密钥、签名密钥标识符和安全算法
var credential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
//构建JwtSecurityToken类实例
var token = new JwtSecurityToken(
//添加颁发者
issuer: issuer,
//添加受众
audience: audience,
//添加其他需要加密的信息
claims,
//自定义过期时间
expires: DateTime.UtcNow.AddHours(expiredHours),
signingCredentials: credential);
//签发token
var jwtToken = new JwtSecurityTokenHandler().WriteToken(token);
return jwtToken;
}
}
}
2.3.4. 在API中添加Token验证的中间件
。
AuthMiddleware.cs
using Microsoft.IdentityModel.Tokens;
using NovelTogether.Core.API.Utils;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
namespace NovelTogether.Core.API.Middlewares
{
public class AuthMiddleware
{
private readonly RequestDelegate _next;
private readonly JwtOption _jwtOption;
public AuthMiddleware(RequestDelegate next, JwtOption jwtOption)
{
_next = next;
_jwtOption = jwtOption;
}
public async Task Invoke(HttpContext context)
{
//Get the upload token, which can be customized and extended
var token = context.Request.Cookies[Consts.TOKEN_NAME]?.Split(" ").Last();
if (token != null)
AttachTokenToContext(context, token);
await _next(context);
}
private void AttachTokenToContext(HttpContext context, string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidAudience = _jwtOption.Audience,
ValidIssuer = _jwtOption.Issuer,
ValidateIssuer = true,
ValidateAudience = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.Secret)),
ClockSkew = new System.TimeSpan(0, 0, 30)
}, out SecurityToken validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
// attach user id to context on successful jwt validation
context.Items[Consts.USER_ID] = jwtToken.Claims.First(x => x.Type == Consts.USER_ID).Value;
}
catch
{
}
}
}
}
2.3.5. 在API中添加自定义属性用替换默认的[Authorize]
属性。
ApiAuthorizeAttribute.cs
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using NovelTogether.Core.Model.ViewModels;
using NovelTogether.Core.API.Utils;
namespace NovelTogether.Core.API.Attributes
{
public class ApiAuthorizeAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var accountId = context.HttpContext.Items[Consts.USER_ID];
if (accountId == null)
{
//context.HttpContext.Response.StatusCode = HttpStatusCode.Unauthorized.GetHashCode();
context.Result = new UnauthorizedObjectResult(new ResponseModel<string>().Failed(HttpStatusCode.Unauthorized.GetHashCode()));
}
}
}
}
2.3.6. 创建UserController,下面的代码中实现了三个方法:登录、登出、获取当前用户信息。
获取用户信息的接口受
[ApiAuthorize]
属性保护。
UserController.cs
using Microsoft.AspNetCore.Mvc;
using NovelTogether.Core.API.Attributes;
using NovelTogether.Core.API.Helpers.Jwt;
using NovelTogether.Core.API.Utils;
using NovelTogether.Core.IService;
using NovelTogether.Core.Model.ViewModels;
using System.Net;
using System.Security.Claims;
namespace NovelTogether.Core.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UserController : Controller
{
private readonly IUserService _userService;
private readonly IConfiguration _configuration;
public UserController(IUserService userService, IConfiguration configuration)
{
_userService = userService;
_configuration = configuration;
}
[HttpPost("Login")]
public async Task<LoginResultModel> Login(LoginModel loginModel)
{
// 验证用户名、密码
var userName = loginModel.UserName;
var password = loginModel.Password;
if (string.IsNullOrEmpty(userName) || string.IsNullOrWhiteSpace(password))
{
return new LoginResultModel().Error(LoginType.Account);
}
var user = await _userService.SelectAsync(x => x.UserName != null && x.UserName.ToLower() == userName.ToLower() && x.Password == password);
if (user == null)
{
return new LoginResultModel().Error(LoginType.Account);
}
var token = JwtHelper.CreateToken(
_configuration.GetValue<string>("Config:JWT:Secret"),
_configuration.GetValue<string>("Config:JWT:Issuer"),
_configuration.GetValue<string>("Config:JWT:Audience"),
_configuration.GetValue<int>("Config:JWT:ExpiredHours"),
new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, userName.ToLower()),
new Claim(Consts.USER_ID, user.ID.ToString())
});
// 返回JWT Token
Response.Cookies.Append(Consts.TOKEN_NAME, token, new CookieOptions
{
HttpOnly = true,
Expires = new DateTimeOffset(DateTime.Now.AddHours(_configuration.GetValue<int>("Config:JWT:TokenExpiredHours")))
});
return new LoginResultModel().Success(LoginType.Account);
}
[HttpPost("Logout")]
public ResponseModel<string> Logout()
{
Response.Cookies.Delete(Consts.TOKEN_NAME);
return new ResponseModel<string>().Ok("Logout");
}
[HttpGet]
[ApiAuthorize]
public async Task<ResponseModel<UserModel>> Get()
{
var userId = int.Parse(HttpContext.Items[Consts.USER_ID].ToString());
var user = await _userService.SelectAsync(x => x.ID == userId);
if (user == null)
{
return new ResponseModel<UserModel>().Failed(HttpStatusCode.NotFound.GetHashCode());
}
// 返回用户信息
return new ResponseModel<UserModel>().Ok(new UserModel() { UserName = user.UserName, NickName = user.NickName });
}
}
}
3. 刷新Jwt Token
Jwt Token是有时效性的,一般都很短,是为了防止Token被别人知道,拿来假冒用户的身份访问API。
但是也不能说,用户才登上网站10分钟,就把他踹下线,说“来,重新登录一下!”
所以,网站需要能够自动续期Token。
这个项目是后台来管理Token的,前台感知不到,所以刷新Token也由后台来负责。
原理如下:
- 登录的时候同时生成Jwt Token和Guid类型的Refresh Token,存到客户端的Cookie中,并随着每次的请求传递到后台。Refresh Token在数据库中存有一个过期时间。
- 后台验证Jwt Token的时候,先不验证
LifeTime
,获取到Token的Payload,然后手动判断是否过期。 - 如果Token过期了,判断Refresh Token是否过期了,如果没有,重新生成两个Token给客户端。如果过期了,返回
401 Unauthorized
3.1. 建表来维护用户的Refresh Token
UserAccessToken.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace NovelTogether.Core.Model.Models
{
public class UserAccessToken
{
[Key]
public int ID { get; set; }
[Required]
[ForeignKey("User")]
public int UserID { get;set; }
[Required]
public string? RefreshToken { get; set; }
public DateTime ExpiredTime { get; set; }
[Required]
public DateTime CreatedTime { get; set; }
public DateTime? ModifiedTime { get; set; }
}
}
3.2 新建工具类来创建两种Token
TokenHelper.cs
using NovelTogether.Core.API.Helpers.Jwt;
using NovelTogether.Core.API.Utils;
using NovelTogether.Core.IService;
using NovelTogether.Core.Model.Models;
using System.Security.Claims;
namespace NovelTogether.Core.API.Helpers
{
public class TokenHelper
{
public static async Task<AuthToken> Generate(IConfiguration configuration, IUserAccessTokenService userAccessTokenService, User user)
{
// 生成Jwt Token
var token = JwtHelper.CreateToken(
configuration.GetValue<string>("Config:JWT:Secret"),
configuration.GetValue<string>("Config:JWT:Issuer"),
configuration.GetValue<string>("Config:JWT:Audience"),
configuration.GetValue<int>("Config:JWT:TokenExpiredHours"),
new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, user.UserName.ToLower()),
new Claim(Consts.USER_ID, user.ID.ToString())
});
// 生成Refresh Token
var refreshToken = Guid.NewGuid().ToString("N");
var userAccessToken = await userAccessTokenService.SelectAsync(x => x.UserID == user.ID);
// 写入数据库
var expiredHours = configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours");
if (userAccessToken == null)
{
await userAccessTokenService.AddAsync(
new UserAccessToken()
{
UserID = user.ID,
RefreshToken = refreshToken,
ExpiredTime = DateTime.Now.AddHours(expiredHours),
CreatedTime = DateTime.Now
});
}
else
{
userAccessToken.RefreshToken = refreshToken;
userAccessToken.ExpiredTime = DateTime.Now.AddHours(expiredHours);
userAccessToken.ModifiedTime = DateTime.Now;
await userAccessTokenService.UpdateAsync(userAccessToken);
}
return new AuthToken() { Token = token, RefreshToken = refreshToken };
}
}
}
3.3 修改Login方法,往Cookie中写入两种Token
UserController.cs
var authToken = await TokenHelper.Generate(_configuration, _userAccessTokenService, user);
// 通过SetCookie往客户端吸入HttpOnly类型的Cookie,这样的Cookie客户端JS无法读取和修改。
context.Response.Cookies.Append(Consts.TOKEN_NAME, token, new CookieOptions
{
// 这里把过期时间设的和refresh token一样长,因为jwt token是用来确认用户身份的,如果它从cookie中消失了,那refresh token也就失去了作用。
HttpOnly = true,
Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
});
context.Response.Cookies.Append(Consts.REFRESH_TOKEN_NAME, refreshToken, new CookieOptions
{
HttpOnly = true,
Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
});
3.4 修改中间件,提供自动续期的功能
注意:服务通过注入从
IServiceProvider
中获取。
AuthMiddleware.cs
private void AttachTokenToContext(HttpContext context, string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
// 如果无法解析,直接会抛错误,然后被catch住,最后由ApiAuthorizeAttribute返回401
tokenHandler.ValidateToken(token, new TokenValidationParameters
{
ValidAudience = _jwtOption.Audience,
ValidIssuer = _jwtOption.Issuer,
ValidateIssuer = true,
ValidateAudience = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.Secret)),
ValidateLifetime = false
}, out SecurityToken validatedToken);
var jwtToken = (JwtSecurityToken)validatedToken;
var userId = jwtToken.Claims.First(x => x.Type == Consts.USER_ID).Value;
// 如果Jwt Token过期了,注意这里是UTC时间
if ((DateTime.UtcNow - jwtToken.ValidTo).TotalSeconds > 0)
{
// 从Cookie中获取Refresh Token来刷新Token
var refreshToken = context.Request.Cookies[Consts.REFRESH_TOKEN_NAME];
var _configuration = _provider.GetService<IConfiguration>();
var _userService = _provider.GetService<IUserService>();
var _userAccessTokenService = _provider.GetService<IUserAccessTokenService>();
var userAccessToken = _userAccessTokenService.SelectAsync(x => x.UserID.ToString() == userId).Result;
// 如果Refresh Token没过期
if (userAccessToken.RefreshToken == refreshToken && (userAccessToken.ExpiredTime - DateTime.Now).TotalSeconds > 0)
{
var user = _userService.SelectAsync(x => x.ID.ToString() == userId).Result;
var authToken = TokenHelper.Generate(_configuration, _userAccessTokenService, user).Result;
// 通过SetCookie往客户端吸入HttpOnly类型的Cookie,这样的Cookie客户端JS无法读取和修改。
context.Response.Cookies.Append(Consts.TOKEN_NAME, token, new CookieOptions
{
// 这里把过期时间设的和refresh token一样长,因为jwt token是用来确认用户身份的,如果它从cookie中消失了,那refresh token也就失去了作用。
HttpOnly = true,
Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
});
context.Response.Cookies.Append(Consts.REFRESH_TOKEN_NAME, refreshToken, new CookieOptions
{
HttpOnly = true,
Expires = new DateTimeOffset(DateTime.Now.AddHours(configuration.GetValue<int>("Config:JWT:RefreshTokenExpiredHours")))
});
// attach user id to context on successful jwt validation
context.Items[Consts.USER_ID] = userId;
}
else
{
// refresh token也过期了,不往Context中添加user id,由ApiAuthorizeAttribute返回401
}
}
else
{
// attach user id to context on successful jwt validation
context.Items[Consts.USER_ID] = userId;
}
}
catch
{
}
}
4. 总结
前后端已然可以安全地交互,那么用日志跟踪用户的行为、程序的运转就随之而来。