在 ASP.NET Core Web API 6 中应用 JWT 访问令牌和刷新令牌






4.95/5 (8投票s)
如何在 ASP.NET Core Web API 6 中应用 JWT 访问令牌和刷新令牌
在本教程中,我们将学习如何在 ASP.NET Core Web API 6 中应用 JWT 访问令牌和刷新令牌。我们将构建一个简单、安全且可靠的 RESTful API 项目,以正确地对用户进行身份验证,并授权他们对 API 执行操作。
我们将使用最新、最棒的 Visual Studio 2022 – 社区版,使用最快的 .NET 标准框架 .NET 6 和 C# 10 来构建我们的 ASP.NET Core Web API。
好消息是 VS 2022 已经集成了最新版本的 .NET 和 C#,所以你不必担心搜索和安装它们。因此,如果你还没有 VS 2022,请务必下载并安装 Visual Studio 2022,以便我们可以开始我们的教程,在 ASP.NET Core Web API 6 中应用 JWT 访问令牌和刷新令牌。
在之前的教程中,我们学习了如何使用 JWT 身份验证来保护 ASP.NET Core Web API,但那时只使用了访问令牌。现在,这是一个从头开始构建的新教程,它将解释使用访问令牌和刷新令牌进行 JWT 身份验证的所有内容。
访问令牌与刷新令牌
当我们使用访问令牌并在授权头中提供它时,它授予用户访问服务器上某些资源的适当授权。访问令牌通常是短期有效的并经过签名,对于 JWT Token 来说,它将包含签名、声明和标头。
另一方面,刷新令牌通常是一个只能用于刷新访问令牌的引用。此类令牌通常存储在后端存储中,可用于撤销用户访问权限,例如,不再有资格访问这些资源的用户,或者在恶意用户窃取了访问令牌的情况下。
在这种情况下,你只需删除这些设备的刷新令牌,这样一旦他们的访问令牌过期,他们就无法使用已撤销的刷新令牌来刷新它,因为他们曾经有效的刷新令牌不再有效,他们将无法再访问你的资源。因此,用户将在应用或 Web 中被注销,他们将不得不重新登录并再次经历正常的登录过程。
现在,说够了枯燥的文字,让我们直接开始构建 API,这些 API 将在 .NET 6 的 ASP.NET Core Web API 中使用访问令牌和刷新令牌实现 JWT 身份验证。
开始教程
我们将构建一个简单的任务管理系统,允许已认证用户管理自己的任务。这只是一个简单基本的任务管理系统演示。
随时可以从 Github 上 fork 下来并在你的个人项目中使用。
数据库准备
在我的大多数教程中,我使用 SQL Server Express 来创建数据库和所需的表。所以请确保下载并安装最新版本的 SQL Server Management Studio 和 SQL Server Express。
一旦两者都安装完毕,打开 SQL Server Management Studio 并连接到安装了 SQL Server Express 的本地计算机。
在对象资源管理器中,右键单击数据库,选择“创建新数据库”,将其命名为 TasksDb
。
然后运行以下命令来创建表并用本教程所需的数据填充它。
USE [TasksDb]
GO
/****** Object: Table [dbo].[RefreshToken] Script Date: 1/18/2022 6:10:48 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[RefreshToken](
[Id] [int] IDENTITY(1,1) NOT NULL,
[UserId] [int] NOT NULL,
[TokenHash] [nvarchar](1000) NOT NULL,
[TokenSalt] [nvarchar](50) NOT NULL,
[TS] [smalldatetime] NOT NULL,
[ExpiryDate] [smalldatetime] NOT NULL,
CONSTRAINT [PK_RefreshToken] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, _
ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[Task] Script Date: 1/18/2022 6:10:48 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Task](
[Id] [int] IDENTITY(1,1) NOT NULL,
[UserId] [int] NOT NULL,
[Name] [nvarchar](100) NOT NULL,
[IsCompleted] [bit] NOT NULL,
[TS] [smalldatetime] NOT NULL,
CONSTRAINT [PK_Task] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, _
ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
/****** Object: Table [dbo].[User] Script Date: 1/18/2022 6:10:48 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[User](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Email] [nvarchar](50) NOT NULL,
[Password] [nvarchar](255) NOT NULL,
[PasswordSalt] [nvarchar](255) NOT NULL,
[FirstName] [nvarchar](255) NOT NULL,
[LastName] [nvarchar](255) NOT NULL,
[TS] [smalldatetime] NOT NULL,
[Active] [bit] NOT NULL,
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, _
ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
SET IDENTITY_INSERT [dbo].[Task] ON
GO
INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) _
VALUES (1, 1, N'Blog about Access Token and Refresh Token Authentication', _
1, CAST(N'2022-01-14T00:00:00' AS SmallDateTime))
GO
INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) _
VALUES (3, 1, N'Vaccum the House', 0, CAST(N'2022-01-14T00:00:00' AS SmallDateTime))
GO
INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) _
VALUES (4, 1, N'Farmers Market Shopping', 0, CAST(N'2022-01-14T00:00:00' AS SmallDateTime))
GO
INSERT [dbo].[Task] ([Id], [UserId], [Name], [IsCompleted], [TS]) _
VALUES (5, 1, N'Practice Juggling', 0, CAST(N'2022-01-15T00:00:00' AS SmallDateTime))
GO
SET IDENTITY_INSERT [dbo].[Task] OFF
GO
SET IDENTITY_INSERT [dbo].[User] ON
GO
INSERT [dbo].[User] ([Id], [Email], [Password], [PasswordSalt], [FirstName], [LastName], _
[TS], [Active]) VALUES (1, N'coding@codingsonata.com', _
N'miLgvYoSVrotOON6/lRp8ACrrbAxCPCmsrsy355x/DI=', _
N'L5hziA8V93SNGTlYdz+meS0B6DPzB3IwsRhDf1vO1GM=', N'Coding', N'Sonata', _
CAST(N'2022-01-14T00:00:00' AS SmallDateTime), 1)
GO
INSERT [dbo].[User] ([Id], [Email], [Password], [PasswordSalt], [FirstName], [LastName], _
[TS], [Active]) VALUES (2, N'test@codingsonata.com', _
N'Fm7/SI9lYAFglzWXLD5oLz0cuq00MQmPkzDZ+nDZNmc=', _
N'kjgIDmRKgUbbWypCOOUHuxlQzZAszdEKw358ds4Xyc4=', N'test', N'postman', _
CAST(N'2022-01-16T14:23:00' AS SmallDateTime), 1)
GO
SET IDENTITY_INSERT [dbo].[User] OFF
GO
ALTER TABLE [dbo].[RefreshToken] WITH CHECK ADD CONSTRAINT [FK_RefreshToken_User] _
FOREIGN KEY([UserId])
REFERENCES [dbo].[User] ([Id])
GO
ALTER TABLE [dbo].[RefreshToken] CHECK CONSTRAINT [FK_RefreshToken_User]
GO
ALTER TABLE [dbo].[Task] WITH CHECK ADD CONSTRAINT [FK_Task_User] FOREIGN KEY([UserId])
REFERENCES [dbo].[User] ([Id])
GO
ALTER TABLE [dbo].[Task] CHECK CONSTRAINT [FK_Task_User]
GO
项目创建
打开 Visual Studio 2022,并创建一个 ASP.NET Core Web API 类型的新项目。
给它起个名字,比如 TasksApi
。
然后选择 .NET 6.0 并创建项目。
一旦 VS 完成项目初始化,按 F5 对模板项目进行初始运行,以确保它工作正常。
现在,让我们从模板项目中删除一些不必要的类。在 **解决方案资源管理器** 中,删除 WeatherForecastController
和 WeatherForecast
文件。
Entity Framework Core 和 DbContext
让我们添加 EF Core 和 EF Core SQL 的 Nuget 包。
实体
现在让我们创建需要的实体,它们将通过 EF Core DbContext
类绑定到数据库表。
我们将创建三个实体来映射到 Tasks
数据库。
RefreshToken
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#nullable disable
namespace TasksApi
{
public partial class RefreshToken
{
public int Id { get; set; }
public int UserId { get; set; }
public string TokenHash { get; set; }
public string TokenSalt { get; set; }
public DateTime Ts { get; set; }
public DateTime ExpiryDate { get; set; }
public virtual User User { get; set; }
}
}
任务
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#nullable disable
using System;
using System.Collections.Generic;
namespace TasksApi
{
public partial class Task
{
public int Id { get; set; }
public int UserId { get; set; }
public string Name { get; set; }
public bool IsCompleted { get; set; }
public DateTime Ts { get; set; }
public virtual User User { get; set; }
}
}
用户
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#nullable disable
using System;
using System.Collections.Generic;
namespace TasksApi
{
public partial class User
{
public User()
{
RefreshTokens = new HashSet<RefreshToken>();
Tasks = new HashSet<Task>();
}
public int Id { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string PasswordSalt { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime Ts { get; set; }
public bool Active { get; set; }
public virtual ICollection<RefreshToken> RefreshTokens { get; set; }
public virtual ICollection<Task> Tasks { get; set; }
}
}
DbContext
现在让我们添加 TasksDbContext
,它将继承自 EF Core 的 DbContext
类。
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#nullable disable
using Microsoft.EntityFrameworkCore;
namespace TasksApi
{
public partial class TasksDbContext : DbContext
{
public TasksDbContext()
{
}
public TasksDbContext(DbContextOptions<TasksDbContext> options)
: base(options)
{
}
public virtual DbSet<RefreshToken> RefreshTokens { get; set; }
public virtual DbSet<Task> Tasks { get; set; }
public virtual DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<RefreshToken>(entity =>
{
entity.Property(e => e.ExpiryDate).HasColumnType("smalldatetime");
entity.Property(e => e.TokenHash)
.IsRequired()
.HasMaxLength(1000);
entity.Property(e => e.TokenSalt)
.IsRequired()
.HasMaxLength(1000);
entity.Property(e => e.Ts)
.HasColumnType("smalldatetime")
.HasColumnName("TS");
entity.HasOne(d => d.User)
.WithMany(p => p.RefreshTokens)
.HasForeignKey(d => d.UserId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("FK_RefreshToken_User");
entity.ToTable("RefreshToken");
});
modelBuilder.Entity<Task>(entity =>
{
entity.Property(e => e.Name)
.IsRequired()
.HasMaxLength(100);
entity.Property(e => e.Ts)
.HasColumnType("smalldatetime")
.HasColumnName("TS");
entity.HasOne(d => d.User)
.WithMany(p => p.Tasks)
.HasForeignKey(d => d.UserId)
.OnDelete(DeleteBehavior.ClientSetNull)
.HasConstraintName("FK_Task_User");
entity.ToTable("Task");
});
modelBuilder.Entity<User>(entity =>
{
entity.Property(e => e.Email)
.IsRequired()
.HasMaxLength(50);
entity.Property(e => e.FirstName)
.IsRequired()
.HasMaxLength(255);
entity.Property(e => e.LastName)
.IsRequired()
.HasMaxLength(255);
entity.Property(e => e.Password)
.IsRequired()
.HasMaxLength(255);
entity.Property(e => e.PasswordSalt)
.IsRequired()
.HasMaxLength(255);
entity.Property(e => e.Ts)
.HasColumnType("smalldatetime")
.HasColumnName("TS");
entity.ToTable("User");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
}
在你的 program.cs 文件中,在 builder.build()
调用之前添加以下内容。
builder.Services.AddDbContext<TasksDbContext>(options => options.UseSqlServer
(builder.Configuration.GetConnectionString("TasksDbConnectionString")));
然后在你的 appsettings.json 中,确保包含数据库的连接 string
。
{
"ConnectionStrings": {
"TasksDbConnectionString": "Server=Home\\SQLEXPRESS;Database=TasksDb;
Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
注意:我使用了出色的扩展 EF Core Power Tools,它可以神奇地将整个数据库结构和关系转换为整洁且规范的 DbContext
实体和配置。
你可以从 Visual Studio 2022 的“扩展”选项卡 -> “管理扩展”中安装它。
如果你遵循先构建数据库,然后构建 EF Core 映射的“设计优先”模型,我强烈建议你使用这个闪电般快速且可靠的工具来执行此类操作,这将大大提高你的生产力并减少手动创建实体和配置可能引入的错误数量。
PasswordHashHelper
为了将密码保存在数据库中,我们需要使用安全的 HMAC 256 哈希和来自安全随机字节(256 位大小)的盐,以便保护宝贵的用户的密码免受那些讨厌的潜伏窃贼的侵害!
using Microsoft.AspNetCore.Cryptography.KeyDerivation;
using System.Security.Cryptography;
namespace TasksApi.Helpers
{
public class PasswordHelper
{
public static byte[] GetSecureSalt()
{
// Starting .NET 6, the Class RNGCryptoServiceProvider is obsolete,
// so now we have to use the RandomNumberGenerator Class
// to generate a secure random number bytes
return RandomNumberGenerator.GetBytes(32);
}
public static string HashUsingPbkdf2(string password, byte[] salt)
{
byte[] derivedKey = KeyDerivation.Pbkdf2
(password, salt, KeyDerivationPrf.HMACSHA256, iterationCount: 100000, 32);
return Convert.ToBase64String(derivedKey);
}
}
}
我们还将使用这些辅助方法以哈希格式以及它们的关联盐来将刷新令牌保存在数据库中。
实现 JWT 身份验证
让我们添加所需的 JWT Bearer 包。
用于构建访问令牌和刷新令牌的 Token Helper
现在让我们添加 TokenHelper
,它将包含两个方法:一个用于生成基于 JWT 的访问令牌,另一个用于生成基于 32 字节的刷新令牌。
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
namespace TasksApi.Helpers
{
public class TokenHelper
{
public const string Issuer = "http://codingsonata.com";
public const string Audience = "http://codingsonata.com";
public const string Secret =
"p0GXO6VuVZLRPef0tyO9jCqK4uZufDa6LP4n8Gj+8hQPB30f94pFiECAnPeMi5N6VT3/uscoGH7+zJrv4AuuPg==";
public static async Task<string> GenerateAccessToken(int userId)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Convert.FromBase64String(Secret);
var claimsIdentity = new ClaimsIdentity(new[] {
new Claim(ClaimTypes.NameIdentifier, userId.ToString())
});
var signingCredentials = new SigningCredentials
(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = claimsIdentity,
Issuer = Issuer,
Audience = Audience,
Expires = DateTime.Now.AddMinutes(15),
SigningCredentials = signingCredentials,
};
var securityToken = tokenHandler.CreateToken(tokenDescriptor);
return await System.Threading.Tasks.Task.Run(() =>
tokenHandler.WriteToken(securityToken));
}
public static async Task<string> GenerateRefreshToken()
{
var secureRandomBytes = new byte[32];
using var randomNumberGenerator = RandomNumberGenerator.Create();
await System.Threading.Tasks.Task.Run(() =>
randomNumberGenerator.GetBytes(secureRandomBytes));
var refreshToken = Convert.ToBase64String(secureRandomBytes);
return refreshToken;
}
}
}
现在让我们确保在 program.cs 文件中将所需的身份验证和授权中间件添加到管道中。
在 builder.build
方法之前添加以下内容。
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = TokenHelper.Issuer,
ValidAudience = TokenHelper.Audience,
IssuerSigningKey = new SymmetricSecurityKey
(Convert.FromBase64String(TokenHelper.Secret))
};
});
builder.Services.AddAuthorization();
然后在 app.run
方法之前,确保应用程序将使用这两个中间件来验证和授权你的用户。
app.UseAuthentication();
app.UseAuthorization();
请求和响应
始终建议你接受和返回结构化对象而不是分离的数据,这就是为什么我们将准备一些 Request
和 Response
类,我们将在整个 API 中使用它们。
让我们添加以下请求类。
LoginRequest
namespace TasksApi.Requests
{
public class LoginRequest
{
public string Email { get; set; }
public string Password { get; set; }
}
}
RefreshTokenRequest
namespace TasksApi.Requests
{
public class RefreshTokenRequest
{
public int UserId { get; set; }
public string RefreshToken { get; set; }
}
}
SignupRequest
using System.ComponentModel.DataAnnotations;
namespace TasksApi.Requests
{
public class SignupRequest
{
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
public string Password { get; set; }
[Required]
public string ConfirmPassword { get; set; }
[Required]
public string FirstName { get; set; }
[Required]
public string LastName { get; set; }
[Required]
public DateTime Ts { get; set; }
}
}
TaskRequest
namespace TasksApi.Requests
{
public class TaskRequest
{
public string Name { get; set; }
public bool IsCompleted { get; set; }
public DateTime Ts { get; set; }
}
}
现在让我们添加响应类,这些将用于向调用 API 的 UI 客户端返回结构化响应。
BaseResponse
这将使用一个基类,以便其他响应类可以继承它并扩展它们的属性。
using System.Text.Json.Serialization;
namespace TasksApi.Responses
{
public abstract class BaseResponse
{
[JsonIgnore()]
public bool Success { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string ErrorCode { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string Error { get; set; }
}
}
DeleteTaskResponse
using System.Text.Json.Serialization;
namespace TasksApi.Responses
{
public class DeleteTaskResponse : BaseResponse
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int TaskId { get; set; }
}
}
GetTasksResponse
namespace TasksApi.Responses
{
public class GetTasksResponse : BaseResponse
{
public List<Task> Tasks { get; set; }
}
}
LogoutResponse
namespace TasksApi.Responses
{
public class LogoutResponse : BaseResponse
{
}
}
SaveTaskResponse
namespace TasksApi.Responses
{
public class SaveTaskResponse : BaseResponse
{
public Task Task { get; set; }
}
}
SignupResponse
namespace TasksApi.Responses
{
public class SignupResponse : BaseResponse
{
public string Email { get; set; }
}
}
TaskResponse
namespace TasksApi.Responses
{
public class TaskResponse
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsCompleted { get; set; }
public DateTime Ts { get; set; }
}
}
TokenResponse
namespace TasksApi.Responses
{
public class TokenResponse: BaseResponse
{
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
}
}
ValidateRefreshTokenResponse
namespace TasksApi.Responses
{
public class ValidateRefreshTokenResponse : BaseResponse
{
public int UserId { get; set; }
}
}
接口
我们将定义三个接口,这些接口将在服务中实现。接口是控制器需要使用的抽象,以便能够处理相关的业务逻辑和数据库调用,每个接口都将在一个服务中实现,该服务将在运行时注入。
这是一个非常有用的策略(或设计模式),可以使你的 API 松散耦合且易于测试。
ITokenService
using TasksApi.Requests;
using TasksApi.Responses;
namespace TasksApi.Interfaces
{
public interface ITokenService
{
Task<Tuple<string, string>> GenerateTokensAsync(int userId);
Task<ValidateRefreshTokenResponse> ValidateRefreshTokenAsync
(RefreshTokenRequest refreshTokenRequest);
Task<bool> RemoveRefreshTokenAsync(User user);
}
}
IUserService
using TasksApi.Requests;
using TasksApi.Responses;
namespace TasksApi.Interfaces
{
public interface IUserService
{
Task<TokenResponse> LoginAsync(LoginRequest loginRequest);
Task<SignupResponse> SignupAsync(SignupRequest signupRequest);
Task<LogoutResponse> LogoutAsync(int userId);
}
}
ITasksInterface
using TasksApi.Responses;
namespace TasksApi.Interfaces
{
public interface ITaskService
{
Task<GetTasksResponse> GetTasks(int userId);
Task<SaveTaskResponse> SaveTask(Task task);
Task<DeleteTaskResponse> DeleteTask(int taskId, int userId);
}
}
服务
服务充当控制器和 DbContext
之间的中间层,它还包括控制器不应该关心的任何业务相关逻辑。服务实现接口。
我们将添加三个服务。
TokenService
这将包括生成令牌、验证和删除刷新令牌的方法。
using Microsoft.EntityFrameworkCore;
using TasksApi.Helpers;
using TasksApi.Interfaces;
using TasksApi.Requests;
using TasksApi.Responses;
namespace TasksApi.Services
{
public class TokenService : ITokenService
{
private readonly TasksDbContext tasksDbContext;
public TokenService(TasksDbContext tasksDbContext)
{
this.tasksDbContext = tasksDbContext;
}
public async Task<Tuple<string, string>> GenerateTokensAsync(int userId)
{
var accessToken = await TokenHelper.GenerateAccessToken(userId);
var refreshToken = await TokenHelper.GenerateRefreshToken();
var userRecord = await tasksDbContext.Users.Include
(o => o.RefreshTokens).FirstOrDefaultAsync(e => e.Id == userId);
if (userRecord == null)
{
return null;
}
var salt = PasswordHelper.GetSecureSalt();
var refreshTokenHashed = PasswordHelper.HashUsingPbkdf2(refreshToken, salt);
if (userRecord.RefreshTokens != null && userRecord.RefreshTokens.Any())
{
await RemoveRefreshTokenAsync(userRecord);
}
userRecord.RefreshTokens?.Add(new RefreshToken
{
ExpiryDate = DateTime.Now.AddDays(30),
Ts = DateTime.Now,
UserId = userId,
TokenHash = refreshTokenHashed,
TokenSalt = Convert.ToBase64String(salt)
});
await tasksDbContext.SaveChangesAsync();
var token = new Tuple<string, string>(accessToken, refreshToken);
return token;
}
public async Task<bool> RemoveRefreshTokenAsync(User user)
{
var userRecord = await tasksDbContext.Users.Include
(o => o.RefreshTokens).FirstOrDefaultAsync(e => e.Id == user.Id);
if (userRecord == null)
{
return false;
}
if (userRecord.RefreshTokens != null && userRecord.RefreshTokens.Any())
{
var currentRefreshToken = userRecord.RefreshTokens.First();
tasksDbContext.RefreshTokens.Remove(currentRefreshToken);
}
return false;
}
public async Task<ValidateRefreshTokenResponse> ValidateRefreshTokenAsync
(RefreshTokenRequest refreshTokenRequest)
{
var refreshToken = await tasksDbContext.RefreshTokens.FirstOrDefaultAsync
(o => o.UserId == refreshTokenRequest.UserId);
var response = new ValidateRefreshTokenResponse();
if (refreshToken == null)
{
response.Success = false;
response.Error = "Invalid session or user is already logged out";
response.ErrorCode = "R02";
return response;
}
var refreshTokenToValidateHash = PasswordHelper.HashUsingPbkdf2
(refreshTokenRequest.RefreshToken,
Convert.FromBase64String(refreshToken.TokenSalt));
if (refreshToken.TokenHash != refreshTokenToValidateHash)
{
response.Success = false;
response.Error = "Invalid refresh token";
response.ErrorCode = "R03";
return response;
}
if (refreshToken.ExpiryDate < DateTime.Now)
{
response.Success = false;
response.Error = "Refresh token has expired";
response.ErrorCode = "R04";
return response;
}
response.Success = true;
response.UserId = refreshToken.UserId;
return response;
}
}
}
UserService
这包括与登录、注销和注册相关的方法。
using Microsoft.EntityFrameworkCore;
using TasksApi.Helpers;
using TasksApi.Interfaces;
using TasksApi.Requests;
using TasksApi.Responses;
namespace TasksApi.Services
{
public class UserService : IUserService
{
private readonly TasksDbContext tasksDbContext;
private readonly ITokenService tokenService;
public UserService(TasksDbContext tasksDbContext, ITokenService tokenService)
{
this.tasksDbContext = tasksDbContext;
this.tokenService = tokenService;
}
public async Task<TokenResponse> LoginAsync(LoginRequest loginRequest)
{
var user = tasksDbContext.Users.SingleOrDefault
(user => user.Active && user.Email == loginRequest.Email);
if (user == null)
{
return new TokenResponse
{
Success = false,
Error = "Email not found",
ErrorCode = "L02"
};
}
var passwordHash = PasswordHelper.HashUsingPbkdf2
(loginRequest.Password, Convert.FromBase64String(user.PasswordSalt));
if (user.Password != passwordHash)
{
return new TokenResponse
{
Success = false,
Error = "Invalid Password",
ErrorCode = "L03"
};
}
var token = await System.Threading.Tasks.Task.Run(() =>
tokenService.GenerateTokensAsync(user.Id));
return new TokenResponse
{
Success = true,
AccessToken = token.Item1,
RefreshToken = token.Item2
};
}
public async Task<LogoutResponse> LogoutAsync(int userId)
{
var refreshToken = await tasksDbContext.RefreshTokens.FirstOrDefaultAsync
(o => o.UserId == userId);
if (refreshToken == null)
{
return new LogoutResponse { Success = true };
}
tasksDbContext.RefreshTokens.Remove(refreshToken);
var saveResponse = await tasksDbContext.SaveChangesAsync();
if (saveResponse >= 0)
{
return new LogoutResponse { Success = true };
}
return new LogoutResponse { Success = false,
Error = "Unable to logout user", ErrorCode = "L04" };
}
public async Task<SignupResponse> SignupAsync(SignupRequest signupRequest)
{
var existingUser = await tasksDbContext.Users.SingleOrDefaultAsync
(user => user.Email == signupRequest.Email);
if (existingUser != null)
{
return new SignupResponse
{
Success = false,
Error = "User already exists with the same email",
ErrorCode = "S02"
};
}
if (signupRequest.Password != signupRequest.ConfirmPassword) {
return new SignupResponse
{
Success = false,
Error = "Password and confirm password do not match",
ErrorCode = "S03"
};
}
if (signupRequest.Password.Length <= 7) // This can be more complicated than
// only length, you can check on alphanumeric and or special characters
{
return new SignupResponse
{
Success = false,
Error = "Password is weak",
ErrorCode = "S04"
};
}
var salt = PasswordHelper.GetSecureSalt();
var passwordHash = PasswordHelper.HashUsingPbkdf2(signupRequest.Password, salt);
var user = new User
{
Email = signupRequest.Email,
Password = passwordHash,
PasswordSalt = Convert.ToBase64String(salt),
FirstName = signupRequest.FirstName,
LastName = signupRequest.LastName,
Ts = signupRequest.Ts,
Active = true // You can save is false and send confirmation email
// to the user, then once the user confirms the email you can make it true
};
await tasksDbContext.Users.AddAsync(user);
var saveResponse = await tasksDbContext.SaveChangesAsync();
if (saveResponse >= 0)
{
return new SignupResponse { Success = true, Email = user.Email };
}
return new SignupResponse
{
Success = false,
Error = "Unable to save the user",
ErrorCode = "S05"
};
}
}
}
TaskService
这包括添加、删除和获取任务的方法。
using Microsoft.EntityFrameworkCore;
using TasksApi.Interfaces;
using TasksApi.Responses;
namespace TasksApi.Services
{
public class TaskService : ITaskService
{
private readonly TasksDbContext tasksDbContext;
public TaskService(TasksDbContext tasksDbContext)
{
this.tasksDbContext = tasksDbContext;
}
public async Task<DeleteTaskResponse> DeleteTask(int taskId, int userId)
{
var task = await tasksDbContext.Tasks.FindAsync(taskId);
if (task == null)
{
return new DeleteTaskResponse
{
Success = false,
Error = "Task not found",
ErrorCode = "T01"
};
}
if (task.UserId != userId)
{
return new DeleteTaskResponse
{
Success = false,
Error = "You don't have access to delete this task",
ErrorCode = "T02"
};
}
tasksDbContext.Tasks.Remove(task);
var saveResponse = await tasksDbContext.SaveChangesAsync();
if (saveResponse >= 0)
{
return new DeleteTaskResponse
{
Success = true,
TaskId = task.Id
};
}
return new DeleteTaskResponse
{
Success = false,
Error = "Unable to delete task",
ErrorCode = "T03"
};
}
public async Task<GetTasksResponse> GetTasks(int userId)
{
var tasks = await tasksDbContext.Tasks.Where
(o => o.UserId == userId).ToListAsync();
if (tasks.Count == 0)
{
return new GetTasksResponse
{
Success = false,
Error = "No tasks found for this user",
ErrorCode = "T04"
};
}
return new GetTasksResponse { Success = true, Tasks = tasks };
}
public async Task<SaveTaskResponse> SaveTask(Task task)
{
await tasksDbContext.Tasks.AddAsync(task);
var saveResponse = await tasksDbContext.SaveChangesAsync();
if (saveResponse >= 0)
{
return new SaveTaskResponse
{
Success = true,
Task = task
};
}
return new SaveTaskResponse
{
Success = false,
Error = "Unable to save task",
ErrorCode = "T05"
};
}
}
}
现在,一旦你添加了这些接口和任务,让我们确保在项目的生成器管道中配置它们。
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<IUserService, UserService>();
builder.Services.AddTransient<ITaskService, TaskService>();
控制器
现在是 API 的最后一部分,即构建用户用于访问后端资源的端点。
首先,我们将创建一个新控制器,它将继承自 ControllerBase
,并在其中,我们将有一个小方法和一个属性来检索已登录的 UserId
,每当提供访问令牌时,从基于 JWT 的访问令牌声明中获取。
所以让我们添加一个 API Controller
,如下所示。
BaseApiController
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace TasksApi.Controllers
{
public class BaseApiController : ControllerBase
{
protected int UserID => int.Parse(FindClaim(ClaimTypes.NameIdentifier));
private string FindClaim(string claimName)
{
var claimsIdentity = HttpContext.User.Identity as ClaimsIdentity;
var claim = claimsIdentity.FindFirst(claimName);
if (claim == null)
{
return null;
}
return claim.Value;
}
}
}
现在我们可以创建我们的控制器,它们将继承自这个 BaseApiController
。
UsersController
让我们从 UsersController
开始,它将包含四个方法:login
、logout
、signup
和 refresh
访问令牌。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TasksApi.Interfaces;
using TasksApi.Requests;
using TasksApi.Responses;
namespace TasksApi.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class UsersController : BaseApiController
{
private readonly IUserService userService;
private readonly ITokenService tokenService;
public UsersController(IUserService userService, ITokenService tokenService)
{
this.userService = userService;
this.tokenService = tokenService;
}
[HttpPost]
[Route("login")]
public async Task<IActionResult> Login(LoginRequest loginRequest)
{
if (loginRequest == null || string.IsNullOrEmpty(loginRequest.Email) ||
string.IsNullOrEmpty(loginRequest.Password))
{
return BadRequest(new TokenResponse
{
Error = "Missing login details",
ErrorCode = "L01"
});
}
var loginResponse = await userService.LoginAsync(loginRequest);
if (!loginResponse.Success)
{
return Unauthorized(new
{
loginResponse.ErrorCode,
loginResponse.Error
});
}
return Ok(loginResponse);
}
[HttpPost]
[Route("refresh_token")]
public async Task<IActionResult> RefreshToken(RefreshTokenRequest refreshTokenRequest)
{
if (refreshTokenRequest == null || string.IsNullOrEmpty
(refreshTokenRequest.RefreshToken) || refreshTokenRequest.UserId == 0)
{
return BadRequest(new TokenResponse
{
Error = "Missing refresh token details",
ErrorCode = "R01"
});
}
var validateRefreshTokenResponse =
await tokenService.ValidateRefreshTokenAsync(refreshTokenRequest);
if (!validateRefreshTokenResponse.Success)
{
return UnprocessableEntity(validateRefreshTokenResponse);
}
var tokenResponse = await tokenService.GenerateTokensAsync
(validateRefreshTokenResponse.UserId);
return Ok(new { AccessToken = tokenResponse.Item1,
Refreshtoken = tokenResponse.Item2 });
}
[HttpPost]
[Route("signup")]
public async Task<IActionResult> Signup(SignupRequest signupRequest)
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values.SelectMany
(x => x.Errors.Select(c => c.ErrorMessage)).ToList();
if (errors.Any())
{
return BadRequest(new TokenResponse
{
Error = $"{string.Join(",", errors)}",
ErrorCode = "S01"
});
}
}
var signupResponse = await userService.SignupAsync(signupRequest);
if (!signupResponse.Success)
{
return UnprocessableEntity(signupResponse);
}
return Ok(signupResponse.Email);
}
[Authorize]
[HttpPost]
[Route("logout")]
public async Task<IActionResult> Logout()
{
var logout = await userService.LogoutAsync(UserID);
if (!logout.Success)
{
return UnprocessableEntity(logout);
}
return Ok();
}
}
}
上面请注意,只有 logout 端点有 Authorize
装饰,这是因为我们知道只要用户登录,他就可以注销,这意味着他有一个有效的访问令牌和刷新令牌。
TasksController
这包括所有将允许用户执行与任务相关的操作的端点,例如获取用户的所有任务、保存和删除该用户的任务。
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TasksApi.Interfaces;
using TasksApi.Requests;
using TasksApi.Responses;
namespace TasksApi.Controllers
{
[Authorize]
[Route("api/[controller]")]
[ApiController]
public class TasksController : BaseApiController
{
private readonly ITaskService taskService;
public TasksController(ITaskService taskService)
{
this.taskService = taskService;
}
[HttpGet]
public async Task<IActionResult> Get()
{
var getTasksResponse = await taskService.GetTasks(UserID);
if (!getTasksResponse.Success)
{
return UnprocessableEntity(getTasksResponse);
}
var tasksResponse = getTasksResponse.Tasks.ConvertAll(o =>
new TaskResponse { Id = o.Id, IsCompleted = o.IsCompleted,
Name = o.Name, Ts = o.Ts });
return Ok(tasksResponse);
}
[HttpPost]
public async Task<IActionResult> Post(TaskRequest taskRequest)
{
var task = new Task { IsCompleted = taskRequest.IsCompleted,
Ts = taskRequest.Ts, Name = taskRequest.Name, UserId = UserID };
var saveTaskResponse = await taskService.SaveTask(task);
if (!saveTaskResponse.Success)
{
return UnprocessableEntity(saveTaskResponse);
}
var taskResponse = new TaskResponse { Id = saveTaskResponse.Task.Id,
IsCompleted = saveTaskResponse.Task.IsCompleted,
Name = saveTaskResponse.Task.Name, Ts = saveTaskResponse.Task.Ts };
return Ok(taskResponse);
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var deleteTaskResponse = await taskService.DeleteTask(id, UserID);
if (!deleteTaskResponse.Success)
{
return UnprocessableEntity(deleteTaskResponse);
}
return Ok(deleteTaskResponse.TaskId);
}
}
}
请注意,[Authorize]
属性装饰了整个 Controller
,因为所有这些操作都需要一个拥有有效访问令牌的已认证用户。
现在我们已经完成了开发部分。按 F5,然后在浏览器中查看你的 API 的 Swagger
UI。
在 Postman 上测试
现在到了 QA 部分,测试我们所有的工作,以确保一切正常并符合要求。
当然,请确保你已安装并打开了最新版本的 Postman。
让我们创建一个新的集合并将其命名为 Tasks Api。
我们需要测试的第一件事是 **login** 端点,因为我们在数据库中已经插入了一些测试用户(包含在本教程开头处的脚本中)。
让我们尝试使电子邮件无效并查看结果。
现在让我们测试 **signup
** 方法。
查看数据库 User
表。
注意第 3 条记录,密码从未以纯文本格式保存,并且与之关联了随机盐。
访问令牌通常会有一个较短的有效期,10 或 15 分钟。一旦过期,你就必须使用刷新令牌静默刷新访问令牌,刷新令牌的有效期通常更长,例如 10 天或 3 周,并且这些令牌是滑动窗口的,所以每当你想要刷新访问令牌时,都可以使用以下端点生成新的令牌对。
refresh_token 端点
现在让我们测试刷新令牌端点。当访问令牌因任何授权 API 调用(如以下调用)而过期后,你需要使用此端点为用户刷新访问令牌。
你将收到 401 响应,因为访问令牌不再有效,你必须使用第一次登录时获得的刷新令牌来请求新的访问令牌。
让我们尝试刷新令牌。
如果我们尝试刷新之前使用的同一个令牌,它将不起作用,因为刷新令牌会触发生成新的访问令牌和新的刷新令牌,所以之前的刷新令牌将被作废(从数据库的 RefreshToken
表中删除)。
现在让我们 **logout** 用户。
恶意用户或已注销用户
现在让我们尝试这种情况:一个有效用户从系统中注销,但碰巧一个恶意用户已经以某种方式获得了该用户的刷新令牌,并试图刷新该用户的令牌以便获得未经授权的访问。我们的 API 将通过向恶意用户返回以下响应来保护我们的有效用户。
这样,恶意用户就无法访问有效用户的数据。
现在让我们为用户执行 **Get Tasks**。
让我们 **添加一个新任务**。
现在让我们 **删除一个任务**。
摘要
今天,我们学习了如何从数据库开始构建一个简单的任务管理系统,使用 SQL Server Express,将其与 Visual Studio 2022 中的 .NET 6 和 C# 10 的 ASP.NET Core Web API 连接。我们使用 EF Core(得益于强大的 EF Core Power Tools 的帮助)映射了数据库,然后使用 JWTBearer Nuget 包,我们在 API 项目上设置和实现了基于 JWT 的身份验证,同时应用了刷新令牌,使其对最终用户更加实用,可以在不重复登录过程的情况下在 10 或 15 分钟内获得 API 的身份验证和授权。
如果你认为本教程有用,请随时在你的在线网络和同事中分享。并且不要忘记订阅我的博客,以便在新教程发布时收到通知。
请在下面的评论区告诉我你的想法或疑问。
参考文献
你可以在我的 GitHub 帐户中找到本教程的源代码。
我还有另一篇教程解释了更多关于 JWT 的内容,你可以在有时间的时候看看 Secure ASP.NET Core Web API using JWT Authentication(使用 JWT 身份验证保护 ASP.NET Core Web API)。
随时查看我的其他教程,我会尽快将它们更新到 .NET 6。
- 使用 API 密钥身份验证保护 ASP.NET Core Web API
- ASP.NET Core Web API 中的异常处理和日志记录
- 连接 Android 与 ASP.NET Core Web API 的完整教程
奖励
请欣赏这首美丽的钢琴奏鸣曲。
无论你在哪里,祝你白天或晚上都愉快,玩得开心,编码愉快!最重要的是……请注意安全!
莫扎特 – A 小调第 8 号钢琴奏鸣曲,K. 310(第一乐章)