Refactor authentication system to use username instead of email; implement change password functionality and logging; add NLog for logging support
Some checks failed
Publish Container / publish (push) Failing after 1m9s
Some checks failed
Publish Container / publish (push) Failing after 1m9s
This commit is contained in:
parent
d3887f1dd0
commit
6e3371514e
12 changed files with 287 additions and 45 deletions
|
|
@ -28,7 +28,7 @@
|
|||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<form action="/logout" method="post">
|
||||
<form action="/api/logout" method="post">
|
||||
<button type="submit" class="nav-link">
|
||||
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
|
||||
</button>
|
||||
|
|
|
|||
33
Components/Pages/ChangePassword.razor
Normal file
33
Components/Pages/ChangePassword.razor
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@page "/change-password"
|
||||
@attribute [Authorize]
|
||||
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<PageTitle>Change Password</PageTitle>
|
||||
|
||||
<h1>Change password</h1>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Error))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@Error</div>
|
||||
}
|
||||
|
||||
<form method="post" action="/api/change-password" class="d-flex flex-column gap-3" style="max-width: 420px;">
|
||||
<div>
|
||||
<label for="newPassword" class="form-label">New password</label>
|
||||
<input id="newPassword" name="newPassword" type="password" autocomplete="new-password" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmPassword" class="form-label">Confirm new password</label>
|
||||
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Change password</button>
|
||||
</form>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
|
@ -13,10 +13,10 @@
|
|||
<div class="alert alert-danger" role="alert">@Error</div>
|
||||
}
|
||||
|
||||
<form method="post" action="/login" class="d-flex flex-column gap-3" style="max-width: 420px;">
|
||||
<form method="post" action="/api/login" class="d-flex flex-column gap-3" style="max-width: 420px;">
|
||||
<div>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input id="email" name="email" value="@Email" autocomplete="username" class="form-control" required />
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" name="username" value="@Username" autocomplete="username" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
public string? Error { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery]
|
||||
public string? Email { get; set; }
|
||||
public string? Username { get; set; }
|
||||
|
||||
private string SafeReturnUrl =>
|
||||
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ public sealed class SingleUserOptions
|
|||
|
||||
public bool SeedOnStartup { get; init; } = true;
|
||||
|
||||
public string Email { get; init; } = "admin@worktracker.local";
|
||||
public string Username { get; init; } = "Admin";
|
||||
|
||||
public string Password { get; init; } = "ChangeThis!123";
|
||||
public string Password { get; init; } = "Disagio";
|
||||
}
|
||||
|
|
|
|||
153
Program.cs
153
Program.cs
|
|
@ -1,10 +1,13 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Claims;
|
||||
using NLog.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Couchbase.Lite;
|
||||
using WorkTracker.Components;
|
||||
using WorkTracker.Configuration;
|
||||
using WorkTracker.Services.Auth;
|
||||
|
|
@ -14,6 +17,9 @@ using WorkTracker.Services.Storage;
|
|||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Host.UseNLog();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
|
@ -78,46 +84,173 @@ app.UseAuthorization();
|
|||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapPost("/login", async (HttpContext context, IAuthService authService) =>
|
||||
app.MapPost("/api/login", async (HttpContext context) =>
|
||||
{
|
||||
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var email = form["email"].ToString();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var returnUrl = form["returnUrl"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return TypedResults.LocalRedirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
context.Response.Redirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await authService.ValidateCredentialsAsync(email, password, context.RequestAborted);
|
||||
var user = await authService.ValidateCredentialsAsync(username, password, context.RequestAborted);
|
||||
if (user is null)
|
||||
{
|
||||
return TypedResults.LocalRedirect($"/login?error=Invalid%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
logger.LogWarning("Login failed for username {Username}", username);
|
||||
context.Response.Redirect($"/login?error=Invalid%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id),
|
||||
new(ClaimTypes.Name, user.Email)
|
||||
new(ClaimTypes.Name, user.Username)
|
||||
};
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme));
|
||||
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
|
||||
// If the seeded or existing user must change password, redirect to change-password flow
|
||||
if (user.MustChangePassword)
|
||||
{
|
||||
context.Response.Redirect("/change-password");
|
||||
return;
|
||||
}
|
||||
|
||||
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
|
||||
? "/"
|
||||
: returnUrl;
|
||||
|
||||
return TypedResults.LocalRedirect(safeReturnUrl);
|
||||
context.Response.Redirect(safeReturnUrl);
|
||||
return;
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPost("/logout", async (HttpContext context) =>
|
||||
app.MapPost("/api/logout", async (HttpContext context) =>
|
||||
{
|
||||
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return TypedResults.LocalRedirect("/login");
|
||||
context.Response.Redirect("/login");
|
||||
return;
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPost("/api/change-password", async (HttpContext context) =>
|
||||
{
|
||||
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
||||
if (!context.User?.Identity?.IsAuthenticated ?? true)
|
||||
{
|
||||
await context.ChallengeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var newPassword = form["newPassword"].ToString();
|
||||
var confirm = form["confirmPassword"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || string.IsNullOrWhiteSpace(confirm) || newPassword != confirm)
|
||||
{
|
||||
context.Response.Redirect("/change-password?error=Passwords%20do%20not%20match");
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await context.ChallengeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = await authService.ChangePasswordAsync(userId, newPassword, context.RequestAborted);
|
||||
if (!changed)
|
||||
{
|
||||
context.Response.Redirect("/change-password?error=Unable%20to%20change%20password");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Redirect("/");
|
||||
return;
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapGet("/debug/reset-admin/{secret}", (HttpContext context, string secret) =>
|
||||
{
|
||||
const string expected = "81f58012-fe0b-4dcf-a638-77f9b99f92e3";
|
||||
if (!string.Equals(secret, expected, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.StatusCode(403);
|
||||
}
|
||||
|
||||
var provider = context.RequestServices.GetRequiredService<CouchbaseLiteDatabaseProvider>();
|
||||
var opts = context.RequestServices.GetRequiredService<IOptions<SingleUserOptions>>();
|
||||
|
||||
var username = opts.Value.Username.Trim();
|
||||
var id = username.Trim().ToUpperInvariant();
|
||||
|
||||
// Hash the configured seed password
|
||||
var passwordHasher = new Microsoft.AspNetCore.Identity.PasswordHasher<WorkTracker.Services.Auth.AuthUser>();
|
||||
var tempUser = new WorkTracker.Services.Auth.AuthUser { Id = id, Username = username, UsernameNormalized = id };
|
||||
var newHash = passwordHasher.HashPassword(tempUser, opts.Value.Password);
|
||||
|
||||
var doc = provider.Users.GetDocument(id);
|
||||
if (doc is null)
|
||||
{
|
||||
var mutable = new MutableDocument(id);
|
||||
mutable.SetString("username", username);
|
||||
mutable.SetString("usernameNormalized", id);
|
||||
mutable.SetString("passwordHash", newHash);
|
||||
mutable.SetBoolean("mustChangePassword", true);
|
||||
provider.Users.Save(mutable);
|
||||
}
|
||||
else
|
||||
{
|
||||
var mutable = doc.ToMutable();
|
||||
mutable.SetString("passwordHash", newHash);
|
||||
mutable.SetBoolean("mustChangePassword", true);
|
||||
provider.Users.Save(mutable);
|
||||
}
|
||||
|
||||
return Results.Json(new { ok = true, id, username });
|
||||
});
|
||||
|
||||
app.MapGet("/debug/users/{secret}", (HttpContext context, string secret) =>
|
||||
{
|
||||
const string expected = "81f58012-fe0b-4dcf-a638-77f9b99f92e3";
|
||||
if (!string.Equals(secret, expected, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.StatusCode(403);
|
||||
}
|
||||
|
||||
var provider = context.RequestServices.GetRequiredService<CouchbaseLiteDatabaseProvider>();
|
||||
var opts = context.RequestServices.GetRequiredService<IOptions<SingleUserOptions>>();
|
||||
|
||||
var username = opts.Value.Username.Trim();
|
||||
var id = username.Trim().ToUpperInvariant();
|
||||
|
||||
var doc = provider.Users.GetDocument(id);
|
||||
if (doc is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
id = doc.Id,
|
||||
username = doc.GetString("username") ?? doc.GetString("email"),
|
||||
usernameNormalized = doc.GetString("usernameNormalized") ?? doc.GetString("emailNormalized"),
|
||||
passwordHash = doc.GetString("passwordHash"),
|
||||
mustChangePassword = doc.GetBoolean("mustChangePassword")
|
||||
};
|
||||
|
||||
return Results.Json(result);
|
||||
});
|
||||
}
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ public sealed class AuthUser
|
|||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string Email { get; init; } = string.Empty;
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string EmailNormalized { get; init; } = string.Empty;
|
||||
public string UsernameNormalized { get; init; } = string.Empty;
|
||||
|
||||
public string PasswordHash { get; init; } = string.Empty;
|
||||
|
||||
public bool MustChangePassword { get; init; } = false;
|
||||
}
|
||||
|
|
@ -27,53 +27,71 @@ public sealed class CouchbaseLiteAuthService : IAuthService
|
|||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var email = options.Value.Email.Trim();
|
||||
var normalizedEmail = NormalizeEmail(email);
|
||||
var username = options.Value.Username.Trim();
|
||||
var normalizedUsername = NormalizeUsername(username);
|
||||
|
||||
if (users.GetDocument(normalizedEmail) is not null)
|
||||
if (users.GetDocument(normalizedUsername) is not null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var user = new AuthUser
|
||||
{
|
||||
Id = normalizedEmail,
|
||||
Email = email,
|
||||
EmailNormalized = normalizedEmail,
|
||||
PasswordHash = string.Empty
|
||||
Id = normalizedUsername,
|
||||
Username = username,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
PasswordHash = string.Empty,
|
||||
MustChangePassword = true
|
||||
};
|
||||
|
||||
var passwordHash = passwordHasher.HashPassword(user, options.Value.Password);
|
||||
var userToCreate = new AuthUser
|
||||
{
|
||||
Id = normalizedEmail,
|
||||
Email = email,
|
||||
EmailNormalized = normalizedEmail,
|
||||
Id = normalizedUsername,
|
||||
Username = username,
|
||||
UsernameNormalized = normalizedUsername,
|
||||
PasswordHash = passwordHash
|
||||
};
|
||||
|
||||
SaveUser(userToCreate);
|
||||
logger.LogInformation("Seeded single user account {Email} in Couchbase Lite", email);
|
||||
// Ensure the seeded user requires a password change on first login
|
||||
var seeded = new AuthUser
|
||||
{
|
||||
Id = userToCreate.Id,
|
||||
Username = userToCreate.Username,
|
||||
UsernameNormalized = userToCreate.UsernameNormalized,
|
||||
PasswordHash = userToCreate.PasswordHash,
|
||||
MustChangePassword = true
|
||||
};
|
||||
|
||||
SaveUser(seeded);
|
||||
logger.LogInformation("Seeded single user account {Username} in Couchbase Lite", username);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AuthUser?> ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken)
|
||||
public Task<AuthUser?> ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var user = GetUser(NormalizeEmail(email));
|
||||
var normalizedUsername = NormalizeUsername(username);
|
||||
var user = GetUser(normalizedUsername);
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogWarning("Authentication failed: user {Username} not found", username);
|
||||
return Task.FromResult<AuthUser?>(null);
|
||||
}
|
||||
|
||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
{
|
||||
logger.LogWarning("Authentication failed: invalid password for user {Username}", username);
|
||||
}
|
||||
|
||||
return Task.FromResult(result == PasswordVerificationResult.Failed ? null : user);
|
||||
}
|
||||
|
||||
private AuthUser? GetUser(string normalizedEmail)
|
||||
private AuthUser? GetUser(string normalizedUsername)
|
||||
{
|
||||
var document = users.GetDocument(normalizedEmail);
|
||||
var document = users.GetDocument(normalizedUsername);
|
||||
if (document is null)
|
||||
{
|
||||
return null;
|
||||
|
|
@ -82,21 +100,49 @@ public sealed class CouchbaseLiteAuthService : IAuthService
|
|||
return new AuthUser
|
||||
{
|
||||
Id = document.Id,
|
||||
Email = document.GetString("email") ?? string.Empty,
|
||||
EmailNormalized = document.GetString("emailNormalized") ?? string.Empty,
|
||||
PasswordHash = document.GetString("passwordHash") ?? string.Empty
|
||||
Username = document.GetString("username") ?? document.GetString("email") ?? string.Empty,
|
||||
UsernameNormalized = document.GetString("usernameNormalized") ?? document.GetString("emailNormalized") ?? string.Empty,
|
||||
PasswordHash = document.GetString("passwordHash") ?? string.Empty,
|
||||
MustChangePassword = document.GetBoolean("mustChangePassword")
|
||||
};
|
||||
}
|
||||
|
||||
private void SaveUser(AuthUser user)
|
||||
{
|
||||
var document = new MutableDocument(user.Id);
|
||||
document.SetString("email", user.Email);
|
||||
document.SetString("emailNormalized", user.EmailNormalized);
|
||||
document.SetString("username", user.Username);
|
||||
document.SetString("usernameNormalized", user.UsernameNormalized);
|
||||
document.SetString("passwordHash", user.PasswordHash);
|
||||
document.SetBoolean("mustChangePassword", user.MustChangePassword);
|
||||
|
||||
users.Save(document);
|
||||
}
|
||||
|
||||
private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
|
||||
public Task<bool> ChangePasswordAsync(string userId, string newPassword, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var document = users.GetDocument(userId);
|
||||
if (document is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var user = GetUser(userId);
|
||||
if (user is null)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var newHash = passwordHasher.HashPassword(user, newPassword);
|
||||
|
||||
var mutable = document.ToMutable();
|
||||
mutable.SetString("passwordHash", newHash);
|
||||
mutable.SetBoolean("mustChangePassword", false);
|
||||
users.Save(mutable);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username) => username.Trim().ToUpperInvariant();
|
||||
}
|
||||
|
|
@ -4,5 +4,7 @@ public interface IAuthService
|
|||
{
|
||||
Task EnsureSeedUserAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<AuthUser?> ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken);
|
||||
Task<AuthUser?> ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ChangePasswordAsync(string userId, string newPassword, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ public sealed class SingleUserSeedService : IHostedService
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unable to seed Couchbase Lite single user account {Email}", options.Value.Email);
|
||||
logger.LogError(ex, "Unable to seed Couchbase Lite single user account {Username}", options.Value.Username);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Couchbase.Lite" Version="4.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="nlog.config">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
},
|
||||
"SingleUser": {
|
||||
"SeedOnStartup": true,
|
||||
"Email": "admin@worktracker.local",
|
||||
"Password": "ChangeThis!123"
|
||||
"Username": "Admin",
|
||||
"Password": "Disagio"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
|
|
|||
18
nlog.config
Normal file
18
nlog.config
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
autoReload="true"
|
||||
throwConfigExceptions="true"
|
||||
internalLogLevel="Warn">
|
||||
|
||||
<targets>
|
||||
<target xsi:type="Console" name="console"
|
||||
layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
|
||||
<target xsi:type="Debugger" name="debug"
|
||||
layout="${longdate}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />
|
||||
</targets>
|
||||
|
||||
<rules>
|
||||
<logger name="*" minlevel="Debug" writeTo="console,debug" />
|
||||
</rules>
|
||||
</nlog>
|
||||
Loading…
Add table
Add a link
Reference in a new issue