diff --git a/API/Controller/Account/Authenticated/ChangePassword.cs b/API/Controller/Account/Authenticated/ChangePassword.cs index 0a93d9f1..fd8afd1c 100644 --- a/API/Controller/Account/Authenticated/ChangePassword.cs +++ b/API/Controller/Account/Authenticated/ChangePassword.cs @@ -17,7 +17,7 @@ public sealed partial class AuthenticatedAccountController [ProducesResponseType(StatusCodes.Status200OK)] public async Task ChangePassword(ChangePasswordRequest data) { - if (!PasswordHashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified) + if (!HashingUtils.VerifyPassword(data.OldPassword, CurrentUser.PasswordHash).Verified) { return Problem(AccountError.PasswordChangeInvalidPassword); } diff --git a/API/Controller/Account/Logout.cs b/API/Controller/Account/Logout.cs index e1028e81..3f098d99 100644 --- a/API/Controller/Account/Logout.cs +++ b/API/Controller/Account/Logout.cs @@ -19,9 +19,9 @@ public async Task Logout( var config = options.Value; // Remove session if valid - if (HttpContext.TryGetUserSession(out var sessionCookie)) + if (HttpContext.TryGetUserSessionToken(out var sessionToken)) { - await sessionService.DeleteSessionById(sessionCookie); + await sessionService.DeleteSessionByToken(sessionToken); } // Make sure cookie is removed, no matter if authenticated or not diff --git a/API/Controller/Sessions/DeleteSessions.cs b/API/Controller/Sessions/DeleteSessions.cs index 1cbd72da..bd161b7b 100644 --- a/API/Controller/Sessions/DeleteSessions.cs +++ b/API/Controller/Sessions/DeleteSessions.cs @@ -14,7 +14,7 @@ public sealed partial class SessionsController [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // SessionNotFound public async Task DeleteSession(Guid sessionId) { - var loginSession = await _sessionService.GetSessionByPulbicId(sessionId); + var loginSession = await _sessionService.GetSessionById(sessionId); // If the session was not found, or the user does not have the privledges to access it, return NotFound if (loginSession == null || !CurrentUser.IsUserOrRole(loginSession.UserId, RoleType.Admin)) diff --git a/API/Controller/Tokens/TokenController.cs b/API/Controller/Tokens/TokenController.cs index cf650e39..39a5bb0e 100644 --- a/API/Controller/Tokens/TokenController.cs +++ b/API/Controller/Tokens/TokenController.cs @@ -74,12 +74,12 @@ public async Task GetTokenById([FromRoute] Guid tokenId) [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] public async Task CreateToken([FromBody] CreateTokenRequest body) { - string token = CryptoUtils.RandomString(HardLimits.ApiKeyTokenLength); + string token = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); var tokenDto = new ApiToken { UserId = CurrentUser.Id, - TokenHash = HashingUtils.HashSha256(token), + TokenHash = HashingUtils.HashToken(token), CreatedByIp = HttpContext.GetRemoteIP(), Permissions = body.Permissions.Distinct().ToList(), Id = Guid.CreateVersion7(), diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index dbb6d990..8d7b23bf 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -1,5 +1,4 @@ -using BCrypt.Net; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using OneOf; using OneOf.Types; @@ -19,8 +18,6 @@ namespace OpenShock.API.Services.Account; /// public sealed class AccountService : IAccountService { - private const HashType HashAlgo = HashType.SHA512; - private readonly OpenShockContext _db; private readonly IEmailService _emailService; private readonly ISessionService _sessionService; @@ -65,7 +62,7 @@ private async Task, AccountWithEmailOrUsernameExists>> Creat Id = newGuid, Name = username, Email = email.ToLowerInvariant(), - PasswordHash = PasswordHashingUtils.HashPassword(password), + PasswordHash = HashingUtils.HashPassword(password), EmailActivated = emailActivated, Roles = [] }; @@ -86,14 +83,14 @@ public async Task, AccountWithEmailOrUsernameExists>> Signup var user = accountCreate.AsT0.Value; var id = Guid.CreateVersion7(); - var secret = CryptoUtils.RandomString(32); - var secretHash = BCrypt.Net.BCrypt.EnhancedHashPassword(secret, HashAlgo); + var secret = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); + var secretHash = HashingUtils.HashToken(secret); _db.UsersActivations.Add(new UsersActivation() { Id = id, UserId = user.Id, - Secret = secretHash + SecretHash = secretHash }); await _db.SaveChangesAsync(); @@ -120,11 +117,9 @@ await Task.Delay(100, if (!await CheckPassword(password, user)) return new NotFound(); - var randomSessionId = CryptoUtils.RandomString(64); - - await _sessionService.CreateSessionAsync(randomSessionId, user.Id, loginContext.UserAgent, loginContext.Ip); + var createdSession = await _sessionService.CreateSessionAsync(user.Id, loginContext.UserAgent, loginContext.Ip); - return new Success(randomSessionId); + return new Success(createdSession.Token); } /// @@ -137,7 +132,10 @@ public async Task> PasswordResetExists(G cancellationToken: cancellationToken); if (reset == null) return new NotFound(); - if (!BCrypt.Net.BCrypt.EnhancedVerify(secret, reset.Secret, HashAlgo)) return new SecretInvalid(); + + var result = HashingUtils.VerifyToken(secret, reset.SecretHash); + if (!result.Verified) return new SecretInvalid(); + return new Success(); } @@ -154,12 +152,12 @@ public async Task> CreatePasswor if (user == null) return new NotFound(); if (user.PasswordResetCount >= 3) return new TooManyPasswordResets(); - var secret = CryptoUtils.RandomString(32); - var hash = BCrypt.Net.BCrypt.EnhancedHashPassword(secret, HashAlgo); + var secret = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); + var secretHash = HashingUtils.HashToken(secret); var passwordReset = new PasswordReset { Id = Guid.CreateVersion7(), - Secret = hash, + SecretHash = secretHash, User = user.User }; _db.PasswordResets.Add(passwordReset); @@ -181,10 +179,12 @@ public async Task> PasswordResetComplete x.Id == passwordResetId && x.UsedOn == null && x.CreatedOn < validUntil); if (reset == null) return new NotFound(); - if (!BCrypt.Net.BCrypt.EnhancedVerify(secret, reset.Secret, HashAlgo)) return new SecretInvalid(); + + var result = HashingUtils.VerifyToken(secret, reset.SecretHash); + if (!result.Verified) return new SecretInvalid(); reset.UsedOn = DateTime.UtcNow; - reset.User.PasswordHash = PasswordHashingUtils.HashPassword(newPassword); + reset.User.PasswordHash = HashingUtils.HashPassword(newPassword); await _db.SaveChangesAsync(); return new Success(); } @@ -248,7 +248,7 @@ public async Task> CheckUsernameAva public async Task> ChangePassword(Guid userId, string newPassword) { var user = await _db.Users.Where(x => x.Id == userId).ExecuteUpdateAsync(calls => - calls.SetProperty(x => x.PasswordHash, PasswordHashingUtils.HashPassword(newPassword))); + calls.SetProperty(x => x.PasswordHash, HashingUtils.HashPassword(newPassword))); return user switch { <= 0 => new NotFound(), @@ -260,7 +260,7 @@ public async Task> ChangePassword(Guid userId, string n private async Task CheckPassword(string password, User user) { - var result = PasswordHashingUtils.VerifyPassword(password, user.PasswordHash); + var result = HashingUtils.VerifyPassword(password, user.PasswordHash); if (!result.Verified) { @@ -271,7 +271,7 @@ private async Task CheckPassword(string password, User user) if (result.NeedsRehash) { _logger.LogInformation("Rehashing password for user ID: [{Id}]", user.Id); - user.PasswordHash = PasswordHashingUtils.HashPassword(password); + user.PasswordHash = HashingUtils.HashPassword(password); await _db.SaveChangesAsync(); } diff --git a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs index 3c0ce0a0..28cc03d4 100644 --- a/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/ApiTokenAuthentication.cs @@ -48,13 +48,13 @@ protected override async Task HandleAuthenticateAsync() return Fail(AuthResultError.HeaderMissingOrInvalid); } - var tokenHash = HashingUtils.HashSha256(token); + var tokenHash = HashingUtils.HashToken(token); var tokenDto = await _db.ApiTokens.Include(x => x.User).FirstOrDefaultAsync(x => x.TokenHash == tokenHash && (x.ValidUntil == null || x.ValidUntil >= DateTime.UtcNow)); if (tokenDto == null) return Fail(AuthResultError.TokenInvalid); - _batchUpdateService.UpdateTokenLastUsed(tokenDto.Id); + _batchUpdateService.UpdateApiTokenLastUsed(tokenDto.Id); _authService.CurrentClient = tokenDto.User; _userReferenceService.AuthReference = tokenDto; diff --git a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs index 21e0fa4e..1444032b 100644 --- a/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs +++ b/Common/Authentication/AuthenticationHandlers/UserSessionAuthentication.cs @@ -48,12 +48,12 @@ public UserSessionAuthentication( protected override async Task HandleAuthenticateAsync() { - if (!Context.TryGetUserSession(out var sessionKey)) + if (!Context.TryGetUserSessionToken(out var sessionToken)) { return Fail(AuthResultError.CookieMissingOrInvalid); } - var session = await _sessionService.GetSessionById(sessionKey); + var session = await _sessionService.GetSessionByToken(sessionToken); if (session == null) return Fail(AuthResultError.SessionInvalid); if (session.Expires!.Value < DateTime.UtcNow.Subtract(Duration.LoginSessionExpansionAfter)) @@ -67,7 +67,7 @@ protected override async Task HandleAuthenticateAsync() }); } - _batchUpdateService.UpdateSessionLastUsed(sessionKey, DateTimeOffset.UtcNow); + _batchUpdateService.UpdateSessionLastUsed(sessionToken, DateTimeOffset.UtcNow); var retrievedUser = await _db.Users.FirstAsync(user => user.Id == session.UserId); diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index e8cb134f..daddd513 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,4 +6,6 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; + + public const int GeneratedTokenLength = 32; } diff --git a/Common/Constants/HardLimits.cs b/Common/Constants/HardLimits.cs index ede045b4..ef22d33a 100644 --- a/Common/Constants/HardLimits.cs +++ b/Common/Constants/HardLimits.cs @@ -20,7 +20,6 @@ public static class HardLimits public const int UserAgentMaxLength = 1024; public const int ApiKeyNameMaxLength = 64; - public const int ApiKeyTokenLength = 64; public const int ApiKeyMaxPermissions = 256; public const int HubNameMinLength = 1; diff --git a/Common/Hubs/ShareLinkHub.cs b/Common/Hubs/ShareLinkHub.cs index 5745e745..67e5e15b 100644 --- a/Common/Hubs/ShareLinkHub.cs +++ b/Common/Hubs/ShareLinkHub.cs @@ -48,9 +48,9 @@ public override async Task OnConnectedAsync() GenericIni? user = null; - if (httpContext.TryGetUserSession(out var sessionCookie)) + if (httpContext.TryGetUserSessionToken(out var sessionToken)) { - user = await SessionAuth(sessionCookie); + user = await SessionAuth(sessionToken); if (user == null) { _logger.LogDebug("Connection tried authentication with invalid user session cookie, terminating connection..."); @@ -132,9 +132,9 @@ public Task Control(IReadOnlyList shocks) private CustomDataHolder CustomData => (CustomDataHolder)Context.Items[ShareLinkCustomData]!; private const string ShareLinkCustomData = "ShareLinkCustomData"; - private async Task SessionAuth(string sessionKey) + private async Task SessionAuth(string sessionToken) { - var session = await _sessionService.GetSessionById(sessionKey); + var session = await _sessionService.GetSessionByToken(sessionToken); if (session == null) return null; return await _db.Users.Select(x => new GenericIni diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 2d65c867..ba686b8a 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -266,7 +266,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_on") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("Secret") + b.Property("SecretHash") .IsRequired() .HasMaxLength(100) .HasColumnType("character varying(100)") @@ -739,7 +739,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("created_on") .HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property("Secret") + b.Property("SecretHash") .IsRequired() .HasMaxLength(128) .HasColumnType("character varying(128)") @@ -779,7 +779,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(320)") .HasColumnName("email"); - b.Property("Secret") + b.Property("SecretHash") .IsRequired() .HasMaxLength(128) .HasColumnType("character varying(128)") diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 27a4c23b..e4446c77 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -238,7 +238,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.CreatedOn) .HasDefaultValueSql("CURRENT_TIMESTAMP") .HasColumnName("created_on"); - entity.Property(e => e.Secret) + entity.Property(e => e.SecretHash) .VarCharWithLength(HardLimits.PasswordResetSecretMaxLength) .HasColumnName("secret"); entity.Property(e => e.UsedOn) @@ -546,7 +546,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.CreatedOn) .HasDefaultValueSql("CURRENT_TIMESTAMP") .HasColumnName("created_on"); - entity.Property(e => e.Secret) + entity.Property(e => e.SecretHash) .VarCharWithLength(HardLimits.UserActivationSecretMaxLength) .HasColumnName("secret"); entity.Property(e => e.UsedOn).HasColumnName("used_on"); @@ -578,7 +578,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Email) .VarCharWithLength(HardLimits.EmailAddressMaxLength) .HasColumnName("email"); - entity.Property(e => e.Secret) + entity.Property(e => e.SecretHash) .VarCharWithLength(HardLimits.UserEmailChangeSecretMaxLength) .HasColumnName("secret"); entity.Property(e => e.UsedOn).HasColumnName("used_on"); diff --git a/Common/OpenShockDb/PasswordReset.cs b/Common/OpenShockDb/PasswordReset.cs index b8b8e352..22eedfec 100644 --- a/Common/OpenShockDb/PasswordReset.cs +++ b/Common/OpenShockDb/PasswordReset.cs @@ -13,7 +13,7 @@ public partial class PasswordReset public DateTime? UsedOn { get; set; } - public string Secret { get; set; } = null!; + public string SecretHash { get; set; } = null!; public virtual User User { get; set; } = null!; } diff --git a/Common/OpenShockDb/UsersActivation.cs b/Common/OpenShockDb/UsersActivation.cs index c7cfb51a..b08ddc7d 100644 --- a/Common/OpenShockDb/UsersActivation.cs +++ b/Common/OpenShockDb/UsersActivation.cs @@ -10,7 +10,7 @@ public partial class UsersActivation public DateTime? UsedOn { get; set; } - public string Secret { get; set; } = null!; + public string SecretHash { get; set; } = null!; public virtual User User { get; set; } = null!; } diff --git a/Common/OpenShockDb/UsersEmailChange.cs b/Common/OpenShockDb/UsersEmailChange.cs index 8ffcce6b..fc62f22f 100644 --- a/Common/OpenShockDb/UsersEmailChange.cs +++ b/Common/OpenShockDb/UsersEmailChange.cs @@ -10,7 +10,7 @@ public partial class UsersEmailChange public DateTime? UsedOn { get; set; } - public string Secret { get; set; } = null!; + public string SecretHash { get; set; } = null!; public string Email { get; set; } = null!; diff --git a/Common/Services/BatchUpdate/BatchUpdateService.cs b/Common/Services/BatchUpdate/BatchUpdateService.cs index 13047f06..18f3fe64 100644 --- a/Common/Services/BatchUpdate/BatchUpdateService.cs +++ b/Common/Services/BatchUpdate/BatchUpdateService.cs @@ -2,8 +2,10 @@ using System.Timers; using Microsoft.EntityFrameworkCore; using NRedisStack.RedisStackCommands; +using OpenShock.Common.Constants; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Redis; +using OpenShock.Common.Utils; using StackExchange.Redis; using Timer = System.Timers.Timer; @@ -97,9 +99,9 @@ private async Task UpdateSessions() var json = _connectionMultiplexer.GetDatabase().JSON(); - foreach (var (sessionKey, lastUsed) in _sessionLastUsed.DequeueAll()) + foreach (var (sessionToken, lastUsed) in _sessionLastUsed.DequeueAll()) { - sessionsToUpdate.Add(json.SetAsync(typeof(LoginSession).FullName + ":" + sessionKey, "LastUsed", lastUsed.ToUnixTimeMilliseconds(), When.Always)); + sessionsToUpdate.Add(json.SetAsync(typeof(LoginSession).FullName + ":" + sessionToken, "LastUsed", lastUsed.ToUnixTimeMilliseconds(), When.Always)); } try @@ -111,14 +113,20 @@ private async Task UpdateSessions() } } - public void UpdateTokenLastUsed(Guid tokenId) + public void UpdateApiTokenLastUsed(Guid apiTokenId) { - _tokenLastUsed.Enqueue(tokenId, false); + _tokenLastUsed.Enqueue(apiTokenId, false); } - public void UpdateSessionLastUsed(string sessionKey, DateTimeOffset lastUsed) + public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed) { - _sessionLastUsed.Enqueue(sessionKey, lastUsed); + // Only hash new tokens, old ones are 64 chars long + if (sessionToken.Length == AuthConstants.GeneratedTokenLength) + { + sessionToken = HashingUtils.HashToken(sessionToken); + } + + _sessionLastUsed.Enqueue(sessionToken, lastUsed); } public Task StartAsync(CancellationToken cancellationToken) diff --git a/Common/Services/BatchUpdate/IBatchUpdateService.cs b/Common/Services/BatchUpdate/IBatchUpdateService.cs index d796da27..ea42604a 100644 --- a/Common/Services/BatchUpdate/IBatchUpdateService.cs +++ b/Common/Services/BatchUpdate/IBatchUpdateService.cs @@ -5,7 +5,7 @@ public interface IBatchUpdateService /// /// Update time of last used for a token /// - /// - public void UpdateTokenLastUsed(Guid tokenId); - public void UpdateSessionLastUsed(string sessionKey, DateTimeOffset lastUsed); + /// + public void UpdateApiTokenLastUsed(Guid apiTokenId); + public void UpdateSessionLastUsed(string sessionToken, DateTimeOffset lastUsed); } \ No newline at end of file diff --git a/Common/Services/Session/ISessionService.cs b/Common/Services/Session/ISessionService.cs index 7fedb224..cd70d40e 100644 --- a/Common/Services/Session/ISessionService.cs +++ b/Common/Services/Session/ISessionService.cs @@ -4,19 +4,21 @@ namespace OpenShock.Common.Services.Session; public interface ISessionService { - public Task CreateSessionAsync(string sessionId, Guid userId, string userAgent, string ipAddress); + public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress); public Task> ListSessionsByUserId(Guid userId); - public Task GetSessionById(string sessionId); + public Task GetSessionByToken(string sessionToken); - public Task GetSessionByPulbicId(Guid publicSessionId); + public Task GetSessionById(Guid sessionId); public Task UpdateSession(LoginSession loginSession, TimeSpan ttl); - public Task DeleteSessionById(string sessionId); + public Task DeleteSessionByToken(string sessionToken); - public Task DeleteSessionByPublicId(Guid publicSessionId); + public Task DeleteSessionById(Guid sessionId); public Task DeleteSession(LoginSession loginSession); -} \ No newline at end of file +} + +public sealed record CreateSessionResult(Guid Id, string Token); \ No newline at end of file diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs index 9fcdc09f..ade1e607 100644 --- a/Common/Services/Session/SessionService.cs +++ b/Common/Services/Session/SessionService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Redis; +using OpenShock.Common.Utils; using Redis.OM; using Redis.OM.Contracts; using Redis.OM.Searching; @@ -23,22 +24,23 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider) _loginSessions = redisConnectionProvider.RedisCollection(false); } - public async Task CreateSessionAsync(string sessionId, Guid userId, string userAgent, string ipAddress) + public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress) { - Guid publicId = Guid.CreateVersion7(); + Guid id = Guid.CreateVersion7(); + string token = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength); await _loginSessions.InsertAsync(new LoginSession { - Id = sessionId, + Id = HashingUtils.HashToken(token), UserId = userId, UserAgent = userAgent, Ip = ipAddress, - PublicId = publicId, + PublicId = id, Created = DateTime.UtcNow, Expires = DateTime.UtcNow.Add(Duration.LoginSessionLifetime), }, Duration.LoginSessionLifetime); - return publicId; + return new CreateSessionResult(id, token); } public async Task> ListSessionsByUserId(Guid userId) @@ -46,14 +48,20 @@ public async Task> ListSessionsByUserId(Guid userId) return await _loginSessions.Where(x => x.UserId == userId).ToArrayAsync(); } - public async Task GetSessionById(string sessionId) + public async Task GetSessionByToken(string sessionToken) { - return await _loginSessions.FindByIdAsync(sessionId); + // Only hash new tokens, old ones are 64 chars long + if (sessionToken.Length == AuthConstants.GeneratedTokenLength) + { + sessionToken = HashingUtils.HashToken(sessionToken); + } + + return await _loginSessions.FindByIdAsync(sessionToken); } - public async Task GetSessionByPulbicId(Guid publicSessionId) + public async Task GetSessionById(Guid sessionId) { - return await _loginSessions.Where(x => x.PublicId == publicSessionId).FirstOrDefaultAsync(); + return await _loginSessions.Where(x => x.PublicId == sessionId).FirstOrDefaultAsync(); } public async Task UpdateSession(LoginSession session, TimeSpan ttl) @@ -61,18 +69,24 @@ public async Task UpdateSession(LoginSession session, TimeSpan ttl) await _loginSessions.UpdateAsync(session, ttl); } - public async Task DeleteSessionById(string sessionId) + public async Task DeleteSessionByToken(string sessionToken) { - var session = await _loginSessions.FindByIdAsync(sessionId); + // Only hash new tokens, old ones are 64 chars long + if (sessionToken.Length == AuthConstants.GeneratedTokenLength) + { + sessionToken = HashingUtils.HashToken(sessionToken); + } + + var session = await _loginSessions.FindByIdAsync(sessionToken); if (session == null) return false; await _loginSessions.DeleteAsync(session); return true; } - public async Task DeleteSessionByPublicId(Guid publicSessionId) + public async Task DeleteSessionById(Guid sessionId) { - var session = await _loginSessions.Where(x => x.PublicId == publicSessionId).FirstOrDefaultAsync(); + var session = await _loginSessions.Where(x => x.PublicId == sessionId).FirstOrDefaultAsync(); if (session == null) return false; await _loginSessions.DeleteAsync(session); diff --git a/Common/Utils/AuthUtils.cs b/Common/Utils/AuthUtils.cs index 1a1f4006..ff7460f6 100644 --- a/Common/Utils/AuthUtils.cs +++ b/Common/Utils/AuthUtils.cs @@ -38,7 +38,7 @@ public static void RemoveSessionKeyCookie(this HttpContext context, string domai context.Response.Cookies.Append(AuthConstants.UserSessionCookieName, string.Empty, GetCookieOptions(domain, TimeSpan.FromDays(-1))); } - public static bool TryGetUserSession(this HttpContext context, [NotNullWhen(true)] out string? sessionToken) + public static bool TryGetUserSessionToken(this HttpContext context, [NotNullWhen(true)] out string? sessionToken) { if (context.Request.Cookies.TryGetValue(AuthConstants.UserSessionCookieName, out sessionToken) && !string.IsNullOrEmpty(sessionToken)) { diff --git a/Common/Utils/HashingUtils.cs b/Common/Utils/HashingUtils.cs index 64605ee7..80ab7ca5 100644 --- a/Common/Utils/HashingUtils.cs +++ b/Common/Utils/HashingUtils.cs @@ -1,11 +1,20 @@ using System.Buffers; using System.Security.Cryptography; using System.Text; +using BCrypt.Net; +using OpenShock.Common.Models; namespace OpenShock.Common.Utils; public static class HashingUtils { + private const string BCryptPrefix = "bcrypt"; + private const string Pbkdf2Prefix = "pbkdf2"; + private const HashType BCryptHashType = HashType.SHA512; + + public readonly record struct VerifyHashResult(bool Verified, bool NeedsRehash); + private static readonly VerifyHashResult VerifyHashFailureResult = new(false, false); + /// /// Hashes string using SHA-256 and returns the result as a uppercase string /// @@ -42,4 +51,71 @@ public static string HashSha256(string str) return Convert.ToHexStringLower(hashDigest); } + + private static PasswordHashingAlgorithm PasswordHashingAlgorithmFromPrefix(ReadOnlySpan prefix) + { + return prefix switch + { + BCryptPrefix => PasswordHashingAlgorithm.BCrypt, + Pbkdf2Prefix => PasswordHashingAlgorithm.PBKDF2, + _ => PasswordHashingAlgorithm.Unknown, + }; + } + public static PasswordHashingAlgorithm GetPasswordHashingAlgorithm(ReadOnlySpan combinedHash) + { + int index = combinedHash.IndexOf(':'); + if (index <= 0) return PasswordHashingAlgorithm.Unknown; + + return PasswordHashingAlgorithmFromPrefix(combinedHash[..index]); + } + + public static string HashPassword(string password) + { + return $"{BCryptPrefix}:{BCrypt.Net.BCrypt.EnhancedHashPassword(password, BCryptHashType)}"; + } + public static VerifyHashResult VerifyPassword(string password, string combinedHash) + { + int index = combinedHash.IndexOf(':'); + if (index <= 0) return VerifyHashFailureResult; + + var algorithm = PasswordHashingAlgorithmFromPrefix(combinedHash.AsSpan(0, index)); + + if (algorithm == PasswordHashingAlgorithm.BCrypt) + { + return new VerifyHashResult + { + Verified = BCrypt.Net.BCrypt.EnhancedVerify(password, combinedHash[(index + 1)..], BCryptHashType), + NeedsRehash = false + }; + } + + if (algorithm == PasswordHashingAlgorithm.PBKDF2) + { +#pragma warning disable CS0618 // Type or member is obsolete + return new VerifyHashResult + { + Verified = PBKDF2PasswordHasher.Verify(password, combinedHash, customName: Pbkdf2Prefix + ":"), + NeedsRehash = true + }; +#pragma warning restore CS0618 // Type or member is obsolete + } + + return VerifyHashFailureResult; + } + + public static string HashToken(string token) + { + return HashSha256(token); + } + public static VerifyHashResult VerifyToken(string token, string hashedToken) + { + if (string.IsNullOrEmpty(token)) return VerifyHashFailureResult; + + if (hashedToken.Contains('$')) + { + return VerifyPassword(token, hashedToken) with { NeedsRehash = true }; + } + + return new VerifyHashResult(HashToken(token) == hashedToken, false); + } } diff --git a/Common/Utils/PasswordHashingUtils.cs b/Common/Utils/PasswordHashingUtils.cs deleted file mode 100644 index 24a7dd91..00000000 --- a/Common/Utils/PasswordHashingUtils.cs +++ /dev/null @@ -1,69 +0,0 @@ -using BCrypt.Net; -using OpenShock.Common.Models; - -namespace OpenShock.Common.Utils; - -public static class PasswordHashingUtils -{ - private const string BCryptPrefix = "bcrypt"; - private const string PBKDF2Prefix = "pbkdf2"; - - private const HashType BCryptHashType = HashType.SHA512; - - public readonly record struct VerifyPasswordResult(bool Verified, bool NeedsRehash); - - private static readonly VerifyPasswordResult VerifyPasswordFailureResult = new(false, false); - - private static PasswordHashingAlgorithm PasswordHashingAlgorithmFromPrefix(ReadOnlySpan prefix) - { - return prefix switch - { - BCryptPrefix => PasswordHashingAlgorithm.BCrypt, - PBKDF2Prefix => PasswordHashingAlgorithm.PBKDF2, - _ => PasswordHashingAlgorithm.Unknown, - }; - } - - public static PasswordHashingAlgorithm GetPasswordHashingAlgorithm(ReadOnlySpan combinedHash) - { - int index = combinedHash.IndexOf(':'); - if (index < 0) return PasswordHashingAlgorithm.Unknown; - - return PasswordHashingAlgorithmFromPrefix(combinedHash[..index]); - } - - public static VerifyPasswordResult VerifyPassword(string password, string combinedHash) - { - int index = combinedHash.IndexOf(':'); - if (index < 0) return VerifyPasswordFailureResult; - - var algorithm = PasswordHashingAlgorithmFromPrefix(combinedHash.AsSpan(0, index)); - - if (algorithm == PasswordHashingAlgorithm.BCrypt) - { - return new VerifyPasswordResult - { - Verified = BCrypt.Net.BCrypt.EnhancedVerify(password, combinedHash[(index + 1)..], BCryptHashType), - NeedsRehash = false - }; - } - - if (algorithm == PasswordHashingAlgorithm.PBKDF2) - { -#pragma warning disable CS0618 // Type or member is obsolete - return new VerifyPasswordResult - { - Verified = PBKDF2PasswordHasher.Verify(password, combinedHash, customName: PBKDF2Prefix + ":"), - NeedsRehash = true - }; -#pragma warning restore CS0618 // Type or member is obsolete - } - - return VerifyPasswordFailureResult; - } - - public static string HashPassword(string password) - { - return $"{BCryptPrefix}:{BCrypt.Net.BCrypt.EnhancedHashPassword(password, BCryptHashType)}"; - } -} diff --git a/Cron/DashboardAdminAuth.cs b/Cron/DashboardAdminAuth.cs index adde7f67..be332218 100644 --- a/Cron/DashboardAdminAuth.cs +++ b/Cron/DashboardAdminAuth.cs @@ -20,7 +20,7 @@ public async Task AuthorizeAsync(DashboardContext context) var userSessions = redis.RedisCollection(false); var db = httpContext.RequestServices.GetRequiredService(); - if (httpContext.TryGetUserSession(out var userSessionCookie)) + if (httpContext.TryGetUserSessionToken(out var userSessionCookie)) { if (await SessionAuthAdmin(userSessionCookie, userSessions, db)) {