@@ -37,7 +37,7 @@
public string? Error { get; set; }
[SupplyParameterFromQuery]
- public string? Username { get; set; }
+ public string? Email { get; set; }
private string SafeReturnUrl =>
string.IsNullOrWhiteSpace(ReturnUrl) || !Uri.IsWellFormedUriString(ReturnUrl, UriKind.Relative)
diff --git a/Configuration/SingleUserOptions.cs b/Configuration/SingleUserOptions.cs
index fd38843..6099f1b 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 Username { get; init; } = "Admin";
+ public string Email { get; init; } = "admin@worktracker.local";
- public string Password { get; init; } = "Disagio";
+ public string Password { get; init; } = "ChangeThis!123";
}
diff --git a/Dockerfile b/Dockerfile
index 41354d4..d452343 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS dev
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble AS dev
WORKDIR /workspace
RUN apt-get update \
@@ -10,7 +10,7 @@ ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
-FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
+FROM mcr.microsoft.com/dotnet/sdk:9.0-noble AS build
WORKDIR /src
COPY ["WorkTracker.csproj", "./"]
@@ -19,7 +19,7 @@ RUN dotnet restore "WorkTracker.csproj"
COPY . .
RUN dotnet publish "WorkTracker.csproj" -c Release -o /app/publish /p:UseAppHost=false
-FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
+FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble AS final
WORKDIR /app
RUN apt-get update \
diff --git a/Program.cs b/Program.cs
index 00e82cb..06dbbe3 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,13 +1,10 @@
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;
@@ -17,9 +14,6 @@ using WorkTracker.Services.Storage;
var builder = WebApplication.CreateBuilder(args);
-builder.Logging.ClearProviders();
-builder.Host.UseNLog();
-
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -75,8 +69,6 @@ if (useHttpsRedirection)
app.UseHttpsRedirection();
}
-app.UseStaticFiles();
-
app.UseRequestLocalization(localizationOptions);
app.UseAuthentication();
@@ -84,173 +76,46 @@ app.UseAuthorization();
app.UseAntiforgery();
-app.MapPost("/api/login", async (HttpContext context) =>
+app.MapPost("/login", async (HttpContext context, IAuthService authService) =>
{
- var authService = context.RequestServices.GetRequiredService
();
- var logger = context.RequestServices.GetRequiredService().CreateLogger("Auth.Login");
var form = await context.Request.ReadFormAsync();
- var username = form["username"].ToString();
+ var email = form["email"].ToString();
var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString();
- if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
{
- context.Response.Redirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
- return;
+ return TypedResults.LocalRedirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
}
- var user = await authService.ValidateCredentialsAsync(username, password, context.RequestAborted);
+ var user = await authService.ValidateCredentialsAsync(email, password, context.RequestAborted);
if (user is null)
{
- logger.LogWarning("Login failed for username {Username}", username);
- context.Response.Redirect($"/login?error=Invalid%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
- return;
+ return TypedResults.LocalRedirect($"/login?error=Invalid%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
}
var claims = new List
{
new(ClaimTypes.NameIdentifier, user.Id),
- new(ClaimTypes.Name, user.Username)
+ new(ClaimTypes.Name, user.Email)
};
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;
- context.Response.Redirect(safeReturnUrl);
- return;
+ return TypedResults.LocalRedirect(safeReturnUrl);
}).DisableAntiforgery();
-app.MapPost("/api/logout", async (HttpContext context) =>
+app.MapPost("/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
- context.Response.Redirect("/login");
- return;
+ return TypedResults.LocalRedirect("/login");
}).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 37b5fa1..15042ca 100644
--- a/Services/Auth/AuthUser.cs
+++ b/Services/Auth/AuthUser.cs
@@ -4,11 +4,9 @@ public sealed class AuthUser
{
public string Id { get; init; } = string.Empty;
- public string Username { get; init; } = string.Empty;
+ public string Email { get; init; } = string.Empty;
- public string UsernameNormalized { get; init; } = string.Empty;
+ public string EmailNormalized { 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 2a00f3c..0e19485 100644
--- a/Services/Auth/CouchbaseLiteAuthService.cs
+++ b/Services/Auth/CouchbaseLiteAuthService.cs
@@ -27,71 +27,53 @@ public sealed class CouchbaseLiteAuthService : IAuthService
{
cancellationToken.ThrowIfCancellationRequested();
- var username = options.Value.Username.Trim();
- var normalizedUsername = NormalizeUsername(username);
+ var email = options.Value.Email.Trim();
+ var normalizedEmail = NormalizeEmail(email);
- if (users.GetDocument(normalizedUsername) is not null)
+ if (users.GetDocument(normalizedEmail) is not null)
{
return Task.CompletedTask;
}
var user = new AuthUser
{
- Id = normalizedUsername,
- Username = username,
- UsernameNormalized = normalizedUsername,
- PasswordHash = string.Empty,
- MustChangePassword = true
+ Id = normalizedEmail,
+ Email = email,
+ EmailNormalized = normalizedEmail,
+ PasswordHash = string.Empty
};
var passwordHash = passwordHasher.HashPassword(user, options.Value.Password);
var userToCreate = new AuthUser
{
- Id = normalizedUsername,
- Username = username,
- UsernameNormalized = normalizedUsername,
+ Id = normalizedEmail,
+ Email = email,
+ EmailNormalized = normalizedEmail,
PasswordHash = passwordHash
};
- // 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);
+ SaveUser(userToCreate);
+ logger.LogInformation("Seeded single user account {Email} in Couchbase Lite", email);
return Task.CompletedTask;
}
- public Task ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken)
+ public Task ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
- var normalizedUsername = NormalizeUsername(username);
- var user = GetUser(normalizedUsername);
+ var user = GetUser(NormalizeEmail(email));
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 normalizedUsername)
+ private AuthUser? GetUser(string normalizedEmail)
{
- var document = users.GetDocument(normalizedUsername);
+ var document = users.GetDocument(normalizedEmail);
if (document is null)
{
return null;
@@ -100,49 +82,21 @@ public sealed class CouchbaseLiteAuthService : IAuthService
return new AuthUser
{
Id = document.Id,
- 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")
+ Email = document.GetString("email") ?? string.Empty,
+ EmailNormalized = document.GetString("emailNormalized") ?? string.Empty,
+ PasswordHash = document.GetString("passwordHash") ?? string.Empty
};
}
private void SaveUser(AuthUser user)
{
var document = new MutableDocument(user.Id);
- document.SetString("username", user.Username);
- document.SetString("usernameNormalized", user.UsernameNormalized);
+ document.SetString("email", user.Email);
+ document.SetString("emailNormalized", user.EmailNormalized);
document.SetString("passwordHash", user.PasswordHash);
- document.SetBoolean("mustChangePassword", user.MustChangePassword);
users.Save(document);
}
- 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();
+ private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
}
\ No newline at end of file
diff --git a/Services/Auth/IAuthService.cs b/Services/Auth/IAuthService.cs
index 223fd88..bbb2ad1 100644
--- a/Services/Auth/IAuthService.cs
+++ b/Services/Auth/IAuthService.cs
@@ -4,7 +4,5 @@ public interface IAuthService
{
Task EnsureSeedUserAsync(CancellationToken cancellationToken);
- Task ValidateCredentialsAsync(string username, string password, CancellationToken cancellationToken);
-
- Task ChangePasswordAsync(string userId, string newPassword, CancellationToken cancellationToken);
+ Task ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken);
}
\ No newline at end of file
diff --git a/Services/Auth/SingleUserSeedService.cs b/Services/Auth/SingleUserSeedService.cs
index 77c91d9..b696175 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 {Username}", options.Value.Username);
+ logger.LogError(ex, "Unable to seed Couchbase Lite single user account {Email}", options.Value.Email);
}
}
diff --git a/WorkTracker.csproj b/WorkTracker.csproj
index c764034..4f2e010 100644
--- a/WorkTracker.csproj
+++ b/WorkTracker.csproj
@@ -1,7 +1,7 @@
- net10.0
+ net9.0
enable
enable
aspnet-WorkTracker-28f934c3-03b2-413d-afbf-a5edbadc5530
@@ -9,14 +9,6 @@
-
-
-
-
-
-
- PreserveNewest
-
diff --git a/appsettings.json b/appsettings.json
index 579b485..5c197d0 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -5,8 +5,8 @@
},
"SingleUser": {
"SeedOnStartup": true,
- "Username": "Admin",
- "Password": "Disagio"
+ "Email": "admin@worktracker.local",
+ "Password": "ChangeThis!123"
},
"Logging": {
"LogLevel": {
diff --git a/nlog.config b/nlog.config
deleted file mode 100644
index 119c063..0000000
--- a/nlog.config
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-