316 lines
11 KiB
C#
316 lines
11 KiB
C#
using System.Globalization;
|
|
using System.Security.Claims;
|
|
using NLog.Web;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
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;
|
|
using WorkTracker.Services.Festivities;
|
|
using WorkTracker.Services.Settings;
|
|
using WorkTracker.Services.Storage;
|
|
using WorkTracker.Services.WorkDays;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Logging.ClearProviders();
|
|
builder.Host.UseNLog();
|
|
|
|
// Add services to the container.
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
|
|
builder.Services.Configure<AppAuthOptions>(builder.Configuration.GetSection(AppAuthOptions.SectionName));
|
|
var appAuthOptions = builder.Configuration.GetSection(AppAuthOptions.SectionName).Get<AppAuthOptions>() ?? new AppAuthOptions();
|
|
var authenticationEnabled = appAuthOptions.Enabled;
|
|
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
builder.Services.AddAuthorization();
|
|
|
|
if (authenticationEnabled)
|
|
{
|
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/login";
|
|
options.LogoutPath = "/logout";
|
|
options.AccessDeniedPath = "/login";
|
|
options.SlidingExpiration = true;
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
builder.Services.AddAuthentication(DefaultAdminAuthenticationHandler.SchemeName)
|
|
.AddScheme<AuthenticationSchemeOptions, DefaultAdminAuthenticationHandler>(
|
|
DefaultAdminAuthenticationHandler.SchemeName,
|
|
static _ => { });
|
|
}
|
|
|
|
builder.Services.AddLocalization();
|
|
|
|
builder.Services.Configure<CouchbaseLiteOptions>(builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName));
|
|
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
|
|
|
|
builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
|
|
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
|
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
|
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
|
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
|
|
builder.Services.AddHostedService<SingleUserSeedService>();
|
|
|
|
var app = builder.Build();
|
|
|
|
var italianCulture = new CultureInfo("it-IT");
|
|
CultureInfo.DefaultThreadCurrentCulture = italianCulture;
|
|
CultureInfo.DefaultThreadCurrentUICulture = italianCulture;
|
|
|
|
var localizationOptions = new RequestLocalizationOptions
|
|
{
|
|
DefaultRequestCulture = new RequestCulture(italianCulture),
|
|
SupportedCultures = [italianCulture],
|
|
SupportedUICultures = [italianCulture]
|
|
};
|
|
|
|
// Configure the HTTP request pipeline.
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
|
app.UseHsts();
|
|
}
|
|
|
|
var useHttpsRedirection = app.Configuration.GetValue("UseHttpsRedirection", !app.Environment.IsDevelopment());
|
|
if (useHttpsRedirection)
|
|
{
|
|
app.UseHttpsRedirection();
|
|
}
|
|
|
|
app.UseStaticFiles();
|
|
|
|
app.UseRequestLocalization(localizationOptions);
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.UseAntiforgery();
|
|
|
|
static string GetSafeReturnUrl(string? returnUrl)
|
|
{
|
|
return string.IsNullOrWhiteSpace(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)
|
|
? "/"
|
|
: returnUrl;
|
|
}
|
|
|
|
app.MapPost("/api/login", async (HttpContext context) =>
|
|
{
|
|
var form = await context.Request.ReadFormAsync();
|
|
var returnUrl = form["returnUrl"].ToString();
|
|
var safeReturnUrl = GetSafeReturnUrl(returnUrl);
|
|
|
|
if (!authenticationEnabled)
|
|
{
|
|
context.Response.Redirect(safeReturnUrl);
|
|
return;
|
|
}
|
|
|
|
var authService = context.RequestServices.GetRequiredService<IAuthService>();
|
|
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Auth.Login");
|
|
var username = form["username"].ToString();
|
|
var password = form["password"].ToString();
|
|
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
context.Response.Redirect($"/login?error=Missing%20credentials&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
|
return;
|
|
}
|
|
|
|
var user = await authService.ValidateCredentialsAsync(username, 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;
|
|
}
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, user.Id),
|
|
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;
|
|
}
|
|
|
|
context.Response.Redirect(safeReturnUrl);
|
|
return;
|
|
}).DisableAntiforgery();
|
|
|
|
app.MapPost("/api/logout", async (HttpContext context) =>
|
|
{
|
|
if (!authenticationEnabled)
|
|
{
|
|
context.Response.Redirect("/");
|
|
return;
|
|
}
|
|
|
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
context.Response.Redirect("/login");
|
|
return;
|
|
}).DisableAntiforgery();
|
|
|
|
app.MapPost("/api/change-password", async (HttpContext context) =>
|
|
{
|
|
if (!authenticationEnabled)
|
|
{
|
|
context.Response.Redirect("/");
|
|
return;
|
|
}
|
|
|
|
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();
|
|
|
|
app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions<AppAuthOptions> authOptions, CouchbaseLiteDatabaseProvider databaseProvider) =>
|
|
{
|
|
return Results.Json(new
|
|
{
|
|
status = "Healthy",
|
|
authenticationEnabled = authOptions.Value.Enabled,
|
|
currentUser = context.User.Identity?.Name,
|
|
environment = app.Environment.EnvironmentName,
|
|
storage = new
|
|
{
|
|
appSettingsReady = databaseProvider.AppSettings is not null,
|
|
usersReady = databaseProvider.Users is not null,
|
|
workDaysReady = databaseProvider.WorkDays is not null
|
|
},
|
|
timestampUtc = DateTimeOffset.UtcNow
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
|
|
app.Run();
|