Phase 4B AuthService

This commit is contained in:
Nick Beckley 2026-06-06 19:01:13 +01:00
parent 6f42c65e35
commit a294df4e14
12 changed files with 441 additions and 19 deletions

View File

@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
public sealed class AccountController(IAuthService authService) : Controller
{
[HttpGet]
public IActionResult Register(string? returnUrl = null)
{
return View(new RegisterViewModel());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.RegisterAsync(model);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Registration could not be completed.");
return View(model);
}
TempData["AuthMessage"] = "Your account has been created. You can now log in.";
return RedirectToAction(nameof(Login));
}
[HttpGet]
public IActionResult Login(string? returnUrl = null)
{
return View(new LoginViewModel { ReturnUrl = returnUrl });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.LoginAsync(model);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed.");
return View(model);
}
if (!string.IsNullOrWhiteSpace(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl))
{
return LocalRedirect(model.ReturnUrl);
}
return RedirectToAction("Index", "Home");
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
await authService.LogoutAsync();
return RedirectToAction("Index", "Home");
}
[HttpGet]
public IActionResult AccessDenied()
{
return View();
}
}

View File

@ -8,7 +8,7 @@ public interface IUserRepository
{
Task<AppUser?> GetByEmailAsync(string email);
Task<AppUser?> GetByIdAsync(int userId);
Task<AppUser> CreateAsync(string email, string displayName, string passwordHash);
Task<AppUser> CreateAsync(string email, string displayName, string passwordHash, bool emailConfirmed);
Task<AppUser?> UpdateLoginSuccessAsync(int userId);
Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc);
Task<bool> ConfirmEmailAsync(int userId, Guid token);
@ -48,12 +48,12 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser> CreateAsync(string email, string displayName, string passwordHash)
public async Task<AppUser> CreateAsync(string email, string displayName, string passwordHash, bool emailConfirmed)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<AppUser>(
"dbo.User_Create",
new { Email = email, DisplayName = displayName, PasswordHash = passwordHash },
new { Email = email, DisplayName = displayName, PasswordHash = passwordHash, EmailConfirmed = emailConfirmed },
commandType: CommandType.StoredProcedure);
}

View File

@ -0,0 +1,21 @@
namespace PlotLine.Models;
public class AuthenticationResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public bool RequiresTwoFactor { get; init; }
public static AuthenticationResult Succeeded() => new() { Success = true };
public static AuthenticationResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
}
public sealed class RegistrationResult : AuthenticationResult
{
public int? UserID { get; init; }
public static RegistrationResult Succeeded(int userId) => new() { Success = true, UserID = userId };
public new static RegistrationResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
}

View File

@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>7ebffbd4-1a5b-4053-840e-b52f93e756b8</UserSecretsId>
</PropertyGroup>
<ItemGroup>

View File

@ -16,6 +16,7 @@ public class Program
builder.Logging.AddDebug();
builder.Services.AddControllersWithViews();
builder.Services.AddHttpContextAccessor();
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@ -87,11 +88,11 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddSingleton<IHelpService, HelpService>();
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
builder.Services.AddScoped<IAuthService>(_ => throw new NotImplementedException("Authentication business logic will be implemented in a later phase."));
builder.Services.AddScoped<IPasswordService>(_ => throw new NotImplementedException("Password hashing will be implemented in a later phase."));
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IPasswordService, PasswordService>();
builder.Services.AddScoped<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
builder.Services.AddScoped<IEmailService>(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase."));
builder.Services.AddScoped<ICurrentUserService>(_ => throw new NotImplementedException("Current user access will be implemented in a later phase."));
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
var app = builder.Build();

View File

@ -1,19 +1,20 @@
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IAuthService
{
Task<bool> RegisterAsync(RegisterViewModel model);
Task<bool> LoginAsync(LoginViewModel model);
Task<bool> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
Task<RegistrationResult> RegisterAsync(RegisterViewModel model);
Task<AuthenticationResult> LoginAsync(LoginViewModel model);
Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
Task LogoutAsync();
Task<bool> VerifyEmailAsync(Guid token);
Task<bool> ForgotPasswordAsync(ForgotPasswordViewModel model);
Task<bool> ResetPasswordAsync(ResetPasswordViewModel model);
Task<bool> ChangePasswordAsync(ChangePasswordViewModel model);
Task<bool> EnableTwoFactorAsync(int userId);
Task<bool> DisableTwoFactorAsync(int userId);
Task<AuthenticationResult> VerifyEmailAsync(Guid token);
Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model);
Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model);
Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model);
Task<AuthenticationResult> EnableTwoFactorAsync(int userId);
Task<AuthenticationResult> DisableTwoFactorAsync(int userId);
Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId);
}

