출처: https://codingsonata.com/apply-jwt-access-tokens-and-refresh-tokens-in-asp-net-core-web-api/
이 자습서에서는 ASP.NET Core Web API에서 JWT 액세스 토큰 및 새로 고침 토큰을 적용하는 방법을 알아봅니다. 간단하고 안전하며 신뢰할 수 있는 RESTful API 프로젝트를 구축하여 사용자를 적절하게 인증하고 API에 대한 작업을 수행할 수 있는 권한을 부여합니다.
메모: 이 자습서와 https://github.com/aram87/TasksApi 소스 코드를 .NET 7 인 최신 버전의 .NET으로 업데이트했습니다.
액세스 토큰을 사용하여 Authorization 헤더에 제공된 서버의 일부 리소스에 액세스할 수 있는 적절한 권한을 사용자에게 부여합니다. 액세스 토큰은 일반적으로 짧은 시간 내에 서명되며 JWT 토큰의 경우 서명, 클레임, 헤더가 포함됩니다.
반면에 새로 고침 토큰은 일반적으로 액세스 토큰을 새로 고치는 데만 사용할 수 있는 참조입니다. 이러한 토큰은 일반적으로 백 엔드 스토리지에 유지되며, 예를 들어 이러한 리소스에 더 이상 액세스할 수 있는 사용자 또는 액세스 토큰을 훔친 악의적인 사용자의 경우 액세스 권한을 취소하는 데 사용할 수 있습니다.
이러한 경우 이러한 디바이스에 대한 새로 고침 토큰을 제거할 수 있으므로 액세스 토큰이 만료되면 한 번 유효한 새로 고침 토큰이 더 이상 유효하지 않고 더 이상 리소스에 액세스할 수 없기 때문에 해지된 새로 고침 토큰을 사용하여 갱신(새로 고침)할 수 없습니다. 따라서 사용자는 앱이나 웹에서 로그아웃되므로 다시 로그인하고 일반적인 로그인 프로세스를 다시 거쳐야 합니다.
이제 엄격한 텍스트로 충분하므로 NET에서 Core Web API를 사용하여 액세스 및 새로 고침 토큰을 모두 사용하여 JWT 인증을 구현하는 API ASP.NET 빌드해 보겠습니다.
source : https://github.com/aram87/TasksApi
데이터베이스 준비
대부분의 자습서에서는 SQL Server Express를 사용하여 필요한 데이터베이스와 테이블을 만듭니다. 따라서 최신 버전의 SQL Server Management Studio 및 SQL Server Express를 다운로드하여 설치해야 합니다.
둘 다 설치되면 SQL Server Management Studio를 열고 SQL Server Express가 설치된 로컬 컴퓨터에 연결합니다.
SQL Server - 연결
개체 탐색기에서 데이터베이스를 마우스 오른쪽 버튼으로 클릭하고 "새 데이터베이스 만들기"를 선택하여 TasksDb와 같은 이름을 지정합니다.
SQL Server - DB 만들기
그런 다음, 아래 명령을 실행하여 테이블을 만들고 이 자습서에 필요한 데이터로 채웁니다.
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
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
GO
ALTER TABLE [dbo].[Task] CHECK CONSTRAINT [FK_Task_User]
GO
프로젝트 생성
Visual Studio 2022를 열고 ASP.NET Core Web API 형식의 새 프로젝트를 만듭니다.
VS2022 - 프로젝트 생성
TasksApi와 같은 이름을 지정합니다.
그런 다음, .NET 7.0을 선택하고 프로젝트를 만듭니다.
VS가 프로젝트 초기화를 완료하면 F5 키를 눌러 템플릿 프로젝트에 대한 초기 실행을 수행하여 제대로 작동하는지 확인합니다.
이제 템플릿 프로젝트에서 불필요한 클래스를 제거해 보겠습니다. 솔루션 탐색기에서 WeatherForecastController 및 WeatherForecast 파일을 삭제합니다.
VS2022 - 프로젝트 템플릿
Entity Framework Core 및 DbContext
EF Core 및 EF Core SQL용 Nuget 패키지를 추가해 보겠습니다.
아시다시피, 이제 EF Core 7이 있으며, EF Core 6에서 7로 많은 개선이 이루어졌으며, 자세한 내용은 EF Core 7의 릴리스 정보를 확인하세요.
엔터티
이제 EF Core DbContext 클래스를 통해 데이터베이스 테이블에 바인딩하는 데 필요한 엔터티를 만들어 보겠습니다.
Tasks 데이터베이스에 매핑할 3개의 엔터티를 만듭니다.
리프레시 토큰(RefreshToken)
// This file has been auto generated by EF Core Power Tools.
#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; }
}
}
과업
// This file has been auto generated by EF Core Power Tools.
#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
// This file has been auto generated by EF Core Power Tools.
#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
Now let’s add the TasksDbContext that will inherit from the DbContext class of the EF Core:
// This file has been auto generated by EF Core Power Tools.
#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);
}
}
And in your program.cs file, add the below just before the builder.build() call:
builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("TasksDbConnectionString")));
Then in your appsettings.json , make sure to include the connection string for the database:
{
"ConnectionStrings": {
"TasksDbConnectionString": "Server=Home\\SQLEXPRESS;Database=TasksDb;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Breaking Change in EF Core 7
Important note here about the TrustServerCertifiicate=True, this is only an unsecure workaround just for the sake of testing localhost.
Since there has been a breaking change with the release of EF Core 7, which is related to the connection encryption with SQL Server. This means that now by default any connection to SQL Server database is encrypted, through the portion of the connection string in case it is not provided otherwise, and there must be a trusted certificate installed on the machine hosting the SQL Server. You can read more about the breaking change in EF Core 7.Encrypt=True
So, just to show you what will happen when we try to connect to SQL server in .NET 7 using .NET Core 7, using the default connection string without installing a trusted certificate, I will fast forward this tutorial and try to run the application on Postman via https://localhost, I will get the below error message:
Microsoft.Data.SqlClient.SqlException (0x80131904): A connection was successfully established with the server, but then an error occurred during the login process. (provider: SSL Provider, error: 0 - The certificate chain was issued by an authority that is not trusted.)
---> System.ComponentModel.Win32Exception (0x80090325): The certificate chain was issued by an authority that is not trusted.
So only for localhost testing purposes, as a workaround, we can bypass this through adding .TrustServerCertificate=True
You can instead install a trusted certificate and then you can remove the from the connection string.TrustServerCertificate=True
EF Core Power Tools
Note: I have used the wonderful extension EF Core Power Tools which in a magical way it can translate a whole database structure and relationships into neat and proper DbContext entities and configurations.
You can install it from Extensions tab -> Manage Extensions of your Visual Studio 2022
vs2022 - ef core power tools
If you are following the design-first model for building your database first and then your EF Core mapping, I would highly recommend that you use this blazing fast and reliable tool to perform such operation, which will therefore boost your productivity and reduce the number of errors that might be introduced from manually creating the entities and configurations.
PasswordHashHelper
In order to save passwords on the database, we need to use secure hash HMAC 256 and salt from a secure random bytes of 256-bit sized, so we can protect the valuable users’ passwords from those nasty lurking thieves!
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: 300000, 32);
return Convert.ToBase64String(derivedKey);
}
}
}
We will also use these helper methods to save the refresh tokens on the database in a hashed format alongside their associated salts.
Implementing the JWT Authentication
Let’s add the needed JWT Bearer Package, which is also available in .NET 7:
Token Helper to build both Access Tokens and Refresh Tokens
Now let’s add the TokenHelper, which will include 2 methods to generate JWT-based access tokens and the other to generate a 32-byte based refresh tokens:
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;
}
}
}
Now let’s make sure to add the needed authentication and authorization middleware to the pipeline in program.cs file:
Add the below before the builder.build method:
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();
And then before the app.run method, make sure you the app will be using both middleware to authenticate and authorize your users:
app.UseAuthentication();
app.UseAuthorization();
Requests and Responses
It is always advised that you accept and return structured objects instead of separated data, this is why we will prepare some Request and Response class that we will use them throughout our API:
Let’s add the below requests classes:
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; }
}
}
Now let’s add the responses classes, these will be used to return the structured responses for the UI client calling the API:
BaseResponse
This will be used a base class so other response classes can inherit from and extend their properties:
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; }
}
}
Interfaces
We will define 3 interfaces that will be implemented within the services. The interfaces are the abstractions that the Controllers would need to use to be able to process the related business logic and database calls, each interface would be implemented within a service which would be injected at runtime.
This is a very useful strategy (or design pattern) to make your API loosely coupled and easily testable.
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);
}
}
ITasks인터페이스
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 사이의 중간 계층 역할을하며 컨트롤러가 신경 쓰지 않아야하는 비즈니스 관련 논리도 포함합니다. 서비스는 인터페이스를 구현합니다.
우리는 3 가지 서비스를 추가 할 것입니다 :
토큰서비스
여기에는 토큰을 생성하고, 새로 고침 토큰의 유효성을 검사하고 제거하는 메서드가 포함됩니다.
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
This will include methods related to login, logout and signup:
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
This includes the methods for adding, removing and getting tasks:
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를 상속할 새 Controller를 만들고 그 안에 JWT 기반 액세스 토큰 클레임에서 액세스 토큰이 제공될 때마다 로그인한 UserId를 검색하는 작은 메서드와 속성이 있습니다.
따라서 아래와 같이 API 컨트롤러를 추가해 보겠습니다.
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부터 시작하여 로그인, 로그아웃, 가입 및 액세스 토큰 새로 고침의 4가지 메서드가 포함됩니다.
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();
}
}
}
Note above that only the logout endpoint has the Authorize decoration, this is because we know that the user will be able to logout as long as he is logged in, which means he has a valid access token and refresh token.
TasksController
This includes all the endpoints that will allow the user to perform tasks-related operations, like getting all user’s tasks, saving and deleting the task for that user.
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);
}
}
}
Note that the [Authorize] attribute is decorating the whole Controller since all these operations require an authenticated user with a valid access token.
Now we are completed with the development part. Press F5 and check the browser is showing Swagger UI for your APIs
vs2022 - run - swagger
Testing on Postman
Now comes the QA part of testing our whole work to make sure everything is working fine and as per the requirements.
Of course, make sure you have the latest version of Postman installed and opened.
Let’s create a new collection and name it , Tasks Api.
First thing we need to test the login endpoint since we have some test user already inserted in the database (included in the script part at the beginning of the tutorial)
postman - login - success
Let’s try to invalidate the email and see the result:
postman - login - fail
Now let’s test the signup method:
postman - signup
Take a look at the database User Table:
Notice the 3rd record, the password was never saved in plain format, and the random salt was associated with it.
The access token usually would have a short duration, 10 or 15 minutes long, and once this is expired you have to silently refresh the access token using the refresh token, which is much longer in duration, like 10 days or 3 weeks for example, and these tokens are sliding in time, so whenever you want to refresh and access token you can just use the below endpoint to generate new pair of tokens.
refresh_token endpoint
Now let’s test the refresh token endpoint. You will need this endpoint to refresh the access token for the user after it becomes expired through any of the authorized API calls, such as the below:
postman - get tasks - fail
You will get 401 response, because the access token is no longer valid and you have to request for a new access token using the refresh token that you had from the first login.
Let’s try to refresh token:
postman - refresh token - success
If we try to refresh the same token used before, it won’t work, simply because the refresh token triggers generating both new access token and new refresh token, so the previous refresh token would be invalidated (removed from the RefreshToken table on the database).
postman - refresh token - fail
Now let’s logout the user
우편 배달부 - 로그아웃
악의적이거나 이미 로그아웃한 사용자
이제 유효한 사용자가 시스템에서 로그아웃하지만 부수적으로 악의적인 사용자가 이미 해당 사용자에 대한 새로 고침 토큰을 얻었고(어떤 식으로든) 무단 액세스를 얻을 수 있도록 해당 사용자의 토큰을 새로 고치려고 하면 API는 악의적인 사용자에 대해 아래 응답을 반환하여 유효한 사용자를 보호합니다.
이렇게 하면 악의적인 사용자가 유효한 사용자의 데이터에 액세스할 수 없습니다.
Postman - 새로 고침 토큰 실패
이제 사용자에 대한 Get Tasks를 수행해 보겠습니다.
Postman - 작업 가져오기
그리고 새로운 작업을 추가해 보겠습니다
Postman - 작업 추가
이제 작업을 삭제해 보겠습니다
우편 배달부 삭제 작업
요약
오늘 우리는 SQL Server Express를 사용하여 데이터베이스에서 시작하여 Visual Studio 7 내에서 .NET 11 및 C# 2022의 ASP.NET Core Web API와 연결하는 작고 간단한 작업 관리 시스템을 빌드하는 방법을 배웠습니다.
데이터베이스를 EF Core 7(훌륭한 EF Core Power Tools의 관대한 도움으로)에 매핑한 다음, JWTBearer Nuget 패키지를 사용하여 새로 고침 토큰을 적용하는 것과 함께 API 프로젝트에서 JWT 기반 인증을 설정 및 구현하여 최종 사용자가 10분 또는 15분마다 로그인 프로세스를 다시 수행할 필요 없이 API에 대해 인증되고 권한을 부여받을 수 있도록 했습니다.
이 자습서가 유용하다고 생각되면 온라인 네트워크 및 동료와 자유롭게 공유하십시오. 그리고 새 튜토리얼이 게시되면 알림을 받을 수 있도록 내 블로그를 구독하는 것을 잊지 마십시오.
아래 의견란에 귀하의 생각이나 문의 사항을 알려주십시오.
참조
내 GitHub 계정에서 이 자습서에 대한 .NET 7로 업데이트된 소스 코드를 찾을 수 있습니다.
JWT에 대해 조금 더 설명하는 또 다른 자습서가 있는데, JWT 인증을 사용하여 언젠가 Secure ASP.NET Core Web API를 살펴볼 수 있습니다.
다른 튜토리얼도 자유롭게 확인하면 .NET 7로 가장 빨리 업데이트 할 것입니다.
API 키 인증을 사용하여 보안 ASP.NET Core Web API
ASP.NET Core Web API의 예외 처리 및 로깅
Android를 ASP.NET Core Web API와 연결하는 전체 자습서
또한 웹 API 보안 개선에 대한 자세한 내용은 내 기사를 확인할 수 있습니다.
ASP.NET Core Web API를 처음 사용하는 경우 내 게시물을 자유롭게 읽으십시오 : Core Web API ASP.NET 배우기위한 빠른 가이드