目录
在本教程中,我们将构建一个简单、安全且可靠的RESTful API项目,以正确验证用户并授权他们对API执行操作。
在本教程中,我们将学习如何在ASP.NET Core Web API 6中应用JWT访问令牌和刷新令牌。我们将构建一个简单、安全且可靠的RESTful API项目来正确验证用户并授权他们对API执行操作。
我们将使用最新最好的Visual Studio 2022社区版来构建我们的ASP.NET Core Web API,使用最快的.NET标准框架,即.NET 6和C# 10。
好消息是VS 2022捆绑了最新版本的.NET和C#,因此您不必担心搜索和安装它们中的任何一个。因此,如果您还没有VS 2022,请确保下载并安装Visual Studio 2022,以便我们可以开始使用我们的教程在ASP.NET Core Web API 6中应用JWT访问令牌和刷新令牌。
在之前的教程中,我们学习了如何使用JWT身份验证来保护ASP.NET Core Web API,但这只是使用访问令牌。现在,这是一个从头开始构建的新教程,解释了使用访问令牌和刷新令牌时有关JWT身份验证的所有内容。
访问令牌与刷新令牌
当Authorization标头中提供访问令牌时,我们使用访问令牌授予用户访问服务器上某些资源的适当授权。访问令牌通常是短期和签名的,对于JWT令牌,这将包括签名、声明、标头。
另一方面,刷新令牌通常是只能用于刷新访问令牌的引用。此类令牌通常保存在后端存储中,可用于撤销用户的访问权限,例如,不再有资格访问这些资源或恶意用户窃取访问令牌的情况。
在这种情况下,您只需删除这些设备的刷新令牌,因此一旦他们的访问令牌过期,他们将无法使用撤销的刷新令牌更新(刷新)它,因为他们曾经有效的刷新令牌不再有效,他们将无法再访问您的资源。因此,用户将在应用程序或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文件。
实体框架核心和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; }
}
}
Task
// <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; }
}
}
User
// <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
现在让我们添加将从EF Core的DbContext类继承的TasksDbContext类:
// <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实体和配置。
您可以从Extensions选项卡 -> Manage Extensions of your 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承载包:
用于构建访问令牌和刷新令牌的令牌助手
现在让我们添加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的最后一部分,即构建用户访问后端资源的端点:
首先,我们将创建一个新的Controller来继承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();
}
}
}
注意上面只有注销端点有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并检查浏览器是否为你的UI显示Swagger API。
在Postman上进行测试
现在是测试我们整个工作的QA部分,以确保一切正常并符合要求。
当然,请确保您安装并打开了最新版本的Postman。
让我们创建一个新集合并将其命名为Tasks Api。
我们需要测试的第一件事是登录端点,因为我们已经在数据库中插入了一些测试用户(包括在教程开头的脚本部分中)。
让我们尝试使电子邮件无效并查看结果:
现在让我们测试该signup 方法:
看一下数据库User表:
注意第3条记录,密码从未以纯格式保存,并且随机盐与它相关联。
访问令牌的持续时间通常很短,为10或15分钟,一旦过期,您必须使用刷新令牌静默刷新访问令牌,其持续时间要长得多,例如10天或3周,并且这些令牌是在时间上滑动的,所以每当你想刷新和访问令牌时,你可以使用下面的端点来生成一对新的令牌。
refresh_token端点
现在让我们测试刷新令牌端点。在通过任何授权的API调用过期后,您将需要此端点来刷新用户的访问令牌,如下所示:
您将收到401响应,因为访问令牌不再有效,您必须使用第一次登录时拥有的刷新令牌请求新的访问令牌。
让我们尝试刷新令牌:
如果我们尝试刷新之前使用的同一个令牌,它不会起作用,只是因为刷新令牌触发同时生成新的访问令牌和新的刷新令牌,所以之前的刷新令牌将失效(从数据库上的RefreshToken表中删除)。
现在让我们注销用户:
恶意或已注销的用户
现在让我们试试这个场景,一个有效用户退出系统,但顺便说一下,一个恶意用户已经获得了该用户的刷新令牌(以某种方式),并尝试刷新该用户的令牌,以便他可以获得未经授权的访问,我们的API将通过为恶意用户返回以下响应来保护我们的有效用户。
这样,恶意用户就无法访问有效用户的数据。
现在让我们为用户执行获取任务:
让我们尝试添加一个新任务:
现在让我们删除一个任务:
总结
今天,我们学习了如何使用SQL Server Express从数据库开始构建一个小型简单的任务管理系统,将其与.NET 6中的ASP.NET Core Web API和Visual Studio 2022中的C# 10连接。我们使用EF映射数据库Core(在强大的EF Core Power Tools 的慷慨帮助下),然后使用JWTBearer Nuget包,我们设法在API项目上设置和实施基于JWT的身份验证,同时应用刷新令牌以使其更加实用用户无需每10或15分钟重新执行一次登录过程即可获得API的身份验证和授权。
您可以在我的GitHub帐户中找到本教程的源代码。
https://www.codeproject.com/Articles/5325297/Apply-JWT-Access-Tokens-and-Refresh-Tokens-in-ASP