diff --git a/PlotLine/Controllers/AccountController.cs b/PlotLine/Controllers/AccountController.cs new file mode 100644 index 0000000..71aadc9 --- /dev/null +++ b/PlotLine/Controllers/AccountController.cs @@ -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 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 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 Logout() + { + await authService.LogoutAsync(); + return RedirectToAction("Index", "Home"); + } + + [HttpGet] + public IActionResult AccessDenied() + { + return View(); + } +} diff --git a/PlotLine/Data/AuthRepositories.cs b/PlotLine/Data/AuthRepositories.cs index 94bef7a..8e73519 100644 --- a/PlotLine/Data/AuthRepositories.cs +++ b/PlotLine/Data/AuthRepositories.cs @@ -8,7 +8,7 @@ public interface IUserRepository { Task GetByEmailAsync(string email); Task GetByIdAsync(int userId); - Task CreateAsync(string email, string displayName, string passwordHash); + Task CreateAsync(string email, string displayName, string passwordHash, bool emailConfirmed); Task UpdateLoginSuccessAsync(int userId); Task UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc); Task ConfirmEmailAsync(int userId, Guid token); @@ -48,12 +48,12 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU commandType: CommandType.StoredProcedure); } - public async Task CreateAsync(string email, string displayName, string passwordHash) + public async Task CreateAsync(string email, string displayName, string passwordHash, bool emailConfirmed) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.User_Create", - new { Email = email, DisplayName = displayName, PasswordHash = passwordHash }, + new { Email = email, DisplayName = displayName, PasswordHash = passwordHash, EmailConfirmed = emailConfirmed }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Models/AuthResultModels.cs b/PlotLine/Models/AuthResultModels.cs new file mode 100644 index 0000000..187794b --- /dev/null +++ b/PlotLine/Models/AuthResultModels.cs @@ -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 }; +} diff --git a/PlotLine/PlotLine.csproj b/PlotLine/PlotLine.csproj index dcd59d4..da6cfad 100644 --- a/PlotLine/PlotLine.csproj +++ b/PlotLine/PlotLine.csproj @@ -1,9 +1,10 @@ - + net10.0 enable enable + 7ebffbd4-1a5b-4053-840e-b52f93e756b8 diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 1c10026..b5f47ab 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddScoped(_ => throw new NotImplementedException("Authentication business logic will be implemented in a later phase.")); - builder.Services.AddScoped(_ => throw new NotImplementedException("Password hashing will be implemented in a later phase.")); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase.")); builder.Services.AddScoped(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase.")); - builder.Services.AddScoped(_ => throw new NotImplementedException("Current user access will be implemented in a later phase.")); + builder.Services.AddScoped(); var app = builder.Build(); diff --git a/PlotLine/Services/AuthServiceInterfaces.cs b/PlotLine/Services/AuthServiceInterfaces.cs index cf2ee8b..1c8b5f8 100644 --- a/PlotLine/Services/AuthServiceInterfaces.cs +++ b/PlotLine/Services/AuthServiceInterfaces.cs @@ -1,19 +1,20 @@ +using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IAuthService { - Task RegisterAsync(RegisterViewModel model); - Task LoginAsync(LoginViewModel model); - Task CompleteTwoFactorLoginAsync(TwoFactorViewModel model); + Task RegisterAsync(RegisterViewModel model); + Task LoginAsync(LoginViewModel model); + Task CompleteTwoFactorLoginAsync(TwoFactorViewModel model); Task LogoutAsync(); - Task VerifyEmailAsync(Guid token); - Task ForgotPasswordAsync(ForgotPasswordViewModel model); - Task ResetPasswordAsync(ResetPasswordViewModel model); - Task ChangePasswordAsync(ChangePasswordViewModel model); - Task EnableTwoFactorAsync(int userId); - Task DisableTwoFactorAsync(int userId); + Task VerifyEmailAsync(Guid token); + Task ForgotPasswordAsync(ForgotPasswordViewModel model); + Task ResetPasswordAsync(ResetPasswordViewModel model); + Task ChangePasswordAsync(ChangePasswordViewModel model); + Task EnableTwoFactorAsync(int userId); + Task DisableTwoFactorAsync(int userId); Task> GenerateRecoveryCodesAsync(int userId); } diff --git a/PlotLine/Services/AuthServices.cs b/PlotLine/Services/AuthServices.cs new file mode 100644 index 0000000..a84ae0c --- /dev/null +++ b/PlotLine/Services/AuthServices.cs @@ -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 _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 logger) : IAuthService +{ + public async Task 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 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 CompleteTwoFactorLoginAsync(TwoFactorViewModel model) + => throw new NotImplementedException("Two-factor login will be implemented in a later phase."); + + public Task VerifyEmailAsync(Guid token) + => throw new NotImplementedException("Email verification will be implemented in a later phase."); + + public Task ForgotPasswordAsync(ForgotPasswordViewModel model) + => throw new NotImplementedException("Forgotten password handling will be implemented in a later phase."); + + public Task ResetPasswordAsync(ResetPasswordViewModel model) + => throw new NotImplementedException("Password reset handling will be implemented in a later phase."); + + public Task ChangePasswordAsync(ChangePasswordViewModel model) + => throw new NotImplementedException("Password changes will be implemented in a later phase."); + + public Task EnableTwoFactorAsync(int userId) + => throw new NotImplementedException("Two-factor setup will be implemented in a later phase."); + + public Task DisableTwoFactorAsync(int userId) + => throw new NotImplementedException("Two-factor setup will be implemented in a later phase."); + + public Task> 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 + { + 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(); +} diff --git a/PlotLine/Sql/038_Phase4B_RegisterLoginLogout.sql b/PlotLine/Sql/038_Phase4B_RegisterLoginLogout.sql new file mode 100644 index 0000000..1537bd9 --- /dev/null +++ b/PlotLine/Sql/038_Phase4B_RegisterLoginLogout.sql @@ -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 diff --git a/PlotLine/Views/Account/AccessDenied.cshtml b/PlotLine/Views/Account/AccessDenied.cshtml new file mode 100644 index 0000000..d5d2c64 --- /dev/null +++ b/PlotLine/Views/Account/AccessDenied.cshtml @@ -0,0 +1,17 @@ +@{ + ViewData["Title"] = "Access denied"; +} + +
+
+

Account

+

Access denied

+
+
+ +
+
+ You do not have access to that part of PlotWeaver Studio. +
+ Back to Home +
diff --git a/PlotLine/Views/Account/Login.cshtml b/PlotLine/Views/Account/Login.cshtml new file mode 100644 index 0000000..058ad63 --- /dev/null +++ b/PlotLine/Views/Account/Login.cshtml @@ -0,0 +1,52 @@ +@model LoginViewModel +@{ + ViewData["Title"] = "Login"; +} + +
+
+

Account

+

Login

+

Sign in to PlotWeaver Studio.

+
+
+ +@if (TempData["AuthMessage"] is string authMessage) +{ +
@authMessage
+} + +
+
+ +
+ +
+
+ + + +
+
+ + + +
+
+ +
+
+ +
+ + Create an account +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Account/Register.cshtml b/PlotLine/Views/Account/Register.cshtml new file mode 100644 index 0000000..2889d17 --- /dev/null +++ b/PlotLine/Views/Account/Register.cshtml @@ -0,0 +1,50 @@ +@model RegisterViewModel +@{ + ViewData["Title"] = "Register"; +} + +
+
+

Account

+

Create your account

+

Create a PlotWeaver Studio account so your workspace can use the new authentication foundation.

+
+
+ +
+
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ + Already have an account? +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 0982b1d..84b7f92 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -53,9 +53,23 @@ About -
- - +
+ @if (User.Identity?.IsAuthenticated == true) + { + Hello @User.Identity.Name +
+ +
+ } + else + { + Login + Register + } +
+ + +