Skip to content

Commit

Permalink
wip: Implement user registration
Browse files Browse the repository at this point in the history
  • Loading branch information
GenjiruSUchiwa committed May 9, 2024
1 parent b5c3889 commit 443d557
Show file tree
Hide file tree
Showing 41 changed files with 1,569 additions and 29 deletions.
2 changes: 2 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ dotnet_naming_style.s_camelcase.capitalization = camel_case

# Custom configurations

dotnet_diagnostic.CS1591.severity = none

# SA1600: Elements should be documented
dotnet_diagnostic.SA1600.severity = none

Expand Down
8 changes: 1 addition & 7 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@
<CSharpier_Check>true</CSharpier_Check>
<CSharpier_FrameworkVersion>net8.0</CSharpier_FrameworkVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>


</Project>
19 changes: 14 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@
</PropertyGroup>
<ItemGroup>
<!-- Analyzers -->
<PackageVersion Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="CSharpier.MsBuild" Version="0.28.2" />
<PackageVersion Include="ErrorOr" Version="2.0.1" />
<PackageVersion Include="FluentEmail.Core" Version="3.0.2" />
<PackageVersion Include="FluentEmail.Razor" Version="3.0.2" />
<PackageVersion Include="FluentEmail.Smtp" Version="3.0.2" />
<PackageVersion Include="MediatR" Version="12.2.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.4.0" />

<PackageVersion Include="coverlet.collector" Version="6.0.0"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0"/>
<PackageVersion Include="xunit" Version="2.5.3"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3"/>
<PackageVersion Include="coverlet.collector" Version="6.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="xunit" Version="2.5.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using MediatR;
using PlaceApi.Domain.Authentication.Entities;

namespace PlaceApi.Application.Authentication.Notifications.Confirmation;

public record SendConfirmationEmail(ApplicationUser User, string Email, bool IsChange = false)
: INotification;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using PlaceApi.Domain.Authentication.Entities;

namespace PlaceApi.Application.Authentication.Notifications.Confirmation;

public class SendConfirmationEmailHandler(
UserManager<ApplicationUser> userManager,
IHttpContextAccessor httpContextAccessor,
LinkGenerator linkGenerator,
IEmailSender<ApplicationUser> emailSender
) : INotificationHandler<SendConfirmationEmail>
{
public async Task Handle(
SendConfirmationEmail notification,
CancellationToken cancellationToken
)
{
string code = notification.IsChange
? await userManager.GenerateChangeEmailTokenAsync(notification.User, notification.Email)
: await userManager.GenerateEmailConfirmationTokenAsync(notification.User);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));

string userId = await userManager.GetUserIdAsync(notification.User);
RouteValueDictionary routeValues = new() { ["userId"] = userId, ["code"] = code };

if (notification.IsChange)
{
routeValues.Add("changedEmail", notification.Email);
}

var confirmEmailEndpoint = linkGenerator.GetUriByName(
httpContextAccessor.HttpContext!,
"ConfirmEmail",
routeValues
);
string confirmEmailUrl =
confirmEmailEndpoint
?? throw new NotSupportedException(
$"Could not find endpoint named '{confirmEmailEndpoint}'."
);

await emailSender.SendConfirmationLinkAsync(
notification.User,
notification.Email,
confirmEmailUrl
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;

namespace PlaceApi.Application.Authentication.Register;

public record RegisterCommand(string UserName, string Email, string Password)
: IRequest<Results<Ok<RegisterResult>, ValidationProblem>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using PlaceApi.Application.Authentication.Notifications.Confirmation;
using PlaceApi.Domain.Authentication.Entities;

namespace PlaceApi.Application.Authentication.Register;

public class RegisterCommandHandler(
UserManager<ApplicationUser> userManager,
IUserStore<ApplicationUser> userStore,
IPublisher publisher
) : IRequestHandler<RegisterCommand, Results<Ok<RegisterResult>, ValidationProblem>>
{
private static readonly EmailAddressAttribute EmailAddressAttribute = new();

public async Task<Results<Ok<RegisterResult>, ValidationProblem>> Handle(
RegisterCommand request,
CancellationToken cancellationToken
)
{
if (!userManager.SupportsUserEmail)
{
throw new NotSupportedException(
$"{nameof(RegisterCommandHandler)} requires a user store with email support."
);
}

if (string.IsNullOrEmpty(request.Email) || !EmailAddressAttribute.IsValid(request.Email))
{
return CreateValidationProblem(
IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(request.Email))
);
}

ApplicationUser user = new();
IUserEmailStore<ApplicationUser> emailStore = (IUserEmailStore<ApplicationUser>)userStore;
await userStore.SetUserNameAsync(user, request.UserName, CancellationToken.None);
await emailStore.SetEmailAsync(user, request.Email, CancellationToken.None);
IdentityResult result = await userManager.CreateAsync(user, request.Password);

if (!result.Succeeded)
{
return CreateValidationProblem(result);
}

var registered = await userManager.FindByEmailAsync(request.Email);

await publisher.Publish(
new SendConfirmationEmail(registered!, registered!.Email!),
cancellationToken
);

return TypedResults.Ok(new RegisterResult(registered.Id, registered.Email!));
}

private static ValidationProblem CreateValidationProblem(IdentityResult result)
{
Debug.Assert(!result.Succeeded);
Dictionary<string, string[]> errorDictionary = new(1);

foreach (IdentityError error in result.Errors)
{
string[] newDescriptions;

if (errorDictionary.TryGetValue(error.Code, out string[]? descriptions))
{
newDescriptions = new string[descriptions.Length + 1];
Array.Copy(descriptions, newDescriptions, descriptions.Length);
newDescriptions[descriptions.Length] = error.Description;
}
else
{
newDescriptions = [error.Description];
}

errorDictionary[error.Code] = newDescriptions;
}

return TypedResults.ValidationProblem(errorDictionary);
}
}
12 changes: 12 additions & 0 deletions src/PlaceApi.Application/Authentication/Register/RegisterErrors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using ErrorOr;

