Phase 4C AuthService
This commit is contained in:
parent
a294df4e14
commit
44416ec445
@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
@ -7,12 +9,14 @@ namespace PlotLine.Controllers;
|
||||
public sealed class AccountController(IAuthService authService) : Controller
|
||||
{
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Register(string? returnUrl = null)
|
||||
{
|
||||
return View(new RegisterViewModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Register(RegisterViewModel model)
|
||||
{
|
||||
@ -21,24 +25,35 @@ public sealed class AccountController(IAuthService authService) : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var result = await authService.RegisterAsync(model);
|
||||
RegistrationResult result;
|
||||
try
|
||||
{
|
||||
result = await authService.RegisterAsync(model);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Your account could not be created because the verification email could not be sent. Please try again later.");
|
||||
return View(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.";
|
||||
TempData["AuthMessage"] = "Your account has been created. Check your email to verify your address before signing in. If it does not arrive, use the resend verification email link below.";
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Login(string? returnUrl = null)
|
||||
{
|
||||
return View(new LoginViewModel { ReturnUrl = returnUrl });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Login(LoginViewModel model)
|
||||
{
|
||||
@ -71,8 +86,141 @@ public sealed class AccountController(IAuthService authService) : Controller
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult AccessDenied()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> VerifyEmail(Guid token)
|
||||
{
|
||||
if (token == Guid.Empty)
|
||||
{
|
||||
return View(new AccountStatusViewModel
|
||||
{
|
||||
Success = false,
|
||||
Title = "Verification link problem",
|
||||
Message = "This verification link is not valid."
|
||||
});
|
||||
}
|
||||
|
||||
var result = await authService.VerifyEmailAsync(token);
|
||||
if (result.Success)
|
||||
{
|
||||
return RedirectToAction(nameof(VerifyEmailConfirmation));
|
||||
}
|
||||
|
||||
return View(new AccountStatusViewModel
|
||||
{
|
||||
Success = false,
|
||||
Title = "Verification link problem",
|
||||
Message = result.ErrorMessage ?? "This verification link could not be used."
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult VerifyEmailConfirmation()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult ForgotPassword()
|
||||
{
|
||||
return View(new ForgotPasswordViewModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await authService.ForgotPasswordAsync(model);
|
||||
if (!result.Success)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "We could not send a password reset email right now. Please try again later.");
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "We could not send a password reset email right now. Please try again later.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
TempData["AuthMessage"] = "If an account exists for that email address, a password reset link has been sent.";
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult ResetPassword(Guid token)
|
||||
{
|
||||
if (token == Guid.Empty)
|
||||
{
|
||||
return View(new ResetPasswordViewModel());
|
||||
}
|
||||
|
||||
return View(new ResetPasswordViewModel { Token = token });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var result = await authService.ResetPasswordAsync(model);
|
||||
if (!result.Success)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Your password could not be reset.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
TempData["AuthMessage"] = "Your password has been reset. You can now log in.";
|
||||
return RedirectToAction(nameof(Login));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public IActionResult ResendVerificationEmail()
|
||||
{
|
||||
return View(new ResendVerificationEmailViewModel());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResendVerificationEmail(ResendVerificationEmailViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
|
||||
var result = await authService.ResendVerificationEmailAsync(model.Email);
|
||||
if (!result.Success)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "We could not send the verification email right now. Please try again in a few minutes.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
TempData["AuthMessage"] = "If an unverified account exists for that email address, a new verification email has been sent.";
|
||||
return View(new ResendVerificationEmailViewModel());
|
||||
}
|
||||
}
|
||||
|
||||
12
PlotLine/Models/EmailSettings.cs
Normal file
12
PlotLine/Models/EmailSettings.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public sealed class EmailSettings
|
||||
{
|
||||
public string SmtpServer { get; set; } = string.Empty;
|
||||
public int Port { get; set; } = 465;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool EnableSsl { get; set; } = true;
|
||||
public string FromAddress { get; set; } = string.Empty;
|
||||
public string FromName { get; set; } = "PlotWeaver Studio";
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
@ -17,6 +18,7 @@ public class Program
|
||||
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
@ -91,7 +93,7 @@ public class Program
|
||||
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<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@ -10,6 +10,7 @@ public interface IAuthService
|
||||
Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
|
||||
Task LogoutAsync();
|
||||
Task<AuthenticationResult> VerifyEmailAsync(Guid token);
|
||||
Task<AuthenticationResult> ResendVerificationEmailAsync(string email);
|
||||
Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model);
|
||||
Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model);
|
||||
Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model);
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Net.Mime;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
@ -44,18 +48,156 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
|
||||
public bool IsAuthenticated => User?.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
|
||||
public sealed class EmailService(
|
||||
IOptions<EmailSettings> emailOptions,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<EmailService> logger) : IEmailService
|
||||
{
|
||||
private readonly EmailSettings _settings = emailOptions.Value;
|
||||
|
||||
public Task SendVerificationEmailAsync(string email, string displayName, Guid token)
|
||||
{
|
||||
var link = BuildAccountUrl("VerifyEmail", token);
|
||||
var html = BuildTemplate(
|
||||
"Verify your PlotWeaver Studio account",
|
||||
displayName,
|
||||
"Please verify your email address to finish setting up your PlotWeaver Studio account.",
|
||||
"Verify account",
|
||||
link,
|
||||
"This link expires in 24 hours.");
|
||||
|
||||
return SendAsync(email, displayName, "Verify your PlotWeaver Studio account", html);
|
||||
}
|
||||
|
||||
public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token)
|
||||
{
|
||||
var link = BuildAccountUrl("ResetPassword", token);
|
||||
var html = BuildTemplate(
|
||||
"Reset your PlotWeaver Studio password",
|
||||
displayName,
|
||||
"Use the link below to choose a new password for your PlotWeaver Studio account.",
|
||||
"Reset password",
|
||||
link,
|
||||
"This link expires in 24 hours. If you did not request a reset, you can ignore this email.");
|
||||
|
||||
return SendAsync(email, displayName, "Reset your PlotWeaver Studio password", html);
|
||||
}
|
||||
|
||||
private async Task SendAsync(string toEmail, string displayName, string subject, string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.SmtpServer)
|
||||
|| string.IsNullOrWhiteSpace(_settings.Username)
|
||||
|| string.IsNullOrWhiteSpace(_settings.Password)
|
||||
|| string.IsNullOrWhiteSpace(_settings.FromAddress))
|
||||
{
|
||||
logger.LogWarning("Email '{Subject}' for {Email} could not be sent because SMTP settings are incomplete.", subject, toEmail);
|
||||
throw new InvalidOperationException("Email settings are not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var message = new MailMessage
|
||||
{
|
||||
From = new MailAddress(_settings.FromAddress, CleanDisplayName(_settings.FromName)),
|
||||
Subject = subject,
|
||||
Body = html,
|
||||
IsBodyHtml = true
|
||||
};
|
||||
message.To.Add(new MailAddress(toEmail, CleanDisplayName(displayName)));
|
||||
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(StripHtml(html), null, MediaTypeNames.Text.Plain));
|
||||
|
||||
using var client = new SmtpClient(_settings.SmtpServer, _settings.Port)
|
||||
{
|
||||
EnableSsl = _settings.EnableSsl,
|
||||
Credentials = new NetworkCredential(_settings.Username, _settings.Password)
|
||||
};
|
||||
|
||||
await client.SendMailAsync(message);
|
||||
logger.LogInformation("Email '{Subject}' sent to {Email}.", subject, toEmail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Email '{Subject}' could not be sent to {Email}.", subject, toEmail);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildAccountUrl(string action, Guid token)
|
||||
{
|
||||
var request = httpContextAccessor.HttpContext?.Request;
|
||||
var host = request?.Host.Value;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot build account email link without an active request host.");
|
||||
}
|
||||
|
||||
return $"https://{host}/Account/{action}?token={Uri.EscapeDataString(token.ToString("D"))}";
|
||||
}
|
||||
|
||||
private static string BuildTemplate(string title, string displayName, string intro, string buttonText, string link, string footnote)
|
||||
{
|
||||
var safeName = WebUtility.HtmlEncode(displayName);
|
||||
var safeTitle = WebUtility.HtmlEncode(title);
|
||||
var safeIntro = WebUtility.HtmlEncode(intro);
|
||||
var safeButton = WebUtility.HtmlEncode(buttonText);
|
||||
var safeLink = WebUtility.HtmlEncode(link);
|
||||
var safeFootnote = WebUtility.HtmlEncode(footnote);
|
||||
|
||||
return $"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
|
||||
<div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
|
||||
<p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotWeaver Studio</p>
|
||||
<h1 style="margin:0 0 16px;font-size:26px;">{safeTitle}</h1>
|
||||
<p>Hello {safeName},</p>
|
||||
<p>{safeIntro}</p>
|
||||
<p style="margin:28px 0;">
|
||||
<a href="{safeLink}" style="display:inline-block;background:#2f6f63;color:#fff;text-decoration:none;padding:12px 18px;border-radius:6px;font-weight:700;">{safeButton}</a>
|
||||
</p>
|
||||
<p>If the button does not work, copy and paste this link into your browser:</p>
|
||||
<p style="word-break:break-all;color:#4d625c;">{safeLink}</p>
|
||||
<p style="color:#6f6256;font-size:14px;">{safeFootnote}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string StripHtml(string html)
|
||||
{
|
||||
return WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " "));
|
||||
}
|
||||
|
||||
private static string CleanDisplayName(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthService(
|
||||
IUserRepository users,
|
||||
IAuthenticationRepository authentication,
|
||||
IPasswordService passwords,
|
||||
IEmailService emails,
|
||||
IOptions<EmailSettings> emailOptions,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<AuthService> logger) : IAuthService
|
||||
{
|
||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
|
||||
private readonly EmailSettings _emailSettings = emailOptions.Value;
|
||||
|
||||
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
|
||||
{
|
||||
var email = Clean(model.Email);
|
||||
var displayName = Clean(model.DisplayName);
|
||||
|
||||
if (!EmailIsConfigured())
|
||||
{
|
||||
logger.LogWarning("Registration could not continue because SMTP settings are incomplete.");
|
||||
return RegistrationResult.Failed("Email sending is not configured yet. Please try again later.");
|
||||
}
|
||||
|
||||
var existingUser = await users.GetByEmailAsync(email);
|
||||
if (existingUser is not null)
|
||||
{
|
||||
@ -63,8 +205,11 @@ public sealed class AuthService(
|
||||
}
|
||||
|
||||
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);
|
||||
var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: false);
|
||||
var token = Guid.NewGuid();
|
||||
await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||
await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token);
|
||||
logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID);
|
||||
|
||||
return RegistrationResult.Succeeded(created.UserID);
|
||||
}
|
||||
@ -72,6 +217,12 @@ public sealed class AuthService(
|
||||
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
|
||||
{
|
||||
var email = Clean(model.Email);
|
||||
if (!EmailIsConfigured())
|
||||
{
|
||||
logger.LogWarning("Password reset email could not be sent because SMTP settings are incomplete.");
|
||||
return AuthenticationResult.Failed("Password reset email could not be sent right now.");
|
||||
}
|
||||
|
||||
var user = await users.GetByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
@ -79,6 +230,12 @@ public sealed class AuthService(
|
||||
return AuthenticationResult.Failed("The email address or password was not recognised.");
|
||||
}
|
||||
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
await InsertLoginAuditAsync(user.UserID, email, false, "Email not verified");
|
||||
return AuthenticationResult.Failed("Please verify your email address before signing in.");
|
||||
}
|
||||
|
||||
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
|
||||
{
|
||||
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
|
||||
@ -105,14 +262,109 @@ public sealed class AuthService(
|
||||
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 async Task<AuthenticationResult> VerifyEmailAsync(Guid token)
|
||||
{
|
||||
var storedToken = await authentication.GetEmailVerificationTokenAsync(token);
|
||||
if (storedToken is null)
|
||||
{
|
||||
logger.LogWarning("Email verification failed because token {Token} was not found.", token);
|
||||
return AuthenticationResult.Failed("This verification link is not valid.");
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
|
||||
=> throw new NotImplementedException("Forgotten password handling will be implemented in a later phase.");
|
||||
if (storedToken.UsedUtc.HasValue)
|
||||
{
|
||||
return AuthenticationResult.Failed("This verification link has already been used.");
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
|
||||
=> throw new NotImplementedException("Password reset handling will be implemented in a later phase.");
|
||||
if (storedToken.ExpiryUtc < DateTime.UtcNow)
|
||||
{
|
||||
return AuthenticationResult.Failed("This verification link has expired.");
|
||||
}
|
||||
|
||||
var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token);
|
||||
return confirmed
|
||||
? AuthenticationResult.Succeeded()
|
||||
: AuthenticationResult.Failed("This verification link could not be used.");
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ResendVerificationEmailAsync(string email)
|
||||
{
|
||||
var cleanedEmail = Clean(email);
|
||||
var user = await users.GetByEmailAsync(cleanedEmail);
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogInformation("Verification resend requested for unknown email address {Email}.", cleanedEmail);
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
if (user.EmailConfirmed)
|
||||
{
|
||||
logger.LogInformation("Verification resend requested for already verified user {UserID}.", user.UserID);
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
if (!EmailIsConfigured())
|
||||
{
|
||||
logger.LogWarning("Verification resend for user {UserID} could not continue because SMTP settings are incomplete.", user.UserID);
|
||||
return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = Guid.NewGuid();
|
||||
await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||
await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token);
|
||||
logger.LogInformation("Verification email resent to user {UserID}.", user.UserID);
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Verification email resend failed for user {UserID}.", user.UserID);
|
||||
return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
|
||||
{
|
||||
var email = Clean(model.Email);
|
||||
var user = await users.GetByEmailAsync(email);
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogInformation("Password reset requested for unknown email address {Email}.", email);
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
var token = Guid.NewGuid();
|
||||
await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
|
||||
await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token);
|
||||
return AuthenticationResult.Succeeded();
|
||||
}
|
||||
|
||||
public async Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
|
||||
{
|
||||
var storedToken = await authentication.GetPasswordResetTokenAsync(model.Token);
|
||||
if (storedToken is null)
|
||||
{
|
||||
logger.LogWarning("Password reset failed because token {Token} was not found.", model.Token);
|
||||
return AuthenticationResult.Failed("This password reset link is not valid.");
|
||||
}
|
||||
|
||||
if (storedToken.UsedUtc.HasValue)
|
||||
{
|
||||
return AuthenticationResult.Failed("This password reset link has already been used.");
|
||||
}
|
||||
|
||||
if (storedToken.ExpiryUtc < DateTime.UtcNow)
|
||||
{
|
||||
return AuthenticationResult.Failed("This password reset link has expired.");
|
||||
}
|
||||
|
||||
var passwordHash = passwords.HashPassword(model.Password);
|
||||
var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash);
|
||||
return reset
|
||||
? AuthenticationResult.Succeeded()
|
||||
: AuthenticationResult.Failed("Your password could not be reset with this link.");
|
||||
}
|
||||
|
||||
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
|
||||
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
|
||||
@ -162,4 +414,12 @@ public sealed class AuthService(
|
||||
}
|
||||
|
||||
private static string Clean(string value) => value.Trim();
|
||||
|
||||
private bool EmailIsConfigured()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_emailSettings.SmtpServer)
|
||||
&& !string.IsNullOrWhiteSpace(_emailSettings.Username)
|
||||
&& !string.IsNullOrWhiteSpace(_emailSettings.Password)
|
||||
&& !string.IsNullOrWhiteSpace(_emailSettings.FromAddress);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,13 +48,16 @@ public sealed class ForgotPasswordViewModel
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordViewModel
|
||||
public sealed class ResendVerificationEmailViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Enter your email address.")]
|
||||
[EmailAddress(ErrorMessage = "Enter a valid email address.")]
|
||||
[StringLength(256, ErrorMessage = "Email must be 256 characters or fewer.")]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Password reset token is missing.")]
|
||||
public Guid Token { get; set; }
|
||||
|
||||
@ -69,6 +72,13 @@ public sealed class ResetPasswordViewModel
|
||||
public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class AccountStatusViewModel
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class TwoFactorViewModel
|
||||
{
|
||||
[Required(ErrorMessage = "Enter your authentication code.")]
|
||||
|
||||
35
PlotLine/Views/Account/ForgotPassword.cshtml
Normal file
35
PlotLine/Views/Account/ForgotPassword.cshtml
Normal file
@ -0,0 +1,35 @@
|
||||
@model ForgotPasswordViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Forgot password";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Forgot password</h1>
|
||||
<p class="lead-text">Enter your email address and, if an account exists, we will send a password reset link.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="edit-panel">
|
||||
<form asp-action="ForgotPassword" 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="Email">Email address</label>
|
||||
<input class="form-control" asp-for="Email" autocomplete="email" />
|
||||
<span class="text-danger" asp-validation-for="Email"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Send reset link</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Login">Back to login</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
@ -43,7 +43,11 @@
|
||||
<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>
|
||||
<a class="btn btn-outline-secondary" asp-action="ForgotPassword">Forgot password?</a>
|
||||
</div>
|
||||
<p class="mt-3 mb-0">
|
||||
<a asp-action="ResendVerificationEmail">Need a new verification email?</a>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
41
PlotLine/Views/Account/ResendVerificationEmail.cshtml
Normal file
41
PlotLine/Views/Account/ResendVerificationEmail.cshtml
Normal file
@ -0,0 +1,41 @@
|
||||
@model ResendVerificationEmailViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Resend verification email";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Resend verification email</h1>
|
||||
<p class="lead-text">If your verification email did not arrive or the link has expired, request a new one here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["AuthMessage"] is string authMessage)
|
||||
{
|
||||
<div class="alert alert-info">@authMessage</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<form asp-action="ResendVerificationEmail" 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="Email">Email address</label>
|
||||
<input class="form-control" asp-for="Email" autocomplete="email" />
|
||||
<span class="text-danger" asp-validation-for="Email"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Send verification email</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Login">Back to Login</a>
|
||||
<a class="btn btn-outline-secondary" asp-action="Register">Back to Register</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
41
PlotLine/Views/Account/ResetPassword.cshtml
Normal file
41
PlotLine/Views/Account/ResetPassword.cshtml
Normal file
@ -0,0 +1,41 @@
|
||||
@model ResetPasswordViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Reset password";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Reset password</h1>
|
||||
<p class="lead-text">Choose a new password for your PlotWeaver Studio account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="edit-panel">
|
||||
<form asp-action="ResetPassword" method="post" class="asset-create-form">
|
||||
<input asp-for="Token" 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="Password">New 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">Reset password</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="Login">Back to login</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
21
PlotLine/Views/Account/VerifyEmail.cshtml
Normal file
21
PlotLine/Views/Account/VerifyEmail.cshtml
Normal file
@ -0,0 +1,21 @@
|
||||
@model AccountStatusViewModel
|
||||
@{
|
||||
ViewData["Title"] = Model.Title;
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>@Model.Title</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="edit-panel">
|
||||
<div class="alert @(Model.Success ? "alert-info" : "alert-warning") mb-3">
|
||||
@Model.Message
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-primary" asp-action="Login">Login</a>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Home" asp-action="Index">Back to Home</a>
|
||||
</div>
|
||||
</section>
|
||||
17
PlotLine/Views/Account/VerifyEmailConfirmation.cshtml
Normal file
17
PlotLine/Views/Account/VerifyEmailConfirmation.cshtml
Normal file
@ -0,0 +1,17 @@
|
||||
@{
|
||||
ViewData["Title"] = "Email verified";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Email verified</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="edit-panel">
|
||||
<div class="alert alert-info mb-3">
|
||||
Your email address has been verified. You can now log in to PlotWeaver Studio.
|
||||
</div>
|
||||
<a class="btn btn-primary" asp-action="Login">Login</a>
|
||||
</section>
|
||||
@ -2,6 +2,15 @@
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=localhost;Database=PlotLine;User Id=PlotLineApp;Password=t6eb1cvd9bNB27czde#h9r;MultipleActiveResultSets=true;Encrypt=false;"
|
||||
},
|
||||
"EmailSettings": {
|
||||
"SmtpServer": "",
|
||||
"Port": 465,
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"EnableSsl": true,
|
||||
"FromAddress": "",
|
||||
"FromName": "PlotWeaver Studio"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user