ASP.NET Core에서 대칭 암호화를 사용한 JWT 인증

22623 단어 encryptionaspdotnet
https://eduardstefanescu.dev/2020/04/11/jwt-authentication-with-symmetric-encryption-in-asp-dotnet-core/에 원래 게시되었습니다.


JWT 인증은 최신 웹 애플리케이션 또는 서비스에서 가장 많이 사용되는 인증 유형 중 하나가 되고 있습니다. 이 문서에서는 ASP.NET Core에서 대칭 키를 사용한 JWT 인증에 대해 설명합니다. 첫 번째 부분에서는 대칭 키가 무엇을 나타내는지에 대한 간략한 소개가 있고 두 번째 부분에는 이 프로젝트의 전제 조건과 이 인증 유형의 실제 구현이 포함되어 있습니다.
이 글은 2편의 시리즈 중 첫 번째 글이고, 두 번째 글은 인증서를 이용한 비대칭 키로 인증하는 내용입니다.

소개

JWT Token is a common way of creating access tokens that can contain several claims (e.g. Username, Roles). JWT Token means JSON (JavaScript Object Notation) Web Token. Every JWT Token has the following structure:

  • Header, containing the encryption algorithm;
  • Payload, containing custom Claims, plus at least two required claims:
    • exp  representing the expiration time when the Token will become unavailable;
    • iat  or Issued at Time, the time when the Token was created;\ The times are formatted using the Unix Timestamp format (e.g. 1582784721).
  • Signature, representing the encoded header, plus  a dot , plus the encoded payload, plus a secret key. The concatenated result will be run through the encryption algorithm specified on the Header to validate the Token.
If you want to read more about JWT Token, this comprehensive paper covers all the concepts:  https://tools.ietf.org/html/rfc7519 .

대칭 키

The Symmetric Key is used both for signing and validation. For example, let's say John wants to share a secret with Jane, when the secret is told, John also tells Jane a password - the key - in order for the secret to being understood. In this way, John - the identity provider or the service - ensures that his secret is well kept by using the given password.


설정

ASP.NET Core 3.1 will be used for this project. Microsoft also offers a great package that provides all that is needed to create a JWT Token-based authentication. The package is called  Microsoft.AspNetCore.Authentication.JwtBearer , this is the only package that the project needs, and can be found here:  https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer .

비밀 키 생성

The signing and validation key will be a user secret key. ASP.NET provides the user secret key feature to store all the confidential data that doesn't have to be committed or shared outside the user or developer environment. For the production or testing environments, the keys need to be store in a cloud vault, like Microsoft Azure offers through Key Vault -  https://azure.microsoft.com/en-us/services/key-vault/ -, 하지만 이것은 다른 문서의 주제가 됩니다.\
먼저 프로젝트 폴더에서 다음 명령을 실행하여 사용자 암호를 사용하기 위해 프로젝트를 시작해야 합니다.

dotnet user-secrets init


그런 다음 다음 명령을 사용하여 사용자 비밀 키가 추가됩니다.

dotnet user-secrets set "AppSettings:EncryptionKey" "POWERFULENCRYPTIONKEY"


이 명령어는 secrets.json 파일에 AppSettings:EncryptionKey 키 값을 POWERFULENCRYPTIONKEY .\로 추가합니다.AppSettings에 대한 여러 값이 있는 경우 다음과 같은 JSON 형식을 사용하여 이 키를 더 쉽게 읽을 수 있습니다.

"AppSettings": {
  "EncryptionKey": "POWERFULENCRYPTIONKEY",
  "Key2": "Value2" 
} 

POWERFULENCRYPTIONKEY는 바이트 배열로 인코딩된 다음 이 바이너리는 Base64로 인코딩되며 이는 서명과 유효성 검사 모두에 필요합니다.

시작

In the  ConfigureServices  method from the  Startup  class, the  AppSettings  section needs to be read. To read a type from the configuration file, a class must be created, so for the  AppSettings  section an equivalent class needs to exists, as is shown below. This class can be seen as a Data Transfer Object, which contains plain properties.

public class AppSettings
{
    public string EncryptionKey { get; set; }
}

After the section is read, the  EncryptionKey  needs to be converted into bytes.

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    IConfigurationSection settingsSection = configuration.GetSection("AppSettings");
    AppSettings settings = settingsSection.Get<AppSettings>();
    byte[] signingKey = Encoding.UTF8.GetBytes(settings.EncryptionKey);

    services.AddAuthentication(signingKey);

    services.Configure<AppSettings>(settingsSection);
    services.AddTransient<AuthenticationService>();
    services.AddTransient<UserService>();
    services.AddTransient<TokenService>();
}

