diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5d56c1c..98a3848 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,25 +5,25 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -32,31 +32,32 @@
-
-
-
+
+
+
+
-
-
+
+
-
+
-
+
-
+
-
+
diff --git a/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs b/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs
index cc4a3a6..e91a1a2 100644
--- a/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs
+++ b/InvoiceReminder.API/AuthenticationSetup/LoginRequest.cs
@@ -1,7 +1,13 @@
+using System.ComponentModel.DataAnnotations;
+
namespace InvoiceReminder.API.AuthenticationSetup;
public record LoginRequest
{
- public string Email { get; set; }
- public string Password { get; set; }
+ [Required]
+ [EmailAddress]
+ public string Email { get; init; }
+
+ [Required]
+ public string Password { get; init; }
}
diff --git a/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs b/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs
index 54993c1..ec4cc91 100644
--- a/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/GoogleOAuthEndpoints.cs
@@ -35,7 +35,7 @@ private static void MapGetAuthUrl(RouteGroupBuilder endpoint)
private static void MapAuthorize(RouteGroupBuilder endpoint)
{
_ = endpoint.MapGet("/authorize",
- async (IGoogleOAuthService oAuthService, CancellationToken ct, Guid state, string code) =>
+ async (IGoogleOAuthService oAuthService, Guid state, string code, CancellationToken ct) =>
{
var result = await oAuthService.GrantAuthorizationAsync(state, code, ct);
@@ -52,7 +52,7 @@ private static void MapAuthorize(RouteGroupBuilder endpoint)
private static void MapRevoke(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapDelete("/revoke", async (IGoogleOAuthService oAuthService, CancellationToken ct, Guid id) =>
+ _ = endpoint.MapDelete("/revoke", async (IGoogleOAuthService oAuthService, Guid id, CancellationToken ct) =>
{
var result = await oAuthService.RevokeAuthorizationAsync(id, ct);
diff --git a/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs b/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs
index 5ef59a6..58c31d5 100644
--- a/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/InvoiceEndpoints.cs
@@ -22,9 +22,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
private static void MapGetInvoices(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/", (IInvoiceAppService invoiceAppService) =>
+ _ = endpoint.MapGet("/", (IInvoiceAppService appService) =>
{
- var result = invoiceAppService.GetAll();
+ var result = appService.GetAll();
return result.IsSuccess
? Results.Ok(result.Value)
@@ -39,9 +39,9 @@ private static void MapGetInvoices(RouteGroupBuilder endpoint)
private static void MapGetInvoice(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/{id}", async (IInvoiceAppService invoiceAppService, CancellationToken ct, Guid id) =>
+ _ = endpoint.MapGet("/{id}", async (IInvoiceAppService appService, Guid id, CancellationToken ct) =>
{
- var result = await invoiceAppService.GetByIdAsync(id, ct);
+ var result = await appService.GetByIdAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -58,9 +58,9 @@ private static void MapGetInvoice(RouteGroupBuilder endpoint)
private static void MapGetInvoiceByBarcode(RouteGroupBuilder endpoint)
{
_ = endpoint.MapGet("/getby-barcode/{value}",
- async (IInvoiceAppService invoiceAppService, CancellationToken ct, string value) =>
+ async (IInvoiceAppService appService, string value, CancellationToken ct) =>
{
- var result = await invoiceAppService.GetByBarcodeAsync(value, ct);
+ var result = await appService.GetByBarcodeAsync(value, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -77,9 +77,9 @@ private static void MapGetInvoiceByBarcode(RouteGroupBuilder endpoint)
private static void MapCreateInvoice(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPost("/",
- async (IInvoiceAppService invoiceAppService, CancellationToken ct, [FromBody] InvoiceViewModel invoiceViewModel) =>
+ async (IInvoiceAppService appService, [FromBody] InvoiceViewModel viewModel, CancellationToken ct) =>
{
- var result = await invoiceAppService.AddAsync(invoiceViewModel, ct);
+ var result = await appService.AddAsync(viewModel, ct);
return result.IsSuccess
? Results.Created($"{basepath}/{result.Value.Barcode}", result.Value)
@@ -95,9 +95,9 @@ private static void MapCreateInvoice(RouteGroupBuilder endpoint)
private static void MapUpdateInvoice(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPut("/",
- async (IInvoiceAppService invoiceAppService, CancellationToken ct, [FromBody] InvoiceViewModel invoiceViewModel) =>
+ async (IInvoiceAppService appService, [FromBody] InvoiceViewModel viewModel, CancellationToken ct) =>
{
- var result = await invoiceAppService.UpdateAsync(invoiceViewModel, ct);
+ var result = await appService.UpdateAsync(viewModel, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -113,9 +113,9 @@ private static void MapUpdateInvoice(RouteGroupBuilder endpoint)
private static void MapDeleteInvoice(RouteGroupBuilder endpoint)
{
_ = endpoint.MapDelete("/",
- async (IInvoiceAppService invoiceAppService, CancellationToken ct, [FromBody] InvoiceViewModel invoiceViewModel) =>
+ async (IInvoiceAppService appService, [FromBody] InvoiceViewModel viewModel, CancellationToken ct) =>
{
- var result = await invoiceAppService.RemoveAsync(invoiceViewModel, ct);
+ var result = await appService.RemoveAsync(viewModel, ct);
return result.IsSuccess
? Results.NoContent()
diff --git a/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs b/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs
index 2db5d02..ed9e853 100644
--- a/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/JobScheduleEndpoints.cs
@@ -22,9 +22,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
private static void MapGetJobSchedules(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/", (IJobScheduleAppService jobScheduleAppService) =>
+ _ = endpoint.MapGet("/", (IJobScheduleAppService appService) =>
{
- var result = jobScheduleAppService.GetAll();
+ var result = appService.GetAll();
return result.IsSuccess
? Results.Ok(result.Value)
@@ -39,9 +39,10 @@ private static void MapGetJobSchedules(RouteGroupBuilder endpoint)
private static void MapGetJobSchedule(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/{id}", async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, Guid id) =>
+ _ = endpoint.MapGet("/{id}",
+ async (IJobScheduleAppService appService, Guid id, CancellationToken ct) =>
{
- var result = await jobScheduleAppService.GetByIdAsync(id, ct);
+ var result = await appService.GetByIdAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -58,9 +59,9 @@ private static void MapGetJobSchedule(RouteGroupBuilder endpoint)
private static void MapGetJobScheduleByUserId(RouteGroupBuilder endpoint)
{
_ = endpoint.MapGet("/getby-userid/{id}",
- async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct, Guid id) =>
+ async (IJobScheduleAppService appService, Guid id, CancellationToken ct) =>
{
- var result = await jobScheduleAppService.GetByUserIdAsync(id, ct);
+ var result = await appService.GetByUserIdAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -77,10 +78,10 @@ private static void MapGetJobScheduleByUserId(RouteGroupBuilder endpoint)
private static void MapCreateJobSchedule(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPost("/",
- async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct,
- [FromBody] JobScheduleViewModel jobScheduleViewModel) =>
+ async (IJobScheduleAppService appService, [FromBody] JobScheduleViewModel viewModel,
+ CancellationToken ct) =>
{
- var result = await jobScheduleAppService.AddNewJobAsync(jobScheduleViewModel, ct);
+ var result = await appService.AddNewJobAsync(viewModel, ct);
return result.IsSuccess
? Results.Created($"{basepath}/{result.Value.Id}", result.Value)
@@ -96,10 +97,10 @@ private static void MapCreateJobSchedule(RouteGroupBuilder endpoint)
private static void MapUpdateJobSchedule(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPut("/",
- async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct,
- [FromBody] JobScheduleViewModel jobScheduleViewModel) =>
+ async (IJobScheduleAppService appService, [FromBody] JobScheduleViewModel viewModel,
+ CancellationToken ct) =>
{
- var result = await jobScheduleAppService.UpdateAsync(jobScheduleViewModel, ct);
+ var result = await appService.UpdateAsync(viewModel, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -115,10 +116,10 @@ private static void MapUpdateJobSchedule(RouteGroupBuilder endpoint)
private static void MapDeleteJobSchedule(RouteGroupBuilder endpoint)
{
_ = endpoint.MapDelete("/",
- async (IJobScheduleAppService jobScheduleAppService, CancellationToken ct,
- [FromBody] JobScheduleViewModel jobScheduleViewModel) =>
+ async (IJobScheduleAppService appService, [FromBody] JobScheduleViewModel viewModel,
+ CancellationToken ct) =>
{
- var result = await jobScheduleAppService.RemoveAsync(jobScheduleViewModel, ct);
+ var result = await appService.RemoveAsync(viewModel, ct);
return result.IsSuccess
? Results.NoContent()
diff --git a/InvoiceReminder.API/Endpoints/LoginEndpoints.cs b/InvoiceReminder.API/Endpoints/LoginEndpoints.cs
index fe65f0b..9f085ea 100644
--- a/InvoiceReminder.API/Endpoints/LoginEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/LoginEndpoints.cs
@@ -1,6 +1,6 @@
using InvoiceReminder.API.AuthenticationSetup;
using InvoiceReminder.Application.Interfaces;
-using InvoiceReminder.Authentication.Extensions;
+using InvoiceReminder.Authentication.Abstractions;
using InvoiceReminder.Authentication.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -20,23 +20,18 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
private static void MapLogin(IEndpointRouteBuilder endpoints)
{
_ = endpoints.MapPost("/",
- async (IJwtProvider jwtProvider,
- IUserAppService userAppService,
- CancellationToken ct,
- [FromBody] LoginRequest request) =>
+ async (IUserAppService appService, IJwtProvider jwtProvider, [FromBody] LoginRequest request,
+ CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request?.Email) || string.IsNullOrWhiteSpace(request?.Password))
{
return Results.BadRequest("Email e senha são obrigatórios");
}
- var result = await userAppService.GetByEmailAsync(request.Email, ct);
+ var result = await appService.ValidateUserPasswordAsync(request.Email, request.Password, ct);
- var isValid = result.IsSuccess
- && request.Password.ToSHA256().Equals(result.Value.Password);
-
- return isValid
- ? Results.Ok(jwtProvider.Generate(result.Value))
+ return result.IsSuccess
+ ? Results.Ok(jwtProvider.Generate(new UserClaims { Id = result.Value.Id, Email = result.Value.Email }))
: Results.Unauthorized();
})
.WithName("Login")
diff --git a/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs b/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs
index b84c469..6986288 100644
--- a/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/ScanEmailDefinitionEndpoints.cs
@@ -23,9 +23,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
private static void MapGetScanEmailDefinitions(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/", (IScanEmailDefinitionAppService scanEmailDefinitionAppService) =>
+ _ = endpoint.MapGet("/", (IScanEmailDefinitionAppService appService) =>
{
- var result = scanEmailDefinitionAppService.GetAll();
+ var result = appService.GetAll();
return result.IsSuccess
? Results.Ok(result.Value)
@@ -41,9 +41,9 @@ private static void MapGetScanEmailDefinitions(RouteGroupBuilder endpoint)
private static void MapGetScanEmailDefinition(RouteGroupBuilder endpoint)
{
_ = endpoint.MapGet("/{id}",
- async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, CancellationToken ct, Guid id) =>
+ async (IScanEmailDefinitionAppService appService, Guid id, CancellationToken ct) =>
{
- var result = await scanEmailDefinitionAppService.GetByIdAsync(id, ct);
+ var result = await appService.GetByIdAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -60,9 +60,9 @@ private static void MapGetScanEmailDefinition(RouteGroupBuilder endpoint)
private static void MapGetByUserId(RouteGroupBuilder endpoint)
{
_ = endpoint.MapGet("/getby-userid/{id}",
- async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, CancellationToken ct, Guid id) =>
+ async (IScanEmailDefinitionAppService appService, Guid id, CancellationToken ct) =>
{
- var result = await scanEmailDefinitionAppService.GetByUserIdAsync(id, ct);
+ var result = await appService.GetByUserIdAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -78,13 +78,10 @@ private static void MapGetByUserId(RouteGroupBuilder endpoint)
private static void MapGetBySenderEmailAddress(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/{email}/{id}",
- async (IScanEmailDefinitionAppService scanEmailDefinitionAppService,
- CancellationToken ct,
- string email,
- Guid id) =>
+ _ = endpoint.MapGet("/getby-sender/{email}/{id}",
+ async (IScanEmailDefinitionAppService appService, string email, Guid id, CancellationToken ct) =>
{
- var result = await scanEmailDefinitionAppService.GetBySenderEmailAddressAsync(email, id, ct);
+ var result = await appService.GetBySenderEmailAddressAsync(email, id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -101,11 +98,10 @@ private static void MapGetBySenderEmailAddress(RouteGroupBuilder endpoint)
private static void MapCreateScanEmailDefinition(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPost("/",
- async (IScanEmailDefinitionAppService scanEmailDefinitionAppService,
- CancellationToken ct,
- [FromBody] ScanEmailDefinitionViewModel scanEmailDefinitionViewModel) =>
+ async (IScanEmailDefinitionAppService appService, [FromBody] ScanEmailDefinitionViewModel viewModel,
+ CancellationToken ct) =>
{
- var result = await scanEmailDefinitionAppService.AddAsync(scanEmailDefinitionViewModel, ct);
+ var result = await appService.AddAsync(viewModel, ct);
return result.IsSuccess
? Results.Created($"{basepath}/{result.Value.Id}", result.Value)
@@ -121,11 +117,10 @@ private static void MapCreateScanEmailDefinition(RouteGroupBuilder endpoint)
private static void MapUpdateScanEmailDefinition(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPut("/",
- async (IScanEmailDefinitionAppService scanEmailDefinitionAppService,
- CancellationToken ct,
- [FromBody] ScanEmailDefinitionViewModel scanEmailDefinitionViewModel) =>
+ async (IScanEmailDefinitionAppService appService, [FromBody] ScanEmailDefinitionViewModel viewModel,
+ CancellationToken ct) =>
{
- var result = await scanEmailDefinitionAppService.UpdateAsync(scanEmailDefinitionViewModel, ct);
+ var result = await appService.UpdateAsync(viewModel, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -141,10 +136,10 @@ private static void MapUpdateScanEmailDefinition(RouteGroupBuilder endpoint)
private static void MapDeleteScanEmailDefinition(RouteGroupBuilder endpoint)
{
_ = endpoint.MapDelete("/",
- async (IScanEmailDefinitionAppService scanEmailDefinitionAppService, CancellationToken ct,
- [FromBody] ScanEmailDefinitionViewModel scanEmailDefinitionViewModel) =>
+ async (IScanEmailDefinitionAppService appService, [FromBody] ScanEmailDefinitionViewModel viewModel,
+ CancellationToken ct) =>
{
- var result = await scanEmailDefinitionAppService.RemoveAsync(scanEmailDefinitionViewModel, ct);
+ var result = await appService.RemoveAsync(viewModel, ct);
return result.IsSuccess
? Results.NoContent()
diff --git a/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs b/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs
index 61e8004..a52811d 100644
--- a/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/SendMessageEndpoints.cs
@@ -15,8 +15,7 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
private static void MapSendMessage(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/{id}",
- async (ISendMessageService messageService, CancellationToken ct, Guid id) =>
+ _ = endpoint.MapGet("/{id}", async (ISendMessageService messageService, Guid id, CancellationToken ct) =>
{
var result = await messageService.SendMessage(id, ct);
diff --git a/InvoiceReminder.API/Endpoints/UserEndpoints.cs b/InvoiceReminder.API/Endpoints/UserEndpoints.cs
index eacb5b4..fd6b971 100644
--- a/InvoiceReminder.API/Endpoints/UserEndpoints.cs
+++ b/InvoiceReminder.API/Endpoints/UserEndpoints.cs
@@ -1,6 +1,5 @@
using InvoiceReminder.Application.Interfaces;
using InvoiceReminder.Application.ViewModels;
-using InvoiceReminder.Authentication.Extensions;
using Microsoft.AspNetCore.Mvc;
namespace InvoiceReminder.API.Endpoints;
@@ -24,9 +23,9 @@ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
private static void MapGetUsers(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/", (IUserAppService userAppService) =>
+ _ = endpoint.MapGet("/", (IUserAppService appService) =>
{
- var result = userAppService.GetAll();
+ var result = appService.GetAll();
return result.IsSuccess
? Results.Ok(result.Value)
@@ -41,9 +40,9 @@ private static void MapGetUsers(RouteGroupBuilder endpoint)
private static void MapGetUser(RouteGroupBuilder endpoint)
{
- _ = endpoint.MapGet("/{id}", async (IUserAppService userAppService, CancellationToken ct, Guid id) =>
+ _ = endpoint.MapGet("/{id}", async (IUserAppService appService, Guid id, CancellationToken ct) =>
{
- var result = await userAppService.GetByIdAsync(id, ct);
+ var result = await appService.GetByIdAsync(id, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -59,9 +58,9 @@ private static void MapGetUser(RouteGroupBuilder endpoint)
private static void MapGetUserByEmail(RouteGroupBuilder endpoint)
{
_ = endpoint.MapGet("/getby-email/{value}",
- async (IUserAppService userAppService, CancellationToken ct, string value) =>
+ async (IUserAppService appService, string value, CancellationToken ct) =>
{
- var result = await userAppService.GetByEmailAsync(value, ct);
+ var result = await appService.GetByEmailAsync(value, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -77,11 +76,9 @@ private static void MapGetUserByEmail(RouteGroupBuilder endpoint)
private static void MapCreateUser(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPost("/",
- async (IUserAppService userAppService, CancellationToken ct, [FromBody] UserViewModel userViewModel) =>
+ async (IUserAppService appService, [FromBody] UserViewModel viewModel, CancellationToken ct) =>
{
- userViewModel.Password = userViewModel.Password.ToSHA256();
-
- var result = await userAppService.AddAsync(userViewModel, ct);
+ var result = await appService.AddAsync(viewModel, ct);
return result.IsSuccess
? Results.Created($"{basepath}/{result.Value.Id}", result.Value)
@@ -97,14 +94,10 @@ private static void MapCreateUser(RouteGroupBuilder endpoint)
private static void MapCreateUsers(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPost("/bulk-insert",
- async (IUserAppService userAppService, CancellationToken ct, [FromBody] ICollection usersViewModel) =>
+ async (IUserAppService appService, [FromBody] ICollection viewModelCollection,
+ CancellationToken ct) =>
{
- foreach (var user in usersViewModel)
- {
- user.Password = user.Password.ToSHA256();
- }
-
- var result = await userAppService.BulkInsertAsync(usersViewModel, ct);
+ var result = await appService.BulkInsertAsync(viewModelCollection, ct);
return result.IsSuccess
? Results.Created($"{basepath}", result.Value)
@@ -120,9 +113,9 @@ private static void MapCreateUsers(RouteGroupBuilder endpoint)
private static void MapUpdateUser(RouteGroupBuilder endpoint)
{
_ = endpoint.MapPut("/",
- async (IUserAppService userAppService, CancellationToken ct, [FromBody] UserViewModel userViewModel) =>
+ async (IUserAppService appService, [FromBody] UserViewModel viewModel, CancellationToken ct) =>
{
- var result = await userAppService.UpdateAsync(userViewModel, ct);
+ var result = await appService.UpdateAsync(viewModel, ct);
return result.IsSuccess
? Results.Ok(result.Value)
@@ -138,9 +131,9 @@ private static void MapUpdateUser(RouteGroupBuilder endpoint)
private static void MapDeleteUser(RouteGroupBuilder endpoint)
{
_ = endpoint.MapDelete("/",
- async (IUserAppService userAppService, CancellationToken ct, [FromBody] UserViewModel userViewModel) =>
+ async (IUserAppService appService, [FromBody] UserViewModel viewModel, CancellationToken ct) =>
{
- var result = await userAppService.RemoveAsync(userViewModel, ct);
+ var result = await appService.RemoveAsync(viewModel, ct);
return result.IsSuccess
? Results.NoContent()
diff --git a/InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs b/InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs
new file mode 100644
index 0000000..997330e
--- /dev/null
+++ b/InvoiceReminder.API/Endpoints/UserPasswordEndpoints.cs
@@ -0,0 +1,95 @@
+using InvoiceReminder.Application.Interfaces;
+using InvoiceReminder.Application.ViewModels;
+using Microsoft.AspNetCore.Mvc;
+
+namespace InvoiceReminder.API.Endpoints;
+
+public class UserPasswordEndpoints : IEndpointDefinition
+{
+ private const string basepath = "/api/user_password";
+
+ public void RegisterEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ var endpoint = endpoints.MapGroup(basepath).WithName("UserPasswordEndpoints");
+
+ MapCreateUserPassword(endpoint);
+ MapCreateUsersPassword(endpoint);
+ MapDeleteUserPassword(endpoint);
+ MapUpdateUserPassword(endpoint);
+ }
+
+ private static void MapCreateUserPassword(RouteGroupBuilder endpoint)
+ {
+ _ = endpoint.MapPost("/",
+ async (IUserPasswordAppService appService, UserPasswordViewModel viewModel, CancellationToken ct) =>
+ {
+ var result = await appService.AddAsync(viewModel, ct);
+
+ return result.IsSuccess
+ ? Results.Created($"/api/user_password/{result.Value.Id}", result.Value)
+ : Results.Problem(result.Error);
+ })
+ .WithName("CreateUserPassword")
+ .RequireAuthorization()
+ .Produces(StatusCodes.Status201Created)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status500InternalServerError);
+ }
+
+ private static void MapCreateUsersPassword(RouteGroupBuilder endpoint)
+ {
+ _ = endpoint.MapPost("/bulk-insert",
+ async (IUserPasswordAppService appService, ICollection viewModelCollection,
+ CancellationToken ct) =>
+ {
+ var result = await appService.BulkInsertAsync(viewModelCollection, ct);
+
+ return result.IsSuccess
+ ? Results.Created($"/api/user_password/bulk-insert", result.Value)
+ : Results.Problem(result.Error);
+ })
+ .WithName("BulkCreateUserPassword")
+ .RequireAuthorization()
+ .Produces(StatusCodes.Status201Created)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status500InternalServerError);
+ }
+
+ private static void MapUpdateUserPassword(RouteGroupBuilder endpoint)
+ {
+ _ = endpoint.MapPut("/",
+ async (IUserPasswordAppService appService, [FromBody] UserPasswordViewModel viewModel,
+ CancellationToken ct) =>
+ {
+ var result = await appService.UpdateAsync(viewModel, ct);
+
+ return result.IsSuccess
+ ? Results.Ok(result.Value)
+ : Results.Problem(result.Error);
+ })
+ .WithName("UpdateUserPassword")
+ .RequireAuthorization()
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status500InternalServerError);
+ }
+
+ private static void MapDeleteUserPassword(RouteGroupBuilder endpoint)
+ {
+ _ = endpoint.MapDelete("/",
+ async (IUserPasswordAppService appService, [FromBody] UserPasswordViewModel viewModel,
+ CancellationToken ct) =>
+ {
+ var result = await appService.RemoveAsync(viewModel, ct);
+
+ return result.IsSuccess
+ ? Results.NoContent()
+ : Results.Problem(result.Error);
+ })
+ .WithName("DeleteUserPassword")
+ .RequireAuthorization()
+ .Produces(StatusCodes.Status204NoContent)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status500InternalServerError);
+ }
+}
diff --git a/InvoiceReminder.API/InvoiceReminder.API.csproj b/InvoiceReminder.API/InvoiceReminder.API.csproj
index 14517a2..2ac0a6d 100644
--- a/InvoiceReminder.API/InvoiceReminder.API.csproj
+++ b/InvoiceReminder.API/InvoiceReminder.API.csproj
@@ -6,7 +6,6 @@
-
diff --git a/InvoiceReminder.API/appsettings.json b/InvoiceReminder.API/appsettings.json
index 2406b57..766550e 100644
--- a/InvoiceReminder.API/appsettings.json
+++ b/InvoiceReminder.API/appsettings.json
@@ -22,5 +22,8 @@
"Issuer": "SECRET_ISSUER",
"Audience": "SECRET_AUDIENCE",
"SecretKey": "SECRET_KEY"
+ },
+ "Security": {
+ "ParallelismFactor": 2
}
}
diff --git a/InvoiceReminder.Application/AppServices/BaseAppService.cs b/InvoiceReminder.Application/AppServices/BaseAppService.cs
index 741db23..fb5d4f0 100644
--- a/InvoiceReminder.Application/AppServices/BaseAppService.cs
+++ b/InvoiceReminder.Application/AppServices/BaseAppService.cs
@@ -35,15 +35,15 @@ public virtual async Task> AddAsync(
}
public virtual async Task> BulkInsertAsync(
- ICollection viewModels,
+ ICollection viewModelCollection,
CancellationToken cancellationToken = default)
{
- if (viewModels is null || viewModels.Count == 0)
+ if (viewModelCollection is null || viewModelCollection.Count == 0)
{
- return Result.Failure($"Parameter {nameof(viewModels)} was Null or Empty.");
+ return Result.Failure($"Parameter {nameof(viewModelCollection)} was Null or Empty.");
}
- var result = await _repository.BulkInsertAsync(viewModels.Adapt>(), cancellationToken);
+ var result = await _repository.BulkInsertAsync(viewModelCollection.Adapt>(), cancellationToken);
return Result.Success(result);
}
diff --git a/InvoiceReminder.Application/AppServices/UserAppService.cs b/InvoiceReminder.Application/AppServices/UserAppService.cs
index f21a055..c42829e 100644
--- a/InvoiceReminder.Application/AppServices/UserAppService.cs
+++ b/InvoiceReminder.Application/AppServices/UserAppService.cs
@@ -1,22 +1,59 @@
using InvoiceReminder.Application.Interfaces;
using InvoiceReminder.Application.ViewModels;
+using InvoiceReminder.Authentication.Extensions;
using InvoiceReminder.Data.Interfaces;
using InvoiceReminder.Domain.Abstractions;
using InvoiceReminder.Domain.Entities;
+using InvoiceReminder.Domain.Services.Configuration;
using Mapster;
namespace InvoiceReminder.Application.AppServices;
public sealed class UserAppService : BaseAppService, IUserAppService
{
+ private readonly int _parallelismFactor;
private readonly IUserRepository _repository;
+ private readonly IUnitOfWork _unitOfWork;
- public UserAppService(IUserRepository repository, IUnitOfWork unitOfWork) : base(repository, unitOfWork)
+ public UserAppService(IConfigurationService configuration, IUserRepository repository, IUnitOfWork unitOfWork)
+ : base(repository, unitOfWork)
{
+ _parallelismFactor = configuration.GetValue("Security:ParallelismFactor");
_repository = repository;
+ _unitOfWork = unitOfWork;
}
- public async Task> GetByEmailAsync(string value, CancellationToken cancellationToken = default)
+ public override async Task> AddAsync(
+ UserViewModel viewModel,
+ CancellationToken cancellationToken = default)
+ {
+ if (viewModel is null)
+ {
+ return Result.Failure($"Parameter {nameof(viewModel)} was Null.");
+ }
+
+ if (viewModel.UserPassword is null || string.IsNullOrWhiteSpace(viewModel.UserPassword.PasswordHash))
+ {
+ return Result.Failure("Password is required.");
+ }
+
+ (var pHash, var pSalt) = viewModel.UserPassword.PasswordHash.HashPassword(_parallelismFactor);
+
+ viewModel.UserPassword.UserId = viewModel.Id;
+ viewModel.UserPassword.PasswordHash = pHash;
+ viewModel.UserPassword.PasswordSalt = pSalt;
+
+ var entity = viewModel.Adapt();
+
+ _ = await _repository.AddAsync(entity, cancellationToken);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(entity.Adapt());
+ }
+
+ public async Task> GetByEmailAsync(
+ string value,
+ CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByEmailAsync(value, cancellationToken);
@@ -24,4 +61,17 @@ public async Task> GetByEmailAsync(string value, Cancellat
? Result.Failure("User not Found.")
: Result.Success(entity.Adapt());
}
+
+ public async Task> ValidateUserPasswordAsync(
+ string email, string password,
+ CancellationToken cancellationToken = default)
+ {
+ var entity = await _repository.GetByEmailAsync(email, cancellationToken);
+ var isValid = entity is not null && password
+ .VerifyPassword(entity.UserPassword.PasswordHash, entity.UserPassword.PasswordSalt, _parallelismFactor);
+
+ return !isValid
+ ? Result.Failure("User not Found.")
+ : Result.Success(entity.Adapt());
+ }
}
diff --git a/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs
new file mode 100644
index 0000000..337bd2d
--- /dev/null
+++ b/InvoiceReminder.Application/AppServices/UserPasswordAppService.cs
@@ -0,0 +1,111 @@
+using InvoiceReminder.Application.Interfaces;
+using InvoiceReminder.Application.ViewModels;
+using InvoiceReminder.Authentication.Extensions;
+using InvoiceReminder.Data.Interfaces;
+using InvoiceReminder.Domain.Abstractions;
+using InvoiceReminder.Domain.Entities;
+using InvoiceReminder.Domain.Services.Configuration;
+using Mapster;
+
+namespace InvoiceReminder.Application.AppServices;
+
+public class UserPasswordAppService : BaseAppService, IUserPasswordAppService
+{
+ private readonly int _parallelismFactor;
+ private readonly IUserPasswordRepository _repository;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public UserPasswordAppService(IConfigurationService configuration, IUserPasswordRepository repository, IUnitOfWork unitOfWork)
+ : base(repository, unitOfWork)
+ {
+ _parallelismFactor = configuration.GetValue("Security:ParallelismFactor");
+ _repository = repository;
+ _unitOfWork = unitOfWork;
+ }
+
+ public override async Task> AddAsync(
+ UserPasswordViewModel viewModel,
+ CancellationToken cancellationToken = default)
+ {
+ if (viewModel is null)
+ {
+ return Result.Failure("The provided obejct data was Null.");
+ }
+
+ if (string.IsNullOrWhiteSpace(viewModel.PasswordHash))
+ {
+ return Result.Failure("Password is required.");
+ }
+
+ (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor);
+
+ viewModel.PasswordHash = pHash;
+ viewModel.PasswordSalt = pSalt;
+
+ var entity = viewModel.Adapt();
+
+ _ = await _repository.AddAsync(entity, cancellationToken);
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(entity.Adapt());
+ }
+
+ public override async Task> BulkInsertAsync(
+ ICollection viewModelCollection,
+ CancellationToken cancellationToken = default)
+ {
+ if (viewModelCollection is null or { Count: 0 })
+ {
+ return Result.Failure("The provided object data was Null or Empty.");
+ }
+
+ foreach (var viewModel in viewModelCollection)
+ {
+ if (string.IsNullOrWhiteSpace(viewModel.PasswordHash))
+ {
+ return Result.Failure("Password is required.");
+ }
+
+ (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor);
+
+ viewModel.PasswordHash = pHash;
+ viewModel.PasswordSalt = pSalt;
+ }
+
+ var result = await _repository
+ .BulkInsertAsync(viewModelCollection.Adapt>(), cancellationToken);
+
+ return Result.Success(result);
+ }
+
+ public async Task> GetByUserIdAsync(
+ Guid userId,
+ CancellationToken cancellationToken = default)
+ {
+ var entity = await _repository.GetByUserIdAsync(userId, cancellationToken);
+
+ return entity is null
+ ? Result.Failure("No user password found for the specified user ID.")
+ : Result.Success(entity.Adapt());
+ }
+
+ public override async Task> UpdateAsync(
+ UserPasswordViewModel viewModel,
+ CancellationToken cancellationToken = default)
+ {
+ if (viewModel is null)
+ {
+ return Result.Failure("The provided object data was Null.");
+ }
+
+ (var pHash, var pSalt) = viewModel.PasswordHash.HashPassword(_parallelismFactor);
+
+ viewModel.PasswordHash = pHash;
+ viewModel.PasswordSalt = pSalt;
+
+ _ = _repository.Update(viewModel.Adapt());
+ await _unitOfWork.SaveChangesAsync(cancellationToken);
+
+ return Result.Success(viewModel);
+ }
+}
diff --git a/InvoiceReminder.Application/Interfaces/IBaseAppService.cs b/InvoiceReminder.Application/Interfaces/IBaseAppService.cs
index 5930b2d..b071b27 100644
--- a/InvoiceReminder.Application/Interfaces/IBaseAppService.cs
+++ b/InvoiceReminder.Application/Interfaces/IBaseAppService.cs
@@ -5,7 +5,7 @@ namespace InvoiceReminder.Application.Interfaces;
public interface IBaseAppService where TEntity : class where TEntityViewModel : class
{
Task> AddAsync(TEntityViewModel viewModel, CancellationToken cancellationToken = default);
- Task> BulkInsertAsync(ICollection viewModels, CancellationToken cancellationToken = default);
+ Task> BulkInsertAsync(ICollection viewModelCollection, CancellationToken cancellationToken = default);
Result> GetAll();
Task> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task> RemoveAsync(TEntityViewModel viewModel, CancellationToken cancellationToken = default);
diff --git a/InvoiceReminder.Application/Interfaces/IUserAppService.cs b/InvoiceReminder.Application/Interfaces/IUserAppService.cs
index 07161ae..7c2fc1a 100644
--- a/InvoiceReminder.Application/Interfaces/IUserAppService.cs
+++ b/InvoiceReminder.Application/Interfaces/IUserAppService.cs
@@ -7,4 +7,6 @@ namespace InvoiceReminder.Application.Interfaces;
public interface IUserAppService : IBaseAppService
{
Task> GetByEmailAsync(string value, CancellationToken cancellationToken = default);
+ Task> ValidateUserPasswordAsync(string email, string password,
+ CancellationToken cancellationToken = default);
}
diff --git a/InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs b/InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs
new file mode 100644
index 0000000..1823030
--- /dev/null
+++ b/InvoiceReminder.Application/Interfaces/IUserPasswordAppService.cs
@@ -0,0 +1,10 @@
+using InvoiceReminder.Application.ViewModels;
+using InvoiceReminder.Domain.Abstractions;
+using InvoiceReminder.Domain.Entities;
+
+namespace InvoiceReminder.Application.Interfaces;
+
+public interface IUserPasswordAppService : IBaseAppService
+{
+ Task> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/InvoiceReminder.Application/InvoiceReminder.Application.csproj b/InvoiceReminder.Application/InvoiceReminder.Application.csproj
index 90a6123..b2acb90 100644
--- a/InvoiceReminder.Application/InvoiceReminder.Application.csproj
+++ b/InvoiceReminder.Application/InvoiceReminder.Application.csproj
@@ -9,6 +9,7 @@
+
@@ -24,7 +25,6 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
diff --git a/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs b/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs
index 9725044..7bb5942 100644
--- a/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs
+++ b/InvoiceReminder.Application/ViewModels/EmailAuthTokenViewModel.cs
@@ -1,20 +1,27 @@
using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
namespace InvoiceReminder.Application.ViewModels;
+
public class EmailAuthTokenViewModel : ViewModelDefaults
{
[Required]
+ [JsonPropertyOrder(2)]
public Guid UserId { get; set; }
[Required]
+ [JsonPropertyOrder(3)]
public string AccessToken { get; set; }
[Required]
+ [JsonPropertyOrder(4)]
public string RefreshToken { get; set; }
[Required]
+ [JsonPropertyOrder(5)]
public DateTime AccessTokenExpiry { get; set; }
+ [JsonPropertyOrder(6)]
public bool IsStale => AccessTokenExpiry < DateTime.UtcNow;
public EmailAuthTokenViewModel()
diff --git a/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs b/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs
index bddb626..57ce860 100644
--- a/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs
+++ b/InvoiceReminder.Application/ViewModels/InvoiceViewModel.cs
@@ -1,24 +1,31 @@
using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
namespace InvoiceReminder.Application.ViewModels;
public class InvoiceViewModel : ViewModelDefaults
{
[Required]
+ [JsonPropertyOrder(2)]
public Guid UserId { get; set; }
[Required]
+ [JsonPropertyOrder(3)]
public string Bank { get; set; }
+ [JsonPropertyOrder(4)]
public string Beneficiary { get; set; }
[Required]
+ [JsonPropertyOrder(5)]
public decimal Amount { get; set; }
[Required]
+ [JsonPropertyOrder(6)]
public string Barcode { get; set; }
[Required]
+ [JsonPropertyOrder(7)]
public DateTime DueDate { get; set; }
public InvoiceViewModel()
diff --git a/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs b/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs
index c07390e..bc41af8 100644
--- a/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs
+++ b/InvoiceReminder.Application/ViewModels/JobScheduleViewModel.cs
@@ -1,12 +1,16 @@
using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
namespace InvoiceReminder.Application.ViewModels;
+
public class JobScheduleViewModel : ViewModelDefaults
{
[Required]
+ [JsonPropertyOrder(2)]
public Guid UserId { get; set; }
[Required]
+ [JsonPropertyOrder(3)]
public string CronExpression { get; set; }
public JobScheduleViewModel()
diff --git a/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs b/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs
index 4abfd62..a0e2ca6 100644
--- a/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs
+++ b/InvoiceReminder.Application/ViewModels/ScanEmailDefinitionViewModel.cs
@@ -1,25 +1,33 @@
using InvoiceReminder.Domain.Enums;
using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
namespace InvoiceReminder.Application.ViewModels;
+
public class ScanEmailDefinitionViewModel : ViewModelDefaults
{
[Required]
+ [JsonPropertyOrder(2)]
public Guid UserId { get; set; }
[Required]
+ [JsonPropertyOrder(3)]
public InvoiceType InvoiceType { get; set; }
[Required]
+ [JsonPropertyOrder(4)]
public string Beneficiary { get; set; }
[Required]
+ [JsonPropertyOrder(5)]
public string Description { get; set; }
[Required]
+ [JsonPropertyOrder(6)]
public string SenderEmailAddress { get; set; }
[Required]
+ [JsonPropertyOrder(7)]
public string AttachmentFileName { get; set; }
public ScanEmailDefinitionViewModel()
diff --git a/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs
new file mode 100644
index 0000000..2ec6aff
--- /dev/null
+++ b/InvoiceReminder.Application/ViewModels/UserPasswordViewModel.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace InvoiceReminder.Application.ViewModels;
+
+public class UserPasswordViewModel : ViewModelDefaults
+{
+ [JsonPropertyOrder(2)]
+ public Guid UserId { get; set; }
+
+ [Required]
+ [StringLength(100, MinimumLength = 6)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)]
+ public string PasswordHash { get; set; }
+
+ [JsonIgnore]
+ public string PasswordSalt { get; set; }
+}
diff --git a/InvoiceReminder.Application/ViewModels/UserViewModel.cs b/InvoiceReminder.Application/ViewModels/UserViewModel.cs
index d861f3b..6f3abce 100644
--- a/InvoiceReminder.Application/ViewModels/UserViewModel.cs
+++ b/InvoiceReminder.Application/ViewModels/UserViewModel.cs
@@ -1,26 +1,32 @@
using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
namespace InvoiceReminder.Application.ViewModels;
public class UserViewModel : ViewModelDefaults
{
+ [JsonPropertyOrder(2)]
public long TelegramChatId { get; set; }
[Required]
+ [JsonPropertyOrder(3)]
public string Name { get; set; }
[Required]
[EmailAddress]
+ [JsonPropertyOrder(4)]
public string Email { get; set; }
- [Required]
- [StringLength(100, MinimumLength = 6)]
- public string Password { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)]
+ public virtual UserPasswordViewModel UserPassword { get; set; }
+ [JsonPropertyOrder(5)]
public virtual ICollection Invoices { get; set; }
+ [JsonPropertyOrder(6)]
public virtual ICollection JobSchedules { get; set; }
+ [JsonPropertyOrder(7)]
public virtual ICollection ScanEmailDefinitions { get; set; }
public UserViewModel()
@@ -29,5 +35,6 @@ public UserViewModel()
Invoices = [];
JobSchedules = [];
ScanEmailDefinitions = [];
+ UserPassword = new();
}
}
diff --git a/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs b/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs
index 7997831..4cc4c95 100644
--- a/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs
+++ b/InvoiceReminder.Application/ViewModels/ViewModelDefaults.cs
@@ -1,8 +1,15 @@
+using System.Text.Json.Serialization;
+
namespace InvoiceReminder.Application.ViewModels;
public class ViewModelDefaults
{
+ [JsonPropertyOrder(1)]
public Guid Id { get; set; }
+
+ [JsonPropertyOrder(11)]
public DateTime CreatedAt { get; set; }
+
+ [JsonPropertyOrder(12)]
public DateTime UpdatedAt { get; set; }
}
diff --git a/InvoiceReminder.Authentication/Abstractions/UserClaims.cs b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs
new file mode 100644
index 0000000..7824f0f
--- /dev/null
+++ b/InvoiceReminder.Authentication/Abstractions/UserClaims.cs
@@ -0,0 +1,7 @@
+namespace InvoiceReminder.Authentication.Abstractions;
+
+public record UserClaims
+{
+ public required Guid Id { get; init; }
+ public required string Email { get; init; }
+}
diff --git a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs
index e57c29f..22ee393 100644
--- a/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs
+++ b/InvoiceReminder.Authentication/Extensions/StringHashExtension.cs
@@ -1,3 +1,4 @@
+using Konscious.Security.Cryptography;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
@@ -6,6 +7,50 @@ namespace InvoiceReminder.Authentication.Extensions;
public static class StringHashExtension
{
+ public static (string Hash, string Salt) HashPassword(this string inputString, int parallelismFactor = 2)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(inputString);
+
+ var salt = RandomNumberGenerator.GetBytes(16);
+
+ using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString))
+ {
+ Salt = salt,
+ DegreeOfParallelism = GetMaxDegreeOfParallelism(parallelismFactor),
+ Iterations = 4,
+ MemorySize = 1024 * 64
+ };
+
+ var hashBytes = argon2.GetBytes(32);
+ var hash = Convert.ToBase64String(hashBytes);
+ var saltBase64 = Convert.ToBase64String(salt);
+
+ return (hash, saltBase64);
+ }
+
+ public static bool VerifyPassword(this string inputString, string storedHash, string storedSalt, int parallelismFactor = 2)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(inputString);
+
+ var salt = Convert.FromBase64String(storedSalt);
+
+ using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(inputString))
+ {
+ Salt = salt,
+ DegreeOfParallelism = GetMaxDegreeOfParallelism(parallelismFactor),
+ Iterations = 4,
+ MemorySize = 1024 * 64
+ };
+
+ var hashBytes = argon2.GetBytes(32);
+ var hash = Convert.ToBase64String(hashBytes);
+
+ return CryptographicOperations.FixedTimeEquals(
+ Convert.FromBase64String(storedHash),
+ Convert.FromBase64String(hash)
+ );
+ }
+
public static string ToSHA256(this string inputString)
{
var bytes = Encoding.UTF8.GetBytes(inputString);
@@ -41,4 +86,9 @@ private static string GetStringFromHash(byte[] hash)
return result.ToString();
}
+
+ private static int GetMaxDegreeOfParallelism(int parallelismFactor)
+ {
+ return Math.Max(1, Environment.ProcessorCount / parallelismFactor);
+ }
}
diff --git a/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs b/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs
index f7e7c63..92888a1 100644
--- a/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs
+++ b/InvoiceReminder.Authentication/Interfaces/IJwtProvider.cs
@@ -1,9 +1,9 @@
-using InvoiceReminder.Application.ViewModels;
+using InvoiceReminder.Authentication.Abstractions;
using InvoiceReminder.Authentication.Jwt;
namespace InvoiceReminder.Authentication.Interfaces;
public interface IJwtProvider
{
- JwtObject Generate(UserViewModel user);
+ JwtObject Generate(UserClaims user);
}
diff --git a/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj b/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj
index 6e98d95..141bb16 100644
--- a/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj
+++ b/InvoiceReminder.Authentication/InvoiceReminder.Authentication.csproj
@@ -1,10 +1,7 @@
-
-
-
-
-
-
+
+
+
@@ -12,6 +9,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
diff --git a/InvoiceReminder.Authentication/Jwt/JwtProvider.cs b/InvoiceReminder.Authentication/Jwt/JwtProvider.cs
index e7cb5b8..8c57650 100644
--- a/InvoiceReminder.Authentication/Jwt/JwtProvider.cs
+++ b/InvoiceReminder.Authentication/Jwt/JwtProvider.cs
@@ -1,4 +1,4 @@
-using InvoiceReminder.Application.ViewModels;
+using InvoiceReminder.Authentication.Abstractions;
using InvoiceReminder.Authentication.Interfaces;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
@@ -20,14 +20,24 @@ public JwtProvider(IOptions jwtOptions)
_jwtOptions = jwtOptions.Value;
}
- public JwtObject Generate(UserViewModel user)
+ public JwtObject Generate(UserClaims user)
{
ArgumentNullException.ThrowIfNull(user);
+ if (user.Id == Guid.Empty)
+ {
+ throw new ArgumentException("User Id cannot be empty.", nameof(user));
+ }
+
+ if (string.IsNullOrWhiteSpace(user.Email))
+ {
+ throw new ArgumentException("User Email cannot be null or empty.", nameof(user));
+ }
+
var claims = new Claim[]
{
- new (JwtRegisteredClaimNames.Sub, user.Id.ToString()),
- new (JwtRegisteredClaimNames.Email, user.Email)
+ new (JwtRegisteredClaimNames.Sub, user.Id.ToString()),
+ new (JwtRegisteredClaimNames.Email, user.Email)
};
var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecretKey));
diff --git a/InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs b/InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs
new file mode 100644
index 0000000..e990407
--- /dev/null
+++ b/InvoiceReminder.Data/Interfaces/IUserPasswordRepository.cs
@@ -0,0 +1,8 @@
+using InvoiceReminder.Domain.Entities;
+
+namespace InvoiceReminder.Data.Interfaces;
+
+public interface IUserPasswordRepository : IBaseRepository
+{
+ Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.Designer.cs b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.Designer.cs
similarity index 79%
rename from InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.Designer.cs
rename to InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.Designer.cs
index 7a38156..b278148 100644
--- a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.Designer.cs
+++ b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.Designer.cs
@@ -12,7 +12,7 @@
namespace InvoiceReminder.Data.Migrations
{
[DbContext(typeof(CoreDbContext))]
- [Migration("20250930210104_Initial_Create")]
+ [Migration("20260109013522_Initial_Create")]
partial class Initial_Create
{
///
@@ -21,7 +21,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("invoice_reminder")
- .HasAnnotation("ProductVersion", "9.0.9")
+ .HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -40,12 +40,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("access_token");
b.Property("AccessTokenExpiry")
- .HasColumnType("timestamp")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("access_token_expiry");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
- .HasColumnType("timestamp")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property("NonceValue")
@@ -67,8 +66,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("token_provider");
b.Property("UpdatedAt")
- .ValueGeneratedOnAddOrUpdate()
- .HasColumnType("timestamp")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property("UserId")
@@ -111,17 +109,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("beneficiary");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property("DueDate")
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("due_date");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property("UserId")
@@ -143,8 +139,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("id");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property("CronExpression")
@@ -154,8 +149,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("cron_expression");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property("UserId")
@@ -189,8 +183,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("beneficiary");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property("Description")
@@ -210,8 +203,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("sender_email_address");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property("UserId")
@@ -233,8 +225,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("id");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property("Email")
@@ -249,12 +240,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnType("character varying(255)")
.HasColumnName("name");
- b.Property("Password")
- .IsRequired()
- .HasMaxLength(255)
- .HasColumnType("character varying(255)")
- .HasColumnName("password");
-
b.Property("TelegramChatId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
@@ -262,19 +247,57 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.HasColumnName("telegram_chat_id");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
- .HasColumnType("date")
+ .HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique()
- .HasDatabaseName("idx_user_email");
+ .HasDatabaseName("IX_user_email");
b.ToTable("user", "invoice_reminder");
});
+ modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("password_hash");
+
+ b.Property("PasswordSalt")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("password_salt");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("user_password", "invoice_reminder");
+ });
+
modelBuilder.Entity("InvoiceReminder.Domain.Entities.EmailAuthToken", b =>
{
b.HasOne("InvoiceReminder.Domain.Entities.User", null)
@@ -311,6 +334,15 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
.IsRequired();
});
+ modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b =>
+ {
+ b.HasOne("InvoiceReminder.Domain.Entities.User", null)
+ .WithOne("UserPassword")
+ .HasForeignKey("InvoiceReminder.Domain.Entities.UserPassword", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("InvoiceReminder.Domain.Entities.User", b =>
{
b.Navigation("EmailAuthTokens");
@@ -320,6 +352,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Navigation("JobSchedules");
b.Navigation("ScanEmailDefinitions");
+
+ b.Navigation("UserPassword");
});
#pragma warning restore 612, 618
}
diff --git a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.cs b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs
similarity index 76%
rename from InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.cs
rename to InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs
index 556f159..1f79ac0 100644
--- a/InvoiceReminder.Data/Migrations/20250930210104_Initial_Create.cs
+++ b/InvoiceReminder.Data/Migrations/20260109013522_Initial_Create.cs
@@ -2,6 +2,7 @@
using System.Diagnostics.CodeAnalysis;
#nullable disable
+#pragma warning disable S1192
namespace InvoiceReminder.Data.Migrations;
@@ -25,24 +26,22 @@ protected override void Up(MigrationBuilder migrationBuilder)
telegram_chat_id = table.Column(type: "bigint", nullable: false, defaultValue: 0L),
name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
email = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
- password = table.Column(type: "character varying(255)", maxLength: 255, nullable: false),
created_at = table.Column(type: "timestamp with time zone", nullable: false),
updated_at = table.Column(type: "timestamp with time zone", nullable: false)
},
- constraints: table => _ = table.PrimaryKey("PK_user", x => x.id));
+ constraints: table => table.PrimaryKey("PK_user", x => x.id));
_ = migrationBuilder.InsertData(
table: "user",
schema: "invoice_reminder",
- columns: ["id", "telegram_chat_id", "name", "email", "password", "created_at", "updated_at"],
+ columns: ["id", "telegram_chat_id", "name", "email", "created_at", "updated_at"],
values: new object[,]
{
{
"0d77a03d-ac35-480c-b409-a08133409c7c",
0,
"John Doe",
- "john.doe@notmail.com",
- "8D969EEF6ECAD3C29A3A629280E686CF0C3F5D5A86AFF3CA12020C923ADC6C92",
+ "john.doe@fakemail.com",
new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc)
},
@@ -50,8 +49,55 @@ protected override void Up(MigrationBuilder migrationBuilder)
"59918776-f6c6-4def-93b1-95d7a7717942",
0,
"Jane Doe",
- "jane.doe@notmail.com",
- "8D969EEF6ECAD3C29A3A629280E686CF0C3F5D5A86AFF3CA12020C923ADC6C92",
+ "jane.doe@fakemail.com",
+ new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc)
+ }
+ });
+
+ _ = migrationBuilder.CreateTable(
+ name: "user_password",
+ schema: "invoice_reminder",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ user_id = table.Column(type: "uuid", nullable: false),
+ password_hash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false),
+ password_salt = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ _ = table.PrimaryKey("PK_user_password", x => x.id);
+ _ = table.ForeignKey(
+ name: "FK_user_password_user_user_id",
+ column: x => x.user_id,
+ principalSchema: "invoice_reminder",
+ principalTable: "user",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ _ = migrationBuilder.InsertData(
+ table: "user_password",
+ schema: "invoice_reminder",
+ columns: ["id", "user_id", "password_hash", "password_salt", "created_at", "updated_at"],
+ values: new object[,]
+ {
+ {
+ "7ac11c2e-eb20-4eba-9aa2-b1ff7f66c534",
+ "0d77a03d-ac35-480c-b409-a08133409c7c",
+ "A1jP0sC5JeSdn9+FMlmf0hATixlmtPyKnTYSkyGq44I=",
+ "jGw9jaT87q29CxlBTTpTQw==",
+ new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc)
+ },
+ {
+ "ceb8ed4f-88ef-43c5-9ddd-2f3bd03dd01b",
+ "59918776-f6c6-4def-93b1-95d7a7717942",
+ "epSsrxJtxqrON9hTo9TONf7o4abblXl2E9hnGQojdNA=",
+ "Cj4bofNoOjj6aDwp4Sbq1w==",
new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2025, 05, 06, 0, 0, 0, DateTimeKind.Utc)
}
@@ -186,11 +232,18 @@ protected override void Up(MigrationBuilder migrationBuilder)
column: "user_id");
_ = migrationBuilder.CreateIndex(
- name: "idx_user_email",
+ name: "IX_user_email",
schema: "invoice_reminder",
table: "user",
column: "email",
unique: true);
+
+ _ = migrationBuilder.CreateIndex(
+ name: "IX_user_password_user_id",
+ schema: "invoice_reminder",
+ table: "user_password",
+ column: "user_id",
+ unique: true);
}
///
@@ -212,8 +265,13 @@ protected override void Down(MigrationBuilder migrationBuilder)
name: "scan_email_definition",
schema: "invoice_reminder");
+ _ = migrationBuilder.DropTable(
+ name: "user_password",
+ schema: "invoice_reminder");
+
_ = migrationBuilder.DropTable(
name: "user",
schema: "invoice_reminder");
}
}
+#pragma warning restore S1192
diff --git a/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs b/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs
index 0d9282f..92eb289 100644
--- a/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs
+++ b/InvoiceReminder.Data/Migrations/CoreDbContextModelSnapshot.cs
@@ -1,17 +1,15 @@
-//
+//
+using System;
using InvoiceReminder.Data.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-using System;
-using System.Diagnostics.CodeAnalysis;
#nullable disable
namespace InvoiceReminder.Data.Migrations
{
- [ExcludeFromCodeCoverage]
[DbContext(typeof(CoreDbContext))]
partial class CoreDbContextModelSnapshot : ModelSnapshot
{
@@ -43,7 +41,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("access_token_expiry");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -66,7 +63,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("token_provider");
b.Property("UpdatedAt")
- .ValueGeneratedOnAddOrUpdate()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -110,7 +106,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("beneficiary");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -119,7 +114,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("due_date");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -142,7 +136,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("id");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -153,7 +146,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("cron_expression");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -188,7 +180,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("beneficiary");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -209,7 +200,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("sender_email_address");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -232,7 +222,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("id");
b.Property("CreatedAt")
- .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -248,12 +237,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("character varying(255)")
.HasColumnName("name");
- b.Property("Password")
- .IsRequired()
- .HasMaxLength(255)
- .HasColumnType("character varying(255)")
- .HasColumnName("password");
-
b.Property("TelegramChatId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
@@ -261,7 +244,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnName("telegram_chat_id");
b.Property("UpdatedAt")
- .ValueGeneratedOnUpdate()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@@ -269,11 +251,50 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Email")
.IsUnique()
- .HasDatabaseName("idx_user_email");
+ .HasDatabaseName("IX_user_email");
b.ToTable("user", "invoice_reminder");
});
+ modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("password_hash");
+
+ b.Property("PasswordSalt")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("password_salt");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("user_password", "invoice_reminder");
+ });
+
modelBuilder.Entity("InvoiceReminder.Domain.Entities.EmailAuthToken", b =>
{
b.HasOne("InvoiceReminder.Domain.Entities.User", null)
@@ -310,6 +331,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.IsRequired();
});
+ modelBuilder.Entity("InvoiceReminder.Domain.Entities.UserPassword", b =>
+ {
+ b.HasOne("InvoiceReminder.Domain.Entities.User", null)
+ .WithOne("UserPassword")
+ .HasForeignKey("InvoiceReminder.Domain.Entities.UserPassword", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("InvoiceReminder.Domain.Entities.User", b =>
{
b.Navigation("EmailAuthTokens");
@@ -319,6 +349,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("JobSchedules");
b.Navigation("ScanEmailDefinitions");
+
+ b.Navigation("UserPassword");
});
#pragma warning restore 612, 618
}
diff --git a/InvoiceReminder.Data/Persistence/CoreDbContext.cs b/InvoiceReminder.Data/Persistence/CoreDbContext.cs
index bdd6c2e..9e4e972 100644
--- a/InvoiceReminder.Data/Persistence/CoreDbContext.cs
+++ b/InvoiceReminder.Data/Persistence/CoreDbContext.cs
@@ -8,6 +8,7 @@ public class CoreDbContext : DbContext
public DbSet Users => Set();
public DbSet Invoices => Set();
public DbSet Schedules => Set();
+ public DbSet UserPasswords => Set();
public DbSet EmailAuthTokens => Set();
public DbSet ScanEmailDefinitions => Set();
diff --git a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs
index d870d57..0c43c73 100644
--- a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs
+++ b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserConfig.cs
@@ -16,7 +16,7 @@ public void Configure(EntityTypeBuilder builder)
_ = builder.HasKey(x => x.Id);
_ = builder.HasIndex(x => x.Email)
- .HasDatabaseName("idx_user_email")
+ .HasDatabaseName("IX_user_email")
.IsUnique();
_ = builder.Property(x => x.Id)
@@ -41,11 +41,6 @@ public void Configure(EntityTypeBuilder builder)
.HasMaxLength(255)
.IsRequired();
- _ = builder.Property(x => x.Password)
- .HasColumnName("password")
- .HasMaxLength(255)
- .IsRequired();
-
_ = builder.Property(x => x.CreatedAt)
.HasColumnName("created_at")
.HasColumnType("timestamp with time zone")
diff --git a/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs
new file mode 100644
index 0000000..3e06a13
--- /dev/null
+++ b/InvoiceReminder.Data/Persistence/EntitiesConfig/UserPasswordConfig.cs
@@ -0,0 +1,58 @@
+using InvoiceReminder.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("InvoiceReminder.UnitTests.Infrastructure")]
+
+namespace InvoiceReminder.Data.Persistence.EntitiesConfig;
+
+internal class UserPasswordConfig : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ _ = builder.ToTable("user_password");
+
+ _ = builder.HasKey(x => x.Id);
+
+ _ = builder.HasIndex(x => x.UserId)
+ .IsUnique();
+
+ _ = builder.Property(x => x.Id)
+ .HasColumnName("id")
+ .HasColumnType("uuid")
+ .ValueGeneratedOnAdd()
+ .IsRequired();
+
+ _ = builder.Property(x => x.UserId)
+ .HasColumnName("user_id")
+ .HasColumnType("uuid")
+ .IsRequired();
+
+ _ = builder.Property(x => x.PasswordHash)
+ .HasColumnName("password_hash")
+ .HasMaxLength(512)
+ .IsRequired();
+
+ _ = builder.Property(x => x.PasswordSalt)
+ .HasColumnName("password_salt")
+ .HasMaxLength(256)
+ .IsRequired();
+
+ _ = builder.Property(x => x.CreatedAt)
+ .HasColumnName("created_at")
+ .HasColumnType("timestamp with time zone")
+ .IsRequired();
+
+ _ = builder.Property(x => x.UpdatedAt)
+ .HasColumnName("updated_at")
+ .HasColumnType("timestamp with time zone")
+ .IsRequired();
+
+ _ = builder.HasOne()
+ .WithOne(x => x.UserPassword)
+ .HasForeignKey(x => x.UserId)
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ }
+}
diff --git a/InvoiceReminder.Data/Repository/UserPasswordRepository.cs b/InvoiceReminder.Data/Repository/UserPasswordRepository.cs
new file mode 100644
index 0000000..9828bd5
--- /dev/null
+++ b/InvoiceReminder.Data/Repository/UserPasswordRepository.cs
@@ -0,0 +1,62 @@
+using Dapper;
+using InvoiceReminder.Data.Exceptions;
+using InvoiceReminder.Data.Interfaces;
+using InvoiceReminder.Data.Persistence;
+using InvoiceReminder.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using System.Data;
+
+namespace InvoiceReminder.Data.Repository;
+
+public class UserPasswordRepository : BaseRepository, IUserPasswordRepository
+{
+ private readonly IDbConnection _dbConnection;
+ private readonly ILogger _logger;
+ private const string LogExceptionMessage = "{ContextualInfo} - Exception: {Message}";
+
+ public UserPasswordRepository(CoreDbContext dbContext, ILogger logger) : base(dbContext)
+ {
+ _dbConnection = dbContext.Database.GetDbConnection();
+ _logger = logger;
+ }
+
+ public async Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
+ {
+ UserPassword userPassword = default;
+
+ try
+ {
+ var query = @"select * from invoice_reminder.user_password up where up.user_id = @userid";
+ var command = new CommandDefinition(query, new { userId }, cancellationToken: cancellationToken);
+
+ userPassword = await _dbConnection.QueryFirstOrDefaultAsync(command);
+ }
+ catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
+ {
+ var method = $"{nameof(UserPasswordRepository)}.{nameof(GetByUserIdAsync)}";
+ var contextualInfo = $"Method {method} execution was interrupted by a CancellationToken Request...";
+
+ if (_logger.IsEnabled(LogLevel.Warning))
+ {
+ _logger.LogWarning(ex, LogExceptionMessage, contextualInfo, ex.Message);
+ }
+
+ throw new OperationCanceledException(contextualInfo, ex, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ var method = $"{nameof(UserPasswordRepository)}.{nameof(GetByUserIdAsync)}";
+ var contextualInfo = $"Exception raised while querying DB >> {method}(...)";
+
+ if (_logger.IsEnabled(LogLevel.Error))
+ {
+ _logger.LogError(ex, LogExceptionMessage, contextualInfo, ex.Message);
+ }
+
+ throw new DataLayerException(contextualInfo, ex);
+ }
+
+ return userPassword;
+ }
+}
diff --git a/InvoiceReminder.Data/Repository/UserRepository.cs b/InvoiceReminder.Data/Repository/UserRepository.cs
index 245d09e..0c2b93e 100644
--- a/InvoiceReminder.Data/Repository/UserRepository.cs
+++ b/InvoiceReminder.Data/Repository/UserRepository.cs
@@ -27,6 +27,8 @@ left join invoice_reminder.invoice i
on u.id = i.user_id
left join invoice_reminder.job_schedule js
on u.id = js.user_id
+ left join invoice_reminder.user_password up
+ on u.id = up.user_id
left join invoice_reminder.email_auth_token eat
on u.id = eat.user_id
left join invoice_reminder.scan_email_definition sed
@@ -43,13 +45,14 @@ public async Task GetByEmailAsync(string value, CancellationToken cancella
var filter = "where u.email = @value";
var command = new CommandDefinition($"{_query} {filter}", new { value }, cancellationToken: cancellationToken);
- _ = await _dbConnection.QueryAsync
- (command, (user, invoice, jobschedule, emailAuthToken, scanEmailDefinition) =>
+ _ = await _dbConnection.QueryAsync
+ (command, (user, invoice, jobschedule, userPassword, emailAuthToken, scanEmailDefinition) =>
{
var parameters = new UserParameters
{
Invoice = invoice,
JobSchedule = jobschedule,
+ UserPassword = userPassword,
EmailAuthToken = emailAuthToken,
ScanEmailDefinition = scanEmailDefinition
};
@@ -97,13 +100,14 @@ public override async Task GetByIdAsync(Guid id, CancellationToken cancell
var filter = "where u.id = @id";
var command = new CommandDefinition($"{_query} {filter}", new { id }, cancellationToken: cancellationToken);
- _ = await _dbConnection.QueryAsync(
- command, (user, invoice, jobschedule, emailAuthToken, scanEmailDefinition) =>
+ _ = await _dbConnection.QueryAsync(
+ command, (user, invoice, jobschedule, userPassword, emailAuthToken, scanEmailDefinition) =>
{
var parameters = new UserParameters
{
Invoice = invoice,
JobSchedule = jobschedule,
+ UserPassword = userPassword,
EmailAuthToken = emailAuthToken,
ScanEmailDefinition = scanEmailDefinition
};
diff --git a/InvoiceReminder.Domain/Entities/User.cs b/InvoiceReminder.Domain/Entities/User.cs
index fbf08a9..f37b62b 100644
--- a/InvoiceReminder.Domain/Entities/User.cs
+++ b/InvoiceReminder.Domain/Entities/User.cs
@@ -5,7 +5,7 @@ public class User : EntityDefaults
public long TelegramChatId { get; set; }
public string Name { get; set; }
public string Email { get; set; }
- public string Password { get; set; }
+ public virtual UserPassword UserPassword { get; set; }
public virtual ICollection EmailAuthTokens { get; set; }
public virtual ICollection Invoices { get; set; }
public virtual ICollection JobSchedules { get; set; }
diff --git a/InvoiceReminder.Domain/Entities/UserPassword.cs b/InvoiceReminder.Domain/Entities/UserPassword.cs
new file mode 100644
index 0000000..5d2439d
--- /dev/null
+++ b/InvoiceReminder.Domain/Entities/UserPassword.cs
@@ -0,0 +1,8 @@
+namespace InvoiceReminder.Domain.Entities;
+
+public class UserPassword : EntityDefaults
+{
+ public Guid UserId { get; set; }
+ public string PasswordHash { get; set; }
+ public string PasswordSalt { get; set; }
+}
diff --git a/InvoiceReminder.Domain/Extensions/UserExtensions.cs b/InvoiceReminder.Domain/Extensions/UserExtensions.cs
index abf484f..a407321 100644
--- a/InvoiceReminder.Domain/Extensions/UserExtensions.cs
+++ b/InvoiceReminder.Domain/Extensions/UserExtensions.cs
@@ -11,6 +11,7 @@ public static IDictionary Handle(
{
if (!result.TryGetValue(user.Id, out var existingUser))
{
+ user.UserPassword = parameters.UserPassword;
parameters.Invoice.AddIfNotExists(user.Invoices);
parameters.JobSchedule.AddIfNotExists(user.JobSchedules);
parameters.EmailAuthToken.AddIfNotExists(user.EmailAuthTokens);
@@ -20,6 +21,7 @@ public static IDictionary Handle(
}
else
{
+ existingUser.UserPassword = parameters.UserPassword;
parameters.Invoice.AddIfNotExists(existingUser.Invoices);
parameters.JobSchedule.AddIfNotExists(existingUser.JobSchedules);
parameters.EmailAuthToken.AddIfNotExists(existingUser.EmailAuthTokens);
diff --git a/InvoiceReminder.Domain/Extensions/UserParameters.cs b/InvoiceReminder.Domain/Extensions/UserParameters.cs
index 5be54f3..bb15867 100644
--- a/InvoiceReminder.Domain/Extensions/UserParameters.cs
+++ b/InvoiceReminder.Domain/Extensions/UserParameters.cs
@@ -6,6 +6,7 @@ public record UserParameters
{
public Invoice Invoice { get; set; }
public JobSchedule JobSchedule { get; set; }
+ public UserPassword UserPassword { get; set; }
public ScanEmailDefinition ScanEmailDefinition { get; set; }
public EmailAuthToken EmailAuthToken { get; set; }
}
diff --git a/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs b/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs
index 522e591..721b716 100644
--- a/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs
+++ b/InvoiceReminder.Domain/Services/Configuration/ConfigurationService.cs
@@ -61,4 +61,9 @@ public T GetSection(string sectionName, T defaultValue) where T : class
{
return GetSection(sectionName) ?? defaultValue;
}
+
+ public T GetValue(string key) where T : struct
+ {
+ return _configuration.GetValue(key);
+ }
}
diff --git a/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs b/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs
index 37cba82..69e1c96 100644
--- a/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs
+++ b/InvoiceReminder.Domain/Services/Configuration/IConfigurationService.cs
@@ -9,4 +9,5 @@ public interface IConfigurationService
string GetSecret(string key, string secretName, string defaultValue);
T GetSection(string sectionName) where T : class;
T GetSection(string sectionName, T defaultValue) where T : class;
+ T GetValue(string key) where T : struct;
}
diff --git a/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs b/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs
index a2d582c..9c3fd1e 100644
--- a/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs
+++ b/InvoiceReminder.IntegrationTests/Data/Repository/BaseRepositoryIntegrationTests.cs
@@ -590,7 +590,7 @@ public void Dispose_Should_Be_Safe_To_Call_Multiple_Times()
#region Helper Methods
- private BaseRepository CreateFreshRepository() where T : class
+ private static BaseRepository CreateFreshRepository() where T : class
{
var options = new DbContextOptionsBuilder()
.UseNpgsql(DatabaseFixture.ConnectionString)
diff --git a/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs b/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs
index 191de0b..add15b8 100644
--- a/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs
+++ b/InvoiceReminder.IntegrationTests/Data/Repository/UnitOfWorkIntegrationTests.cs
@@ -48,8 +48,7 @@ private static Faker UserFaker()
.RuleFor(u => u.Id, _ => Guid.NewGuid())
.RuleFor(u => u.TelegramChatId, f => f.Random.Long(100000000, long.MaxValue))
.RuleFor(u => u.Name, f => f.Person.FullName)
- .RuleFor(u => u.Email, f => f.Internet.Email())
- .RuleFor(u => u.Password, f => f.Internet.Password(length: 16, memorable: false));
+ .RuleFor(u => u.Email, f => f.Internet.Email());
}
#endregion
diff --git a/InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs b/InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs
new file mode 100644
index 0000000..e6ae894
--- /dev/null
+++ b/InvoiceReminder.IntegrationTests/Data/Repository/UserPasswordRepositoryIntegrationTests.cs
@@ -0,0 +1,248 @@
+using InvoiceReminder.Data.Exceptions;
+using InvoiceReminder.Data.Interfaces;
+using InvoiceReminder.Data.Persistence;
+using InvoiceReminder.Data.Repository;
+using InvoiceReminder.Domain.Entities;
+using InvoiceReminder.IntegrationTests.Data.ContainerSetup;
+using InvoiceReminder.IntegrationTests.Data.Utils;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Shouldly;
+
+namespace InvoiceReminder.IntegrationTests.Data.Repository;
+
+[TestClass]
+public sealed class UserPasswordRepositoryIntegrationTests
+{
+ private readonly CoreDbContext _dbContext;
+ private readonly ILogger _repositoryLogger;
+ private readonly ILogger _unitOfWorkLogger;
+ private readonly UserPasswordRepository _repository;
+ private readonly UnitOfWork _unitOfWork;
+
+ public TestContext TestContext { get; set; }
+
+ public UserPasswordRepositoryIntegrationTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(DatabaseFixture.ConnectionString)
+ .Options;
+
+ _dbContext = new CoreDbContext(options);
+ _repositoryLogger = Substitute.For>();
+ _unitOfWorkLogger = Substitute.For>();
+ _repository = new UserPasswordRepository(_dbContext, _repositoryLogger);
+ _unitOfWork = new UnitOfWork(_dbContext, _unitOfWorkLogger);
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ _unitOfWork?.Dispose();
+ _repository?.Dispose();
+ _dbContext?.Dispose();
+ }
+
+ [TestMethod]
+ public void UserPasswordRepository_ShouldBeAssignableToItsInterface_And_GenericInterface_And_GenericRepository()
+ {
+ // Arrange && Act
+ var repository = new UserPasswordRepository(_dbContext, _repositoryLogger);
+
+ // Assert
+ repository.ShouldSatisfyAllConditions(() =>
+ {
+ _ = repository.ShouldBeAssignableTo();
+ _ = repository.ShouldBeAssignableTo>();
+ _ = repository.ShouldBeAssignableTo>();
+
+ _ = repository.ShouldNotBeNull();
+ _ = repository.ShouldBeOfType();
+ });
+ }
+
+ #region GetByIdAsync Tests
+
+ [TestMethod]
+ public async Task GetByIdAsync_Should_Return_UserPassword_By_Id()
+ {
+ // Arrange
+ var userPassword = await CreateAndSaveUserPasswordAsync();
+
+ // Act
+ var result = await _repository.GetByIdAsync(userPassword.Id, TestContext.CancellationToken);
+
+ // Assert
+ result.ShouldSatisfyAllConditions(() =>
+ {
+ _ = result.ShouldNotBeNull();
+ _ = result.ShouldBeOfType();
+ result.Id.ShouldBe(userPassword.Id);
+ });
+ }
+
+ [TestMethod]
+ public async Task GetByIdAsync_Should_Return_Null_For_NonExistent_UserPassword()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act
+ var result = await _repository.GetByIdAsync(nonExistentId, TestContext.CancellationToken);
+
+ // Assert
+ result.ShouldBeNull();
+ }
+
+ [TestMethod]
+ public async Task GetByIdAsync_Should_Throw_Exception_On_Database_Error()
+ {
+ // Arrange
+ var disposedContext = new CoreDbContext(new DbContextOptionsBuilder()
+ .UseNpgsql(DatabaseFixture.ConnectionString)
+ .Options);
+
+ var logger = Substitute.For>();
+
+ var repository = new UserPasswordRepository(disposedContext, logger);
+
+ // Act & Assert
+ await disposedContext.DisposeAsync();
+
+ _ = await Should.ThrowAsync(
+ async () => await repository.GetByIdAsync(Guid.NewGuid(), TestContext.CancellationToken)
+ );
+ }
+
+ #endregion
+
+ #region GetByUserIdAsync Tests
+
+ [TestMethod]
+ public async Task GetByUserIdAsync_Should_Return_UserPassword_By_UserId()
+ {
+ // Arrange
+ var userPassword = await CreateAndSaveUserPasswordAsync();
+
+ // Act
+ var result = await _repository.GetByUserIdAsync(userPassword.UserId, TestContext.CancellationToken);
+
+ // Assert
+ result.ShouldSatisfyAllConditions(() =>
+ {
+ _ = result.ShouldNotBeNull();
+ _ = result.ShouldBeOfType();
+ result.UserId.ShouldBe(userPassword.UserId);
+ result.PasswordHash.ShouldBe(userPassword.PasswordHash);
+ result.PasswordSalt.ShouldBe(userPassword.PasswordSalt);
+ });
+ }
+
+ [TestMethod]
+ public async Task GetByUserIdAsync_Should_Return_Null_For_NonExistent_UserId()
+ {
+ // Arrange
+ var nonExistentUserId = Guid.NewGuid();
+
+ // Act
+ var result = await _repository.GetByUserIdAsync(nonExistentUserId, TestContext.CancellationToken);
+
+ // Assert
+ result.ShouldBeNull();
+ }
+
+ [TestMethod]
+ public async Task GetByUserIdAsync_Should_Throw_Exception_On_Database_Error()
+ {
+ // Arrange
+ var disposedContext = new CoreDbContext(new DbContextOptionsBuilder()
+ .UseNpgsql(DatabaseFixture.ConnectionString)
+ .Options);
+
+ var logger = Substitute.For>();
+
+ var repository = new UserPasswordRepository(disposedContext, logger);
+
+ _ = logger.IsEnabled(Arg.Any()).Returns(true);
+
+ // Act & Assert
+ await disposedContext.DisposeAsync();
+
+ _ = await Should.ThrowAsync(
+ async () => await repository.GetByUserIdAsync(Guid.NewGuid(), TestContext.CancellationToken)
+ );
+
+ logger.Received(1).Log(
+ LogLevel.Error,
+ Arg.Any(),
+ Arg.Any