@@ -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)
diff --git a/Configuration/SingleUserOptions.cs b/Configuration/SingleUserOptions.cs
index 6099f1b..fd38843 100644
--- a/Configuration/SingleUserOptions.cs
+++ b/Configuration/SingleUserOptions.cs
@@ -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";
}
diff --git a/Program.cs b/Program.cs
index c53deed..00e82cb 100644
--- a/Program.cs
+++ b/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
();
+ var logger = context.RequestServices.GetRequiredService().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
{
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();
+ 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();
+ var opts = context.RequestServices.GetRequiredService>();
+
+ var username = opts.Value.Username.Trim();
+ var id = username.Trim().ToUpperInvariant();
+
+ // Hash the configured seed password
+ var passwordHasher = new Microsoft.AspNetCore.Identity.PasswordHasher();
+ 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();
+ var opts = context.RequestServices.GetRequiredService>();
+
+ 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()
.AddInteractiveServerRenderMode();
diff --git a/Services/Auth/AuthUser.cs b/Services/Auth/AuthUser.cs
index 15042ca..37b5fa1 100644
--- a/Services/Auth/AuthUser.cs
+++ b/Services/Auth/AuthUser.cs
@@ -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;
}
\ No newline at end of file
diff --git a/Services/Auth/CouchbaseLiteAuthService.cs b/Services/Auth/CouchbaseLiteAuthService.cs
index 0e19485..2a00f3c 100644
--- a/Services/Auth/CouchbaseLiteAuthService.cs
+++ b/Services/Auth/CouchbaseLiteAuthService.cs
@@ -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 ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken)
+ public Task 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(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 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();
}
\ No newline at end of file
diff --git a/Services/Auth/IAuthService.cs b/Services/Auth/IAuthService.cs
index bbb2ad1..223fd88 100644
--- a/Services/Auth/IAuthService.cs
+++ b/Services/Auth/IAuthService.cs
@@ -4,5 +4,7 @@ public interface IAuthService
{
Task EnsureSeedUserAsync(CancellationToken cancellationToken);
- Task ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken);
+ Task ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken);
+
+ Task ChangePasswordAsync(string userId, string newPassword, CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/Services/Auth/SingleUserSeedService.cs b/Services/Auth/SingleUserSeedService.cs
index b696175..77c91d9 100644
--- a/Services/Auth/SingleUserSeedService.cs
+++ b/Services/Auth/SingleUserSeedService.cs
@@ -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);
}
}
diff --git a/WorkTracker.csproj b/WorkTracker.csproj
index d034e36..c764034 100644
--- a/WorkTracker.csproj
+++ b/WorkTracker.csproj
@@ -9,6 +9,14 @@
+
+
+
+
+
+
+ PreserveNewest
+
diff --git a/appsettings.json b/appsettings.json
index 5c197d0..579b485 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -5,8 +5,8 @@
},
"SingleUser": {
"SeedOnStartup": true,
- "Email": "admin@worktracker.local",
- "Password": "ChangeThis!123"
+ "Username": "Admin",
+ "Password": "Disagio"
},
"Logging": {
"LogLevel": {
diff --git a/nlog.config b/nlog.config
new file mode 100644
index 0000000..119c063
--- /dev/null
+++ b/nlog.config
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+