从这篇文章开始,我们就进入到了项目开发阶段。我们的项目是面向用户的,因此我们首先要做的是和用户相关的逻辑代码。
一、需求
首先,我们来看一下服务端的需求:
编号 | 需求标题 | 需求内容 |
---|---|---|
1 | 登录 | 传入参数用户名、密码和验证码,三个参数都验证通过后返回token和刷新token,反之返回登录失败相关信息 |
2 | 注册 | 传入参数用户名、密码和验证码,三个参数都验证通过后返回注册成功相关信息,返回返回注册失败相关信息 |
3 | 找回密码 | 传入参数用户名和验证码,两个参数都验证通过后返回验证成功相关信息,然后传入新密码重置密码,返回找回密码成功相关信息,反之返回找回失败相关信息 |
4 | 刷新token | 传入刷新token,返回新token |
二、User类和User表
在这一小结,我们一起来创建项目的第一个类和第一个表:SysUser。
SysUser类是数据库中SysUser表的映射,因此它的结构和数据库中表的结构是一样的(列名称、列类型等都一样)。下面的代码就是新建的SysUser类。
using SporeAccounting.BaseModels;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace SporeAccounting.Models;
/// <summary>
/// 用户表自定义属性类
/// </summary>
[Table(name:"SysUser")]
public class SysUser:BaseModel
{
/// <summary>
/// 用户名
/// </summary>
[Column(TypeName = "nvarchar(20)")]
[Required]
public string UserName { get; set; }
/// <summary>
/// 加密后的密码
/// </summary>
[Column(TypeName = "nvarchar(50)")]
[Required]
public string Password { get; set; }
/// <summary>
/// 加密用的盐
/// </summary>
[Column(TypeName = "nvarchar(36)")]
[Required]
public string Salt { get; set; }
/// <summary>
/// 邮箱
/// </summary>
[Column(TypeName = "nvarchar(50)")]
public string Email { get; set; }
/// <summary>
/// 手机号
/// </summary>
[Column(TypeName = "nvarchar(11)")]
public string PhoneNumber { get; set; }
}
这个类继承自 BaseModel
,并且使用了 System.ComponentModel.DataAnnotations
和 System.ComponentModel.DataAnnotations.Schema
命名空间中的特性来配置数据库表结构和字段的约束。代码中一共出现了三个引用了:包含基础模型类的命名空间 SporeAccounting.BaseModels
、包含数据注释属性的 System.ComponentModel.DataAnnotations
命名空间,它主要用于验证模型数据,以及包含数据库表和列注释属性的 System.ComponentModel.DataAnnotations.Schem
的命名空间,它用于配置数据库结构。
我们使用 [Table(name: "SysUser")]
将 SysUser
类映射到数据库中的 SysUser
表,并在类中指定了数据库表的列(类的属性)以及列的属性。其中特性 Column
用于指定数据库表的列的数据类型,Required
特性指定了列不能为空。
SysUser 表的映射类定义完了,接下来我们要做的是进行数据库迁移。执行迁移前,我们需要将 SysUser
类加入到数据库连接上下文 SporeAccountingDBContext
中,代码如下:
public DbSet<SysUser> SysUsers { get; set; }
然后,我们在程序包管理器控制台中执行添加迁移命令 Add-Migration InitSysUser
来创建一个迁移文件,接着在执行更新数据库命令 Update-Database
即可完成数据库的迁移。迁移完成后我们在数据库中就能看到创建的 SysUser
表。
Tip:数据库迁移是指在数据库管理系统中对数据库结构进行变更的一系列步骤和操作。这些变更通常涉及添加、修改或删除数据库表、列、索引和其他数据库对象,以便数据库能够满足应用程序的新需求或优化其性能。数据库迁移的目的是确保数据的完整性和一致性,同时使得数据库结构适应业务需求的变化。
数据库迁移的详细讲解,请关注我的EF Core 专栏。
三、创建用户接口
3.1. 注册
注册用户是用户使用我们应用的第一步,这一小节我们来一起编写注册的逻辑。
- 服务
我们在项目中创建一个 User 服务接口ISysUserServer
和一个 User 服务接口实现类SysUserImp
,并在其中新增Add
接口,代码如下:
ISysUserServer
using SporeAccounting.Models;
namespace SporeAccounting.Server.Interface;
/// <summary>
/// 用户接口
/// </summary>
public interface ISysUserServer
{
/// <summary>
/// 新增用户
/// </summary>
void Add(SysUser sysUser);
}
SysUserImp
using SporeAccounting.Models;
using SporeAccounting.Server.Interface;
namespace SporeAccounting.Server;
/// <summary>
/// 用户实现类
/// </summary>
public class SysUserImp : ISysUserServer
{
private SporeAccountingDBContext _dbContext;
public SysUserImp(SporeAccountingDBContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 新增用户
/// </summary>
/// <param name="sysUser">用户实体</param>
public void Add(SysUser sysUser)
{
try
{
_dbContext.SysUsers.Add(sysUser);
_dbContext.SaveChanges();
}
catch (Exception ex)
{
throw ex;
}
}
}
我们看到,在 SysUserImp
代码的构造函数中包含 SporeAccountingDBContext
类型的参数 dbContext
,这个参数是上一篇文章中我们编写的数据库上下文,通过依赖注入的方式把它注入到了 SysUserImp
类中。
- 注入User服务
User 服务创建完成,我们需要将这个服务注入到项目中。和注入数据库上下的方式类似,我们只需要在Program
类中加入如下代码即可:
builder.Services.AddScoped(typeof(ISysUserServer), typeof(SysUserImp));
在这个代码段中,我们将User服务注册为了 Scoped 类型,Scoped 类型表示每次请求都会创建一个User服务的实例。这么做的原因是因为我们要保证请求之间不会相互影响。
Tip:相关的作用域范围请关注我的后续文章,这里不详细讲解。
- 控制器
注册功能的最后一步,就是创建 Web Api 接口。新建SysUserController
控制器类,并编写注册 Action :
using System.Net;
using System.Security.Cryptography;
using System.Text;
using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SporeAccounting.BaseModels;
using SporeAccounting.Models;
using SporeAccounting.Models.ViewModels;
using SporeAccounting.Server.Interface;
namespace SporeAccounting.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class SysUserController : ControllerBase
{
private readonly ISysUserServer _sysUserServer;
private readonly IMapper _mapper;
public SysUserController(ISysUserServer sysUserServer, IMapper mapper)
{
_sysUserServer = sysUserServer;
_mapper = mapper;
}
[HttpPost]
[Route("Register")]
public ActionResult<ResponseData<bool>> Register(SysUserViewModel sysUserViewModel)
{
try
{
SysUser sysUser = _mapper.Map<SysUser>(sysUserViewModel);
sysUser.Salt = Guid.NewGuid().ToString("N");
sysUser.Password = HashPasswordWithSalt(sysUser.Password, sysUser.Salt);
sysUser.CreateUserId = sysUser.Id;
_sysUserServer.Add(sysUser);
return new ResponseData<bool>(HttpStatusCode.OK, "", false);
}
catch (Exception ex)
{
return new ResponseData<bool>(HttpStatusCode.InternalServerError, "服务端异常", false);
}
}
private string HashPasswordWithSalt(string password, string salt)
{
using (var sha256 = SHA256.Create())
{
string saltedPassword = password + salt;
byte[] saltedPasswordBytes = Encoding.UTF8.GetBytes(saltedPassword);
byte[] hashBytes = sha256.ComputeHash(saltedPasswordBytes);
return Convert.ToBase64String(hashBytes);
}
}
}
}
上面的代码中我们定义了 SysUserController
作为控制器,它继承自 ControllerBase
,用来处理与用户相关的 API 请求。并且加上了两个属性:
[Route("api/[controller]")]
定义了路由前缀(根路径),也就是说这个控制器的根路径是api/SysUser
。[ApiController]
是一个属性修饰符,用来注明这个类是一个 API 控制器,它会自动处理请求验证以及响应格式化。
然后我们通过注入的方式在构造函数注入了 ISysUserServer
(用于与数据库交互)和 IMapper
(用于对象映射)。接着我们定义了 Register
方法作为Action,并通过 [HttpPost]
属性将这个Action 标注为只接受 HTTP POST 请求。又通过 [Route("Register")]
属性指定了这个Action的路由路径为 api/SysUser/Register
。在这个 Register
方法中它接收了一个 SysUserViewModel
类型的参数,用于从客户端接收用户注册信息。并使用了 AutoMapper
将 SysUserViewModel
映射为 SysUser
实体类。接着我们为 Salt
属性生成一个随机生成的一个随机的字符串用作密加密的盐。最后调用 _sysUserServer.Add(sysUser)
将用户数据保存到数据库。
在控制器中我们还定义了一个私有方法 HashPasswordWithSalt
,它主要用来对密码进行加盐哈希处理。使用的是 SHA256 哈希算法,它将加盐后的密码进行哈希处理,然后将结果转换为 Base64 字符串进行存储。通过加盐处理,即使两个用户使用相同的密码,也会生成不同的哈希值,提升了安全性。
Tip:为什么要对密码加密?这个问题属于老生常谈了,之所以对密码加密是因为保护用户隐私,防止密码被直接泄露或被恶意攻击者获取。通过使用哈希算法和加盐技术,即使数据库被攻破,密码仍难以被还原,提升了系统安全性,并降低了潜在的连锁风险。
3.2. 登录
用户注册完就要开始登录了,同样,我们也要开始编写登录代码了。
- 服务
我们首先在ISysUserServer
接口中增加Get
方法:
/// <summary>
/// 根据用户名获取用户
/// </summary>
/// <param name="sysUser"></param>
SysUser Get(string userName);
接着,我们在 SysUserImp
类中实现新增的 Get
方法:
/// <summary>
/// 根据用户名获取用户
/// </summary>
/// <param name="userName"></param>
/// <returns></returns>
public SysUser Get(string userName)
{
try
{
SysUser sysUser= _dbContext.SysUsers.FirstOrDefault(p=>p.UserName==userName);
return sysUser;
}
catch (Exception ex)
{
throw ex;
}
}
这段代码很简单,我们不做讲解,接下来我们再来看看控制器的编写。
- 控制器
我们在SysUserController
控制器中新增Login
Action ,这个Action 是用来登录的,需要验证用户名和密码,验证通过后在调用生成token和生成刷新token的方法,生成我们所需的token。代码如下:
/// <summary>
/// 登录
/// </summary>
/// <param name="userName"></param>
/// <param name="password"></param>
/// <returns></returns>
[HttpGet]
[Route("Login/{userName}/{password}")]
public ActionResult<ResponseData<TokenViewModel>> Login([FromRoute] string userName, [FromRoute] string password)
{
try
{
//验证用户
SysUser sysUser = _sysUserServer.GetByUserName(userName);
if (sysUser == null)
{
return Ok(new ResponseData<bool>(HttpStatusCode.BadRequest, "用户或密码错误!", false));
}
string passwordHash = HashPasswordWithSalt(password, sysUser.Salt);
//验证密码
if (sysUser.Password != passwordHash)
{
return Ok(new ResponseData<bool>(HttpStatusCode.OK, "用户或密码错误!", false));
}
//生成Token和刷新Token
TokenViewModel sysToken = new TokenViewModel();
sysToken.RefreshToken = GenerateRefreshToken();
sysToken.Token = GenerateToken(sysUser.Id, sysToken.RefreshToken);
return Ok(new ResponseData<TokenViewModel>(HttpStatusCode.OK, data: sysToken));
}
catch (Exception ex)
{
return Ok(new ResponseData<bool>(HttpStatusCode.InternalServerError, "服务端异常", false));
}
}
/// <summary>
/// 生成Token
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
private string GenerateToken(string userId, string refreshToken)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim("refreshToken",refreshToken)
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JWT:IssuerSigningKey"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
int tokenExpirces = int.Parse(_config["JWT:Expirces"]);
var token = new JwtSecurityToken(
issuer: _config["JWT:ValidIssuer"],
audience: _config["JWT:ValidAudience"],
claims: claims,
expires: DateTime.Now.AddMinutes(tokenExpirces), // Token 有效期
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// 生成刷新Token
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
private string GenerateRefreshToken()
{
var randomNumber = new byte[32];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}
Login
Action 的内容很简单,我们不做讲解了,这里主要讲解一下 GenerateToken
和 GenerateRefreshToken
这两个方法。GenerateToken
方法的生成一个Token 用来标识用户身份的访问令牌,其中包含用户的身份信息、声明(claims)、有效期,刷新token等。这个令牌通常会附带在用户后续请求的Authorization头中,以便服务器验证用户的身份。RefreshAccessToken
方法的主要作用是使用一个有效的刷新令牌(Refresh Token)生成一个新的访问令牌(Access Token)。刷新令牌通常是一个长期有效的凭证,用于在访问令牌过期后,客户端请求新的访问令牌,以保持用户的持续认证状态,而无需重新登录。
3.3. 找回密码
作为用户,在某些情况下可能出现忘记密码的情况,那么这时我们就需要给用户提供找回密码的功能。但是由于存储的密码都是加盐之后在存储的,无法从将原始密码返给用户,因此需要随机生成一个密码返回给用户。那么,下面我们就来看看这个功能如何实现。
- 服务
我们首先在ISysUserServer
接口中增加Update
方法:
/// <summary>
/// 修改用户
/// </summary>
/// <param name="sysUser"></param>
void Update(SysUser sysUser);
接着,我们在 SysUserImp
类中实现这个 Update
方法:
/// <summary>
/// 修改用户
/// </summary>
/// <param name="sysUser"></param>
public void Update(SysUser sysUser)
{
try
{
_dbContext.SysUsers.Update(sysUser);
_dbContext.SaveChanges();
}
catch (Exception ex)
{
throw ex;
}
}
我们通过上面的代码更新数据库中 SysUser 对象的记录,调用 EF Core DbSet 提供的 Update
方法将对象 sysUser
标记为 “已修改” 状态,告知 EF Core sysUser
对象的属性将在保存时要更新到数据库中。最后调用 SaveChanges
方法将修改的数据保存到数据库中。
- 控制器
服务编写完成后,我们开始编写接口,代码如下:
/// <summary>
/// 找回密码
/// </summary>
/// <param name="userName"></param>
/// <param name="password"></param>
/// <returns></returns>
[HttpGet]
[Route("RetrievePassword/{userName}/{email}")]
public ActionResult<ResponseData<string>> RetrievePassword([FromRoute] string userName, [FromRoute] string email)
{
try
{
//验证用户是否存在
SysUser sysUser = _sysUserServer.Get(userName);
if (sysUser == null)
{
return Ok(new ResponseData<bool>(HttpStatusCode.BadRequest, "用户不存在!", false));
}
if (sysUser.Email != email)
{
return Ok(new ResponseData<bool>(HttpStatusCode.BadRequest, "邮箱不正确!", false));
}
//生成12位随机密码
string newPassword = GenerateRandomPassword(12);
sysUser.Password= HashPasswordWithSalt(newPassword,sysUser.Salt);
_sysUserServer.Update(sysUser);
return Ok(new ResponseData<string>(HttpStatusCode.OK, data: newPassword));
}
catch (Exception ex)
{
return Ok(new ResponseData<bool>(HttpStatusCode.InternalServerError, "服务端异常", false));
}
}
前面的代码用于根据用户名和邮箱重置用户密码,它是一个 HTTP GET 请求。首先,通过 _sysUserServer.Get(userName)
检查用户名是否存在。如果用户不存在或邮箱不匹配,返回 400 Bad Request 状态和错误信息。如果验证通过,则通过 GenerateRandomPassword
方法生成一个 12 位随机密码,并通过 HashPasswordWithSalt
方法加密密码后更新用户记录。成功后,返回状态码 200 OK 和新密码。若出现异常,则捕获并返回 500 Internal Server Error 和相应的错误信息。
接下来,我们编写随机生成密码的方法 GenerateRandomPassword
,代码如下:
/// <summary>
/// 随机密码生成
/// </summary>
/// <param name="length"></param>
/// <returns></returns>
private string GenerateRandomPassword(int length)
{
const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()";
StringBuilder password = new StringBuilder();
using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
{
byte[] buffer = new byte[sizeof(uint)];
while (password.Length < length)
{
rng.GetBytes(buffer);
uint num = BitConverter.ToUInt32(buffer, 0);
password.Append(validChars[(int)(num % (uint)validChars.Length)]);
}
}
return password.ToString();
}
在这里,我们使用了 System.Security.Cryptography
命名空间中的类来生成随机密码,其中局部变量 validChars
包含了密码中可以使用的所有字符,RNGCryptoServiceProvider
用于生成加密安全的随机数。
3.4. 刷新Token
所有网站的token都会有有效期,那么token如果过期了怎么办呢?难道要用户再次输入用户名和密码登录吗?当然不是,我们可以使用前面生成的 refreshToken
来生成一个新的Token发给用户。下面我们一起来看看如何实现它。
- 服务
我们首先在ISysUserServer
接口中增加GetById
方法,这个方法实现了根据传入的用户Id来查询用户:
/// <summary>
/// 根据用户id获取用户
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
SysUser GetById(string userId);
接着我们在 SysUserImp
类中具体实现它,代码如下:
/// <summary>
/// 根据用户Id获取用户
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public SysUser GetById(string userId)
{
try
{
SysUser sysUser = _dbContext.SysUsers.FirstOrDefault(p => p.Id == userId);
return sysUser;
}
catch (Exception ex)
{
throw ex;
}
}
这段实现代码用于从数据库中根据 userId
获取一个 SysUser
对象。它使用 Entity Framework Core 从 _dbContext.SysUsers
集合中查找第一个匹配的用户。若查询过程中发生异常,捕获异常并重新抛出。
- 控制器
刷新Token的Action稍显复杂,我们需要首先获取到Token,然后解析出Token中的UserId和refreshToken,然后验证 refreshToken 和 UserId 验证通过后生成新token。这里比较复杂的是解析Token信息,下面我们一步一步的来看看。
/// <summary>
/// 刷新token
/// </summary>
/// <param name="refreshToken"></param>
/// <returns></returns>
[HttpGet]
[Route("RefreshToken/{refreshToken}")]
public ActionResult<ResponseData<string>> RefreshToken([FromRoute] string refreshToken)
{
try
{
//获取token中的user id 和 refreshToken
string token = HttpContext.Request.Headers["Authorization"].ToString()
.Substring("Bearer ".Length).Trim();
(string userId, _, string reToken) = GetTokenInfo(token);
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(reToken) || refreshToken != reToken)
{
return Ok(new ResponseData<bool>(HttpStatusCode.BadRequest, "数据违规!", false));
}
//根据userid查询用户
SysUser sysUser = _sysUserServer.GetById(userId);
if (sysUser == null)
{
return Ok(new ResponseData<bool>(HttpStatusCode.NotFound, "用户不存在!", false));
}
//使用刷新token刷新token
string newToken = GenerateToken(userId, refreshToken);
return Ok(new ResponseData<string>(HttpStatusCode.OK, data: newToken));
}
catch (Exception ex)
{
return Ok(new ResponseData<bool>(HttpStatusCode.InternalServerError, "服务端异常", false));
}
}
/// <summary>
/// 获取token中的userid
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private (string, string, string) GetTokenInfo(string token)
{
var handler = new JwtSecurityTokenHandler();
// 验证令牌格式
if (!handler.CanReadToken(token))
{
throw new ArgumentException("无效的令牌");
}
// 读取令牌
var jwtToken = handler.ReadJwtToken(token);
// 从声明中提取用户ID(通常是“sub”声明)
var userIdClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "sub");
var jtiClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "jti");
var refreshTokenClaim = jwtToken.Claims.FirstOrDefault(claim => claim.Type == "refreshToken");
return (userIdClaim?.Value, jtiClaim.Value, refreshTokenClaim.Value);
}
在上面的代码中 RefreshToken
Action 方法接收一个 refreshToken
作为路径参数,并从请求头中提取当前的 Authorization
令牌,然后 GetTokenInfo
方法从令牌中解析出用户 ID (userId
)、令牌 ID (jti
)、和刷新令牌 (reToken
),如果 userId
或 reToken
为空,或提供的 refreshToken
与解析出的 reToken
不匹配,返回 BadRequest
响应。反之使用 userId
查询用户,若用户不存在,则返回 NotFound
响应。一切验证都通过后就生成新的 JWT 令牌并返回成功响应。
GetTokenInfo
方法主要解析 JWT 令牌,提取令牌的声明(sub
、jti
和 refreshToken
)。它使用 JwtSecurityTokenHandler
验证令牌的格式和合法性,并从 JWT 声明中提取用户 ID (sub
)、令牌 ID (jti
)、刷新令牌 (refreshToken
) 的值。
四、总结
该文章介绍了一个面向用户的项目开发,首先梳理了服务端的需求,如登录、注册、找回密码、用户查询和管理等功能。接着,创建了用户类 SysUser
并通过 EF Core 映射到数据库,完成数据迁移。然后详细讲解了如何创建用户服务和 Web API 接口,处理注册、登录和找回密码等操作,包括密码的加盐哈希处理和生成 JWT Token。最终,通过依赖注入,将这些服务集成到项目中,确保了用户数据的安全性和系统的扩展性。