Реализовать авторизацию для Persistence.Client

This commit is contained in:
Roman Efremov 2024-11-25 10:09:38 +05:00
parent 6518aeabf1
commit 3806e395eb
18 changed files with 297 additions and 298 deletions

View File

@ -1,10 +1,12 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Persistence.Models; using Persistence.Models;
using Persistence.Repositories; using Persistence.Repositories;
namespace Persistence.API.Controllers namespace Persistence.API.Controllers
{ {
[ApiController] [ApiController]
[Authorize]
[Route("api/[controller]")] [Route("api/[controller]")]
public class SetpointController : ControllerBase, ISetpointApi public class SetpointController : ControllerBase, ISetpointApi
{ {

View File

@ -1,7 +1,13 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Persistence.Models;
using Persistence.Models.Configurations;
using System.Data.Common;
using System.Text;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace Persistence.API; namespace Persistence.API;
@ -30,6 +36,10 @@ public static class DependencyInjection
}); });
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Persistence web api", Version = "v1" }); c.SwaggerDoc("v1", new OpenApiInfo { Title = "Persistence web api", Version = "v1" });
var needUseKeyCloak = configuration.GetSection("NeedUseKeyCloak").Get<bool>();
if (needUseKeyCloak)
{
c.AddSecurityDefinition("Keycloack", new OpenApiSecurityScheme c.AddSecurityDefinition("Keycloack", new OpenApiSecurityScheme
{ {
Description = @"JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below. Example: 'Bearer 12345abcdef'", Description = @"JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below. Example: 'Bearer 12345abcdef'",
@ -62,6 +72,36 @@ public static class DependencyInjection
new List<string>() new List<string>()
} }
}); });
}
else
{
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = @"JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below. Example: 'Bearer 12345abcdef'",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
Scheme = "Bearer",
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement()
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
new List<string>()
}
});
}
//var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; //var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
//var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); //var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
@ -73,16 +113,67 @@ public static class DependencyInjection
public static void AddJWTAuthentication(this IServiceCollection services, IConfiguration configuration) public static void AddJWTAuthentication(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) var needUseKeyCloak = configuration
.AddJwtBearer(o => .GetSection("NeedUseKeyCloak")
.Get<bool>();
if (needUseKeyCloak) services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{ {
o.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
o.Audience = configuration["Authentication:Audience"]; options.Audience = configuration["Authentication:Audience"];
o.MetadataAddress = configuration["Authentication:MetadataAddress"]!; options.MetadataAddress = configuration["Authentication:MetadataAddress"]!;
o.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidIssuer = configuration["Authentication:ValidIssuer"], ValidIssuer = configuration["Authentication:ValidIssuer"],
}; };
}); });
else services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = JwtParams.Issuer,
ValidateAudience = true,
ValidAudience = JwtParams.Audience,
ValidateLifetime = true,
IssuerSigningKey = JwtParams.SecurityKey,
ValidateIssuerSigningKey = false
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Headers["Authorization"]
.ToString()
.Replace(JwtBearerDefaults.AuthenticationScheme, string.Empty)
.Trim();
context.Token = accessToken;
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var username = context.Principal?.Claims
.FirstOrDefault(e => e.Type == "username")?.Value;
var password = context.Principal?.Claims
.FirstOrDefault(e => e.Type == "password")?.Value;
var keyCloakUser = configuration
.GetSection(nameof(AuthUser))
.Get<AuthUser>()!;
if (username != keyCloakUser.Username || password != keyCloakUser.Password)
{
context.Fail("username or password did not match");
}
return Task.CompletedTask;
}
};
});
} }
} }

View File

@ -8,7 +8,7 @@
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"dotnetRunMessages": true, "dotnetRunMessages": true,
"applicationUrl": "http://localhost:5032" "applicationUrl": "http://localhost:13616"
}, },
"IIS Express": { "IIS Express": {
"commandName": "IISExpress", "commandName": "IISExpress",

View File

@ -4,5 +4,6 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} },
"NeedUseKeyCloak": false
} }

View File

