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>
|
/// <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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,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