mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-07-21 21:13:05 +01:00
Implement basic session invalidation subscriptions
This commit is contained in:
@@ -53,6 +53,11 @@ namespace Tgstation.Server.Host.Authority
|
||||
/// </summary>
|
||||
readonly IIdentityCache identityCache;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ISessionInvalidationTracker"/> for the <see cref="LoginAuthority"/>.
|
||||
/// </summary>
|
||||
readonly ISessionInvalidationTracker sessionInvalidationTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Generate an <see cref="AuthorityResponse{TResult}"/> for a given <paramref name="headersException"/>.
|
||||
/// </summary>
|
||||
@@ -99,6 +104,7 @@ namespace Tgstation.Server.Host.Authority
|
||||
/// <param name="tokenFactory">The value of <see cref="tokenFactory"/>.</param>
|
||||
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
|
||||
/// <param name="identityCache">The value of <see cref="identityCache"/>.</param>
|
||||
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
|
||||
public LoginAuthority(
|
||||
IAuthenticationContext authenticationContext,
|
||||
IDatabaseContext databaseContext,
|
||||
@@ -108,7 +114,8 @@ namespace Tgstation.Server.Host.Authority
|
||||
IOAuthProviders oAuthProviders,
|
||||
ITokenFactory tokenFactory,
|
||||
ICryptographySuite cryptographySuite,
|
||||
IIdentityCache identityCache)
|
||||
IIdentityCache identityCache,
|
||||
ISessionInvalidationTracker sessionInvalidationTracker)
|
||||
: base(
|
||||
authenticationContext,
|
||||
databaseContext,
|
||||
@@ -120,6 +127,7 @@ namespace Tgstation.Server.Host.Authority
|
||||
this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory));
|
||||
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
|
||||
this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache));
|
||||
this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -230,14 +238,6 @@ namespace Tgstation.Server.Host.Authority
|
||||
if (isLikelyDbUser || usernameMismatch)
|
||||
{
|
||||
DatabaseContext.Users.Attach(user);
|
||||
if (isLikelyDbUser)
|
||||
{
|
||||
// cleanup from https://github.com/tgstation/tgstation-server/issues/1528
|
||||
Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id);
|
||||
user.PasswordHash = null;
|
||||
user.LastPasswordUpdate = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (usernameMismatch)
|
||||
{
|
||||
// System identity username change update
|
||||
@@ -246,6 +246,14 @@ namespace Tgstation.Server.Host.Authority
|
||||
user.CanonicalName = User.CanonicalizeName(user.Name);
|
||||
}
|
||||
|
||||
if (isLikelyDbUser)
|
||||
{
|
||||
// cleanup from https://github.com/tgstation/tgstation-server/issues/1528
|
||||
Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", user.Id);
|
||||
user.PasswordHash = null;
|
||||
sessionInvalidationTracker.UserModifiedInvalidateSessions(user);
|
||||
}
|
||||
|
||||
await DatabaseContext.Save(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ namespace Tgstation.Server.Host.Authority
|
||||
/// </summary>
|
||||
readonly ICryptographySuite cryptographySuite;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ISessionInvalidationTracker"/> for the <see cref="UserAuthority"/>.
|
||||
/// </summary>
|
||||
readonly ISessionInvalidationTracker sessionInvalidationTracker;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IOptionsSnapshot{TOptions}"/> of <see cref="GeneralConfiguration"/> for the <see cref="UserAuthority"/>.
|
||||
/// </summary>
|
||||
@@ -136,6 +141,7 @@ namespace Tgstation.Server.Host.Authority
|
||||
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
|
||||
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
|
||||
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
|
||||
/// <param name="sessionInvalidationTracker">The value of <see cref="sessionInvalidationTracker"/>.</param>
|
||||
/// <param name="generalConfigurationOptions">The value of <see cref="generalConfigurationOptions"/>.</param>
|
||||
public UserAuthority(
|
||||
IAuthenticationContext authenticationContext,
|
||||
@@ -146,6 +152,7 @@ namespace Tgstation.Server.Host.Authority
|
||||
ISystemIdentityFactory systemIdentityFactory,
|
||||
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
|
||||
ICryptographySuite cryptographySuite,
|
||||
ISessionInvalidationTracker sessionInvalidationTracker,
|
||||
IOptionsSnapshot<GeneralConfiguration> generalConfigurationOptions)
|
||||
: base(
|
||||
authenticationContext,
|
||||
@@ -157,6 +164,7 @@ namespace Tgstation.Server.Host.Authority
|
||||
this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
|
||||
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
|
||||
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
|
||||
this.sessionInvalidationTracker = sessionInvalidationTracker ?? throw new ArgumentNullException(nameof(sessionInvalidationTracker));
|
||||
this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
|
||||
}
|
||||
|
||||
@@ -381,12 +389,14 @@ namespace Tgstation.Server.Host.Authority
|
||||
return Forbid<User>();
|
||||
|
||||
var originalUserHasSid = originalUser.SystemIdentifier != null;
|
||||
var invalidateSessions = false;
|
||||
if (originalUserHasSid && originalUser.PasswordHash != null)
|
||||
{
|
||||
// cleanup from https://github.com/tgstation/tgstation-server/issues/1528
|
||||
Logger.LogDebug("System user ID {userId}'s PasswordHash is polluted, updating database.", originalUser.Id);
|
||||
originalUser.PasswordHash = null;
|
||||
originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow;
|
||||
|
||||
invalidateSessions = true;
|
||||
}
|
||||
|
||||
if (model.SystemIdentifier != null && model.SystemIdentifier != originalUser.SystemIdentifier)
|
||||
@@ -400,23 +410,13 @@ namespace Tgstation.Server.Host.Authority
|
||||
var result = TrySetPassword(originalUser, model.Password, false);
|
||||
if (result != null)
|
||||
return result;
|
||||
|
||||
invalidateSessions = true;
|
||||
}
|
||||
|
||||
if (model.Name != null && User.CanonicalizeName(model.Name) != originalUser.CanonicalName)
|
||||
return BadRequest<User>(ErrorCode.UserNameChange);
|
||||
|
||||
bool userWasDisabled;
|
||||
if (model.Enabled.HasValue)
|
||||
{
|
||||
userWasDisabled = originalUser.Require(x => x.Enabled) && !model.Enabled.Value;
|
||||
if (userWasDisabled)
|
||||
originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow;
|
||||
|
||||
originalUser.Enabled = model.Enabled.Value;
|
||||
}
|
||||
else
|
||||
userWasDisabled = false;
|
||||
|
||||
if (model.OAuthConnections != null
|
||||
&& (model.OAuthConnections.Count != originalUser.OAuthConnections!.Count
|
||||
|| !model.OAuthConnections.All(x => originalUser.OAuthConnections.Any(y => y.Provider == x.Provider && y.ExternalUserId == x.ExternalUserId))))
|
||||
@@ -477,11 +477,20 @@ namespace Tgstation.Server.Host.Authority
|
||||
|
||||
originalUser.Name = model.Name ?? originalUser.Name;
|
||||
|
||||
if (model.Enabled.HasValue)
|
||||
{
|
||||
invalidateSessions = originalUser.Require(x => x.Enabled) && !model.Enabled.Value;
|
||||
originalUser.Enabled = model.Enabled.Value;
|
||||
}
|
||||
|
||||
if (invalidateSessions)
|
||||
sessionInvalidationTracker.UserModifiedInvalidateSessions(originalUser);
|
||||
|
||||
await DatabaseContext.Save(cancellationToken);
|
||||
|
||||
Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id);
|
||||
|
||||
if (userWasDisabled)
|
||||
if (invalidateSessions)
|
||||
await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
|
||||
|
||||
// return id only if not a self update and cannot read users
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
@@ -309,6 +309,7 @@ namespace Tgstation.Server.Host.Core
|
||||
})
|
||||
#endif
|
||||
.AddMutationConventions()
|
||||
.AddInMemorySubscriptions()
|
||||
.AddGlobalObjectIdentification()
|
||||
.AddQueryFieldToMutationPayloads()
|
||||
.ModifyOptions(options =>
|
||||
@@ -334,7 +335,8 @@ namespace Tgstation.Server.Host.Core
|
||||
.BindRuntimeType<Version, SemverType>()
|
||||
.TryAddTypeInterceptor<RightsTypeInterceptor>()
|
||||
.AddQueryType<Query>()
|
||||
.AddMutationType<Mutation>();
|
||||
.AddMutationType<Mutation>()
|
||||
.AddSubscriptionType<Subscription>();
|
||||
|
||||
void AddTypedContext<TContext>()
|
||||
where TContext : DatabaseContext
|
||||
@@ -383,6 +385,7 @@ namespace Tgstation.Server.Host.Core
|
||||
services.AddSingleton<IIdentityCache, IdentityCache>();
|
||||
services.AddSingleton<ICryptographySuite, CryptographySuite>();
|
||||
services.AddSingleton<ITokenFactory, TokenFactory>();
|
||||
services.AddSingleton<ISessionInvalidationTracker, SessionInvalidationTracker>();
|
||||
services.AddSingleton<IPasswordHasher<Models.User>, PasswordHasher<Models.User>>();
|
||||
|
||||
// configure platform specific services
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using HotChocolate;
|
||||
using HotChocolate.Execution;
|
||||
using HotChocolate.Subscriptions;
|
||||
using HotChocolate.Types;
|
||||
|
||||
using Tgstation.Server.Host.GraphQL.Types;
|
||||
using Tgstation.Server.Host.Security;
|
||||
|
||||
namespace Tgstation.Server.Host.GraphQL
|
||||
{
|
||||
/// <summary>
|
||||
/// Root type for GraphQL subscriptions.
|
||||
/// </summary>
|
||||
/// <remarks>Intentionally left mostly empty, use type extensions to properly scope operations to domains.</remarks>
|
||||
public sealed class Subscription
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the topic name for the login session represented by a given <paramref name="authenticationContext"/>.
|
||||
/// </summary>
|
||||
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to generate the topic for.</param>
|
||||
/// <returns>The <see cref="SessionInvalidated(SessionInvalidationReason)"/> topic for the given <paramref name="authenticationContext"/>.</returns>
|
||||
public static string SessionInvalidatedTopic(IAuthenticationContext authenticationContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authenticationContext);
|
||||
return $"SessionInvalidated.{authenticationContext.SessionId}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ISourceStream"/> for <see cref="SessionInvalidated(SessionInvalidationReason)"/>.
|
||||
/// </summary>
|
||||
/// <param name="receiver">The <see cref="ITopicEventReceiver"/>.</param>
|
||||
/// <param name="invalidationTracker">The <see cref="ISessionInvalidationTracker"/>.</param>
|
||||
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the request.</param>
|
||||
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="ISourceStream{TMessage}"/> of the <see cref="SessionInvalidationReason"/> for the <paramref name="authenticationContext"/>.</returns>
|
||||
public ValueTask<ISourceStream<SessionInvalidationReason>> SessionInvalidatedStream(
|
||||
[Service] ITopicEventReceiver receiver,
|
||||
[Service] ISessionInvalidationTracker invalidationTracker,
|
||||
[Service] IAuthenticationContext authenticationContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(receiver);
|
||||
ArgumentNullException.ThrowIfNull(invalidationTracker);
|
||||
|
||||
var subscription = receiver.SubscribeAsync<SessionInvalidationReason>(SessionInvalidatedTopic(authenticationContext));
|
||||
invalidationTracker.TrackSession(authenticationContext);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receive a <see cref="SessionInvalidationReason"/> immediately before the current login session is invalidated.
|
||||
/// </summary>
|
||||
/// <param name="sessionInvalidationReason">The <see cref="SessionInvalidationReason"/> received from the publisher.</param>
|
||||
/// <returns>The <see cref="SessionInvalidationReason"/>.</returns>
|
||||
[Subscribe(With = nameof(SessionInvalidatedStream))]
|
||||
[TgsGraphQLAuthorize]
|
||||
public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason)
|
||||
=> sessionInvalidationReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Tgstation.Server.Host.GraphQL.Types
|
||||
{
|
||||
/// <summary>
|
||||
/// Reasons TGS may invalidate a user's login session.
|
||||
/// </summary>
|
||||
public enum SessionInvalidationReason
|
||||
{
|
||||
/// <summary>
|
||||
/// The callers JWT expired.
|
||||
/// </summary>
|
||||
TokenExpired,
|
||||
|
||||
/// <summary>
|
||||
/// An update to the caller's identity requiring reauthentication was made.
|
||||
/// </summary>
|
||||
UserUpdated,
|
||||
|
||||
/// <summary>
|
||||
/// TGS is shutting down or restarting. Note, depending on server configuration, the current session may not actually be invalid upon restarting. However, the information required to determine this is not exposed to clients.
|
||||
/// </summary>
|
||||
ServerShutdown,
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,10 @@ namespace Tgstation.Server.Host.Security
|
||||
public bool Valid { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public User User => user ?? throw new InvalidOperationException("AuthenticationContext is invalid!");
|
||||
public User User => user ?? throw InvalidContext();
|
||||
|
||||
/// <inheritdoc />
|
||||
public PermissionSet PermissionSet => permissionSet ?? throw new InvalidOperationException("AuthenticationContext is invalid!");
|
||||
public PermissionSet PermissionSet => permissionSet ?? throw InvalidContext();
|
||||
|
||||
/// <inheritdoc />
|
||||
public InstancePermissionSet? InstancePermissionSet { get; private set; }
|
||||
@@ -24,6 +24,12 @@ namespace Tgstation.Server.Host.Security
|
||||
/// <inheritdoc />
|
||||
public ISystemIdentity? SystemIdentity { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset SessionExpiry => sessionExpiry ?? throw InvalidContext();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SessionId => sessionId ?? throw InvalidContext();
|
||||
|
||||
/// <summary>
|
||||
/// Backing field for <see cref="User"/>.
|
||||
/// </summary>
|
||||
@@ -34,6 +40,16 @@ namespace Tgstation.Server.Host.Security
|
||||
/// </summary>
|
||||
PermissionSet? permissionSet;
|
||||
|
||||
/// <summary>
|
||||
/// Backing field for <see cref="SessionExpiry"/>.
|
||||
/// </summary>
|
||||
DateTimeOffset? sessionExpiry;
|
||||
|
||||
/// <summary>
|
||||
/// Backing field for <see cref="SessionId"/>.
|
||||
/// </summary>
|
||||
string? sessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AuthenticationContext"/> class.
|
||||
/// </summary>
|
||||
@@ -41,25 +57,44 @@ namespace Tgstation.Server.Host.Security
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="InvalidOperationException"/> for accessing fields on an In<see cref="Valid"/> <see cref="AuthenticationContext"/>.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="InvalidOperationException"/>.</returns>
|
||||
static InvalidOperationException InvalidContext()
|
||||
=> new("AuthenticationContext is invalid!");
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => SystemIdentity?.Dispose();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the <see cref="AuthenticationContext"/>.
|
||||
/// </summary>
|
||||
/// <param name="systemIdentity">The value of <see cref="SystemIdentity"/>.</param>
|
||||
/// <param name="user">The value of <see cref="User"/>.</param>
|
||||
/// <param name="sessionExpiry">The value of <see cref="SessionExpiry"/>.</param>
|
||||
/// <param name="sessionId">The value of <see cref="SessionId"/>.</param>
|
||||
/// <param name="instanceUser">The value of <see cref="InstancePermissionSet"/>.</param>
|
||||
public void Initialize(ISystemIdentity? systemIdentity, User user, InstancePermissionSet? instanceUser)
|
||||
/// <param name="systemIdentity">The value of <see cref="SystemIdentity"/>.</param>
|
||||
public void Initialize(
|
||||
User user,
|
||||
DateTimeOffset sessionExpiry,
|
||||
string sessionId,
|
||||
InstancePermissionSet? instanceUser,
|
||||
ISystemIdentity? systemIdentity)
|
||||
{
|
||||
this.user = user ?? throw new ArgumentNullException(nameof(user));
|
||||
if (systemIdentity == null && User.SystemIdentifier != null)
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(sessionId);
|
||||
if (systemIdentity == null && user.SystemIdentifier != null)
|
||||
throw new ArgumentNullException(nameof(systemIdentity));
|
||||
|
||||
permissionSet = user.PermissionSet
|
||||
?? user.Group!.PermissionSet
|
||||
?? throw new ArgumentException("No PermissionSet provider", nameof(user));
|
||||
this.user = user;
|
||||
InstancePermissionSet = instanceUser;
|
||||
SystemIdentity = systemIdentity;
|
||||
this.sessionId = sessionId;
|
||||
this.sessionExpiry = sessionExpiry;
|
||||
|
||||
Valid = true;
|
||||
}
|
||||
|
||||
@@ -119,22 +119,27 @@ namespace Tgstation.Server.Host.Security
|
||||
throw new InvalidOperationException("Failed to parse user ID!", e);
|
||||
}
|
||||
|
||||
var nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf);
|
||||
if (nbfClaim == default)
|
||||
throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!");
|
||||
DateTimeOffset ParseTime(string key)
|
||||
{
|
||||
var claim = principal.FindFirst(key);
|
||||
if (claim == default)
|
||||
throw new InvalidOperationException($"Missing '{key}' claim!");
|
||||
|
||||
DateTimeOffset notBefore;
|
||||
try
|
||||
{
|
||||
notBefore = new DateTimeOffset(
|
||||
EpochTime.DateTime(
|
||||
Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to parse nbf!", ex);
|
||||
try
|
||||
{
|
||||
return new DateTimeOffset(
|
||||
EpochTime.DateTime(
|
||||
Int64.Parse(claim.Value, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse '{key}'!", ex);
|
||||
}
|
||||
}
|
||||
|
||||
var notBefore = ParseTime(JwtRegisteredClaimNames.Nbf);
|
||||
var expires = ParseTime(JwtRegisteredClaimNames.Exp);
|
||||
|
||||
var user = await databaseContext
|
||||
.Users
|
||||
.AsQueryable()
|
||||
@@ -183,9 +188,12 @@ namespace Tgstation.Server.Host.Security
|
||||
}
|
||||
|
||||
currentAuthenticationContext.Initialize(
|
||||
systemIdentity,
|
||||
user,
|
||||
instancePermissionSet);
|
||||
expires,
|
||||
// signature is enough to uniquely identify the session as it is composite of all the inputs
|
||||
jwt.EncodedSignature,
|
||||
instancePermissionSet,
|
||||
systemIdentity);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Tgstation.Server.Api.Rights;
|
||||
using System;
|
||||
|
||||
using Tgstation.Server.Api.Rights;
|
||||
using Tgstation.Server.Host.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.Security
|
||||
@@ -11,7 +13,17 @@ namespace Tgstation.Server.Host.Security
|
||||
/// <summary>
|
||||
/// If the <see cref="IAuthenticationContext"/> is for a valid login.
|
||||
/// </summary>
|
||||
public bool Valid { get; }
|
||||
bool Valid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="string"/> that uniquely identifies the login session.
|
||||
/// </summary>
|
||||
string SessionId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the login session expires.
|
||||
/// </summary>
|
||||
DateTimeOffset SessionExpiry { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated user.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Tgstation.Server.Host.Models;
|
||||
|
||||
namespace Tgstation.Server.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles invalidating user sessions.
|
||||
/// </summary>
|
||||
public interface ISessionInvalidationTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Invalidate all sessions for a given <paramref name="user"/>.
|
||||
/// </summary>
|
||||
/// <param name="user">The <see cref="User"/> whose sessions should be invalidated.</param>
|
||||
public void UserModifiedInvalidateSessions(User user);
|
||||
|
||||
/// <summary>
|
||||
/// Track the session represented by a given <paramref name="authenticationContext"/>.
|
||||
/// </summary>
|
||||
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> representing the session to track.</param>
|
||||
public void TrackSession(IAuthenticationContext authenticationContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using HotChocolate.Subscriptions;
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Tgstation.Server.Host.GraphQL;
|
||||
using Tgstation.Server.Host.GraphQL.Types;
|
||||
using Tgstation.Server.Host.Models;
|
||||
using Tgstation.Server.Host.Utils;
|
||||
|
||||
namespace Tgstation.Server.Host.Security
|
||||
{
|
||||
/// <inheritdoc />
|
||||
sealed class SessionInvalidationTracker : ISessionInvalidationTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="ITopicEventSender"/> for the <see cref="SessionInvalidationTracker"/>.
|
||||
/// </summary>
|
||||
readonly ITopicEventSender eventSender;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IAsyncDelayer"/> for the <see cref="SessionInvalidationTracker"/>.
|
||||
/// </summary>
|
||||
readonly IAsyncDelayer asyncDelayer;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IHostApplicationLifetime"/> for the <see cref="SessionInvalidationTracker"/>.
|
||||
/// </summary>
|
||||
readonly IHostApplicationLifetime applicationLifetime;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ILogger"/> for the <see cref="SessionInvalidationTracker"/>.
|
||||
/// </summary>
|
||||
readonly ILogger<SessionInvalidationTracker> logger;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> of tracked <see cref="IAuthenticationContext.SessionId"/>s and <see cref="Models.User"/> <see cref="Api.Models.EntityId.Id"/>s to the <see cref="TaskCompletionSource{TResult}"/> for their <see cref="SessionInvalidationReason"/>s.
|
||||
/// </summary>
|
||||
readonly ConcurrentDictionary<(string SessionId, long UserId), TaskCompletionSource<SessionInvalidationReason>> trackedSessions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SessionInvalidationTracker"/> class.
|
||||
/// </summary>
|
||||
/// <param name="eventSender">The value of <see cref="eventSender"/>.</param>
|
||||
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/>.</param>
|
||||
/// <param name="applicationLifetime">The value of <see cref="applicationLifetime"/>.</param>
|
||||
/// <param name="logger">The value of <see cref="logger"/>.</param>
|
||||
public SessionInvalidationTracker(
|
||||
ITopicEventSender eventSender,
|
||||
IAsyncDelayer asyncDelayer,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<SessionInvalidationTracker> logger)
|
||||
{
|
||||
this.eventSender = eventSender ?? throw new ArgumentNullException(nameof(eventSender));
|
||||
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
|
||||
this.applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
trackedSessions = new ConcurrentDictionary<(string, long), TaskCompletionSource<SessionInvalidationReason>>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void TrackSession(IAuthenticationContext authenticationContext)
|
||||
{
|
||||
trackedSessions.GetOrAdd(
|
||||
(authenticationContext.SessionId, authenticationContext.User.Require(x => x.Id)),
|
||||
tuple =>
|
||||
{
|
||||
var (localSessionId, localUserId) = tuple;
|
||||
logger.LogTrace("Tracking session ID for user {userId}: {sessionId}", localUserId, localSessionId);
|
||||
var tcs = new TaskCompletionSource<SessionInvalidationReason>();
|
||||
async void SendInvalidationTopic()
|
||||
{
|
||||
try
|
||||
{
|
||||
SessionInvalidationReason invalidationReason;
|
||||
try
|
||||
{
|
||||
var otherCancellationReason = tcs.Task;
|
||||
var timeTillSessionExpiry = authenticationContext.SessionExpiry - DateTimeOffset.UtcNow;
|
||||
if (timeTillSessionExpiry > TimeSpan.Zero)
|
||||
{
|
||||
var delayTask = asyncDelayer.Delay(timeTillSessionExpiry, applicationLifetime.ApplicationStopping);
|
||||
|
||||
await Task.WhenAny(delayTask, otherCancellationReason);
|
||||
|
||||
if (delayTask.IsCompleted)
|
||||
await delayTask;
|
||||
}
|
||||
|
||||
invalidationReason = otherCancellationReason.IsCompleted
|
||||
? await otherCancellationReason
|
||||
: SessionInvalidationReason.TokenExpired;
|
||||
|
||||
logger.LogTrace("Invalidating session ID {sessionID}: {reason}", localSessionId, invalidationReason);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
logger.LogTrace(ex, "Invalidating session ID {sessionID} due to server shutdown", localSessionId);
|
||||
invalidationReason = SessionInvalidationReason.ServerShutdown;
|
||||
}
|
||||
|
||||
var topicName = Subscription.SessionInvalidatedTopic(authenticationContext);
|
||||
await eventSender.SendAsync(topicName, invalidationReason, CancellationToken.None); // DCT: Session close messages should always be sent
|
||||
await eventSender.CompleteAsync(topicName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error tracking session {sessionId}!", localSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
SendInvalidationTopic();
|
||||
return tcs;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UserModifiedInvalidateSessions(Models.User user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var userId = user.Require(x => x.Id);
|
||||
user.LastPasswordUpdate = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var key in trackedSessions
|
||||
.Keys
|
||||
.Where(key => key.UserId == userId)
|
||||
.ToList())
|
||||
if (trackedSessions.TryRemove(key, out var tcs))
|
||||
tcs.TrySetResult(SessionInvalidationReason.UserUpdated);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user