@ -1,11 +1,12 @@
{ {
"DbConnection": { "DbConnection": {
"Host": "localhost", "Host": "localhost",
"Port": 5432, "Port": 5432,
"Username": "postgres", "Username": "postgres",
"Password": "q" "Password": "q"
}, },
"KeycloakTestUser": { "NeedUseKeyCloak": false,
"AuthUser": {
"username": "myuser", "username": "myuser",
"password": 12345, "password": 12345,
"clientId": "webapi", "clientId": "webapi",

View File

@ -1,43 +1,72 @@
namespace Persistence.Client.Helpers; using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using Persistence.Models.Configurations;
using RestSharp;
namespace Persistence.Client.Helpers;
public static class ApiTokenHelper public static class ApiTokenHelper
{ {
public static string GetAdminUserToken() public static void Authorize(this HttpClient httpClient, IConfiguration configuration)
{ {
//var user = new User() var authUser = configuration
//{ .GetSection(nameof(AuthUser))
// Id = 1, .Get<AuthUser>()!;
// IdCompany = 1, var needUseKeyCloak = configuration
// Login = "test_user" .GetSection("NeedUseKeyCloak")
//}; .Get<bool>()!;
//var roles = new[] { "root" }; var keycloakGetTokenUrl = configuration.GetSection("KeycloakGetTokenUrl").Get<string>() ?? string.Empty;
return string.Empty; var jwtToken = needUseKeyCloak
? authUser.CreateKeyCloakJwtToken(keycloakGetTokenUrl)
: authUser.CreateDefaultJwtToken();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken);
} }
//private static string CreateToken(User user, IEnumerable<string> roles) private static string CreateDefaultJwtToken(this AuthUser authUser)
//{ {
// var claims = new List<Claim> var claims = new List<Claim>()
// { {
// new("id", user.Id.ToString()), new("client_id", authUser.ClientId),
// new(ClaimsIdentity.DefaultNameClaimType, user.Login), new("username", authUser.Username),
// new("idCompany", user.IdCompany.ToString()), new("password", authUser.Password),
// }; new("grant_type", authUser.GrantType)
};
// claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); var tokenDescriptor = new SecurityTokenDescriptor
{
// const string secret = "супер секретный ключ для шифрования"; Issuer = JwtParams.Issuer,
Audience = JwtParams.Audience,
// var key = Encoding.ASCII.GetBytes(secret); Subject = new ClaimsIdentity(claims),
// var tokenDescriptor = new SecurityTokenDescriptor Expires = DateTime.UtcNow.AddHours(1),
// { SigningCredentials = new SigningCredentials(JwtParams.SecurityKey, SecurityAlgorithms.HmacSha256Signature)
// Issuer = "a", };
// Audience = "a", var tokenHandler = new JwtSecurityTokenHandler();
// Subject = new ClaimsIdentity(claims), var token = tokenHandler.CreateToken(tokenDescriptor);
// Expires = DateTime.UtcNow.AddHours(1), return tokenHandler.WriteToken(token);
// SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }
// };
// var tokenHandler = new JwtSecurityTokenHandler(); private static string CreateKeyCloakJwtToken(this AuthUser authUser, string keycloakGetTokenUrl)
// var token = tokenHandler.CreateToken(tokenDescriptor); {
// return tokenHandler.WriteToken(token); var restClient = new RestClient();
//}
var request = new RestRequest(keycloakGetTokenUrl, Method.Post);
request.AddParameter("username", authUser.Username);
request.AddParameter("password", authUser.Password);
request.AddParameter("client_id", authUser.ClientId);
request.AddParameter("grant_type", authUser.GrantType);
var keyCloackResponse = restClient.Post(request);
if (keyCloackResponse.IsSuccessful && !String.IsNullOrEmpty(keyCloackResponse.Content))
{
var token = JsonSerializer.Deserialize<JwtToken>(keyCloackResponse.Content)!;
return token.AccessToken;
}
return String.Empty;
}
} }

View File

@ -7,12 +7,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageReference Include="Refit" Version="8.0.0" /> <PackageReference Include="Refit" Version="8.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" /> <PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageReference Include="RestSharp" Version="112.1.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Persistence\Persistence.csproj" /> <ProjectReference Include="..\Persistence\Persistence.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Models\" />
</ItemGroup>
</Project> </Project>

View File

@ -1,48 +1,30 @@
using System; using System.Text.Json;
using System.Collections.Generic; using Microsoft.Extensions.Configuration;
using System.Linq;
using System.Net.Http.Headers;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Refit;
using Persistence.Client.Helpers; using Persistence.Client.Helpers;
using Persistence.Models.Configurations;
using Refit;
namespace Persistence.Client namespace Persistence.Client
{ {
public static class PersistenceClientFactory public class PersistenceClientFactory
{ {
private static readonly JsonSerializerOptions JsonSerializerOptions = new() private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private static readonly RefitSettings RefitSettings = new(new SystemTextJsonContentSerializer(JsonSerializerOptions)); private static readonly RefitSettings RefitSettings = new(new SystemTextJsonContentSerializer(JsonSerializerOptions));
private HttpClient httpClient;
public static T GetClient<T>(HttpClient client) public PersistenceClientFactory(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{ {
return RestService.For<T>(client, RefitSettings); this.httpClient = httpClientFactory.CreateClient();
httpClient.Authorize(configuration);
} }
public static T GetClient<T>(string baseUrl) public T GetClient<T>()
{ {
var client = new HttpClient(); return RestService.For<T>(httpClient, RefitSettings);
client.BaseAddress = new Uri(baseUrl);
return RestService.For<T>(client, RefitSettings);
}
private static HttpClient GetAuthorizedClient()
{
var httpClient = new HttpClient();
var jwtToken = ApiTokenHelper.GetAdminUserToken();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken);
return httpClient;
} }
} }
} }

View File

@ -1,43 +0,0 @@
namespace Persistence.IntegrationTests;
public static class ApiTokenHelper
{
//public static string GetAdminUserToken()
//{
// var user = new User()
// {
// Id = 1,
// IdCompany = 1,
// Login = "test_user"
// };
// var roles = new[] { "root" };
// return CreateToken(user, roles);
//}
//private static string CreateToken(User user, IEnumerable<string> roles)
//{
// var claims = new List<Claim>
// {
// new("id", user.Id.ToString()),
// new(ClaimsIdentity.DefaultNameClaimType, user.Login),
// new("idCompany", user.IdCompany.ToString()),
// };
// claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
// const string secret = "супер секретный ключ для шифрования";
// var key = Encoding.ASCII.GetBytes(secret);
// var tokenDescriptor = new SecurityTokenDescriptor
// {
// Issuer = "a",
// Audience = "a",
// Subject = new ClaimsIdentity(claims),
// Expires = DateTime.UtcNow.AddHours(1),
// SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
// };
// var tokenHandler = new JwtSecurityTokenHandler();
// var token = tokenHandler.CreateToken(tokenDescriptor);
// return tokenHandler.WriteToken(token);
//}
}

View File

@ -17,11 +17,10 @@ namespace Persistence.IntegrationTests.Controllers
public SetpointControllerTest(WebAppFactoryFixture factory) : base(factory) public SetpointControllerTest(WebAppFactoryFixture factory) : base(factory)
{ {
var scope = factory.Services.CreateScope(); var scope = factory.Services.CreateScope();
var httpClient = scope.ServiceProvider var persistenceClientFactory = scope.ServiceProvider
.GetRequiredService<IHttpClientFactory>() .GetRequiredService<PersistenceClientFactory>();
.CreateClient();
setpointClient = PersistenceClientFactory.GetClient<ISetpointClient>(httpClient); setpointClient = persistenceClientFactory.GetClient<ISetpointClient>();
} }
[Fact] [Fact]

View File

@ -17,11 +17,10 @@ public abstract class TimeSeriesBaseControllerTest<TEntity, TDto> : BaseIntegrat
dbContext.CleanupDbSet<TEntity>(); dbContext.CleanupDbSet<TEntity>();
var scope = factory.Services.CreateScope(); var scope = factory.Services.CreateScope();
var httpClient = scope.ServiceProvider var persistenceClientFactory = scope.ServiceProvider
.GetRequiredService<IHttpClientFactory>() .GetRequiredService<PersistenceClientFactory>();
.CreateClient();
timeSeriesClient = PersistenceClientFactory.GetClient<ITimeSeriesClient<TDto>>(httpClient); timeSeriesClient = persistenceClientFactory.GetClient<ITimeSeriesClient<TDto>>();
} }
public async Task InsertRangeSuccess(TDto dto) public async Task InsertRangeSuccess(TDto dto)

