Merge pull request #4 from Franciskid/server-minted-guest-sessions
Stop storing guest passwords, mint sessions server side
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>
|
||||
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>
|
||||
public bool MetadataTouched { get; set; }
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.ShareLinks.Models;
|
||||
using Jellyfin.Plugin.ShareLinks.Storage;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -20,6 +22,7 @@ public sealed class ShareLinkRedemptionService
|
||||
private readonly ItemTagService _itemTagService;
|
||||
private readonly JellyfinGuestUserService _guestUserService;
|
||||
private readonly ShareLinkCleanupService _cleanupService;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly ILogger<ShareLinkRedemptionService> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
|
||||
@@ -30,6 +33,7 @@ public sealed class ShareLinkRedemptionService
|
||||
ItemTagService itemTagService,
|
||||
JellyfinGuestUserService guestUserService,
|
||||
ShareLinkCleanupService cleanupService,
|
||||
ISessionManager sessionManager,
|
||||
ILogger<ShareLinkRedemptionService> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
@@ -38,6 +42,7 @@ public sealed class ShareLinkRedemptionService
|
||||
_itemTagService = itemTagService;
|
||||
_guestUserService = guestUserService;
|
||||
_cleanupService = cleanupService;
|
||||
_sessionManager = sessionManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -101,17 +106,33 @@ public sealed class ShareLinkRedemptionService
|
||||
record.CleanupError = null;
|
||||
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))
|
||||
{
|
||||
record.GuestUserName = JellyfinGuestUserService.BuildGuestUsername(record);
|
||||
}
|
||||
|
||||
AuthenticationResult authResult;
|
||||
try
|
||||
{
|
||||
var user = await _guestUserService.EnsureGuestUserAsync(record, password, cancellationToken).ConfigureAwait(false);
|
||||
record.GuestUserId = user.Id;
|
||||
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.Status = ShareLinkStatus.Redeemed;
|
||||
record.CleanupError = null;
|
||||
@@ -127,29 +148,7 @@ public sealed class ShareLinkRedemptionService
|
||||
return null;
|
||||
}
|
||||
|
||||
return BuildBootstrapHtml(request, record, password, 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;
|
||||
return BuildBootstrapHtml(request, authResult, itemId);
|
||||
}
|
||||
|
||||
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 authUrl = $"{pathBase}/Users/AuthenticateByName";
|
||||
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
|
||||
{
|
||||
Username = username,
|
||||
Pw = password
|
||||
});
|
||||
|
||||
var authUrlJson = JsonSerializer.Serialize(authUrl);
|
||||
var accessTokenJson = JsonSerializer.Serialize(authResult.AccessToken);
|
||||
var userIdJson = JsonSerializer.Serialize(authResult.User.Id.ToString("N"));
|
||||
var redirectUrlJson = JsonSerializer.Serialize(redirectUrl);
|
||||
var usernameJson = JsonSerializer.Serialize(username);
|
||||
var deviceIdJson = JsonSerializer.Serialize(deviceId);
|
||||
var infoUrlJson = JsonSerializer.Serialize($"{pathBase}/System/Info/Public");
|
||||
var pathBaseJson = JsonSerializer.Serialize(pathBase);
|
||||
|
||||
@@ -221,31 +210,11 @@ public sealed class ShareLinkRedemptionService
|
||||
</main>
|
||||
<script>
|
||||
(async () => {
|
||||
const authUrl = {{authUrlJson}};
|
||||
const redirectUrl = {{redirectUrlJson}};
|
||||
const username = {{usernameJson}};
|
||||
const deviceId = {{deviceIdJson}} || crypto.randomUUID().replace(/-/g, "");
|
||||
const accessToken = {{accessTokenJson}};
|
||||
const userId = {{userIdJson}};
|
||||
|
||||
document.getElementById("status").textContent = "Authenticating " + username + ".";
|
||||
|
||||
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 ?? "";
|
||||
document.getElementById("status").textContent = "Opening your title.";
|
||||
|
||||
const info = await fetch({{infoUrlJson}}, {
|
||||
credentials: "same-origin",
|
||||
|
||||
@@ -88,61 +88,6 @@ public sealed class ShareTokenService
|
||||
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)
|
||||
{
|
||||
if (_secretKey is not null)
|
||||
|
||||
@@ -8,7 +8,8 @@ using MediaBrowser.Model.Tasks;
|
||||
namespace Jellyfin.Plugin.ShareLinks.Tasks;
|
||||
|
||||
/// <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>
|
||||
public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurableScheduledTask
|
||||
{
|
||||
@@ -27,7 +28,7 @@ public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurab
|
||||
public string Key => "ShareLinksCleanup";
|
||||
|
||||
/// <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 />
|
||||
public string Category => "ShareLinks";
|
||||
@@ -53,8 +54,8 @@ public sealed class CleanupShareLinksScheduledTask : IScheduledTask, IConfigurab
|
||||
{
|
||||
yield return new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.DailyTrigger,
|
||||
TimeOfDayTicks = TimeSpan.FromHours(4).Ticks,
|
||||
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||
IntervalTicks = TimeSpan.FromMinutes(30).Ticks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ up the link. So:
|
||||
6. the real access boundary is the server-side tag policy; the web-client
|
||||
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
|
||||
|
||||
All of these live on the plugin's dashboard page:
|
||||
|
||||
Référencer dans un nouveau ticket
Bloquer un utilisateur