View File

@ -0,0 +1,165 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public sealed class PasswordService : IPasswordService
{
private readonly PasswordHasher<AppUser> _passwordHasher = new();
public string HashPassword(string password)
{
return _passwordHasher.HashPassword(new AppUser(), password);
}
public bool VerifyPassword(string password, string passwordHash)
{
var result = _passwordHasher.VerifyHashedPassword(new AppUser(), passwordHash, password);
return result is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
}
}
public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) : ICurrentUserService
{
private ClaimsPrincipal? User => httpContextAccessor.HttpContext?.User;
public int? UserId
{
get
{
var value = User?.FindFirstValue(ClaimTypes.NameIdentifier);
return int.TryParse(value, out var userId) ? userId : null;
}
}
public string? Email => User?.FindFirstValue(ClaimTypes.Email);
public string? DisplayName => User?.FindFirstValue(ClaimTypes.Name);
public bool IsAuthenticated => User?.Identity?.IsAuthenticated == true;
}
public sealed class AuthService(
IUserRepository users,
IAuthenticationRepository authentication,
IPasswordService passwords,
IHttpContextAccessor httpContextAccessor,
ILogger<AuthService> logger) : IAuthService
{
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
{
var email = Clean(model.Email);
var displayName = Clean(model.DisplayName);
var existingUser = await users.GetByEmailAsync(email);
if (existingUser is not null)
{
return RegistrationResult.Failed("An account already exists for that email address.");
}
var passwordHash = passwords.HashPassword(model.Password);
var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: true);
logger.LogInformation("User {UserID} registered with email confirmation temporarily enabled for Phase 4B.", created.UserID);
return RegistrationResult.Succeeded(created.UserID);
}
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
{
var email = Clean(model.Email);
var user = await users.GetByEmailAsync(email);
if (user is null)
{
await InsertLoginAuditAsync(null, email, false, "User not found");
return AuthenticationResult.Failed("The email address or password was not recognised.");
}
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
{
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
await InsertLoginAuditAsync(user.UserID, email, false, "Invalid password");
return AuthenticationResult.Failed("The email address or password was not recognised.");
}
var updatedUser = await users.UpdateLoginSuccessAsync(user.UserID) ?? user;
await InsertLoginAuditAsync(user.UserID, email, true, "Login successful");
await SignInAsync(updatedUser, model.RememberMe);
return AuthenticationResult.Succeeded();
}
public async Task LogoutAsync()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is not null)
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
public Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model)
=> throw new NotImplementedException("Two-factor login will be implemented in a later phase.");
public Task<AuthenticationResult> VerifyEmailAsync(Guid token)
=> throw new NotImplementedException("Email verification will be implemented in a later phase.");
public Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
=> throw new NotImplementedException("Forgotten password handling will be implemented in a later phase.");
public Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
=> throw new NotImplementedException("Password reset handling will be implemented in a later phase.");
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
public Task<AuthenticationResult> EnableTwoFactorAsync(int userId)
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
public Task<AuthenticationResult> DisableTwoFactorAsync(int userId)
=> throw new NotImplementedException("Two-factor setup will be implemented in a later phase.");
public Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId)
=> throw new NotImplementedException("Recovery codes will be implemented in a later phase.");
private async Task SignInAsync(AppUser user, bool rememberMe)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.UserID.ToString()),
new(ClaimTypes.Name, user.DisplayName),
new(ClaimTypes.Email, user.Email)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var properties = new AuthenticationProperties
{
IsPersistent = rememberMe,
AllowRefresh = true
};
var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HTTP context is available.");
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);
}
private async Task InsertLoginAuditAsync(int? userId, string emailAttempted, bool wasSuccessful, string reason)
{
var httpContext = httpContextAccessor.HttpContext;
await authentication.InsertLoginAuditAsync(new UserLoginAudit
{
UserID = userId,
EmailAttempted = emailAttempted,
IpAddress = httpContext?.Connection.RemoteIpAddress?.ToString(),
UserAgent = httpContext?.Request.Headers.UserAgent.ToString(),
WasSuccessful = wasSuccessful,
Reason = reason
});
}
private static string Clean(string value) => value.Trim();
}

