VB.net 2010 视频教程 VB.net 2010 视频教程 python基础视频教程
SQL Server 2008 视频教程 c#入门经典教程 Visual Basic从门到精通视频教程
当前位置:
首页 > 编程开发 > c#编程 >
  • C#关于身份验证——JWT令牌登录

第三部分:Web应用开发
7. 身份验证——JWT令牌登录
实例介绍
之前做电商后台登录功能时用的是Session:用户登录后服务器生成SessionId存在Cookie里,每次请求带Cookie验证身份。但部署到阿里云负载均衡的3台服务器后,用户登录后切换到另一台服务器就需要重vb.net教程C#教程python教程SQL教程access 2010教程新登录——因为Session存在单台服务器的内存里,无法共享;而且前端是Vue项目,跨域调用API时Cookie带不过去,只能用JSONP或者CORS配置,折腾了好几天才勉强能用。换成JWT令牌登录后,服务器生成令牌返回给前端,前端每次请求把令牌放在Header里,服务器无状态,多台服务器都能验证令牌,跨域也直接带Header就行,彻底解决了Session的痛点。这节就带你从零实现JWT登录,包括令牌生成、刷新、权限控制、密码加密、防止暴力破解,覆盖生产环境的所有核心需求。
需求分析
JWT登录要解决“分布式身份验证、跨域支持、无状态、权限控制、安全可靠”的问题,具体需求如下:
1.核心登录流程:用户输入用户名密码,验证通过后生成访问令牌(短有效期)和刷新令牌(长有效期);
2.令牌验证:所有需要认证的API自动验证JWT令牌的合法性(签名、有效期、发行者);
3.令牌刷新:访问令牌过期后,用户用刷新令牌获取新的访问令牌,无需重新输入密码;
4.权限控制:基于角色的授权,比如管理员才能访问用户管理接口,普通用户只能查看自己的信息;
5.密码安全:密码用BCrypt加盐哈希存储,绝对不能明文存储;
6.防止暴力破解:限制登录失败次数(比如5次失败后锁定账号15分钟);
7.令牌安全:HTTPS传输令牌,访问令牌短有效期(15分钟),刷新令牌存储在数据库并加密;
8.统一错误处理:认证失败返回401,权限不足返回403,令牌过期返回401并提示刷新;
9.可配置性:JWT的密钥、有效期、刷新令牌有效期通过配置文件管理;
10.注销登录:注销时失效刷新令牌,防止被滥用。
代码实现
前置条件:.NET 6+、ASP.NET Core Web API;已存在User模型和IUserRepository;需安装以下NuGet包:
Microsoft.AspNetCore.Authentication.JwtBearer:JWT认证中间件
System.IdentityModel.Tokens.Jwt:JWT令牌生成与验证
BCrypt.Net-Next:密码加盐哈希
Microsoft.Extensions.Caching.StackExchangeRedis:Redis缓存(可选,用于防止暴力破解)
场景1:基础JWT登录(令牌生成与验证)
实现用户登录接口,生成JWT令牌,配置JWT认证中间件验证令牌。
步骤1:定义JWT配置模型(Models/Settings/JwtSettings.cs)
csharp

	namespace ECommerceWeb.Models.Settings
	{
	public class JwtSettings
	{
	public string Issuer { get; set; } = string.Empty; // 令牌发行者
	public string Audience { get; set; } = string.Empty; // 令牌受众
	public string SecretKey { get; set; } = string.Empty; // 签名密钥(至少16位字符)
	public int AccessTokenExpiresInMinutes { get; set; } = 15; // 访问令牌有效期(分钟)
	public int RefreshTokenExpiresInDays { get; set; } = 7; // 刷新令牌有效期(天)
	}
	}

步骤2:定义登录DTO(Models/Dtos/AuthDtos.cs)
csharp

	using System.ComponentModel.DataAnnotations;
	
	namespace ECommerceWeb.Models.Dtos
	{
	// 登录请求DTO
	public record LoginRequest(
	[Required(ErrorMessage = "用户名不能为空")]
	string UserName,
	
	[Required(ErrorMessage = "密码不能为空")]
	string Password);
	
	// 登录响应DTO
	public record LoginResponse( 
	string AccessToken, // 访问令牌
	string RefreshToken, // 刷新令牌
	DateTime AccessTokenExpiresAt, // 访问令牌过期时间
	DateTime RefreshTokenExpiresAt); // 刷新令牌过期时间
	
	// 刷新令牌请求DTO
	public record RefreshTokenRequest(
	[Required(ErrorMessage = "刷新令牌不能为空")]
	string RefreshToken);
	}