On  line 9  the  Authentication  service is added into the App container, this service is responsible, for managing  Authentication  settings, like  IssuerSigningKey  or  LifeTimeValidation .\
For this step, an extension method is created called  AddAuthentication , which receives the  service  and the  signingKey  converted earlier.\
From  line 11  to  14 , the services are configured for the Dependency Injection, we will return to the implementation of these services in a moment.

public static IServiceCollection AddAuthentication(this IServiceCollection services,
    byte[] signingKey)
{
    services.AddAuthentication(authOptions =>
        {
            authOptions.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            authOptions.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(jwtOptions =>
        {
            jwtOptions.SaveToken = true;
            jwtOptions.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateAudience = false,
                ValidateIssuer = false,
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(signingKey),
                ValidateLifetime = true,
                LifetimeValidator = LifetimeValidator
            };
        });

    return services;
}

The  authenticationOptions  need to configure the  Authenticate  and  Challenge  Schemes, in order to verify that the endpoint(s) which receives a JWT Token will go through the validation step, as is described below starting from  line 12 . The same Schema will be seen on the endpoints that use the  AuthorizeAttribute .\
Then the  JwtBearer  is added to the  Authentication  process, using the following properties:

  • SaveToken  is self-explanatory. It's used to persist the Token into local storage. The token will be valid even if the service restarts, so its lifetime is different from the application;
  • ValidateAudience  and  ValidateIssuer  must be used for the service to skip or to validate the Audience or the Issuer. The Audience refers to the server or the Identity Provider, in this case, our ASP.NET Service. And the  Issuer  refers to the client(s) that makes HTTP request(s). For the sake of this example, both are set  false . Please note that even if you don't want to validate the  Audience  or/and the  Issuer  these values must be set;
  • ValidateIssuerSigningKey  needs to be set to  true , in order to validate the received Token;
  • For  IssuerSigningKey  will use the  SymmetricSecurityKey , the same approach will be also used when the Token will be created.
  • LifeTimeValidator  is important if the generated  Token  has set an expiration time.
All the JWT Bearer Options can be found on the Microsoft website:  https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.jwtbearer.jwtbeareroptions .
LifeTimeValidator 핸들러는 다음과 같이 만료 날짜가 현재 날짜보다 큰지 확인합니다.

private static bool LifetimeValidator(DateTime? notBefore,
    DateTime? expires,
    SecurityToken securityToken,
    TokenValidationParameters validationParameters)
{
    return expires != null && expires > DateTime.Now;
}


서비스를 구성한 후 AuthenticationAuthorization 미들웨어를 Configure 메소드에서 앱 파이프라인에 추가해야 합니다.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...
    app.UseAuthentication();
    app.UseAuthorization();
    ...
}


사용자 자격 증명

User's Credentials will be used as a Data Transfer Object, this class will be received on the authentication endpoint and sent to the  AuthenticationService . It's a plain class that contains only the  Username  and the  Password  of the user.

public class UserCredentials
{
    public string Username { get; set; }
    public string Password { get; set; }
}

인증 서비스

The  AuthenticationService  is used like a middleware that receives the  UserCredentials  from the  Controller , validates them using the  UserService  and if the credentials are valid, it creates a Token using the  TokenService . Both the  User  and  Token  services are injected into the constructor.

public string Authenticate(UserCredentials userCredentials)
{
    userService.ValidateCredentials(userCredentials);
    string securityToken = tokenService.GetToken();

    return securityToken;
}

사용자 서비스

For the sake of this example, the  UserService  contains a list of users created on the constructor. In a real-life scenario, this will be read from storage or from a service.

public class UserService
{
    private readonly IEnumerable<UserCredentials> users;

    public UserService()
    {
        users = new List<UserCredentials>
        {
            new UserCredentials
            {
                Username = "john.doe",
                Password = "john.password"
            }
        };
    }
...

This is more like a  UserValidation  service, but to better illustrate that it also reads the users, the  UserService  name will be kept.

...
    public void ValidateCredentials(UserCredentials userCredentials)
    {
        bool isValid =
            users.Any(u =>
                u.Username == userCredentials.Username &&
                u.Password == userCredentials.Password);

        if (!isValid)
        {
            throw new InvalidCredentialsException();
        }
    }
}