View File

@ -1,8 +0,0 @@
using System.Text.Json.Serialization;
namespace Persistence.IntegrationTests;
public class JwtToken
{
[JsonPropertyName("access_token")]
public required string AccessToken { get; set; }
}

View File

@ -1,27 +0,0 @@
namespace Persistence.IntegrationTests;
/// <summary>
/// настройки credentials для пользователя в KeyCloak
/// </summary>
public class KeyCloakUser
{
/// <summary>
///
/// </summary>
public required string Username { get; set; }
/// <summary>
///
/// </summary>
public required string Password { get; set; }
/// <summary>
///
/// </summary>
public required string ClientId { get; set; }
/// <summary>
///
/// </summary>
public required string GrantType { get; set; }
}

View File

@ -3,57 +3,33 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Persistence.API; using Persistence.API;
using Persistence.Client;
using Persistence.Database.Model; using Persistence.Database.Model;
using Persistence.Database.Postgres; using Persistence.Database.Postgres;
using Refit;
using RestSharp; using RestSharp;
using System.Net.Http.Headers;
using System.Text.Json;
using Persistence.Database.Postgres;
using System.Net.Http.Headers;
using Persistence.Client;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Persistence.IntegrationTests; namespace Persistence.IntegrationTests;
public class WebAppFactoryFixture : WebApplicationFactory<Startup> public class WebAppFactoryFixture : WebApplicationFactory<Startup>
{ {
private static readonly JsonSerializerOptions JsonSerializerOptions = new() private string connectionString = string.Empty;
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
//Converters = { new ValidationResultConverter() }
};
private static readonly RefitSettings RefitSettings = new(new SystemTextJsonContentSerializer(JsonSerializerOptions));
private readonly string connectionString;
private readonly KeyCloakUser keycloakTestUser;
public readonly string KeycloakGetTokenUrl;
public WebAppFactoryFixture()
{
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.Tests.json")
.Build();
var dbConnection = configuration.GetSection("DbConnection").Get<DbConnection>()!;
connectionString = dbConnection.GetConnectionString();
keycloakTestUser = configuration.GetSection("KeycloakTestUser").Get<KeyCloakUser>()!;
KeycloakGetTokenUrl = configuration.GetSection("KeycloakGetTokenUrl").Value!;
}
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.ConfigureAppConfiguration((hostingContext, config) =>
{
config.AddJsonFile("appsettings.Tests.json");
var dbConnection = config.Build().GetSection("DbConnection").Get<DbConnection>()!;
connectionString = dbConnection.GetConnectionString();
});
builder.ConfigureServices(services => builder.ConfigureServices(services =>
{ {
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<PersistenceDbContext>)); var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<PersistenceDbContext>));
if (descriptor != null) if (descriptor != null)
services.Remove(descriptor); services.Remove(descriptor);
services.AddDbContext<PersistenceDbContext>(options => services.AddDbContext<PersistenceDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
@ -63,6 +39,8 @@ public class WebAppFactoryFixture : WebApplicationFactory<Startup>
return new TestHttpClientFactory(this); return new TestHttpClientFactory(this);
}); });
services.AddSingleton<PersistenceClientFactory>();
var serviceProvider = services.BuildServiceProvider(); var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
@ -83,57 +61,4 @@ public class WebAppFactoryFixture : WebApplicationFactory<Startup>
await dbContext.Database.EnsureDeletedAsync(); await dbContext.Database.EnsureDeletedAsync();
} }
public T GetHttpClient<T>(string uriSuffix)
{
var httpClient = CreateClient();
if (string.IsNullOrEmpty(uriSuffix))
return RestService.For<T>(httpClient, RefitSettings);
if (httpClient.BaseAddress is not null)
httpClient.BaseAddress = new Uri(httpClient.BaseAddress, uriSuffix);
return RestService.For<T>(httpClient, RefitSettings);
}
public async Task<T> GetAuthorizedHttpClient<T>(string uriSuffix)
{
var httpClient = await GetAuthorizedHttpClient();
if (string.IsNullOrEmpty(uriSuffix))
return RestService.For<T>(httpClient, RefitSettings);
if (httpClient.BaseAddress is not null)
httpClient.BaseAddress = new Uri(httpClient.BaseAddress, uriSuffix);
return RestService.For<T>(httpClient, RefitSettings);
}
private async Task<HttpClient> GetAuthorizedHttpClient()
{
var httpClient = CreateClient();
var token = await GetTokenAsync();
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return httpClient;
}
private async Task<string> GetTokenAsync()
{
var restClient = new RestClient();
var request = new RestRequest(KeycloakGetTokenUrl, Method.Post);
request.AddParameter("username", keycloakTestUser.Username);
request.AddParameter("password", keycloakTestUser.Password);
request.AddParameter("client_id", keycloakTestUser.ClientId);
request.AddParameter("grant_type", keycloakTestUser.GrantType);
var keyCloackResponse = await restClient.PostAsync(request);
if (keyCloackResponse.IsSuccessful && !String.IsNullOrEmpty(keyCloackResponse.Content))
{
var token = JsonSerializer.Deserialize<JwtToken>(keyCloackResponse.Content)!;
return token.AccessToken;
}
return String.Empty;
}
} }

View File

@ -0,0 +1,12 @@
namespace Persistence.Models.Configurations;
/// <summary>
/// Настройки credentials для авторизации
/// </summary>
public class AuthUser
{
public required string Username { get; set; }
public required string Password { get; set; }
public required string ClientId { get; set; }
public required string GrantType { get; set; }
}

View File

@ -0,0 +1,18 @@
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Persistence.Models.Configurations
{
public static class JwtParams
{
private static readonly string KeyValue = "супер секретный ключ для шифрования";
public static SymmetricSecurityKey SecurityKey
{
get { return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(KeyValue)); }
}
public static readonly string Issuer = "a";
public static readonly string Audience = "a";
}
}

View File

@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace Persistence.Models.Configurations
{
public class JwtToken
{
[JsonPropertyName("access_token")]
public required string AccessToken { get; set; }
}
}

View File

@ -9,6 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>