步骤3:更新User模型,添加角色和刷新令牌字段(Models/User.cs)
csharp

	public class User
	{
	public int Id { get; set; }
	public string UserName { get; set; } = string.Empty;
	public string Email { get; set; } = string.Empty;
	public string PasswordHash { get; set; } = string.Empty; // 密码哈希,不是明文
	public string Role { get; set; } = "User"; // 角色:Admin/User
	public string? RefreshToken { get; set; } // 刷新令牌(存储哈希值)
	public DateTime? RefreshTokenExpiresAt { get; set; } // 刷新令牌过期时间
	public DateTime RegisterTime { get; set; }
	public bool IsEnabled { get; set; } = true;
	public bool IsLocked { get; set; } = false; // 是否锁定账号
	public DateTime? LockedUntil { get; set; } // 锁定到期时间
	public int LoginFailedCount { get; set; } = 0; // 登录失败次数
	}

步骤4:实现登录接口(Controllers/AuthController.cs)
csharp

	using Microsoft.AspNetCore.Mvc;
	using Microsoft.IdentityModel.Tokens;
	using System.IdentityModel.Tokens.Jwt;
	using System.Security.Claims;
	using System.Text;
	using BCrypt.Net;
	using ECommerceWeb.Models.Dtos;
	using ECommerceWeb.Models.Settings;
	using ECommerceWeb.Repositories;
	
	namespace ECommerceWeb.Controllers
	{
	[Route("api/[controller]")]
	[ApiController]
	public class AuthController : ControllerBase
	{
	private readonly IUserRepository _userRepo;
	private readonly JwtSettings _jwtSettings;
	private readonly IConfiguration _config;
	
	// 构造函数注入依赖
	public AuthController(IUserRepository userRepo, IOptions<JwtSettings> jwtSettings, IConfiguration config)
	{
	_userRepo = userRepo;
	_jwtSettings = jwtSettings.Value;
	_config = config;
	}
	
	// 登录接口
	// POST /api/auth/login
	[HttpPost("login")]
	public async Task<ActionResult<LoginResponse>> Login(LoginRequest request)
	{
	// 1. 查找用户
	var user = await _userRepo.GetAll()
	.FirstOrDefaultAsync(u => u.UserName == request.UserName);
	if (user == null)
	{
	// 为了防止暴力破解,不明确提示用户名不存在
	return Unauthorized(new ApiError(401, "用户名或密码错误"));
	}
	
	// 2. 检查账号是否锁定
	if (user.IsLocked && user.LockedUntil > DateTime.Now)
	{
	var remainingMinutes = (int)(user.LockedUntil.Value - DateTime.Now).TotalMinutes;
	return Unauthorized(new ApiError(401, $"账号已锁定,请{remainingMinutes}分钟后再试"));
	}
	else if (user.IsLocked && user.LockedUntil <= DateTime.Now)
	{
	// 锁定时间到期,解锁账号
	user.IsLocked = false;
	user.LoginFailedCount = 0;
	await _userRepo.UpdateAsync(user);
	}
	
	// 3. 验证密码(BCrypt验证哈希)
	if (!BCrypt.Verify(request.Password, user.PasswordHash))
	{
	// 登录失败次数+1
	user.LoginFailedCount++;
	if (user.LoginFailedCount >= 5)
	{
	// 失败5次,锁定账号15分钟
	user.IsLocked = true;
	user.LockedUntil = DateTime.Now.AddMinutes(15);
	}
	await _userRepo.UpdateAsync(user);
	return Unauthorized(new ApiError(401, "用户名或密码错误"));
	}
	
	// 4. 登录成功,重置失败次数
	user.LoginFailedCount = 0;
	await _userRepo.UpdateAsync(user);
	
	// 5. 生成JWT访问令牌和刷新令牌
	var (accessToken, accessTokenExpiresAt) = GenerateAccessToken(user);
	var (refreshToken, refreshTokenExpiresAt) = GenerateRefreshToken(user);
	
	// 6. 返回响应
	return Ok(new LoginResponse(
	accessToken,
	refreshToken,
	accessTokenExpiresAt,
	refreshTokenExpiresAt));
	}
	
	// 生成访问令牌
	private (string AccessToken, DateTime ExpiresAt) GenerateAccessToken(User user)
	{
	// 1. 创建Claims(存储用户标识、角色等信息,令牌解码后可读取)
	var claims = new List<Claim>
	{
	new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), // 用户ID
	new Claim(ClaimTypes.Name, user.UserName), // 用户名
	new Claim(ClaimTypes.Role, user.Role), // 用户角色
	new Claim("Email", user.Email) // 自定义Claim,存储邮箱
	};
	
	// 2. 签名密钥(必须和认证中间件的密钥一致)
	var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
	var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256Signature);
	
	// 3. 令牌描述符
	var tokenDescriptor = new SecurityTokenDescriptor
	{
	Subject = new ClaimsIdentity(claims),
	Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiresInMinutes), // UTC时间,避免时区问题
	SigningCredentials = credentials,
	Issuer = _jwtSettings.Issuer,
	Audience = _jwtSettings.Audience
	};
	
	// 4. 生成令牌
	var tokenHandler = new JwtSecurityTokenHandler();
	var token = tokenHandler.CreateToken(tokenDescriptor);
	
	// 5. 返回令牌和过期时间
	return (tokenHandler.WriteToken(token), token.ValidTo.ToLocalTime());
	}
	
	// 生成刷新令牌
	private (string RefreshToken, DateTime ExpiresAt) GenerateRefreshToken(User user)
	{
	// 1. 生成随机刷新令牌(32位随机字符串)
	var refreshToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
	
	// 2. 哈希刷新令牌(存储哈希值,防止数据库泄露后被滥用)
	var refreshTokenHash = BCrypt.HashPassword(refreshToken);
	
	// 3. 更新用户的刷新令牌和过期时间
	user.RefreshToken = refreshTokenHash;
	user.RefreshTokenExpiresAt = DateTime.Now.AddDays(_jwtSettings.RefreshTokenExpiresInDays);
	_userRepo.UpdateAsync(user).Wait(); // 同步等待,避免异步问题
	
	// 4. 返回原始刷新令牌(只返回一次,用户保存,服务器只存哈希)
	return (refreshToken, user.RefreshTokenExpiresAt.Value);
	}
	}
	}

