Service refactor (#153)

* remove email out of register service

* Update auth service, move JWT handling out of controller

* add docker config for service auth test

* Update mock email system

* Format: ./src/Core/Service

* Refactor authentication payloads and services for registration and login processes

* Format: src/Core/API, src/Core/Service
This commit is contained in:
Aaron Po
2026-02-16 15:12:59 -05:00
committed by GitHub
parent 0d52c937ce
commit 2cad88e3f6
31 changed files with 762 additions and 484 deletions

View File

@@ -4,5 +4,5 @@ namespace Service.Auth;
public interface ILoginService
{
Task<UserAccount> LoginAsync(string username, string password);
Task<LoginServiceReturn> LoginAsync(string username, string password);
}

View File

@@ -2,7 +2,38 @@ using Domain.Entities;
namespace Service.Auth;
public record RegisterServiceReturn
{
public bool IsAuthenticated { get; init; } = false;
public bool EmailSent { get; init; } = false;
public UserAccount UserAccount { get; init; }
public string AccessToken { get; init; } = string.Empty;
public string RefreshToken { get; init; } = string.Empty;
public RegisterServiceReturn(
UserAccount userAccount,
string accessToken,
string refreshToken,
bool emailSent
)
{
IsAuthenticated = true;
EmailSent = emailSent;
UserAccount = userAccount;
AccessToken = accessToken;
RefreshToken = refreshToken;
}
public RegisterServiceReturn(UserAccount userAccount)
{
UserAccount = userAccount;
}
}
public interface IRegisterService
{
Task<UserAccount> RegisterAsync(UserAccount userAccount, string password);
Task<RegisterServiceReturn> RegisterAsync(
UserAccount userAccount,
string password
);
}

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Infrastructure.Jwt;
namespace Service.Auth;
public interface ITokenService
{
public string GenerateAccessToken(UserAccount user);
public string GenerateRefreshToken(UserAccount user);
}
public class TokenService(ITokenInfrastructure tokenInfrastructure)
: ITokenService
{
public string GenerateAccessToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
);
}
public string GenerateRefreshToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddDays(21);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
);
}
}

View File

@@ -5,30 +5,42 @@ using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public class LoginService(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : ILoginService
{
public async Task<UserAccount> LoginAsync(string username, string password)
public async Task<LoginServiceReturn> LoginAsync(
string username,
string password
)
{
// Attempt lookup by username
var user = await authRepo.GetUserByUsernameAsync(username);
// the user was not found
if (user is null)
throw new UnauthorizedException("Invalid username or password.");
var user =
await authRepo.GetUserByUsernameAsync(username)
?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null)
throw new UnauthorizedException("Invalid username or password.");
var activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(
user.UserAccountId
)
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password.");
return user;
string accessToken = tokenService.GenerateAccessToken(user);
string refreshToken = tokenService.GenerateRefreshToken(user);
return new LoginServiceReturn(user, refreshToken, accessToken);
}
}

View File

@@ -4,28 +4,40 @@ using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Microsoft.Extensions.Logging;
using Service.Emails;
namespace Service.Auth;
public class RegisterService(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
IEmailProvider emailProvider,
IEmailTemplateProvider emailTemplateProvider
ITokenService tokenService,
IEmailService emailService
) : IRegisterService
{
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
private async Task ValidateUserDoesNotExist(UserAccount userAccount)
{
// Check if user already exists
var existingUsername = await authRepo.GetUserByUsernameAsync(userAccount.Username);
var existingEmail = await authRepo.GetUserByEmailAsync(userAccount.Email);
var existingUsername = await authRepo.GetUserByUsernameAsync(
userAccount.Username
);
var existingEmail = await authRepo.GetUserByEmailAsync(
userAccount.Email
);
if (existingUsername != null || existingEmail != null)
{
throw new ConflictException("Username or email already exists");
}
}
public async Task<RegisterServiceReturn> RegisterAsync(
UserAccount userAccount,
string password
)
{
await ValidateUserDoesNotExist(userAccount);
// password hashing
var hashed = passwordInfrastructure.Hash(password);
@@ -36,26 +48,41 @@ public class RegisterService(
userAccount.LastName,
userAccount.Email,
userAccount.DateOfBirth,
hashed);
// Generate confirmation link (TODO: implement proper token-based confirmation)
var confirmationLink = $"https://thebiergarten.app/confirm?email={Uri.EscapeDataString(createdUser.Email)}";
// Render email template
var emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
createdUser.FirstName,
confirmationLink
hashed
);
// Send welcome email with rendered template
await emailProvider.SendAsync(
createdUser.Email,
"Welcome to The Biergarten App!",
emailHtml,
isHtml: true
);
var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
return createdUser;
if (
string.IsNullOrEmpty(accessToken)
|| string.IsNullOrEmpty(refreshToken)
)
{
return new RegisterServiceReturn(createdUser);
}
bool emailSent = false;
try
{
// send confirmation email
await emailService.SendRegistrationEmailAsync(
createdUser,
"some-confirmation-token"
);
emailSent = true;
}
catch
{
// ignored
}
return new RegisterServiceReturn(
createdUser,
accessToken,
refreshToken,
emailSent
);
}
}

View File

@@ -5,20 +5,14 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
</ItemGroup>
</Project>