diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 58186e1891..8767a735b9 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -90,7 +90,7 @@ namespace Tgstation.Server.Host.Controllers return; } - await authenticationContextFactory.CreateAuthenticationContext(userId, apiHeaders.InstanceId, context.HttpContext.RequestAborted).ConfigureAwait(false); + await authenticationContextFactory.CreateAuthenticationContext(userId, apiHeaders.InstanceId, context.SecurityToken.ValidFrom, context.HttpContext.RequestAborted).ConfigureAwait(false); var authenticationContext = authenticationContextFactory.CurrentAuthenticationContext; @@ -100,11 +100,12 @@ namespace Tgstation.Server.Host.Controllers { //if there's no instance user, do a weird thing and add all the instance roles //we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid - var rightInt = RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null ? ~0 : authenticationContext.GetRight(I); + //if user is null that means they got the token with an expired password + var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0 : authenticationContext.GetRight(I); var rightEnum = RightsHelper.RightToType(I); var right = (Enum)Enum.ToObject(rightEnum, rightInt); - foreach(Enum J in Enum.GetValues(rightEnum)) - if(right.HasFlag(J)) + foreach (Enum J in Enum.GetValues(rightEnum)) + if (right.HasFlag(J)) claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(I, J))); } @@ -132,6 +133,13 @@ namespace Tgstation.Server.Host.Controllers /// public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { + if (AuthenticationContext != null && AuthenticationContext.User == null) + { + //valid token, expired password + await Unauthorized().ExecuteResultAsync(context).ConfigureAwait(false); + return; + } + //validate the headers try { diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs index b30c01969e..17432de245 100644 --- a/src/Tgstation.Server.Host/Models/User.cs +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace Tgstation.Server.Host.Models { @@ -18,8 +20,14 @@ namespace Tgstation.Server.Host.Models /// /// The uppercase invariant of /// + [Required] public string CanonicalName { get; set; } + /// + /// When was last changed + /// + public DateTimeOffset? LastPasswordUpdate { get; set; } + /// /// s created by this /// diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 47d3cac17e..32ff73c8e3 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -11,7 +11,15 @@ namespace Tgstation.Server.Host.Security sealed class AuthenticationContext : IAuthenticationContext { /// - public User User { get; } + public User User + { + get + { + if (user == null) + throw new InvalidOperationException("AuthenticationContext has no user!"); + return user; + } + } /// public InstanceUser InstanceUser { get; } @@ -20,14 +28,24 @@ namespace Tgstation.Server.Host.Security public ISystemIdentity SystemIdentity { get; } /// - /// Construct a + /// Backing field for + /// + readonly User user; + + /// + /// Construct an empty + /// + public AuthenticationContext() { } + + /// + /// Construct an /// /// The value of /// The value of /// The value of public AuthenticationContext(ISystemIdentity systemIdentity, User user, InstanceUser instanceUser) { - User = user ?? throw new ArgumentNullException(nameof(user)); + this.user = user ?? throw new ArgumentNullException(nameof(user)); if (systemIdentity == null && User.SystemIdentifier != null) throw new ArgumentNullException(nameof(systemIdentity)); InstanceUser = instanceUser; @@ -45,6 +63,9 @@ namespace Tgstation.Server.Host.Security { var isInstance = RightsHelper.IsInstanceRight(rightsType); + //forces the null user check + var pullThis = User; + if (isInstance && InstanceUser == null) return 0; var rightsEnum = RightsHelper.RightToType(rightsType); diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 18fa1536ce..97cc262684 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -42,12 +42,12 @@ namespace Tgstation.Server.Host.Security } /// - public async Task CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken) + public async Task CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset validBefore, CancellationToken cancellationToken) { if (CurrentAuthenticationContext != null) throw new InvalidOperationException("Authentication context has already been loaded"); - var userQuery = databaseContext.Users.Where(x => x.Id == userId).FirstAsync(cancellationToken); + var userQuery = databaseContext.Users.Where(x => x.Id == userId).FirstOrDefaultAsync(cancellationToken); var instanceUser = instanceId.HasValue ? (await databaseContext.InstanceUsers .Where(x => x.UserId == userId) @@ -56,6 +56,8 @@ namespace Tgstation.Server.Host.Security .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false)) : null; var user = await userQuery.ConfigureAwait(false); + if (user == default) + return; ISystemIdentity systemIdentity; if (user.SystemIdentifier != null) @@ -65,7 +67,14 @@ namespace Tgstation.Server.Host.Security throw new InvalidOperationException("Cached system identity has expired!"); } else + { + if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validBefore) + { + CurrentAuthenticationContext = new AuthenticationContext(); + return; + } systemIdentity = null; + } CurrentAuthenticationContext = new AuthenticationContext(systemIdentity, user, instanceUser); } diff --git a/src/Tgstation.Server.Host/Security/CryptographySuite.cs b/src/Tgstation.Server.Host/Security/CryptographySuite.cs index b0f68e7a5c..f2b99a7ea0 100644 --- a/src/Tgstation.Server.Host/Security/CryptographySuite.cs +++ b/src/Tgstation.Server.Host/Security/CryptographySuite.cs @@ -41,6 +41,7 @@ namespace Tgstation.Server.Host.Security if (String.IsNullOrEmpty(newPassword)) throw new ArgumentNullException(nameof(newPassword)); user.PasswordHash = passwordHasher.HashPassword(user, newPassword); + user.LastPasswordUpdate = DateTimeOffset.Now; } /// @@ -52,6 +53,7 @@ namespace Tgstation.Server.Host.Security return false; case PasswordVerificationResult.SuccessRehashNeeded: user.PasswordHash = passwordHasher.HashPassword(user, password); + //don't update LastPasswordUpdate since it hasn't actually changed break; } return true; diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs index 121a9cc46b..ccc1b403de 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs @@ -1,7 +1,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { @@ -20,8 +19,9 @@ namespace Tgstation.Server.Host.Security /// /// The of the /// The of the operation + /// The the resulting 's password must be valid before /// The for the operation /// A representing the running operation - Task CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken); + Task CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset validBefore, CancellationToken cancellationToken); } }