diff --git a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs index 4c5d2d6a77..7559ac023a 100644 --- a/src/Tgstation.Server.Host/Authority/LoginAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/LoginAuthority.cs @@ -53,6 +53,11 @@ namespace Tgstation.Server.Host.Authority /// readonly IIdentityCache identityCache; + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + /// /// Generate an for a given . /// @@ -99,6 +104,7 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The value of . /// The value of . + /// The value of . 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)); } /// @@ -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); } } diff --git a/src/Tgstation.Server.Host/Authority/UserAuthority.cs b/src/Tgstation.Server.Host/Authority/UserAuthority.cs index c3002ed288..e740d7e40b 100644 --- a/src/Tgstation.Server.Host/Authority/UserAuthority.cs +++ b/src/Tgstation.Server.Host/Authority/UserAuthority.cs @@ -52,6 +52,11 @@ namespace Tgstation.Server.Host.Authority /// readonly ICryptographySuite cryptographySuite; + /// + /// The for the . + /// + readonly ISessionInvalidationTracker sessionInvalidationTracker; + /// /// The of for the . /// @@ -136,6 +141,7 @@ namespace Tgstation.Server.Host.Authority /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . public UserAuthority( IAuthenticationContext authenticationContext, @@ -146,6 +152,7 @@ namespace Tgstation.Server.Host.Authority ISystemIdentityFactory systemIdentityFactory, IPermissionsUpdateNotifyee permissionsUpdateNotifyee, ICryptographySuite cryptographySuite, + ISessionInvalidationTracker sessionInvalidationTracker, IOptionsSnapshot 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(); 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(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 diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index f6be54edfb..9e3a599bd6 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -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() .TryAddTypeInterceptor() .AddQueryType() - .AddMutationType(); + .AddMutationType() + .AddSubscriptionType(); void AddTypedContext() where TContext : DatabaseContext @@ -383,6 +385,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton, PasswordHasher>(); // configure platform specific services diff --git a/src/Tgstation.Server.Host/GraphQL/Subscription.cs b/src/Tgstation.Server.Host/GraphQL/Subscription.cs new file mode 100644 index 0000000000..ba39c3d13b --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Subscription.cs @@ -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 +{ + /// + /// Root type for GraphQL subscriptions. + /// + /// Intentionally left mostly empty, use type extensions to properly scope operations to domains. + public sealed class Subscription + { + /// + /// Gets the topic name for the login session represented by a given . + /// + /// The to generate the topic for. + /// The topic for the given . + public static string SessionInvalidatedTopic(IAuthenticationContext authenticationContext) + { + ArgumentNullException.ThrowIfNull(authenticationContext); + return $"SessionInvalidated.{authenticationContext.SessionId}"; + } + + /// + /// for . + /// + /// The . + /// The . + /// The for the request. + /// A resulting in a of the for the . + public ValueTask> SessionInvalidatedStream( + [Service] ITopicEventReceiver receiver, + [Service] ISessionInvalidationTracker invalidationTracker, + [Service] IAuthenticationContext authenticationContext) + { + ArgumentNullException.ThrowIfNull(receiver); + ArgumentNullException.ThrowIfNull(invalidationTracker); + + var subscription = receiver.SubscribeAsync(SessionInvalidatedTopic(authenticationContext)); + invalidationTracker.TrackSession(authenticationContext); + return subscription; + } + + /// + /// Receive a immediately before the current login session is invalidated. + /// + /// The received from the publisher. + /// The . + [Subscribe(With = nameof(SessionInvalidatedStream))] + [TgsGraphQLAuthorize] + public SessionInvalidationReason SessionInvalidated([EventMessage] SessionInvalidationReason sessionInvalidationReason) + => sessionInvalidationReason; + } +} diff --git a/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs b/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs new file mode 100644 index 0000000000..1c0e7ebe4a --- /dev/null +++ b/src/Tgstation.Server.Host/GraphQL/Types/SessionInvalidationReason.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.GraphQL.Types +{ + /// + /// Reasons TGS may invalidate a user's login session. + /// + public enum SessionInvalidationReason + { + /// + /// The callers JWT expired. + /// + TokenExpired, + + /// + /// An update to the caller's identity requiring reauthentication was made. + /// + UserUpdated, + + /// + /// 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. + /// + ServerShutdown, + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 53bf23c2a2..8659db7d56 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -13,10 +13,10 @@ namespace Tgstation.Server.Host.Security public bool Valid { get; private set; } /// - public User User => user ?? throw new InvalidOperationException("AuthenticationContext is invalid!"); + public User User => user ?? throw InvalidContext(); /// - public PermissionSet PermissionSet => permissionSet ?? throw new InvalidOperationException("AuthenticationContext is invalid!"); + public PermissionSet PermissionSet => permissionSet ?? throw InvalidContext(); /// public InstancePermissionSet? InstancePermissionSet { get; private set; } @@ -24,6 +24,12 @@ namespace Tgstation.Server.Host.Security /// public ISystemIdentity? SystemIdentity { get; private set; } + /// + public DateTimeOffset SessionExpiry => sessionExpiry ?? throw InvalidContext(); + + /// + public string SessionId => sessionId ?? throw InvalidContext(); + /// /// Backing field for . /// @@ -34,6 +40,16 @@ namespace Tgstation.Server.Host.Security /// PermissionSet? permissionSet; + /// + /// Backing field for . + /// + DateTimeOffset? sessionExpiry; + + /// + /// Backing field for . + /// + string? sessionId; + /// /// Initializes a new instance of the class. /// @@ -41,25 +57,44 @@ namespace Tgstation.Server.Host.Security { } + /// + /// for accessing fields on an In . + /// + /// A new . + static InvalidOperationException InvalidContext() + => new("AuthenticationContext is invalid!"); + /// public void Dispose() => SystemIdentity?.Dispose(); /// /// Initializes the . /// - /// The value of . /// The value of . + /// The value of . + /// The value of . /// The value of . - public void Initialize(ISystemIdentity? systemIdentity, User user, InstancePermissionSet? instanceUser) + /// The value of . + 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; } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 1d861ccea1..a8e0b59ff9 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -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 { diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 6aa4888f0b..d827185dd2 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -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 /// /// If the is for a valid login. /// - public bool Valid { get; } + bool Valid { get; } + + /// + /// A that uniquely identifies the login session. + /// + string SessionId { get; } + + /// + /// When the login session expires. + /// + DateTimeOffset SessionExpiry { get; } /// /// The authenticated user. diff --git a/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs new file mode 100644 index 0000000000..ac4cec0b81 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ISessionInvalidationTracker.cs @@ -0,0 +1,22 @@ +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Handles invalidating user sessions. + /// + public interface ISessionInvalidationTracker + { + /// + /// Invalidate all sessions for a given . + /// + /// The whose sessions should be invalidated. + public void UserModifiedInvalidateSessions(User user); + + /// + /// Track the session represented by a given . + /// + /// The representing the session to track. + public void TrackSession(IAuthenticationContext authenticationContext); + } +} diff --git a/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs new file mode 100644 index 0000000000..e25287210f --- /dev/null +++ b/src/Tgstation.Server.Host/Security/SessionInvalidationTracker.cs @@ -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 +{ + /// + sealed class SessionInvalidationTracker : ISessionInvalidationTracker + { + /// + /// The for the . + /// + readonly ITopicEventSender eventSender; + + /// + /// The for the . + /// + readonly IAsyncDelayer asyncDelayer; + + /// + /// The for the . + /// + readonly IHostApplicationLifetime applicationLifetime; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// of tracked s and s to the for their s. + /// + readonly ConcurrentDictionary<(string SessionId, long UserId), TaskCompletionSource> trackedSessions; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public SessionInvalidationTracker( + ITopicEventSender eventSender, + IAsyncDelayer asyncDelayer, + IHostApplicationLifetime applicationLifetime, + ILogger 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>(); + } + + /// + 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(); + 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; + }); + } + + /// + 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); + } + } +}