步骤5:配置JWT认证中间件(Program.cs)
csharp

	// 1. 绑定JWT配置
	builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("Jwt"));
	
	// 2. 添加JWT认证服务
	builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
	.AddJwtBearer(options =>
	{
	var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
	options.TokenValidationParameters = new TokenValidationParameters
	{
	ValidateIssuer = true, // 验证发行者
	ValidateAudience = true, // 验证受众
	ValidateLifetime = true, // 验证有效期
	ValidateIssuerSigningKey = true, // 验证签名密钥
	ValidIssuer = jwtSettings.Issuer,
	ValidAudience = jwtSettings.Audience,
	IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
	ClockSkew = TimeSpan.Zero // 关闭时钟偏移容忍,令牌过期立即失效
	};
	
	// 自定义认证失败响应
	options.Events = new JwtBearerEvents
	{
	OnAuthenticationFailed = context =>
	{
	if (context.Exception is SecurityTokenExpiredException)
	{
	context.Response.Headers.Append("Token-Expired", "true");
	}
	return Task.CompletedTask;
	}
	};
	});
	
	// 3. 添加授权服务
	builder.Services.AddAuthorization();
	
	// ...其他服务注册
	
	var app = builder.Build();
	
	// 4. 启用认证和授权中间件(顺序不能错:认证在前,授权在后)
	app.UseAuthentication();
	app.UseAuthorization();
	
	// ...其他中间件配置
	app.MapControllers();
	
	app.Run();

步骤6:配置文件(appsettings.json)
json

	{
	"Jwt": {
	"Issuer": "ECommerceWeb",
	"Audience": "ECommerceWebClients",
	"SecretKey": "your-strong-secret-key-here-123456", // 至少16位,生产环境用环境变量存储
	"AccessTokenExpiresInMinutes": 15,
	"RefreshTokenExpiresInDays": 7
	},
	"ConnectionStrings": {
	"DefaultConnection": "Server=(localdb)\mssqllocaldb;Database=ECommerceWeb;Trusted_Connection=True;"
	}
	}