namespace PlaceApi.Application.Authentication.Register;

public static class RegisterErrors
{
public static Error InvalidEmail { get; } =
Error.Validation(
code: nameof(InvalidEmail),
description: "The email provided is not a valid. "
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using System;

namespace PlaceApi.Application.Authentication.Register;

public record RegisterResult(string UserId, string Email);
8 changes: 8 additions & 0 deletions src/PlaceApi.Application/ConfirmEmail/ConfirmEmailCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using MediatR;

namespace PlaceApi.Application.ConfirmEmail;

using ErrorOr;

public record ConfirmEmailCommand(string UserId, string Code, string? ChangedEmail)
: IRequest<ErrorOr<bool>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ErrorOr;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.WebUtilities;
using PlaceApi.Domain.Authentication.Entities;

namespace PlaceApi.Application.ConfirmEmail;

public class ConfirmEmailCommandHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<ConfirmEmailCommand, ErrorOr<bool>>
{
public async Task<ErrorOr<bool>> Handle(
ConfirmEmailCommand request,
CancellationToken cancellationToken
)
{
var code = request.Code;

if (await userManager.FindByIdAsync(request.UserId) is not { } user)
{
return Error.Unauthorized();
}

try
{
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
}
catch (FormatException)
{
return Error.Unauthorized();
}

IdentityResult result;

if (string.IsNullOrEmpty(request.ChangedEmail))
{
result = await userManager.ConfirmEmailAsync(user, code);
}
else
{
result = await userManager.ChangeEmailAsync(user, request.ChangedEmail, code);

if (result.Succeeded)
{
result = await userManager.SetUserNameAsync(user, request.ChangedEmail);
}
}

if (!result.Succeeded)
{
return Error.Unauthorized();
}

return true;
}
}
15 changes: 15 additions & 0 deletions src/PlaceApi.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;

namespace PlaceApi.Application;

public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddMediatR(options =>
{
options.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
});
return services;
}
}
8 changes: 8 additions & 0 deletions src/PlaceApi.Application/PlaceApi.Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>

Expand All @@ -10,6 +11,13 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ErrorOr" />
<PackageReference Include="MediatR" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PlaceApi.Domain\PlaceApi.Domain.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Microsoft.AspNetCore.Identity;

namespace PlaceApi.Domain.Authentication.Entities;

/// <inheritdoc />
public class ApplicationUser : IdentityUser;
1 change: 1 addition & 0 deletions src/PlaceApi.Domain/PlaceApi.Domain.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
</ItemGroup>
</Project>
14 changes: 14 additions & 0 deletions src/PlaceApi.Infrastructure/Authentication/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using PlaceApi.Infrastructure.Authentication.Persistence;

namespace PlaceApi.Infrastructure.Authentication;

public static class DependencyInjection
{
public static IServiceCollection AddAuth(this IServiceCollection services)
{
services.AddAuthenticationPersistence();

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using PlaceApi.Domain.Authentication.Entities;

namespace PlaceApi.Infrastructure.Authentication.Persistence;

public class AuthenticationDbContext : IdentityDbContext<ApplicationUser>
{
public AuthenticationDbContext(DbContextOptions<AuthenticationDbContext> options)
: base(options) { }
}
Loading

0 comments on commit 443d557

Please sign in to comment.