stop storing guest passwords, mint sessions server side
The old flow encrypted the guest password on disk next to its own key, then sent it to the guest in the bootstrap HTML anyway. Now redemption mints the session with ISessionManager.AuthenticateDirect and the page only ever carries the session token. The guest account still gets a random password nobody knows, so blank login stays impossible, but no password is stored or sent anywhere anymore. Cleanup now defaults to every 30 minutes instead of daily at 4am so expired guests die fast.
Cette révision appartient à :
@@ -56,9 +56,6 @@ public sealed class ShareLinkRecord
|
|||||||
/// <summary>Gets or sets a value indicating whether the link may be used once only.</summary>
|
/// <summary>Gets or sets a value indicating whether the link may be used once only.</summary>
|
||||||
public bool OneUse { get; set; } = true;
|
public bool OneUse { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>Gets or sets the encrypted guest password, if one has been generated.</summary>
|
|
||||||
public string? GuestPasswordEncrypted { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Gets or sets a value indicating whether metadata was touched during cleanup.</summary>
|
/// <summary>Gets or sets a value indicating whether metadata was touched during cleanup.</summary>
|
||||||
public bool MetadataTouched { get; set; }
|
public bool MetadataTouched { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.ShareLinks.Models;
|
using Jellyfin.Plugin.ShareLinks.Models;
|
||||||
using Jellyfin.Plugin.ShareLinks.Storage;
|
using Jellyfin.Plugin.ShareLinks.Storage;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ public sealed class ShareLinkRedemptionService
|
|||||||
private readonly ItemTagService _itemTagService;
|
private readonly ItemTagService _itemTagService;
|
||||||
private readonly JellyfinGuestUserService _guestUserService;
|
private readonly JellyfinGuestUserService _guestUserService;
|
||||||
private readonly ShareLinkCleanupService _cleanupService;
|
private readonly ShareLinkCleanupService _cleanupService;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly ILogger<ShareLinkRedemptionService> _logger;
|
private readonly ILogger<ShareLinkRedemptionService> _logger;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
|
||||||
@@ -30,6 +33,7 @@ public sealed class ShareLinkRedemptionService
|
|||||||
ItemTagService itemTagService,
|
ItemTagService itemTagService,
|
||||||
JellyfinGuestUserService guestUserService,
|
JellyfinGuestUserService guestUserService,
|
||||||
ShareLinkCleanupService cleanupService,
|
ShareLinkCleanupService cleanupService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
ILogger<ShareLinkRedemptionService> logger)
|
ILogger<ShareLinkRedemptionService> logger)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
@@ -38,6 +42,7 @@ public sealed class ShareLinkRedemptionService
|
|||||||
_itemTagService = itemTagService;
|
_itemTagService = itemTagService;
|
||||||
_guestUserService = guestUserService;
|
_guestUserService = guestUserService;
|
||||||
_cleanupService = cleanupService;
|
_cleanupService = cleanupService;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,17 +106,33 @@ public sealed class ShareLinkRedemptionService
|
|||||||
record.CleanupError = null;
|
record.CleanupError = null;
|
||||||
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var password = await GetOrCreatePasswordAsync(record, cancellationToken).ConfigureAwait(false);
|
// The account still needs a password so it can never be authenticated with a blank
|
||||||
|
// login; it is generated fresh on every redemption and is never stored or sent
|
||||||
|
// anywhere. The browser only ever receives a server-minted session token.
|
||||||
|
var password = JellyfinGuestUserService.GeneratePassword();
|
||||||
if (string.IsNullOrWhiteSpace(record.GuestUserName))
|
if (string.IsNullOrWhiteSpace(record.GuestUserName))
|
||||||
{
|
{
|
||||||
record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record);
|
record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthenticationResult authResult;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false);
|
var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false);
|
||||||
record.GuestUserId = user.Id;
|
record.GuestUserId = user.Id;
|
||||||
record.GuestUserName = user.Username;
|
record.GuestUserName = user.Username;
|
||||||
|
|
||||||
|
authResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
|
||||||
|
{
|
||||||
|
Username = record.GuestUserName,
|
||||||
|
UserId = record.GuestUserId.Value,
|
||||||
|
App = "ShareLinks",
|
||||||
|
AppVersion = "1.0.0",
|
||||||
|
DeviceId = record.DeviceId,
|
||||||
|
DeviceName = "ShareLinks",
|
||||||
|
RemoteEndPoint = request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
record.RedeemedAtUtc ??= now;
|
record.RedeemedAtUtc ??= now;
|
||||||
record.Status = ShareLinkStatus.Redeemed;
|
record.Status = ShareLinkStatus.Redeemed;
|
||||||
record.CleanupError = null;
|
record.CleanupError = null;
|
||||||
@@ -127,29 +148,7 @@ public sealed class ShareLinkRedemptionService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BuildBootstrapHtml(request, record, password, itemId);
|
return BuildBootstrapHtml(request, authResult, itemId);
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> GetOrCreatePasswordAsync(ShareLinkRecord record, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(record.GuestPasswordEncrypted))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await _tokenService.UnprotectStringAsync(record.GuestPasswordEncrypted, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "ShareLinks: stored guest password could not be decrypted for record {RecordId}; generating a replacement.", record.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var password = JellyfinGuestUserService.GeneratePassword();
|
|
||||||
record.GuestPasswordEncrypted = await _tokenService.ProtectStringAsync(password, cancellationToken).ConfigureAwait(false);
|
|
||||||
record.Status = ShareLinkStatus.Redeeming;
|
|
||||||
record.CleanupError = null;
|
|
||||||
await _store.UpdateAsync(record, cancellationToken).ConfigureAwait(false);
|
|
||||||
return password;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
|
private async Task HandleTerminalRecordAsync(ShareLinkRecord record, ShareLinkStatus terminalStatus, string reason, CancellationToken cancellationToken)
|
||||||
@@ -180,24 +179,14 @@ public sealed class ShareLinkRedemptionService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildBootstrapHtml(HttpRequest request, ShareLinkRecord record, string password, Guid itemId)
|
private static string BuildBootstrapHtml(HttpRequest request, AuthenticationResult authResult, Guid itemId)
|
||||||
{
|
{
|
||||||
var pathBase = request.PathBase.Value ?? string.Empty;
|
var pathBase = request.PathBase.Value ?? string.Empty;
|
||||||
var authUrl = $"{pathBase}/Users/AuthenticateByName";
|
|
||||||
var redirectUrl = $"{pathBase}/web/index.html#/details?id={Uri.EscapeDataString(itemId.ToString("D"))}";
|
var redirectUrl = $"{pathBase}/web/index.html#/details?id={Uri.EscapeDataString(itemId.ToString("D"))}";
|
||||||
var username = record.GuestUserName ?? JellyfinGuestUserService.BuildGuestUsername(record);
|
|
||||||
var deviceId = record.DeviceId ?? string.Empty;
|
|
||||||
|
|
||||||
var authJson = JsonSerializer.Serialize(new
|
var accessTokenJson = JsonSerializer.Serialize(authResult.AccessToken);
|
||||||
{
|
var userIdJson = JsonSerializer.Serialize(authResult.User.Id.ToString("N"));
|
||||||
Username = username,
|
|
||||||
Pw = password
|
|
||||||
});
|
|
||||||
|
|
||||||
var authUrlJson = JsonSerializer.Serialize(authUrl);
|
|
||||||
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
|
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
|
||||||
var usernameJson = JsonSerializer.Serialize(username);
|
|
||||||
var deviceIdJson = JsonSerializer.Serialize(deviceId);
|
|
||||||
var infoUrlJson = JsonSerializer.Serialize($"{pathBase}/System/Info/Public");
|
var infoUrlJson = JsonSerializer.Serialize($"{pathBase}/System/Info/Public");
|
||||||
var pathBaseJson = JsonSerializer.Serialize(pathBase);
|
var pathBaseJson = JsonSerializer.Serialize(pathBase);
|
||||||
|
|
||||||
@@ -221,31 +210,11 @@ public sealed class ShareLinkRedemptionService
|
|||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
(async () => {
|
(async () => {
|
||||||
const authUrl = {{authUrlJson}};
|
|
||||||
const redirectUrl = {{redirectUrlJson}};
|
const redirectUrl = {{redirectUrlJson}};
|
||||||
const username = {{usernameJson}};
|
const accessToken = {{accessTokenJson}};
|
||||||
const deviceId = {{deviceIdJson}} || crypto.randomUUID().replace(/-/g, "");
|
const userId = {{userIdJson}};
|
||||||
|
|
||||||
document.getElementById("status").textContent = "Authenticating " + username + ".";
|
document.getElementById("status").textContent = "Opening your title.";
|
||||||
|
|
||||||
const response = await fetch(authUrl, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"X-Emby-Authorization": `MediaBrowser Client="ShareLinks", Device="ShareLinks", DeviceId="${deviceId}", Version="1.0.0"`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({{authJson}})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Authentication failed (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await response.json();
|
|
||||||
const accessToken = auth.AccessToken ?? auth.accessToken ?? "";
|
|
||||||
const userId = auth.User?.Id ?? auth.user?.Id ?? auth.UserId ?? auth.userId ?? "";
|
|
||||||
|
|
||||||
const info = await fetch({{infoUrlJson}}, {
|
const info = await fetch({{infoUrlJson}}, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
|||||||
@@ -88,61 +88,6 @@ public sealed class ShareTokenService
|
|||||||
Encoding.UTF8.GetBytes(expectedHash));
|
Encoding.UTF8.GetBytes(expectedHash));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Encrypts sensitive text using the shared plugin secret.</summary>
|
|
||||||
public async Task<string> ProtectStringAsync(string value, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
{
|
|
||||||
throw new ArgumentNullException(nameof(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
var plaintext = Encoding.UTF8.GetBytes(value);
|
|
||||||
var nonce = new byte[12];
|
|
||||||
RandomNumberGenerator.Fill(nonce);
|
|
||||||
var cipher = new byte[plaintext.Length];
|
|
||||||
var tag = new byte[16];
|
|
||||||
|
|
||||||
using (var aes = new AesGcm(secret, 16))
|
|
||||||
{
|
|
||||||
aes.Encrypt(nonce, plaintext, cipher, tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload = new byte[nonce.Length + cipher.Length + tag.Length];
|
|
||||||
Buffer.BlockCopy(nonce, 0, payload, 0, nonce.Length);
|
|
||||||
Buffer.BlockCopy(cipher, 0, payload, nonce.Length, cipher.Length);
|
|
||||||
Buffer.BlockCopy(tag, 0, payload, nonce.Length + cipher.Length, tag.Length);
|
|
||||||
return Base64UrlEncode(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Decrypts a sensitive string protected by <see cref="ProtectStringAsync"/>.</summary>
|
|
||||||
public async Task<string> UnprotectStringAsync(string protectedValue, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(protectedValue))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Protected value cannot be empty.", nameof(protectedValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload = Base64UrlDecode(protectedValue);
|
|
||||||
if (payload.Length < 12 + 16)
|
|
||||||
{
|
|
||||||
throw new CryptographicException("Protected payload is invalid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var secret = await GetSecretAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
var nonce = payload[..12];
|
|
||||||
var tag = payload[^16..];
|
|
||||||
var cipher = payload[12..^16];
|
|
||||||
var plaintext = new byte[cipher.Length];
|
|
||||||
|
|
||||||
using (var aes = new AesGcm(secret, 16))
|
|
||||||
{
|
|
||||||
aes.Decrypt(nonce, cipher, tag, plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Encoding.UTF8.GetString(plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<byte[]> GetSecretAsync(CancellationToken cancellationToken)
|
private async Task<byte[]> GetSecretAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_secretKey is not null)
|
if (_secretKey is not null)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ using MediaBrowser.Model.Tasks;
|
|||||||
namespace Jellyfin.Plugin.ShareLinks.Tasks;
|
namespace Jellyfin.Plugin.ShareLinks.Tasks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scheduled cleanup shell for later link-expiry and guest-account teardown work.
|
/// Scheduled task that expires share links past their <c>ExpiresAtUtc</c> and tears down
|
||||||
|
/// the associated guest users and tags.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurableScheduledTask
|
public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurableScheduledTask
|
||||||
{
|
{
|
||||||
@@ -27,7 +28,7 @@ public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurab
|
|||||||
public string Key => "ShareLinksCleanup";
|
public string Key => "ShareLinksCleanup";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Description => "Removes expired share links and performs future guest-account cleanup.";
|
public string Description => "Expires share links past their expiry time and removes their guest users and tags.";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string Category => "ShareLinks";
|
public string Category => "ShareLinks";
|
||||||
@@ -53,8 +54,8 @@ public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurab
|
|||||||
{
|
{
|
||||||
yield return new TaskTriggerInfo
|
yield return new TaskTriggerInfo
|
||||||
{
|
{
|
||||||
Type = TaskTriggerInfoType.DailyTrigger,
|
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||||
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks,
|
IntervalTicks = TimeSpan.FromMinutes(30).Ticks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ up the link. So:
|
|||||||
6. the real access boundary is the server-side tag policy; the web-client
|
6. the real access boundary is the server-side tag policy; the web-client
|
||||||
lockdown is convenience on top of it
|
lockdown is convenience on top of it
|
||||||
|
|
||||||
|
The same applies to the guest's login. The plugin mints the guest session itself
|
||||||
|
on the server, using Jellyfin's own session manager. No password is ever stored
|
||||||
|
anywhere, not even encrypted, and no password ever appears in the page sent to
|
||||||
|
the guest. The only thing the guest's browser receives is a session token
|
||||||
|
scoped to that one guest account, and that token dies the moment the guest
|
||||||
|
account is cleaned up.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All of these live on the plugin's dashboard page:
|
All of these live on the plugin's dashboard page:
|
||||||
|
|||||||
Référencer dans un nouveau ticket
Bloquer un utilisateur