场景2:令牌刷新与注销登录
实现刷新令牌接口,用户用刷新令牌获取新的访问令牌;实现注销接口,失效刷新令牌。
步骤1:添加刷新令牌和注销接口(AuthController.cs)
csharp

	// 刷新令牌接口
	// POST /api/auth/refresh-token
	[HttpPost("refresh-token")]
	public async Task<ActionResult<LoginResponse>> RefreshToken(RefreshTokenRequest request)
	{
	// 1. 查找拥有该刷新令牌哈希的用户
	var user = await _userRepo.GetAll()
	.FirstOrDefaultAsync(u => u.RefreshToken != null && BCrypt.Verify(request.RefreshToken, u.RefreshToken));
	if (user == null)
	{
	return Unauthorized(new ApiError(401, "无效的刷新令牌"));
	}
	
	// 2. 检查刷新令牌是否过期
	if (user.RefreshTokenExpiresAt < DateTime.Now)
	{
	return Unauthorized(new ApiError(401, "刷新令牌已过期,请重新登录"));
	}
	
	// 3. 生成新的访问令牌和刷新令牌(刷新令牌可以续期或生成新的,这里生成新的)
	var (accessToken, accessTokenExpiresAt) = GenerateAccessToken(user);
	var (newRefreshToken, refreshTokenExpiresAt) = GenerateRefreshToken(user);
	
	// 4. 返回新的令牌
	return Ok(new LoginResponse(
	accessToken,
	newRefreshToken,
	accessTokenExpiresAt,
	refreshTokenExpiresAt));
	}
	
	// 注销登录接口
	// POST /api/auth/logout
	[Authorize] // 需要认证才能访问
	[HttpPost("logout")]
	public async Task<IActionResult> Logout()
	{
	// 1. 从令牌中获取用户ID
	var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
	var user = await _userRepo.GetByIdAsync(userId);
	if (user == null)
	{
	return NotFound(new ApiError(404, "用户不存在"));
	}
	
	// 2. 清空刷新令牌
	user.RefreshToken = null;
	user.RefreshTokenExpiresAt = null;
	await _userRepo.UpdateAsync(user);
	
	return NoContent(); // 204状态码:注销成功
	}

场景3:基于角色的权限控制
给API添加角色授权,比如管理员才能访问用户管理接口,普通用户只能查看自己的信息。
步骤1:保护用户管理接口(UsersController.cs)
csharp

	[Authorize] // 所有接口需要认证
	[Route("api/[controller]")]
	[ApiController]
	public class UsersController : ControllerBase
	{
	private readonly IUserRepository _userRepo;
	
	public UsersController(IUserRepository userRepo)
	{
	_userRepo = userRepo;
	}
	
	// 管理员才能访问:获取所有用户列表
	[Authorize(Roles = "Admin")] // 只有Admin角色能访问
	[HttpGet]
	public async Task<ActionResult<PagedResult<UserDto>>> GetUsers(...)
	{
	// ...实现代码
	}
	
	// 所有认证用户都能访问:查看自己的信息
	[HttpGet("me")]
	public async Task<ActionResult<UserDto>> GetCurrentUser()
	{
	// 从令牌中获取用户ID
	var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
	var user = await _userRepo.GetByIdAsync(userId);
	if (user == null)
	{
	return NotFound(new ApiError(404, "用户不存在"));
	}
	
	return Ok(new UserDto(
	user.Id,
	user.UserName,
	user.Email,
	user.RegisterTime,
	user.IsEnabled));
	}
	
	// ...其他接口
	}

