Name Message Date
📁 Properties Add web dashboard for viewing monitor status and detections. 1 month ago
📁 Protos Initialize project 3 months ago
📁 wwwroot Store element screenshots with detections and show them in the View drawer. 1 month ago
📄 .containerfile Containerize 1 month ago
📄 .dockerignore Containerize 1 month ago
📄 .editorconfig Initialize project 3 months ago
📄 .gitignore Remove database file from repository 1 month ago
📄 AdminAuthService.cs Add cookie-based admin auth for the dashboard. 1 month ago
📄 AppSettings.cs Add configurable settings and per-monitoring SMS recipients. 1 month ago
📄 appsettings.Development.json Add cookie-based admin auth for the dashboard. 1 month ago
📄 appsettings.json Add cookie-based admin auth for the dashboard. 1 month ago
📄 BfiMonitor.csproj Add cookie-based admin auth for the dashboard. 1 month ago
📄 BfiMonitor.slnx Initialize project 3 months ago
📄 CheckMonitoringJob.cs Store element screenshots with detections and show them in the View drawer. 1 month ago
📄 dotnet-tools.json Initialize project 3 months ago
📄 global.json Fix Claudes mess 1 month ago
📄 IntervalScheduler.cs Add configurable settings and per-monitoring SMS recipients. 1 month ago
📄 MonitoringCheckScheduler.cs Add multi-monitoring management with scan triggers and editing. 1 month ago
📄 MonitorOptions.cs Add configurable settings and per-monitoring SMS recipients. 1 month ago
📄 OpenTelemetryExtensions.cs Add web dashboard for viewing monitor status and detections. 1 month ago
📄 packages.lock.json Add cookie-based admin auth for the dashboard. 1 month ago
📄 PlaywrightBrowserService.cs Use Playwright settings from Lukas 1 month ago
📄 Program.cs Add cookie-based admin auth for the dashboard. 1 month ago
📄 ScheduleMonitoringChecksJob.cs Add multi-monitoring management with scan triggers and editing. 1 month ago
📄 ScreeningRepository.cs Store element screenshots with detections and show them in the View drawer. 1 month ago
📄 SendSmsJob.cs Add tracing 1 month ago
📄 Tracing.cs Add configurable settings and per-monitoring SMS recipients. 1 month ago
📄 AdminAuthService.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Options;

namespace BfiMonitor;

public sealed class AdminAuthService(
    ILogger<AdminAuthService> logger,
    IOptions<AdminAuthOptions> options,
    TimeProvider timeProvider,
    IHttpContextAccessor httpContextAccessor
)
{
    private readonly AdminAuthOptions authOptions = options.Value;

    public bool IsEnabled => !string.IsNullOrEmpty(authOptions.Hash);

    public bool ValidatePassword(string password) => BCrypt.Net.BCrypt.Verify(password, authOptions.Hash);

    public async Task SignInAsync()
    {
        if (httpContextAccessor.HttpContext is not { } httpContext)
        {
            logger.LogError("No HttpContext when signing in.");
            return;
        }

        var now = timeProvider.GetUtcNow();
        var authProperties = new AuthenticationProperties
        {
            IsPersistent = true,
            AllowRefresh = true,
            IssuedUtc = now,
            ExpiresUtc = now + authOptions.LoginTime,
        };

        await httpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            new ClaimsPrincipal(
                new ClaimsIdentity(
                    [new Claim(ClaimTypes.Role, "Admin")],
                    CookieAuthenticationDefaults.AuthenticationScheme
                )
            ),
            authProperties
        );
    }

    public async Task SignOutAsync()
    {
        if (httpContextAccessor.HttpContext is not { } httpContext)
        {
            logger.LogError("No HttpContext when signing out.");
            return;
        }

        await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

public sealed class AdminAuthOptions
{
    public string Hash { get; set; } = "";

    public TimeSpan LoginTime { get; set; } = TimeSpan.FromDays(14);
}

public static class AdminAuthServiceCollectionExtensions
{
    public static IServiceCollection AddAdminAuth(this IServiceCollection services, IConfiguration configuration)
    {
        services.AddOptions<AdminAuthOptions>().BindConfiguration("Admin");
        services.AddHttpContextAccessor();
        services.AddTransient<AdminAuthService>();

        var authOptions = configuration.GetSection("Admin").Get<AdminAuthOptions>() ?? new AdminAuthOptions();
        if (string.IsNullOrEmpty(authOptions.Hash))
        {
            return services;
        }

        services
            .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(options =>
            {
                options.LoginPath = "/login.html";
                options.LogoutPath = "/api/admin/logout";
                options.AccessDeniedPath = "/login.html";
                options.Cookie.Name = "BfiMonitor.AdminAuth";
                options.SlidingExpiration = true;
                options.Events.OnRedirectToLogin = context =>
                {
                    if (context.Request.Path.Equals(LoginPath, StringComparison.OrdinalIgnoreCase))
                    {
                        return Task.CompletedTask;
                    }

                    if (context.Request.Path.StartsWithSegments("/api"))
                    {
                        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                        return Task.CompletedTask;
                    }

                    context.Response.Redirect(context.RedirectUri);
                    return Task.CompletedTask;
                };
            });

        return services;
    }

    public static IApplicationBuilder UseAdminAuth(this IApplicationBuilder app)
    {
        var authOptions = app.ApplicationServices.GetRequiredService<IOptions<AdminAuthOptions>>().Value;
        if (string.IsNullOrEmpty(authOptions.Hash))
        {
            return app;
        }

        app.UseAuthentication();
        app.Use(RequireAdminForApi);

        return app;
    }

    public static IApplicationBuilder UseRequireAdminForApp(this IApplicationBuilder app)
    {
        var authOptions = app.ApplicationServices.GetRequiredService<IOptions<AdminAuthOptions>>().Value;
        if (string.IsNullOrEmpty(authOptions.Hash))
        {
            return app;
        }

        app.Use(RequireAdminForAppContent);
        return app;
    }

    private static readonly PathString LoginPath = "/login.html";

    private static bool IsPublicPath(PathString path)
    {
        if (path.Equals(LoginPath, StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }

        return path.Equals("/styles.css", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsAnonymousApiPath(PathString path) =>
        path.StartsWithSegments("/api/auth", StringComparison.OrdinalIgnoreCase)
        || path.StartsWithSegments("/api/admin/login", StringComparison.OrdinalIgnoreCase)
        || path.StartsWithSegments("/api/admin/logout", StringComparison.OrdinalIgnoreCase);

    private static async Task RequireAdminForApi(HttpContext context, RequestDelegate next)
    {
        var path = context.Request.Path;
        if (!path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || IsAnonymousApiPath(path))
        {
            await next(context);
            return;
        }

        if (!context.User.IsInRole("Admin"))
        {
            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            return;
        }

        await next(context);
    }

    private static Task RequireAdminForAppContent(HttpContext context, RequestDelegate next)
    {
        var path = context.Request.Path;
        if (IsPublicPath(path) || path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
        {
            return next(context);
        }

        if (!context.User.IsInRole("Admin"))
        {
            context.Response.Redirect(LoginPath);
            return Task.CompletedTask;
        }

        return next(context);
    }
}

internal sealed record AdminLoginRequest(string Password);