The  ValidateCredentials  method checks if the  username  and  password  pair exists, and if it doesn't it will throw the  InvalidCredentialsException  which will be caught on the  Controller .

토큰 서비스

TokenService  is receiving on the constructor the  AppSettings , which will be used on the  GetTokenDescriptor  method to set up the Token.

public class TokenService
{
    private readonly AppSettings appSettings;

    public TokenService(IOptions<AppSettings> options)
    {
        appSettings = options.Value;
    }
...

The public  GetToken  method is used to get the token description, to create the Token and write it into a string, that will be returned to the calling service, in this case to the  AuthenticationService .

public string GetToken()
{
    SecurityTokenDescriptor tokenDescriptor = GetTokenDescriptor();
    var tokenHandler = new JwtSecurityTokenHandler();
    SecurityToken securityToken = tokenHandler.CreateToken(tokenDescriptor);
    string token = tokenHandler.WriteToken(securityToken);

    return token;
}

On the  GetTokenDescriptor  method, the token is constructed. In this method, the  ExpirationTime  and  SigningCredentials  are set. Because the Claims are not in the main focus of this article, I will create another one, in which I will explain how the Claims can be set on the Token and how they can be used.

private SecurityTokenDescriptor GetTokenDescriptor()
{
    const int expiringDays = 7;

    byte[] securityKey = Encoding.UTF8.GetBytes(appSettings.EncryptionKey);
    var symmetricSecurityKey = new SymmetricSecurityKey(securityKey);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Expires = DateTime.UtcNow.AddDays(expiringDays),
        SigningCredentials = new SigningCredentials(symmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature)
    };

    return tokenDescriptor;
}
All the Token Descriptors can be found on the Microsoft website:  https://docs.microsoft.com/en-us/dotnet/api/system.identitymodel.tokens.securitytokendescriptor .

인증 컨트롤러

Now, all we have to do, is to create an  AuthenticationController  which receives the  UserCredentials  and uses the previously created  AuthenticationService .\
On the constructor the  AuthenticationService  is injected, to be used on the  Authentication  endpoint.

[Route("identity/[controller]")]
public class AuthenticationController : ControllerBase
{
    private readonly AuthenticationService authenticationService;

    public AuthenticationController(AuthenticationService authenticationService)
    {
        this.authenticationService = authenticationService;
    }
...

The authentication endpoint accepts HTTP Post requests, receives the  UserCredentials  as previously mentioned, and uses the  AuthenticationService  to authenticate and create the Token.

...
    [HttpPost]
    public IActionResult Authenticate([FromBody] UserCredentials userCredentials)
    {
        try
        {
            string token = authenticationService.Authenticate(userCredentials);
            return Ok(token);
        }
        catch (InvalidCredentialsException)
        {
            return Unauthorized();
        }
    }
}

If the credentials are valid, then the endpoint will return an  OK  HTTP Status code and the generated token. Otherwise, if the  InvalidCredentialsException  is thrown, the  Unauthorized  HTTP Status code is returned.

유효성 검사 컨트롤러

The purpose of the  ValidationController  is to check that the signing process is working, in order to validate the Token.

Route("identity/[controller]")]
public class ValidationController : ControllerBase
{
    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    public IActionResult Validate()
    {
        return Ok();
    }
}

You may notice that the  Validate  endpoint has the  AuthorizeAttribute  which has on its constructor the same  AuthenticationSchemes  as was set on the  Authentication  service.

결과

Firstly, the happy flow for the  AuthenticationController  is tested, so we'll provide the correct combination of the username and password, in order to receive the token.



올바르지 않은 자격 증명으로 테스트해 보겠습니다. 응답은 Unauthorized여야 합니다.



둘째, 생성된 토큰은 Validation 컨트롤러를 사용하여 테스트해야 합니다. 첫 번째 테스트는 유효성 검사가 통과되었는지 확인하기 위해 생성된 토큰을 사용합니다.



두 번째 테스트는 유효성 검사를 위해 잘못된 토큰이 제공되는 경우입니다.




이 기사의 소스 코드는 내 GitHub 계정https://github.com/StefanescuEduard/JwtAuthentication에서 찾을 수 있습니다.

이 기사를 읽어 주셔서 감사합니다. 흥미로웠다면 동료 및 친구들과 공유하십시오. 또는 개선할 수 있는 부분이 있으면 알려주세요.

좋은 웹페이지 즐겨찾기