View File

@ -0,0 +1,22 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.User_Create
@Email nvarchar(256),
@DisplayName nvarchar(200),
@PasswordHash nvarchar(max),
@EmailConfirmed bit = 0
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.AppUser (Email, DisplayName, PasswordHash, EmailConfirmed)
VALUES (@Email, @DisplayName, @PasswordHash, @EmailConfirmed);
DECLARE @UserID int = SCOPE_IDENTITY();
EXEC dbo.User_GetById @UserID = @UserID;
END;
GO

View File

@ -0,0 +1,17 @@
@{
ViewData["Title"] = "Access denied";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Access denied</h1>
</div>
</div>
<section class="edit-panel">
<div class="alert alert-warning mb-3">
You do not have access to that part of PlotWeaver Studio.
</div>
<a class="btn btn-outline-secondary" asp-controller="Home" asp-action="Index">Back to Home</a>
</section>

View File

@ -0,0 +1,52 @@
@model LoginViewModel
@{
ViewData["Title"] = "Login";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Login</h1>
<p class="lead-text">Sign in to PlotWeaver Studio.</p>
</div>
</div>
@if (TempData["AuthMessage"] is string authMessage)
{
<div class="alert alert-info">@authMessage</div>
}
<section class="edit-panel">
<form asp-action="Login" method="post" class="asset-create-form">
<input asp-for="ReturnUrl" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Email">Email address</label>
<input class="form-control" asp-for="Email" autocomplete="email" />
<span class="text-danger" asp-validation-for="Email"></span>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Password">Password</label>
<input class="form-control" asp-for="Password" autocomplete="current-password" />
<span class="text-danger" asp-validation-for="Password"></span>
</div>
<div class="col-12">
<label class="form-check">
<input class="form-check-input" asp-for="RememberMe" />
<span class="form-check-label">Remember me</span>
</label>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Login</button>
<a class="btn btn-outline-secondary" asp-action="Register">Create an account</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,50 @@
@model RegisterViewModel
@{
ViewData["Title"] = "Register";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Create your account</h1>
<p class="lead-text">Create a PlotWeaver Studio account so your workspace can use the new authentication foundation.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="Register" method="post" class="asset-create-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="DisplayName">Display name</label>
<input class="form-control" asp-for="DisplayName" autocomplete="name" />
<span class="text-danger" asp-validation-for="DisplayName"></span>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Email">Email address</label>
<input class="form-control" asp-for="Email" autocomplete="email" />
<span class="text-danger" asp-validation-for="Email"></span>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="Password">Password</label>
<input class="form-control" asp-for="Password" autocomplete="new-password" />
<span class="text-danger" asp-validation-for="Password"></span>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="ConfirmPassword">Confirm password</label>
<input class="form-control" asp-for="ConfirmPassword" autocomplete="new-password" />
<span class="text-danger" asp-validation-for="ConfirmPassword"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Register</button>
<a class="btn btn-outline-secondary" asp-action="Login">Already have an account?</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -53,9 +53,23 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">About</a>
</li>
</ul>
<div class="theme-switcher btn-group btn-group-sm" role="group" aria-label="Theme selection">
<button type="button" class="btn btn-outline-secondary" data-theme-choice="light" aria-pressed="true">Light</button>
<button type="button" class="btn btn-outline-secondary" data-theme-choice="dark" aria-pressed="false">Dark</button>
<div class="d-flex align-items-center gap-2 flex-wrap">
@if (User.Identity?.IsAuthenticated == true)
{
<span class="navbar-text text-dark">Hello @User.Identity.Name</span>
<form asp-controller="Account" asp-action="Logout" method="post" class="m-0">
<button class="btn btn-outline-secondary btn-sm" type="submit">Logout</button>
</form>
}
else
{
<a class="btn btn-outline-secondary btn-sm" asp-controller="Account" asp-action="Login">Login</a>
<a class="btn btn-outline-primary btn-sm" asp-controller="Account" asp-action="Register">Register</a>
}
<div class="theme-switcher btn-group btn-group-sm" role="group" aria-label="Theme selection">
<button type="button" class="btn btn-outline-secondary" data-theme-choice="light" aria-pressed="true">Light</button>
<button type="button" class="btn btn-outline-secondary" data-theme-choice="dark" aria-pressed="false">Dark</button>
</div>
</div>
</div>
</div>