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();
|
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.";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user