mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 02:32:48 -04:00
feat(auth): persist admin web sessions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user