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();
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")
{
if (string.IsNullOrWhiteSpace(code))
{
throw new InvalidOperationException("Guest access code cannot be empty.");
}
var cleanedCode = InputRules.CleanAccessCode(code);
var cleanedLabel = InputRules.CleanRequiredText(label, "a label", 80);
var document = new AccessCodeDocument
{
Id = GuestAccessCodeId,
Label = label,
Label = cleanedLabel,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
document.CodeHash = _hasher.HashPassword(document, code);
document.CodeHash = _hasher.HashPassword(document, cleanedCode);
await _accessCodes.ReplaceOneAsync(
x => x.Id == GuestAccessCodeId,
@@ -42,7 +48,13 @@ public sealed class AccessCodeService(MongoDbContext db)
public async Task<bool> ValidateGuestAccessCodeAsync(string code)
{
if (string.IsNullOrWhiteSpace(code))
string cleanedCode;
try
{
cleanedCode = InputRules.CleanAccessCode(code);
}
catch (InvalidOperationException)
{
return false;
}
@@ -64,7 +76,7 @@ public sealed class AccessCodeService(MongoDbContext db)
var result = _hasher.VerifyHashedPassword(
document,
document.CodeHash,
code);
cleanedCode);
return result == PasswordVerificationResult.Success;
}
@@ -97,6 +97,11 @@ public partial class Guests : ComponentBase
userId,
username);
}
catch (InvalidOperationException exception)
{
ErrorMessage = exception.Message;
return;
}
catch
{
ErrorMessage = "Guest could not be added.";
@@ -180,6 +185,11 @@ public partial class Guests : ComponentBase
EditGuestName,
EditGuestNotes);
}
catch (InvalidOperationException exception)
{
ErrorMessage = exception.Message;
return;
}
catch
{
ErrorMessage = "Guest could not be updated.";
+2 -2
View File
@@ -10,7 +10,7 @@
<div class="card-body">
<h2 class="h5 card-title">Manage guests</h2>
<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>
</section>
@@ -18,7 +18,7 @@
<div class="card-body">
<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>
<a class="btn btn-outline-primary" href="/list">Open guest list</a>
<a class="btn btn-primary" href="/list">Open guest list</a>
</div>
</section>
+35 -9
View File
@@ -15,11 +15,26 @@ public sealed class GuestEntryService(MongoDbContext db)
.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)
{
var normalizedName = name.Trim();
string normalizedName;
if (string.IsNullOrWhiteSpace(normalizedName))
try
{
normalizedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
}
catch (InvalidOperationException)
{
return false;
}
@@ -38,14 +53,19 @@ public sealed class GuestEntryService(MongoDbContext db)
public async Task<bool> NameAndNotesExistAsync(string name, string? notes, string? excludeId = null)
{
var normalizedName = name.Trim();
var normalizedNotes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
string normalizedName;
if (string.IsNullOrWhiteSpace(normalizedName))
try
{
normalizedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
}
catch (InvalidOperationException)
{
return false;
}
var normalizedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength);
var filter = Builders<GuestEntryDocument>.Filter.Regex(
x => x.Name,
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)
{
var cleanedName = InputRules.CleanRequiredText(name, "a guest name", InputRules.GuestNameMaxLength);
var cleanedNotes = InputRules.CleanOptionalText(notes, "Notes", InputRules.GuestNotesMaxLength);
var entry = new GuestEntryDocument
{
Name = name.Trim(),
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(),
Name = cleanedName,
Notes = cleanedNotes,
AddedByUserId = addedByUserId,
AddedByUsername = addedByUsername,
CreatedAt = DateTime.UtcNow
@@ -87,9 +110,12 @@ public sealed class GuestEntryService(MongoDbContext db)
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
.Set(x => x.Name, name.Trim())
.Set(x => x.Notes, string.IsNullOrWhiteSpace(notes) ? null : notes.Trim());
.Set(x => x.Name, cleanedName)
.Set(x => x.Notes, cleanedNotes);
await _guestEntries.UpdateOneAsync(x => x.Id == id, update);
}
+34
View File
@@ -28,6 +28,11 @@ builder.Services
{
options.LoginPath = "/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();
@@ -43,10 +48,22 @@ builder.Services.AddSession(options =>
options.IdleTimeout = TimeSpan.FromHours(8);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
});
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 (
HttpContext httpContext,
UserService userService) =>
@@ -57,6 +74,13 @@ app.MapPost("/auth/login", async (
var password = form["password"].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;
try
@@ -116,6 +140,11 @@ app.MapPost("/guest/access", async (
var form = await httpContext.Request.ReadFormAsync();
var accessCode = form["accessCode"].ToString();
if (accessCode.Length > InputRules.AccessCodeMaxLength)
{
return Results.Redirect("/guest?error=invalid");
}
bool isValid;
try
@@ -147,7 +176,12 @@ app.MapPost("/guest/logout", (HttpContext httpContext) =>
using (var scope = app.Services.CreateScope())
{
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
var guestEntryService = scope.ServiceProvider.GetRequiredService<GuestEntryService>();
var accessCodeService = scope.ServiceProvider.GetRequiredService<AccessCodeService>();
await userService.EnsureIndexesAsync();
await guestEntryService.EnsureIndexesAsync();
await accessCodeService.EnsureIndexesAsync();
}
// 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)
{
var normalizedUsername = username.Trim().ToLowerInvariant();
var normalizedUsername = InputRules.NormalizeUsername(username);
await _users.DeleteOneAsync(x => x.Username == normalizedUsername);
}
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))
{
@@ -50,7 +55,7 @@ public sealed class UserService(MongoDbContext db)
var user = new UserDocument
{
DisplayName = displayName.Trim(),
DisplayName = cleanedDisplayName,
Username = normalizedUsername,
Role = role,
IsActive = true,
@@ -143,7 +148,7 @@ public sealed class UserService(MongoDbContext db)
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;
}
@@ -158,7 +163,26 @@ public sealed class UserService(MongoDbContext db)
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
.Find(x => x.Username == normalizedUsername && x.IsActive)