Begin work on user confirmation workflow

This commit is contained in:
Aaron Po
2026-02-21 20:44:49 -05:00
parent 17eb04e20c
commit 0ab2eaaec9
15 changed files with 233 additions and 41 deletions

View File

@@ -17,3 +17,7 @@ public record RegistrationPayload(
string AccessToken,
bool ConfirmationEmailSent
);
public record ConfirmationPayload(
Guid UserAccountId,
DateTime ConfirmedDate);

View File

@@ -8,7 +8,10 @@ namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class AuthController(IRegisterService register, ILoginService login)
public class AuthController(
IRegisterService registerService,
ILoginService loginService,
IConfirmationService confirmationService)
: ControllerBase
{
[HttpPost("register")]
@@ -16,7 +19,7 @@ namespace API.Core.Controllers
[FromBody] RegisterRequest req
)
{
var rtn = await register.RegisterAsync(
var rtn = await registerService.RegisterAsync(
new UserAccount
{
UserAccountId = Guid.Empty,
@@ -46,7 +49,7 @@ namespace API.Core.Controllers
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
var rtn = await login.LoginAsync(req.Username, req.Password);
var rtn = await loginService.LoginAsync(req.Username, req.Password);
return Ok(
new ResponseBody<LoginPayload>
@@ -61,5 +64,18 @@ namespace API.Core.Controllers
}
);
}
[HttpPost("confirm")]
public async Task<ActionResult> Confirm([FromQuery] string token)
{
var rtn = await confirmationService.ConfirmUserAsync(token);
return Ok(new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + rtn.userId + " is confirmed.",
Payload = new ConfirmationPayload(
rtn.userId, rtn.confirmedAt
)
});
}
}
}
}

View File

@@ -64,6 +64,7 @@ builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();

View File

@@ -2,5 +2,10 @@ namespace Infrastructure.Jwt;
public interface ITokenInfrastructure
{
string GenerateJwt(Guid userId, string username, DateTime expiry);
string GenerateJwt(
Guid userId,
string username,
DateTime expiry,
string secret
);
}

View File

@@ -8,16 +8,17 @@ namespace Infrastructure.Jwt;
public class JwtInfrastructure : ITokenInfrastructure
{
private readonly string? _secret = Environment.GetEnvironmentVariable(
"JWT_SECRET"
);
public string GenerateJwt(Guid userId, string username, DateTime expiry)
public string GenerateJwt(
Guid userId,
string username,
DateTime expiry,
string secret
)
{
var handler = new JsonWebTokenHandler();
var key = Encoding.UTF8.GetBytes(
_secret ?? throw new InvalidOperationException("secret not set")
secret ?? throw new InvalidOperationException("secret not set")
);
// Base claims (always present)

View File

@@ -0,0 +1,21 @@
using System.Runtime.InteropServices.JavaScript;
using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record ConfirmationServiceReturn(DateTime confirmedAt, Guid userId);
public interface IConfirmationService
{
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
}
public class ConfirmationService(IAuthRepository authRepository) : IConfirmationService
{
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken)
{
return new ConfirmationServiceReturn(DateTime.Now, Guid.NewGuid());
}
}

View File

@@ -2,6 +2,11 @@ using Domain.Entities;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public interface ILoginService
{
Task<LoginServiceReturn> LoginAsync(string username, string password);

View File

@@ -36,4 +36,4 @@ public interface IRegisterService
UserAccount userAccount,
string password
);
}
}

View File

@@ -7,28 +7,73 @@ public interface ITokenService
{
public string GenerateAccessToken(UserAccount user);
public string GenerateRefreshToken(UserAccount user);
public string GenerateConfirmationToken(UserAccount user);
}
public static class TokenServiceExpirationHours
{
public const double AccessTokenHours = 1;
public const double RefreshTokenHours = 504; // 21 days
public const double ConfirmationTokenHours = 0.5; // 30 minutes
}
public class TokenService(ITokenInfrastructure tokenInfrastructure)
: ITokenService
{
private readonly string _accessTokenSecret =
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set"
);
private readonly string _refreshTokenSecret =
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set"
);
private readonly string _confirmationTokenSecret =
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
);
public string GenerateAccessToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
var jwtExpiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.AccessTokenHours
);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
jwtExpiresAt,
_accessTokenSecret
);
}
public string GenerateRefreshToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddDays(21);
var jwtExpiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.RefreshTokenHours
);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt
jwtExpiresAt,
_refreshTokenSecret
);
}
public string GenerateConfirmationToken(UserAccount userAccount)
{
var jwtExpiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.ConfirmationTokenHours
);
return tokenInfrastructure.GenerateJwt(
userAccount.UserAccountId,
userAccount.Username,
jwtExpiresAt,
_confirmationTokenSecret
);
}
}

View File

@@ -5,11 +5,6 @@ using Infrastructure.Repository.Auth;
namespace Service.Auth;
public record LoginServiceReturn(
UserAccount UserAccount,
string RefreshToken,
string AccessToken
);
public class LoginService(
IAuthRepository authRepo,

View File

@@ -53,6 +53,7 @@ public class RegisterService(
var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (
string.IsNullOrEmpty(accessToken)
@@ -67,14 +68,15 @@ public class RegisterService(
{
// send confirmation email
await emailService.SendRegistrationEmailAsync(
createdUser,
"some-confirmation-token"
createdUser, confirmationToken
);
emailSent = true;
}
catch
catch (Exception ex)
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
// ignored
}
@@ -85,4 +87,4 @@ public class RegisterService(
emailSent
);
}
}
}