逐行讲解
场景1:基础JWT登录核心代码
1.JwtSettings配置:
Issuer和Audience:验证令牌的发行者和受众,防止其他系统生成的令牌被滥用;
SecretKey:签名密钥,必须保密,生产环境用环境变量存储,不能硬编码;
AccessTokenExpiresInMinutes:访问令牌短有效期(15分钟),减少令牌泄露的风险;
2.密码加密与验证:
oBCrypt.HashPassword(request.Password):生成加盐哈希,每个密码的盐都不同,彩虹表破解无效;
oBCrypt.Verify(request.Password, user.PasswordHash):验证密码和哈希是否匹配,自动处理盐;
3.Claims设计:
存储用户的核心标识(ID、用户名)和角色,不要存储敏感信息(比如密码);
自定义Claim(比如Email)可以存储业务需要的非敏感信息;
4.令牌生成:
SecurityTokenDescriptor:包含令牌的所有信息,Claims、有效期、签名、发行者;
HmacSha256Signature:用SHA256哈希算法签名,确保令牌不被篡改;
DateTime.UtcNow:用UTC时间生成有效期,避免时区差异导致的提前过期;
5.防止暴力破解:
登录失败次数累计,5次失败后锁定账号15分钟;
不明确提示“用户名不存在”,防止攻击者枚举用户名;
场景2:令牌刷新与注销
1.刷新令牌设计:
服务器只存储刷新令牌的哈希值,原始令牌只返回给用户一次,即使数据库泄露,攻击者也无法使用哈希值获取新令牌;
刷新令牌长有效期(7天),用户无需频繁登录;
2.注销登录:
清空用户的刷新令牌,即使用户的刷新令牌泄露,也无法再获取新的访问令牌;
3.令牌过期处理:
认证中间件的ClockSkew = TimeSpan.Zero:关闭时钟偏移容忍,令牌过期立即失效,避免安全风险;
场景3:权限控制
1.[Authorize]特性:
加在控制器上:所有接口需要认证;
加在动作上:单个接口需要认证;
[Authorize(Roles = "Admin")]:只有指定角色的用户能访问;
2.获取当前用户:
oUser.FindFirstValue(ClaimTypes.NameIdentifier):从令牌的Claims中获取用户ID,无需查询数据库(除非需要最新的用户信息);
基础知识拓展

  1. JWT令牌结构(Header.Payload.Signature)
    Header:Base64编码的JSON,包含令牌类型和签名算法,比如:
    {"alg":"HS256","typ":"JWT"}
    Payload:Base64编码的JSON,包含Claims(用户信息、有效期等),比如:
    {"sub":"1","name":"admin","role":"Admin","exp":1735689600}
    Signature:用Header指定的算法,结合SecretKey对Header和Payload的哈希值签名,确保令牌不被篡改;
    注意:Payload是Base64编码,不是加密,任何人都可以解码查看,所以绝对不能存储敏感信息!

  2. Session vs JWT对比
    | 特性 | Session| JWT |
    | ---- | ---- | ---- |
    | 状态 | 服务器有状态(存储Session) | 服务器无状态(令牌存储在客户端) |
    | 分布式支持 | 需要Session共享(Redis/数据库) | 天然支持,多台服务器都能验证令牌 |
    | 跨域支持 | 依赖Cookie,跨域需要配置CORS | 令牌放在Header里,跨域直接携带 |
    | 过期处理 | 服务器主动失效Session | 令牌过期自动失效,无需服务器干预 |
    | 存储位置 | 服务器内存/Redis/数据库 | 客户端LocalStorage/SessionStorage/Cookie |
    | 安全风险 | SessionId泄露后可被伪造请求 | 令牌泄露后可被伪造请求,需短有效期 |

  3. JWT安全最佳实践
    HTTPS传输:令牌必须用HTTPS传输,防止被中间人劫持;
    访问令牌短有效期:15-30分钟,减少泄露后的危害时间;
    刷新令牌安全存储:
    Web端:用HttpOnly、Secure的Cookie存储刷新令牌,防止XSS攻击;
    移动端:存储在安全的Keychain中;
    签名密钥保密:生产环境用环境变量存储,绝对不能硬编码在代码里;
    避免存储敏感信息:Payload只能存储非敏感的用户标识和角色;
    令牌验证严格:验证签名、有效期、发行者、受众,关闭时钟偏移容忍;
    刷新令牌失效:用户注销、修改密码时,立即失效刷新令牌;

  4. 防止暴力破解的进阶方案
    Redis缓存登录失败次数:比数据库更快,适合高并发场景;
    验证码:登录失败3次后要求输入验证码;
    IP限制:限制单个IP的登录请求频率;

  5. 第三方登录集成(比如微信/支付宝)
    第三方登录成功后,获取用户的OpenID,生成JWT令牌返回给前端,流程和密码登录一致;
    存储用户的OpenID,下次登录时直接验证OpenID即可;
    总结
    JWT登录的价值在于分布式身份验证、跨域支持、无状态、安全可靠,彻底解决了Session在分布式和跨域场景下的痛点。关键要点:
    1.令牌设计:短有效期的访问令牌+长有效期的刷新令牌,平衡用户体验和安全性;
    2.密码安全:BCrypt加盐哈希存储,绝对不能明文存储;
    3.权限控制:基于角色的授权,细粒度控制API的访问权限;
    4.安全防护:HTTPS传输、令牌严格验证、防止暴力破解、刷新令牌安全存储;
    5.可配置性:所有敏感配置(密钥、有效期)通过配置文件或环境变量管理;
    比如电商后台中,JWT登录让分布式部署变得简单,跨域的前端项目可以直接调用API,用户体验好,安全性高。掌握JWT的核心原理和最佳实践,你可以轻松实现生产环境级别的身份验证系统!

来源:https://www.xin3721.com/ArticlecSharp/c49477.html


相关教程