Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
namespace Backend.Security;
|
||||
|
||||
public static class AdminRoles
|
||||
{
|
||||
public const string Viewer = "viewer";
|
||||
public const string ContentAdmin = "content_admin";
|
||||
public const string Admin = "admin";
|
||||
public const string Owner = "owner";
|
||||
|
||||
public static string Normalize(string? role)
|
||||
{
|
||||
var normalizedRole = (role ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_');
|
||||
|
||||
return normalizedRole switch
|
||||
{
|
||||
Owner => Owner,
|
||||
Admin => Admin,
|
||||
ContentAdmin => ContentAdmin,
|
||||
_ => Viewer,
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsKnownRole(string? role)
|
||||
{
|
||||
var normalizedRole = (role ?? string.Empty).Trim().ToLowerInvariant().Replace('-', '_');
|
||||
return normalizedRole is Viewer or ContentAdmin or Admin or Owner;
|
||||
}
|
||||
|
||||
public static bool CanAccessAdmin(string? role) =>
|
||||
Normalize(role) is ContentAdmin or Admin or Owner;
|
||||
|
||||
public static bool CanManageContent(string? role) =>
|
||||
Normalize(role) is ContentAdmin or Admin or Owner;
|
||||
|
||||
public static bool CanManageAdminWorkspace(string? role) =>
|
||||
Normalize(role) is Admin or Owner;
|
||||
|
||||
public static bool CanManageOperationalSettings(string? role) =>
|
||||
Normalize(role) is Owner;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Backend.Services;
|
||||
|
||||
namespace Backend.Security;
|
||||
|
||||
public sealed class AdminSessionFilter(IUserSessionService userSessionService) : IEndpointFilter
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
var session = await userSessionService.ResolveSessionAsync(context.HttpContext, context.HttpContext.RequestAborted);
|
||||
if (session is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!AdminRoles.CanAccessAdmin(session.Role))
|
||||
{
|
||||
return Results.Json(new { message = "Admin access requires an elevated role." }, statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
context.HttpContext.SetCurrentSession(session);
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Backend.Security;
|
||||
|
||||
public static class DemoCredentialHasher
|
||||
{
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
private const int Iterations = 100_000;
|
||||
|
||||
public static (string Hash, string Salt) HashPassword(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
salt,
|
||||
Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
HashSize);
|
||||
|
||||
return (Convert.ToBase64String(hash), Convert.ToBase64String(salt));
|
||||
}
|
||||
|
||||
public static bool VerifyPassword(string password, string expectedHash, string salt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedHash) || string.IsNullOrWhiteSpace(salt))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var saltBytes = Convert.FromBase64String(salt);
|
||||
var expectedBytes = Convert.FromBase64String(expectedHash);
|
||||
var candidateBytes = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
saltBytes,
|
||||
Iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
expectedBytes.Length);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool FixedTimePlainTextEquals(string candidate, string expected)
|
||||
{
|
||||
var candidateBytes = Encoding.UTF8.GetBytes(candidate);
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(expected);
|
||||
|
||||
return candidateBytes.Length == expectedBytes.Length
|
||||
&& CryptographicOperations.FixedTimeEquals(candidateBytes, expectedBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Backend.Domain;
|
||||
|
||||
namespace Backend.Security;
|
||||
|
||||
public static class HttpContextSessionExtensions
|
||||
{
|
||||
private const string SessionItemKey = "__current_session";
|
||||
|
||||
public static void SetCurrentSession(this HttpContext context, UserSession session) =>
|
||||
context.Items[SessionItemKey] = session;
|
||||
|
||||
public static UserSession? GetCurrentSession(this HttpContext context) =>
|
||||
context.Items.TryGetValue(SessionItemKey, out var value) ? value as UserSession : null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Backend.Security;
|
||||
|
||||
public sealed class SecurityHeadersMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
var headers = context.Response.Headers;
|
||||
headers["X-Content-Type-Options"] = "nosniff";
|
||||
headers["X-Frame-Options"] = "DENY";
|
||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
|
||||
headers["Cross-Origin-Opener-Policy"] = "same-origin";
|
||||
|
||||
if (!headers.ContainsKey("Content-Security-Policy"))
|
||||
{
|
||||
headers["Content-Security-Policy"] =
|
||||
"default-src 'self'; " +
|
||||
"img-src 'self' data: https:; " +
|
||||
"font-src 'self' https://fonts.gstatic.com data:; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"script-src 'self'; " +
|
||||
"connect-src 'self' https:; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self';";
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user