hardened app with gpt
This commit is contained in:
@@ -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.";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user