hardened app with gpt

This commit is contained in:
BeatriceAl
2026-05-11 21:58:42 +02:00
parent 0e7769ed3a
commit 74af533c37
6 changed files with 130 additions and 24 deletions
+20 -8
View File
@@ -12,22 +12,28 @@ public sealed class AccessCodeService(MongoDbContext db)
private readonly PasswordHasher<AccessCodeDocument> _hasher = new(); private readonly PasswordHasher<AccessCodeDocument> _hasher = new();
public async Task EnsureIndexesAsync()
{
var activeIndex = new CreateIndexModel<AccessCodeDocument>(
Builders<AccessCodeDocument>.IndexKeys.Ascending(x => x.IsActive));
await _accessCodes.Indexes.CreateOneAsync(activeIndex);
}
public async Task SetGuestAccessCodeAsync(string code, string label = "Guest access") public async Task SetGuestAccessCodeAsync(string code, string label = "Guest access")
{ {
if (string.IsNullOrWhiteSpace(code)) var cleanedCode = InputRules.CleanAccessCode(code);
{ var cleanedLabel = InputRules.CleanRequiredText(label, "a label", 80);
throw new InvalidOperationException("Guest access code cannot be empty.");
}
var document = new AccessCodeDocument var document = new AccessCodeDocument
{ {
Id = GuestAccessCodeId, Id = GuestAccessCodeId,
Label = label, Label = cleanedLabel,
IsActive = true, IsActive = true,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
document.CodeHash = _hasher.HashPassword(document, code); document.CodeHash = _hasher.HashPassword(document, cleanedCode);
await _accessCodes.ReplaceOneAsync( await _accessCodes.ReplaceOneAsync(
x => x.Id == GuestAccessCodeId, x => x.Id == GuestAccessCodeId,
@@ -42,7 +48,13 @@ public sealed class AccessCodeService(MongoDbContext db)
public async Task<bool> ValidateGuestAccessCodeAsync(string code) public async Task<bool> ValidateGuestAccessCodeAsync(string code)
{ {
if (string.IsNullOrWhiteSpace(code)) string cleanedCode;
try
{
cleanedCode = InputRules.CleanAccessCode(code);
}
catch (InvalidOperationException)
{ {
return false; return false;
} }
@@ -64,7 +76,7 @@ public sealed class AccessCodeService(MongoDbContext db)
var result = _hasher.VerifyHashedPassword( var result = _hasher.VerifyHashedPassword(
document, document,
document.CodeHash, document.CodeHash,
code); cleanedCode);
return result == PasswordVerificationResult.Success; return result == PasswordVerificationResult.Success;
} }
@@ -97,6 +97,11 @@ public partial class Guests : ComponentBase
userId, userId,
username); username);
} }
catch (InvalidOperationException exception)
{
ErrorMessage = exception.Message;
return;
}
catch catch
{ {
ErrorMessage = "Guest could not be added."; ErrorMessage = "Guest could not be added.";
@@ -180,6 +185,11 @@ public partial class Guests : ComponentBase
EditGuestName, EditGuestName,
EditGuestNotes); EditGuestNotes);
} }
catch (InvalidOperationException exception)
{
ErrorMessage = exception.Message;
return;
}
catch catch
{ {
ErrorMessage = "Guest could not be updated."; ErrorMessage = "Guest could not be updated.";
+2 -2
View File
@@ -10,7 +10,7 @@
<div class="card-body"> <div class="card-body">
<h2 class="h5 card-title">Manage guests</h2> <h2 class="h5 card-title">Manage guests</h2>
<p class="text-secondary">Add, edit and delete guest entries.</p> <p class="text-secondary">Add, edit and delete guest entries.</p>
<a class="btn btn-primary" href="/guests">Open management</a> <a class="btn btn-outline-primary" href="/guests">Open management</a>
</div> </div>
</section> </section>
@@ -18,7 +18,7 @@
<div class="card-body"> <div class="card-body">
<h2 class="h5 card-title">Guest list</h2> <h2 class="h5 card-title">Guest list</h2>
<p class="text-secondary">View the read-only list. Guests are asked for an access code when needed.</p> <p class="text-secondary">View the read-only list. Guests are asked for an access code when needed.</p>
<a class="btn btn-outline-primary" href="/list">Open guest list</a> <a class="btn btn-primary" href="/list">Open guest list</a>
</div> </div>
</section> </section>
+35 -9
View File
@@ -15,11 +15,26 @@ public sealed class GuestEntryService(MongoDbContext db)
.ToListAsync(); .ToListAsync();
} }
public async Task EnsureIndexesAsync()
{
var nameIndex = new CreateIndexModel<GuestEntryDocument>(
Builders<GuestEntryDocument>.IndexKeys.Ascending(x => x.Name));
var createdAtIndex = new CreateIndexModel<GuestEntryDocument>(
Builders<GuestEntryDocument>.IndexKeys.Descending(x => x.CreatedAt));
await _guestEntries.Indexes.CreateManyAsync([nameIndex, createdAtIndex]);
}
public async Task<bool> NameExistsAsync(string name, string? excludeId = null) public async Task<bool> NameExistsAsync(string name, string? excludeId = null)
{ {
var normalizedName = name.Trim(); string normalizedName;
if (string.IsNullOrWhiteSpace(normalizedName)) try
{
normalizedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
}
catch (InvalidOperationException)
{ {
return false; return false;
} }
@@ -38,14 +53,19 @@ public sealed class GuestEntryService(MongoDbContext db)
public async Task<bool> NameAndNotesExistAsync(string name, string? notes, string? excludeId = null) public async Task<bool> NameAndNotesExistAsync(string name, string? notes, string? excludeId = null)
{ {
var normalizedName = name.Trim(); string normalizedName;
var normalizedNotes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
if (string.IsNullOrWhiteSpace(normalizedName)) try
{
normalizedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
}
catch (InvalidOperationException)
{ {
return false; return false;
} }
var normalizedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength);
var filter = Builders<GuestEntryDocument>.Filter.Regex( var filter = Builders<GuestEntryDocument>.Filter.Regex(
x => x.Name, x => x.Name,
new MongoDB.Bson.BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(normalizedName)}$", "i")); new MongoDB.Bson.BsonRegularExpression($"^{System.Text.RegularExpressions.Regex.Escape(normalizedName)}$", "i"));
@@ -68,10 +88,13 @@ public sealed class GuestEntryService(MongoDbContext db)
public async Task AddAsync(string name, string? notes, string addedByUserId, string addedByUsername) public async Task AddAsync(string name, string? notes, string addedByUserId, string addedByUsername)
{ {
var cleanedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
var cleanedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength);
var entry = new GuestEntryDocument var entry = new GuestEntryDocument
{ {
Name = name.Trim(), Name = cleanedName,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(), Notes = cleanedNotes,
AddedByUserId = addedByUserId, AddedByUserId = addedByUserId,
AddedByUsername = addedByUsername, AddedByUsername = addedByUsername,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -87,9 +110,12 @@ public sealed class GuestEntryService(MongoDbContext db)
public async Task UpdateAsync(string id, string name, string? notes) public async Task UpdateAsync(string id, string name, string? notes)
{ {
var cleanedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
var cleanedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength);
var update = Builders<GuestEntryDocument>.Update var update = Builders<GuestEntryDocument>.Update
.Set(x => x.Name, name.Trim()) .Set(x => x.Name, cleanedName)
.Set(x => x.Notes, string.IsNullOrWhiteSpace(notes) ? null : notes.Trim()); .Set(x => x.Notes, cleanedNotes);
await _guestEntries.UpdateOneAsync(x => x.Id == id, update); await _guestEntries.UpdateOneAsync(x => x.Id == id, update);
} }
+34
View File
@@ -28,6 +28,11 @@ builder.Services
{ {
options.LoginPath = "/login"; options.LoginPath = "/login";
options.AccessDeniedPath = "/login"; options.AccessDeniedPath = "/login";
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
@@ -43,10 +48,22 @@ builder.Services.AddSession(options =>
options.IdleTimeout = TimeSpan.FromHours(8); options.IdleTimeout = TimeSpan.FromHours(8);
options.Cookie.HttpOnly = true; options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true; options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
}); });
var app = builder.Build(); var app = builder.Build();
app.Use(async (context, next) =>
{
context.Response.Headers.TryAdd("X-Content-Type-Options", "nosniff");
context.Response.Headers.TryAdd("X-Frame-Options", "DENY");
context.Response.Headers.TryAdd("Referrer-Policy", "no-referrer");
context.Response.Headers.TryAdd("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
await next();
});
app.MapPost("/auth/login", async ( app.MapPost("/auth/login", async (
HttpContext httpContext, HttpContext httpContext,
UserService userService) => UserService userService) =>
@@ -57,6 +74,13 @@ app.MapPost("/auth/login", async (
var password = form["password"].ToString(); var password = form["password"].ToString();
var returnUrl = form["returnUrl"].ToString(); var returnUrl = form["returnUrl"].ToString();
if (username.Length > InputRules.UsernameMaxLength ||
password.Length > InputRules.PasswordMaxLength ||
returnUrl.Length > 300)
{
return Results.Redirect("/login?error=invalid");
}
UserDocument? user; UserDocument? user;
try try
@@ -116,6 +140,11 @@ app.MapPost("/guest/access", async (
var form = await httpContext.Request.ReadFormAsync(); var form = await httpContext.Request.ReadFormAsync();
var accessCode = form["accessCode"].ToString(); var accessCode = form["accessCode"].ToString();
if (accessCode.Length > InputRules.AccessCodeMaxLength)
{
return Results.Redirect("/guest?error=invalid");
}
bool isValid; bool isValid;
try try
@@ -147,7 +176,12 @@ app.MapPost("/guest/logout", (HttpContext httpContext) =>
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var userService = scope.ServiceProvider.GetRequiredService<UserService>(); var userService = scope.ServiceProvider.GetRequiredService<UserService>();
var guestEntryService = scope.ServiceProvider.GetRequiredService<GuestEntryService>();
var accessCodeService = scope.ServiceProvider.GetRequiredService<AccessCodeService>();
await userService.EnsureIndexesAsync(); await userService.EnsureIndexesAsync();
await guestEntryService.EnsureIndexesAsync();
await accessCodeService.EnsureIndexesAsync();
} }
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
+29 -5
View File
@@ -35,13 +35,18 @@ public sealed class UserService(MongoDbContext db)
public async Task DeleteUserByUsernameAsync(string username) public async Task DeleteUserByUsernameAsync(string username)
{ {
var normalizedUsername = username.Trim().ToLowerInvariant(); var normalizedUsername = InputRules.NormalizeUsername(username);
await _users.DeleteOneAsync(x => x.Username == normalizedUsername); await _users.DeleteOneAsync(x => x.Username == normalizedUsername);
} }
private async Task CreateUserAsync(string displayName, string username, string password, string role) private async Task CreateUserAsync(string displayName, string username, string password, string role)
{ {
var normalizedUsername = username.Trim().ToLowerInvariant(); var normalizedUsername = InputRules.NormalizeUsername(username);
var cleanedDisplayName = string.IsNullOrWhiteSpace(displayName)
? normalizedUsername
: InputRules.CleanRequiredText(displayName, "a name", InputRules.DisplayNameMaxLength);
InputRules.ValidatePassword(password);
if (await UsernameExistsAsync(normalizedUsername)) if (await UsernameExistsAsync(normalizedUsername))
{ {
@@ -50,7 +55,7 @@ public sealed class UserService(MongoDbContext db)
var user = new UserDocument var user = new UserDocument
{ {
DisplayName = displayName.Trim(), DisplayName = cleanedDisplayName,
Username = normalizedUsername, Username = normalizedUsername,
Role = role, Role = role,
IsActive = true, IsActive = true,
@@ -143,7 +148,7 @@ public sealed class UserService(MongoDbContext db)
public async Task<bool> UsernameExistsAsync(string username) public async Task<bool> UsernameExistsAsync(string username)
{ {
var normalizedUsername = username.Trim().ToLowerInvariant(); var normalizedUsername = InputRules.NormalizeUsername(username);
return await _users.CountDocumentsAsync(x => x.Username == normalizedUsername) > 0; return await _users.CountDocumentsAsync(x => x.Username == normalizedUsername) > 0;
} }
@@ -158,7 +163,26 @@ public sealed class UserService(MongoDbContext db)
public async Task<UserDocument?> ValidateCredentialsAsync(string username, string password) public async Task<UserDocument?> ValidateCredentialsAsync(string username, string password)
{ {
var normalizedUsername = username.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return null;
}
if (password.Length > InputRules.PasswordMaxLength)
{
return null;
}
string normalizedUsername;
try
{
normalizedUsername = InputRules.NormalizeUsername(username);
}
catch (InvalidOperationException)
{
return null;
}
var user = await _users var user = await _users
.Find(x => x.Username == normalizedUsername && x.IsActive) .Find(x => x.Username == normalizedUsername && x.IsActive)