migrate admin panel to actual accs, configs, etc (closes #16 and #13)

This commit is contained in:
Lapis
2026-06-18 11:17:50 -04:00
parent 004f1921a6
commit 3794e6e0ab
33 changed files with 1552 additions and 1255 deletions
@@ -1,7 +1,4 @@
@using CannedNet.Admin.Services
@inherits LayoutComponentBase
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
@inherits LayoutComponentBase
@{
var body = Body;
@@ -16,18 +16,6 @@
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Setup</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="setup/nameserver">
<span class="bi bi-server-nav-menu" aria-hidden="true"></span> Nameserver
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="setup/accounts">
<span class="bi bi-people-fill-nav-menu" aria-hidden="true"></span> Admin Accounts
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Users</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="users/search">
@@ -41,18 +29,8 @@
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Search Rooms
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="rooms/create">
<span class="bi bi-plus-circle-fill-nav-menu" aria-hidden="true"></span> Create Room
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Config</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="config/nameserver">
<span class="bi bi-diagram-3-fill-nav-menu" aria-hidden="true"></span> NS Config
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="config/gameconfig">
<span class="bi bi-sliders-nav-menu" aria-hidden="true"></span> Game Config
@@ -1,13 +1,8 @@
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
if (!AuthService.HasAnyUser())
Navigation.NavigateTo("/setup", forceLoad: true);
else
Navigation.NavigateTo("/login", forceLoad: true);
Navigation.NavigateTo("/login", forceLoad: true);
}
}
@@ -1,9 +1,7 @@
@page "/change-password"
@attribute [Authorize]
@rendermode InteractiveServer
@using System.Security.Claims
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -44,8 +42,6 @@
</div>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private string currentPassword = "";
private string newPassword = "";
private string confirmNewPassword = "";
@@ -75,17 +71,15 @@
return;
}
var state = await AuthState!;
var userIdClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim == null || !int.TryParse(userIdClaim, out var userId))
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
message = "Error: Could not identify user.";
return;
}
["newPassword"] = newPassword
});
if (!AuthService.ChangePassword(userId, currentPassword, newPassword))
var response = await Http.PutAsync("/accounts/me/changepassword", content);
if (!response.IsSuccessStatusCode)
{
message = "Error: Current password is incorrect.";
message = "Error: Failed to change password.";
return;
}
@@ -1,115 +0,0 @@
@page "/config/nameserver"
@rendermode InteractiveServer
@using CannedNet.Admin.Models
@using System.Text.Json
<PageTitle>NS Config</PageTitle>
<h3>Nameserver (NS) Config</h3>
<p class="text-muted">Manage the nameserver endpoints.json file that controls where the game client connects.</p>
<div class="alert alert-info">
This is the same editor as the <a href="setup/nameserver">Setup &gt; Nameserver</a> page.
All endpoint URLs are derived from a single base URL.
</div>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else
{
<form>
<div class="mb-3">
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
}
</div>
<div class="mb-3">
<label class="form-label" for="baseUrl">Base URL:</label>
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
<div class="form-text">All endpoint URLs will use this as the base.</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Service</th>
<th>Full URL</th>
</tr>
</thead>
<tbody>
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
{
<tr>
<td><code>@name</code></td>
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
</tr>
}
</tbody>
</table>
</div>
</form>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private bool saveError;
private string? error;
private string? saveMessage;
private string baseUrl = "";
protected override async Task OnInitializedAsync()
{
try
{
var json = await Http.GetStringAsync("/admin/api/endpoints");
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
}
catch (Exception ex)
{
error = $"Failed to load: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task Save()
{
saving = true;
saveMessage = null;
saveError = false;
try
{
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await Http.PutAsync("/admin/api/endpoints", content);
response.EnsureSuccessStatusCode();
saveMessage = "Saved successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
saveError = true;
}
finally
{
saving = false;
}
}
}
@@ -8,15 +8,6 @@
<p class="lead text-muted">Manage your CannedNet Rec Room private server instance.</p>
<div class="row g-4 mt-3">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Setup</h5>
<p class="card-text">Configure nameserver endpoints, admin accounts, and authentication.</p>
<a href="setup/nameserver" class="btn btn-primary">Go to Setup</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
+21 -40
View File
@@ -1,52 +1,33 @@
@page "/login"
@rendermode InteractiveServer
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
<PageTitle>Login</PageTitle>
@if (AuthService.HasAnyUser() == false)
{
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h4>No Admin Accounts</h4>
<p class="text-muted">No admin accounts exist yet. Complete the first-time setup.</p>
<a href="/setup" class="btn btn-primary">Go to Setup</a>
</div>
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
<div class="card-body">
<form method="post" action="/admin/login">
@if (errorMessage != null)
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
}
else
{
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
<div class="card-body">
<form method="post" action="/admin/login">
@if (errorMessage != null)
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
}
</div>
@code {
private string? errorMessage;
@@ -1,176 +0,0 @@
@page "/rooms/create"
@rendermode InteractiveServer
<PageTitle>Create Room</PageTitle>
<h3>Create Room</h3>
<p class="text-muted">Create a new room and optionally a default subroom.</p>
<form>
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">Room Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Room ID (leave 0 for auto)</label>
<input type="number" class="form-control" @bind="roomId" />
</div>
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" @bind="name" required />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" rows="3" @bind="description"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Creator Account ID</label>
<input type="number" class="form-control" @bind="creatorAccountId" />
</div>
<div class="mb-3">
<label class="form-label">Image Name</label>
<input type="text" class="form-control" @bind="imageName" />
</div>
<div class="mb-3">
<label class="form-label">Accessibility</label>
<input type="number" class="form-control" @bind="accessibility" />
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">SubRoom</div>
<div class="card-body">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" @bind="createSubRoom" />
<label class="form-check-label">Create a default subroom</label>
</div>
@if (createSubRoom)
{
<div class="mb-3">
<label class="form-label">SubRoom Name</label>
<input type="text" class="form-control" @bind="subRoomName" />
</div>
<div class="mb-3">
<label class="form-label">Max Players</label>
<input type="number" class="form-control" @bind="maxPlayers" />
</div>
}
</div>
</div>
<div class="card mb-3">
<div class="card-header">Platform Support</div>
<div class="card-body">
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsVRLow" />
<label class="form-check-label">VR Low</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsQuest2" />
<label class="form-check-label">Quest 2</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsMobile" />
<label class="form-check-label">Mobile</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsScreens" />
<label class="form-check-label">Screens</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsWalkVR" />
<label class="form-check-label">Walk VR</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsTeleportVR" />
<label class="form-check-label">Teleport VR</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsJuniors" />
<label class="form-check-label">Supports Juniors</label>
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" @onclick="CreateRoom" disabled="@creating">
@(creating ? "Creating..." : "Create Room")
</button>
<a href="rooms/search" class="btn btn-outline-secondary ms-2">Cancel</a>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
}
@if (createdRoom != null)
{
<div class="alert alert-success mt-2">
Room created! <a href="rooms/edit/@createdRoom.RoomId">Edit Room #@createdRoom.RoomId</a>
</div>
}
</form>
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool creating;
private string? message;
private Room? createdRoom;
private int roomId;
private string name = "";
private string description = "";
private int creatorAccountId;
private string imageName = "";
private int accessibility;
private bool createSubRoom = true;
private string subRoomName = "";
private int maxPlayers = 16;
private bool supportsVRLow = true;
private bool supportsQuest2 = true;
private bool supportsMobile = true;
private bool supportsScreens = true;
private bool supportsWalkVR = true;
private bool supportsTeleportVR = true;
private bool supportsJuniors = true;
private async Task CreateRoom()
{
creating = true;
message = null;
try
{
var payload = new
{
roomId = roomId > 0 ? roomId : 0,
name,
description,
creatorAccountId,
imageName,
accessibility,
createSubRoom,
subRoomName = createSubRoom ? (string.IsNullOrWhiteSpace(subRoomName) ? name : subRoomName) : "",
maxPlayers
};
var response = await Http.PostAsJsonAsync("/admin/api/rooms", payload);
response.EnsureSuccessStatusCode();
createdRoom = await response.Content.ReadFromJsonAsync<Room>();
message = $"Room '{createdRoom?.Name}' created successfully!";
}
catch (Exception ex)
{
message = $"Error: {ex.Message}";
}
finally
{
creating = false;
}
}
}
@@ -1,144 +0,0 @@
@page "/setup/accounts"
@attribute [Authorize(Policy = "OwnerOnly")]
@rendermode InteractiveServer
@using CannedNet.Admin.Models
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
<PageTitle>Admin Accounts</PageTitle>
<h3>Admin Accounts</h3>
<p class="text-muted">Manage admin panel accounts. Only the owner can create or delete accounts.</p>
<div class="card mb-4">
<div class="card-header">Create New Admin Account</div>
<div class="card-body">
<form>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="newUsername" required />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" @bind="newPassword" required minlength="6" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" @bind="newConfirmPassword" required />
</div>
</div>
<button type="submit" class="btn btn-success" @onclick="CreateAccount" disabled="@creating">
@(creating ? "Creating..." : "Create Account")
</button>
@if (!string.IsNullOrEmpty(createMessage))
{
<span class="ms-2 @(createMessage.Contains("Error") ? "text-danger" : "text-success")">@createMessage</span>
}
</form>
</div>
</div>
<div class="card">
<div class="card-header">Admin Accounts</div>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in AuthService.GetAll())
{
<tr>
<td>@user.Id</td>
<td>@user.Username</td>
<td>
@if (user.IsOwner)
{
<td>Owner</td>
}
else
{
<td>Admin</td>
}
</td>
<td>@user.CreatedAt.ToString("g")</td>
<td>
@if (!user.IsOwner)
{
<button class="btn btn-sm btn-danger" @onclick="() => DeleteUser(user.Id)">Delete</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@code {
private string newUsername = "";
private string newPassword = "";
private string newConfirmPassword = "";
private bool creating;
private string? createMessage;
private async Task CreateAccount()
{
creating = true;
createMessage = null;
try
{
if (string.IsNullOrWhiteSpace(newUsername))
{
createMessage = "Username is required.";
return;
}
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
{
createMessage = "Password must be at least 6 characters.";
return;
}
if (newPassword != newConfirmPassword)
{
createMessage = "Passwords do not match.";
return;
}
if (AuthService.GetByUsername(newUsername) != null)
{
createMessage = "Username already exists.";
return;
}
AuthService.CreateUser(newUsername, newPassword, isOwner: false);
createMessage = "Admin account created successfully!";
newUsername = "";
newPassword = "";
newConfirmPassword = "";
}
catch (Exception ex)
{
createMessage = $"Error: {ex.Message}";
}
finally
{
creating = false;
}
}
private async Task DeleteUser(int id)
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Delete this admin account?");
if (!confirmed) return;
AuthService.DeleteUser(id);
}
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
}
@@ -1,110 +0,0 @@
@page "/setup"
@rendermode InteractiveServer
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
<PageTitle>First-Time Setup</PageTitle>
<div class="row justify-content-center mt-5">
<div class="col-md-6">
<div class="card">
<div class="card-header"><h4 class="mb-0">First-Time Setup</h4></div>
<div class="card-body">
@if (completed)
{
<div class="alert alert-success">
<h5>Owner account created!</h5>
<p>You can now <a href="/login">log in</a> with your new admin account.</p>
</div>
}
else if (AuthService.HasAnyUser())
{
<div class="alert alert-info">
Admin accounts already exist. <a href="/login">Go to login</a>.
</div>
}
else
{
<p class="text-muted">Create the initial owner account. This account will have full access and can create additional admin accounts.</p>
<form>
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="username" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" @bind="password" required minlength="6" />
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" @bind="confirmPassword" required />
</div>
<button type="submit" class="btn btn-primary w-100" @onclick="CreateOwner" disabled="@creating">
@(creating ? "Creating..." : "Create Owner Account")
</button>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
}
</form>
}
</div>
</div>
</div>
</div>
@code {
private string username = "";
private string password = "";
private string confirmPassword = "";
private bool creating;
private bool completed;
private string? message;
private async Task CreateOwner()
{
creating = true;
message = null;
try
{
if (string.IsNullOrWhiteSpace(username))
{
message = "Username is required.";
return;
}
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
{
message = "Password must be at least 6 characters.";
return;
}
if (password != confirmPassword)
{
message = "Passwords do not match.";
return;
}
if (AuthService.HasAnyUser())
{
message = "Admin accounts already exist.";
return;
}
if (AuthService.GetByUsername(username) != null)
{
message = "Username already taken.";
return;
}
AuthService.CreateUser(username, password, isOwner: true);
completed = true;
}
catch (Exception ex)
{
message = $"Error: {ex.Message}";
}
finally
{
creating = false;
}
}
}
@@ -1,110 +0,0 @@
@page "/setup/nameserver"
@rendermode InteractiveServer
@using CannedNet.Admin.Models
@using System.Text.Json
<PageTitle>Nameserver Setup</PageTitle>
<h3>Nameserver Endpoints</h3>
<p class="text-muted">Configure the base server URL the game client connects to. All endpoints are derived from this URL.</p>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else
{
<form>
<div class="mb-3">
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
}
</div>
<div class="mb-3">
<label class="form-label" for="baseUrl">Base URL:</label>
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
<div class="form-text">All endpoint URLs will use this as the base.</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Service</th>
<th>Full URL</th>
</tr>
</thead>
<tbody>
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
{
<tr>
<td><code>@name</code></td>
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
</tr>
}
</tbody>
</table>
</div>
</form>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private bool saveError;
private string? error;
private string? saveMessage;
private string baseUrl = "";
protected override async Task OnInitializedAsync()
{
try
{
var json = await Http.GetStringAsync("/admin/api/endpoints");
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
}
catch (Exception ex)
{
error = $"Failed to load: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task Save()
{
saving = true;
saveMessage = null;
saveError = false;
try
{
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await Http.PutAsync("/admin/api/endpoints", content);
response.EnsureSuccessStatusCode();
saveMessage = "Saved successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
saveError = true;
}
finally
{
saving = false;
}
}
}
-21
View File
@@ -1,21 +0,0 @@
using System.Text.Json.Serialization;
namespace CannedNet.Admin.Models;
public class AdminUser
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = "";
[JsonPropertyName("passwordHash")]
public string PasswordHash { get; set; } = "";
[JsonPropertyName("isOwner")]
public bool IsOwner { get; set; }
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
}
+61 -16
View File
@@ -1,8 +1,9 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using CannedNet.Admin.Components;
using CannedNet.Admin.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
@@ -23,22 +24,17 @@ builder.Services.AddAuthentication("AdminCookie")
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OwnerOnly", policy => policy.RequireClaim("role", "Owner"));
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<AdminAuthService>();
var imagesBaseUrl = builder.Configuration.GetValue<string>("ImagesBaseUrl") ?? "http://localhost:5000/img";
builder.Services.AddSingleton(new ImageUrlService(imagesBaseUrl));
builder.Services.AddHttpContextAccessor();
var serverUrl = builder.Configuration.GetValue<string>("ServerBaseUrl") ?? "http://localhost:5000";
builder.Services.AddTransient<AdminApiKeyHandler>();
builder.Services.AddTransient<JwtBearerHandler>();
builder.Services.AddScoped(sp =>
{
var handler = sp.GetRequiredService<AdminApiKeyHandler>();
var handler = sp.GetRequiredService<JwtBearerHandler>();
handler.InnerHandler = new SocketsHttpHandler();
return new HttpClient(handler) { BaseAddress = new Uri(serverUrl) };
});
@@ -47,7 +43,7 @@ var app = builder.Build();
// --- Admin Auth Endpoints ---
app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authService) =>
app.MapPost("/admin/login", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].FirstOrDefault() ?? "";
@@ -59,20 +55,55 @@ app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authSer
return;
}
var user = authService.GetByUsername(username);
if (user == null || !authService.VerifyPassword(password, user.PasswordHash))
var serverUrl = app.Services.GetRequiredService<IConfiguration>().GetValue<string>("ServerBaseUrl") ?? "http://localhost:5000";
using var httpClient = new HttpClient { BaseAddress = new Uri(serverUrl) };
var tokenResponse = await httpClient.PostAsync("/auth/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = username,
["password"] = password
}));
if (!tokenResponse.IsSuccessStatusCode)
{
context.Response.Redirect("/login?error=invalid");
return;
}
var claims = new[]
var json = await tokenResponse.Content.ReadAsStringAsync();
using var doc = System.Text.Json.JsonDocument.Parse(json);
var accessToken = doc.RootElement.GetProperty("access_token").GetString();
if (string.IsNullOrEmpty(accessToken))
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("role", user.IsOwner ? "Owner" : "Admin")
context.Response.Redirect("/login?error=invalid");
return;
}
var payloadJson = DecodeJwtPayload(accessToken);
using var payload = JsonDocument.Parse(payloadJson);
var accountId = payload.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() ?? "" : "";
var role = "";
if (payload.RootElement.TryGetProperty("role", out var r))
{
if (r.ValueKind == JsonValueKind.String)
role = r.GetString() ?? "";
else if (r.ValueKind == JsonValueKind.Array)
role = string.Join(",", r.EnumerateArray().Select(x => x.GetString() ?? ""));
}
var name = payload.RootElement.TryGetProperty("platform_id", out var pid) ? pid.GetString() ?? username : username;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, accountId),
new(ClaimTypes.Name, name),
new("access_token", accessToken)
};
// Map JWT roles for admin app authorization
if (role.Split(',', StringSplitOptions.RemoveEmptyEntries).Any(rr => rr == "developer" || rr == "admin"))
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
var identity = new ClaimsIdentity(claims, "AdminCookie");
var principal = new ClaimsPrincipal(identity);
@@ -111,3 +142,17 @@ app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
static string DecodeJwtPayload(string jwt)
{
var parts = jwt.Split('.');
if (parts.Length < 2) return "{}";
var payload = parts[1];
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
var bytes = Convert.FromBase64String(payload);
return Encoding.UTF8.GetString(bytes);
}
@@ -1,21 +0,0 @@
namespace CannedNet.Admin.Services;
public class AdminApiKeyHandler : DelegatingHandler
{
private readonly string _apiKey;
public AdminApiKeyHandler(IConfiguration config)
{
_apiKey = config.GetValue<string>("Admin:ApiKey")
?? config.GetValue<string>("AdminApiKey")
?? "";
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(_apiKey))
request.Headers.Add("X-Admin-Api-Key", _apiKey);
return await base.SendAsync(request, cancellationToken);
}
}
@@ -1,127 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using CannedNet.Admin.Models;
namespace CannedNet.Admin.Services;
public class AdminAuthService
{
private readonly string _filePath;
private List<AdminUser> _users = new();
private readonly object _lock = new();
private int _nextId = 1;
public AdminAuthService(IWebHostEnvironment env)
{
_filePath = Path.Combine(env.ContentRootPath, "admin_users.json");
Load();
}
public bool HasAnyUser()
{
lock (_lock) { return _users.Count > 0; }
}
public AdminUser? GetById(int id)
{
lock (_lock) { return _users.FirstOrDefault(u => u.Id == id); }
}
public AdminUser? GetByUsername(string username)
{
lock (_lock) { return _users.FirstOrDefault(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); }
}
public List<AdminUser> GetAll()
{
lock (_lock) { return _users.OrderBy(u => u.Id).ToList(); }
}
public bool IsOwner(int id)
{
lock (_lock) { return _users.Any(u => u.Id == id && u.IsOwner); }
}
public AdminUser CreateUser(string username, string password, bool isOwner)
{
lock (_lock)
{
var user = new AdminUser
{
Id = _nextId++,
Username = username,
PasswordHash = HashPassword(password),
IsOwner = isOwner,
CreatedAt = DateTime.UtcNow
};
_users.Add(user);
Save();
return user;
}
}
public bool ChangePassword(int id, string oldPassword, string newPassword)
{
lock (_lock)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) return false;
if (user.PasswordHash != HashPassword(oldPassword)) return false;
user.PasswordHash = HashPassword(newPassword);
Save();
return true;
}
}
public bool DeleteUser(int id)
{
lock (_lock)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) return false;
_users.Remove(user);
Save();
return true;
}
}
public bool VerifyPassword(string password, string hash)
{
return HashPassword(password) == hash;
}
private string HashPassword(string password)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password + "CannedNetAdminSalt!@#$"));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private void Load()
{
try
{
if (File.Exists(_filePath))
{
var json = File.ReadAllText(_filePath);
var users = JsonSerializer.Deserialize<List<AdminUser>>(json);
if (users != null && users.Count > 0)
{
_users = users;
_nextId = _users.Max(u => u.Id) + 1;
}
}
}
catch { }
}
private void Save()
{
try
{
var json = JsonSerializer.Serialize(_users, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
catch { }
}
}
@@ -0,0 +1,24 @@
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Components.Authorization;
namespace CannedNet.Admin.Services;
public class JwtBearerHandler : DelegatingHandler
{
private readonly AuthenticationStateProvider _authStateProvider;
public JwtBearerHandler(AuthenticationStateProvider authStateProvider)
{
_authStateProvider = authStateProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var state = await _authStateProvider.GetAuthenticationStateAsync();
var token = state.User.FindFirst("access_token")?.Value;
if (token != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}