Commit: 7db02e2
Parent: 481ab72

Password protect admin pages

Mårten Åsberg committed on 2026-05-04 at 18:46
Directory.Packages.props +1 -0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a04bba8..ce397d0 100644
@@ -10,6 +10,7 @@
</GlobalPackageReference>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
MatDenDagen/Components/Pages/Admin/Login.razor +97 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Login.razor b/MatDenDagen/Components/Pages/Admin/Login.razor
new file mode 100644
index 0000000..3995f61
@@ -0,0 +1,97 @@
@page "/admin/login"
@using MatDenDagen.Services
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Authorization
@inject AdminAuthService AdminAuthService
@inject NavigationManager NavigationManager
<h1>Admin Login</h1>
@if (errorMessage is not null)
{
<div class="error">
<p>@errorMessage</p>
</div>
}
<EditForm FormName="SignIn" Model="@model" OnSubmit="@HandleSignin" Enhance>
<p>
<label for="password">
<span>Lösenord:</span>
<input type="password" name="model.Password" required />
</label>
</p>
<p>
<button type="submit">Logga in</button>
</p>
</EditForm>
@code {
[SupplyParameterFromForm]
private Model? model { get; set; }
private string? errorMessage = null;
protected override void OnInitialized()
{
model ??= new();
}
private async Task HandleSignin()
{
if (string.IsNullOrWhiteSpace(model?.Password))
{
errorMessage = "Lösenord krävs";
return;
}
if (!AdminAuthService.ValidatePassword(model.Password))
{
errorMessage = "Fel lösenord";
return;
}
await AdminAuthService.SignIn();
NavigationManager.NavigateTo("/admin/questions", true);
}
private sealed class Model
{
public string? Password { get; set; }
}
}
<style>
.error {
color: red;
margin-bottom: 1rem;
}
form {
max-width: 400px;
margin: 0 auto;
}
div {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
}
input {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
}
button {
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
}
</style>
MatDenDagen/Components/Pages/Admin/Logout.razor +14 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Logout.razor b/MatDenDagen/Components/Pages/Admin/Logout.razor
new file mode 100644
index 0000000..e3b5b84
@@ -0,0 +1,14 @@
@page "/admin/logout"
@using MatDenDagen.Services
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Http
@inject AdminAuthService AdminAuthService
@inject NavigationManager NavigationManager
@code {
protected override async Task OnInitializedAsync()
{
await AdminAuthService.SignOut();
NavigationManager.NavigateTo("/admin/login", true);
}
}
MatDenDagen/Components/Pages/Admin/Participants.razor +3 -1
diff --git a/MatDenDagen/Components/Pages/Admin/Participants.razor b/MatDenDagen/Components/Pages/Admin/Participants.razor
index a6522bd..8d209bd 100644
@@ -1,6 +1,8 @@
@page "/admin/participants"
@attribute [Authorize(Roles = "Admin")]
@using MatDenDagen.Infrastructure.Storage.Database
@using MatDenDagen.Models
@using Microsoft.AspNetCore.Authorization
@using Microsoft.EntityFrameworkCore
@inject TimeProvider timeProvider
@inject QuestionnaireContext questionnaireContext
@@ -88,4 +90,4 @@
{
public string? ParticipantId { get; set; }
}
}
\ No newline at end of file
}
MatDenDagen/Components/Pages/Admin/Questions.razor +2 -0
diff --git a/MatDenDagen/Components/Pages/Admin/Questions.razor b/MatDenDagen/Components/Pages/Admin/Questions.razor
index 7480d15..2f49943 100644
@@ -1,6 +1,8 @@
@page "/admin/questions"
@attribute [Authorize(Roles = "Admin")]
@using MatDenDagen.Infrastructure.Storage.Database
@using MatDenDagen.Models
@using Microsoft.AspNetCore.Authorization
@using Microsoft.EntityFrameworkCore
@inject TimeProvider timeProvider
@inject QuestionnaireContext questionnaireContext
MatDenDagen/MatDenDagen.csproj +1 -0
diff --git a/MatDenDagen/MatDenDagen.csproj b/MatDenDagen/MatDenDagen.csproj
index ec4381b..986c9ec 100644
@@ -5,6 +5,7 @@
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
MatDenDagen/Program.cs +17 -0
diff --git a/MatDenDagen/Program.cs b/MatDenDagen/Program.cs
index d9cf0e3..026c32d 100644
@@ -1,6 +1,8 @@
using MatDenDagen;
using MatDenDagen.Components;
using MatDenDagen.Infrastructure.Storage;
using MatDenDagen.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
@@ -12,8 +14,23 @@ builder.Services.AddStorageServices();
builder.Services.AddRazorComponents();
builder
.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/admin/login";
options.LogoutPath = "/admin/logout";
options.AccessDeniedPath = "/admin/login";
options.Cookie.Name = "AdminAuthCookie";
});
builder.Services.AddAuthorization();
builder.Services.AddAdminService();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.MapStaticAssets();
app.MapRazorComponents<App>().DisableAntiforgery();
MatDenDagen/Services/AdminAuthService.cs +90 -0
diff --git a/MatDenDagen/Services/AdminAuthService.cs b/MatDenDagen/Services/AdminAuthService.cs
new file mode 100644
index 0000000..7156ef4
@@ -0,0 +1,90 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MatDenDagen.Services;
public sealed class AdminAuthService(
ILogger<AdminAuthService> logger,
IOptions<AdminAuthOptions> options,
TimeProvider timeProvider,
IHttpContextAccessor httpContextAccessor
)
{
private readonly string hash = options.Value.Hash;
private readonly TimeSpan loginTime = options.Value.LoginTime;
public bool ValidatePassword(string password) => BCrypt.Net.BCrypt.Verify(password, hash);
public async Task SignIn()
{
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 + loginTime,
};
await httpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(
new ClaimsIdentity([new(ClaimTypes.Role, "Admin")], CookieAuthenticationDefaults.AuthenticationScheme)
),
authProperties
);
}
public async Task SignOut()
{
if (httpContextAccessor.HttpContext is not { } httpContext)
{
logger.LogError("No HttpContext when signing out.");
return;
}
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
}
public sealed class AdminAuthOptions
{
[Required]
public required string Hash { get; set; }
[Required]
public required TimeSpan LoginTime { get; set; }
}
[OptionsValidator]
public sealed partial class AdminAuthOptionsValidator : IValidateOptions<AdminAuthOptions>;
public static class AdminAuthServiceCollectionExtensions
{
public static IServiceCollection AddAdminService(this IServiceCollection services)
{
services.AddOptions<AdminAuthOptions>().BindConfiguration("Admin").ValidateOnStart();
services.AddTransient<IValidateOptions<AdminAuthOptions>, AdminAuthOptionsValidator>();
services.AddHttpContextAccessor();
services.TryAddSingleton(TimeProvider.System);
services.AddTransient<AdminAuthService>();
return services;
}
}
MatDenDagen/appsettings.Development.json +4 -0
diff --git a/MatDenDagen/appsettings.Development.json b/MatDenDagen/appsettings.Development.json
index cf9e8b4..a426635 100644
@@ -11,5 +11,9 @@
},
"Storage": {
"BlobDirectory": "../blobs"
},
"Admin": {
"Hash": "$2a$12$MZUSzXjWbGcABxprQaviPe3rTNm.ulVGqceDPuKd787jHCnfMAc/C", // admin123
"LoginTime": "08:00:00.000"
}
}
MatDenDagen/packages.lock.json +6 -0
diff --git a/MatDenDagen/packages.lock.json b/MatDenDagen/packages.lock.json
index 7b95fa1..52c6159 100644
@@ -2,6 +2,12 @@
"version": 2,
"dependencies": {
"net10.0": {
"BCrypt.Net-Next": {
"type": "Direct",
"requested": "[4.1.0, )",
"resolved": "4.1.0",
"contentHash": "5YT3DKllmtkyW68PjURu/V1TOe4MKiByKwsRNVcfYE1S5KuFTeozdmKzyNzolKiQF391OXCaQtINvYT3j1ERzQ=="
},
"CSharpier.MsBuild": {
"type": "Direct",
"requested": "[1.2.6, )",
README.md +1 -1
diff --git a/README.md b/README.md
index 8bfa7bf..3c6c518 100644
@@ -7,7 +7,7 @@
- [x] Submit questionnaire
- [x] Questionnaire only accepts phone numbers from configured participants
- [ ] Admin pages
- [ ] Password protection
- [x] Password protection
- [ ] Configure possible date span for **the day**
- [ ] (Re)roll date of **the day**
- [x] Configure participants