feat(auth): persist admin web sessions

This commit is contained in:
2026-04-18 22:42:04 -04:00
parent 34d307fd4e
commit 00a6cbc20e
8 changed files with 255 additions and 12 deletions
+5 -1
View File
@@ -114,7 +114,8 @@ public class AdminAuthController : ControllerBase
userName: userName,
isAdministrator: isAdministrator,
jellyfinAccessToken: accessToken,
jellyfinServerId: serverId);
jellyfinServerId: serverId,
isPersistent: request.RememberMe);
SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
@@ -130,6 +131,7 @@ public class AdminAuthController : ControllerBase
name = session.UserName,
isAdministrator = session.IsAdministrator
},
rememberMe = session.IsPersistent,
expiresAtUtc = session.ExpiresAtUtc
});
}
@@ -159,6 +161,7 @@ public class AdminAuthController : ControllerBase
name = session.UserName,
isAdministrator = session.IsAdministrator
},
rememberMe = session.IsPersistent,
expiresAtUtc = session.ExpiresAtUtc
});
}
@@ -196,6 +199,7 @@ public class AdminAuthController : ControllerBase
{
public string? Username { get; set; }
public string? Password { get; set; }
public bool RememberMe { get; set; }
}
private sealed class JellyfinAuthenticateRequest
+7
View File
@@ -12,8 +12,10 @@ using allstarr.Services.Lyrics;
using allstarr.Services.Scrobbling;
using allstarr.Middleware;
using allstarr.Filters;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Http;
using System.Net;
using System.IO;
var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
@@ -198,6 +200,11 @@ builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
var dataProtectionKeysDirectory = new DirectoryInfo("/app/cache/data-protection");
dataProtectionKeysDirectory.Create();
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(dataProtectionKeysDirectory)
.SetApplicationName("allstarr-admin");
// Exception handling
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
@@ -1,5 +1,8 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Services.Admin;
@@ -11,27 +14,66 @@ public sealed class AdminAuthSession
public required bool IsAdministrator { get; init; }
public required string JellyfinAccessToken { get; init; }
public string? JellyfinServerId { get; init; }
public required DateTime ExpiresAtUtc { get; init; }
public bool IsPersistent { get; init; }
public required DateTime ExpiresAtUtc { get; set; }
public DateTime LastSeenUtc { get; set; }
}
/// <summary>
/// In-memory authenticated admin sessions for the local Web UI.
/// Cookie-backed admin sessions for the local Web UI.
/// Session IDs stay in the browser cookie, while the authenticated Jellyfin
/// session details are protected and persisted on disk so brief app restarts
/// do not force a relogin.
/// </summary>
public class AdminAuthSessionService
{
public const string SessionCookieName = "allstarr_admin_session";
public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session";
private static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12);
public static readonly TimeSpan DefaultSessionLifetime = TimeSpan.FromHours(12);
public static readonly TimeSpan PersistentSessionLifetime = TimeSpan.FromDays(30);
private const string SessionStoreFilePath = "/app/cache/admin-auth/sessions.protected";
private readonly ConcurrentDictionary<string, AdminAuthSession> _sessions = new();
private readonly IDataProtector _protector;
private readonly ILogger<AdminAuthSessionService> _logger;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private readonly object _persistLock = new();
public AdminAuthSessionService(
IDataProtectionProvider dataProtectionProvider,
ILogger<AdminAuthSessionService> logger)
{
_protector = dataProtectionProvider.CreateProtector("allstarr.admin.auth.sessions.v1");
_logger = logger;
var directory = Path.GetDirectoryName(SessionStoreFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
LoadSessionsFromDisk();
}
public AdminAuthSessionService(ILogger<AdminAuthSessionService> logger)
: this(CreateFallbackDataProtectionProvider(), logger)
{
}
public AdminAuthSessionService()
: this(CreateFallbackDataProtectionProvider(), NullLogger<AdminAuthSessionService>.Instance)
{
}
public AdminAuthSession CreateSession(
string userId,
string userName,
bool isAdministrator,
string jellyfinAccessToken,
string? jellyfinServerId)
string? jellyfinServerId,
bool isPersistent = false)
{
RemoveExpiredSessions();
@@ -44,11 +86,13 @@ public class AdminAuthSessionService
IsAdministrator = isAdministrator,
JellyfinAccessToken = jellyfinAccessToken,
JellyfinServerId = jellyfinServerId,
ExpiresAtUtc = now.Add(SessionLifetime),
IsPersistent = isPersistent,
ExpiresAtUtc = now.Add(isPersistent ? PersistentSessionLifetime : DefaultSessionLifetime),
LastSeenUtc = now
};
_sessions[session.SessionId] = session;
PersistSessions();
return session;
}
@@ -69,6 +113,7 @@ public class AdminAuthSessionService
if (existing.ExpiresAtUtc <= DateTime.UtcNow)
{
_sessions.TryRemove(sessionId, out _);
PersistSessions();
return false;
}
@@ -84,17 +129,117 @@ public class AdminAuthSessionService
return;
}
_sessions.TryRemove(sessionId, out _);
if (_sessions.TryRemove(sessionId, out _))
{
PersistSessions();
}
}
private void RemoveExpiredSessions()
{
var now = DateTime.UtcNow;
var removedAny = false;
foreach (var kvp in _sessions)
{
if (kvp.Value.ExpiresAtUtc <= now)
if (kvp.Value.ExpiresAtUtc <= now &&
_sessions.TryRemove(kvp.Key, out _))
{
_sessions.TryRemove(kvp.Key, out _);
removedAny = true;
}
}
if (removedAny)
{
PersistSessions();
}
}
private void LoadSessionsFromDisk()
{
try
{
if (!File.Exists(SessionStoreFilePath))
{
return;
}
var protectedPayload = File.ReadAllText(SessionStoreFilePath);
if (string.IsNullOrWhiteSpace(protectedPayload))
{
return;
}
var json = _protector.Unprotect(protectedPayload);
var sessions = JsonSerializer.Deserialize<List<PersistedAdminAuthSession>>(json, _jsonOptions)
?? [];
var now = DateTime.UtcNow;
foreach (var persisted in sessions)
{
if (string.IsNullOrWhiteSpace(persisted.SessionId) ||
string.IsNullOrWhiteSpace(persisted.UserId) ||
string.IsNullOrWhiteSpace(persisted.UserName) ||
string.IsNullOrWhiteSpace(persisted.JellyfinAccessToken) ||
persisted.ExpiresAtUtc <= now)
{
continue;
}
_sessions[persisted.SessionId] = new AdminAuthSession
{
SessionId = persisted.SessionId,
UserId = persisted.UserId,
UserName = persisted.UserName,
IsAdministrator = persisted.IsAdministrator,
JellyfinAccessToken = persisted.JellyfinAccessToken,
JellyfinServerId = persisted.JellyfinServerId,
IsPersistent = persisted.IsPersistent,
ExpiresAtUtc = persisted.ExpiresAtUtc,
LastSeenUtc = persisted.LastSeenUtc
};
}
if (_sessions.Count > 0)
{
_logger.LogInformation("Loaded {Count} persisted admin auth sessions", _sessions.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load persisted admin auth sessions; starting with an empty session store");
_sessions.Clear();
}
}
private void PersistSessions()
{
lock (_persistLock)
{
try
{
var activeSessions = _sessions.Values
.Where(session => session.ExpiresAtUtc > DateTime.UtcNow)
.Select(session => new PersistedAdminAuthSession
{
SessionId = session.SessionId,
UserId = session.UserId,
UserName = session.UserName,
IsAdministrator = session.IsAdministrator,
JellyfinAccessToken = session.JellyfinAccessToken,
JellyfinServerId = session.JellyfinServerId,
IsPersistent = session.IsPersistent,
ExpiresAtUtc = session.ExpiresAtUtc,
LastSeenUtc = session.LastSeenUtc
})
.ToList();
var json = JsonSerializer.Serialize(activeSessions, _jsonOptions);
var protectedPayload = _protector.Protect(json);
File.WriteAllText(SessionStoreFilePath, protectedPayload);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to persist admin auth sessions");
}
}
}
@@ -105,4 +250,27 @@ public class AdminAuthSessionService
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static IDataProtectionProvider CreateFallbackDataProtectionProvider()
{
var keysDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "allstarr-admin-auth-keys"));
keysDirectory.Create();
return DataProtectionProvider.Create(keysDirectory, configuration =>
{
configuration.SetApplicationName("allstarr-admin");
});
}
private sealed class PersistedAdminAuthSession
{
public required string SessionId { get; init; }
public required string UserId { get; init; }
public required string UserName { get; init; }
public required bool IsAdministrator { get; init; }
public required string JellyfinAccessToken { get; init; }
public string? JellyfinServerId { get; init; }
public required bool IsPersistent { get; init; }
public required DateTime ExpiresAtUtc { get; init; }
public required DateTime LastSeenUtc { get; init; }
}
}
+7
View File
@@ -28,6 +28,12 @@
<label for="auth-password">Password</label>
<input id="auth-password" type="password" required>
<label class="auth-checkbox" for="auth-remember-me">
<input id="auth-remember-me" type="checkbox">
<span>Keep me signed in for 30 days on this browser</span>
</label>
<small class="auth-note">Use only on a device you trust.</small>
<button class="primary" type="submit">Sign In</button>
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
@@ -951,6 +957,7 @@
<div class="card-actions-row">
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
<button type="button" id="copy-issue-report-btn">Copy Report</button>
<button type="button" id="clear-issue-report-btn">Clear Report</button>
</div>
</div>
+2 -2
View File
@@ -56,10 +56,10 @@ export async function fetchAdminSession() {
);
}
export async function loginAdminSession(username, password) {
export async function loginAdminSession(username, password, rememberMe = false) {
return requestJson(
"/api/admin/auth/login",
asJsonBody({ username, password }),
asJsonBody({ username, password, rememberMe }),
"Authentication failed",
);
}
+3 -1
View File
@@ -197,9 +197,11 @@ function wireLoginForm() {
const usernameInput = document.getElementById("auth-username");
const passwordInput = document.getElementById("auth-password");
const rememberMeInput = document.getElementById("auth-remember-me");
const authError = document.getElementById("auth-error");
const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value || "";
const rememberMe = Boolean(rememberMeInput?.checked);
if (!username || !password) {
if (authError) {
@@ -213,7 +215,7 @@ function wireLoginForm() {
authError.textContent = "";
}
const result = await API.loginAdminSession(username, password);
const result = await API.loginAdminSession(username, password, rememberMe);
if (passwordInput) {
passwordInput.value = "";
}
+35
View File
@@ -336,6 +336,36 @@ async function copyIssueReport({ silent = false } = {}) {
return copied;
}
function clearIssueReport() {
const titleInput = getElement("issue-report-title");
const primaryInput = getElement("issue-report-primary");
const secondaryInput = getElement("issue-report-secondary");
const tertiaryInput = getElement("issue-report-tertiary");
const contextInput = getElement("issue-report-context");
const hasDraft = [
titleInput?.value,
primaryInput?.value,
secondaryInput?.value,
tertiaryInput?.value,
contextInput?.value,
].some((value) => String(value ?? "").trim().length > 0);
if (hasDraft && !window.confirm("Clear the current report draft?")) {
return;
}
if (titleInput) titleInput.value = "";
if (primaryInput) primaryInput.value = "";
if (secondaryInput) secondaryInput.value = "";
if (tertiaryInput) tertiaryInput.value = "";
if (contextInput) contextInput.value = "";
refreshIssueReportPreview();
titleInput?.focus();
showToast("Report draft cleared", "success", 2500);
}
function validateTitle() {
const titleInput = getElement("issue-report-title");
if (!titleInput?.value?.trim()) {
@@ -411,6 +441,7 @@ export function initIssueReporter() {
const tertiaryInput = getElement("issue-report-tertiary");
const contextInput = getElement("issue-report-context");
const copyButton = getElement("copy-issue-report-btn");
const clearButton = getElement("clear-issue-report-btn");
const openButton = getElement("open-github-issue-btn");
if (
@@ -421,6 +452,7 @@ export function initIssueReporter() {
!tertiaryInput ||
!contextInput ||
!copyButton ||
!clearButton ||
!openButton
) {
return;
@@ -436,6 +468,9 @@ export function initIssueReporter() {
copyButton.addEventListener("click", () => {
copyIssueReport();
});
clearButton.addEventListener("click", () => {
clearIssueReport();
});
openButton.addEventListener("click", () => {
openGithubIssueDraft();
});
+20
View File
@@ -58,6 +58,26 @@ body {
gap: 10px;
}
.auth-checkbox {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.88rem;
margin-top: 4px;
}
.auth-checkbox input {
width: auto;
margin: 0;
}
.auth-note {
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: -4px;
}
.auth-card label {
color: var(--text-secondary);
font-size: 0.85rem;