diff --git a/Notesnook.API/Controllers/UsersController.cs b/Notesnook.API/Controllers/UsersController.cs index 183ddb9..bd01d6f 100644 --- a/Notesnook.API/Controllers/UsersController.cs +++ b/Notesnook.API/Controllers/UsersController.cs @@ -18,7 +18,9 @@ along with this program. If not, see . */ using System; +using System.Net.Http; using System.Security.Claims; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.Timeouts; @@ -28,13 +30,16 @@ using Notesnook.API.Interfaces; using Notesnook.API.Models; using Notesnook.API.Models.Responses; using Streetwriters.Common; +using Streetwriters.Common.Accessors; +using Streetwriters.Common.Extensions; +using Streetwriters.Common.Messages; namespace Notesnook.API.Controllers { [ApiController] [Authorize] [Route("users")] - public class UsersController(IUserService UserService, ILogger logger) : ControllerBase + public class UsersController(IUserService UserService, WampServiceAccessor serviceAccessor, ILogger logger) : ControllerBase { [HttpPost] [AllowAnonymous] @@ -85,6 +90,43 @@ namespace Notesnook.API.Controllers } } + [HttpPatch("password/{type}")] + public async Task ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form) + { + var userId = User.GetUserId(); + var clientId = User.FindFirstValue("client_id"); + var jti = User.FindFirstValue("jti"); + var isPasswordReset = type == "reset"; + try + { + var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword); + if (!result) + return BadRequest("Failed to change password."); + + await UserService.SetUserKeysAsync(userId, form.UserKeys); + + await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null); + + await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage + { + UserId = userId, + OriginTokenId = User.FindFirstValue("jti"), + Message = new Message + { + Type = "logout", + Data = JsonSerializer.Serialize(new { reason = "Password changed." }) + } + }); + + return Ok(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to change password"); + return BadRequest(new { error = ex.Message }); + } + } + [HttpPost("reset")] public async Task Reset([FromForm] bool removeAttachments) { diff --git a/Notesnook.API/Models/ChangePasswordForm.cs b/Notesnook.API/Models/ChangePasswordForm.cs new file mode 100644 index 0000000..bcec423 --- /dev/null +++ b/Notesnook.API/Models/ChangePasswordForm.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Notesnook.API.Models +{ + public class ChangePasswordForm + { + public string? OldPassword + { + get; set; + } + + [Required] + public required string NewPassword + { + get; set; + } + + [Required] + public required UserKeys UserKeys + { + get; set; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/ResetPasswordForm.cs b/Notesnook.API/Models/ResetPasswordForm.cs new file mode 100644 index 0000000..ac3c870 --- /dev/null +++ b/Notesnook.API/Models/ResetPasswordForm.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Notesnook.API.Models +{ + public class ResetPasswordForm + { + [Required] + public required string NewPassword + { + get; set; + } + + [Required] + public required UserKeys UserKeys + { + get; set; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/Responses/UserResponse.cs b/Notesnook.API/Models/Responses/UserResponse.cs index 0481477..536c95f 100644 --- a/Notesnook.API/Models/Responses/UserResponse.cs +++ b/Notesnook.API/Models/Responses/UserResponse.cs @@ -15,6 +15,11 @@ namespace Notesnook.API.Models.Responses [JsonPropertyName("monographPasswordsKey")] public EncryptedData? MonographPasswordsKey { get; set; } + [JsonPropertyName("dataEncryptionKey")] + public EncryptedData? DataEncryptionKey { get; set; } + + [JsonPropertyName("legacyDataEncryptionKey")] + public EncryptedData? LegacyDataEncryptionKey { get; set; } [JsonPropertyName("inboxKeys")] public InboxKeys? InboxKeys { get; set; } diff --git a/Notesnook.API/Models/SyncItem.cs b/Notesnook.API/Models/SyncItem.cs index 95d97ed..c44a343 100644 --- a/Notesnook.API/Models/SyncItem.cs +++ b/Notesnook.API/Models/SyncItem.cs @@ -98,6 +98,14 @@ namespace Notesnook.API.Models get; set; } + [JsonPropertyName("keyVersion")] + [DataMember(Name = "keyVersion")] + [MessagePack.Key("keyVersion")] + public int? KeyVersion + { + get; set; + } + [JsonPropertyName("alg")] [DataMember(Name = "alg")] [MessagePack.Key("alg")] diff --git a/Notesnook.API/Models/UserKeys.cs b/Notesnook.API/Models/UserKeys.cs index 62d07c9..03466e7 100644 --- a/Notesnook.API/Models/UserKeys.cs +++ b/Notesnook.API/Models/UserKeys.cs @@ -24,6 +24,8 @@ namespace Notesnook.API.Models public EncryptedData? AttachmentsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; } public InboxKeys? InboxKeys { get; set; } + public EncryptedData? DataEncryptionKey { get; set; } + public EncryptedData? LegacyDataEncryptionKey { get; set; } } public class InboxKeys diff --git a/Notesnook.API/Models/UserSettings.cs b/Notesnook.API/Models/UserSettings.cs index ef817a6..5480d8b 100644 --- a/Notesnook.API/Models/UserSettings.cs +++ b/Notesnook.API/Models/UserSettings.cs @@ -55,6 +55,8 @@ namespace Notesnook.API.Models public EncryptedData? VaultKey { get; set; } public EncryptedData? AttachmentsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; } + public EncryptedData? DataEncryptionKey { get; set; } + public EncryptedData? LegacyDataEncryptionKey { get; set; } public InboxKeys? InboxKeys { get; set; } public Limit? StorageLimit { get; set; } diff --git a/Notesnook.API/Services/UserService.cs b/Notesnook.API/Services/UserService.cs index f40cd6f..c1ffaad 100644 --- a/Notesnook.API/Services/UserService.cs +++ b/Notesnook.API/Services/UserService.cs @@ -18,6 +18,7 @@ along with this program. If not, see . */ using System; +using System.Collections.Generic; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -133,6 +134,8 @@ namespace Notesnook.API.Services PhoneNumber = user.PhoneNumber, AttachmentsKey = userSettings.AttachmentsKey, MonographPasswordsKey = userSettings.MonographPasswordsKey, + DataEncryptionKey = userSettings.DataEncryptionKey, + LegacyDataEncryptionKey = userSettings.LegacyDataEncryptionKey, InboxKeys = userSettings.InboxKeys, Salt = userSettings.Salt, Subscription = subscription, @@ -155,6 +158,11 @@ namespace Notesnook.API.Services { userSettings.MonographPasswordsKey = keys.MonographPasswordsKey; } + if (keys.DataEncryptionKey != null) + userSettings.DataEncryptionKey = keys.DataEncryptionKey; + if (keys.LegacyDataEncryptionKey != null) + userSettings.LegacyDataEncryptionKey = keys.LegacyDataEncryptionKey; + if (keys.InboxKeys != null) { if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null) @@ -268,6 +276,8 @@ namespace Notesnook.API.Services userSettings.AttachmentsKey = null; userSettings.MonographPasswordsKey = null; + userSettings.DataEncryptionKey = null; + userSettings.LegacyDataEncryptionKey = null; userSettings.VaultKey = null; userSettings.InboxKeys = null; userSettings.LastSynced = 0; diff --git a/Streetwriters.Common/Interfaces/IUserAccountService.cs b/Streetwriters.Common/Interfaces/IUserAccountService.cs index 74c16b9..307fc8e 100644 --- a/Streetwriters.Common/Interfaces/IUserAccountService.cs +++ b/Streetwriters.Common/Interfaces/IUserAccountService.cs @@ -10,7 +10,11 @@ namespace Streetwriters.Common.Interfaces Task GetUserAsync(string clientId, string userId); [WampProcedure("co.streetwriters.identity.users.delete_user")] Task DeleteUserAsync(string clientId, string userId, string password); - // [WampProcedure("co.streetwriters.identity.users.create_user")] - // Task CreateUserAsync(); + [WampProcedure("co.streetwriters.identity.users.change_password")] + Task ChangePasswordAsync(string userId, string oldPassword, string newPassword); + [WampProcedure("co.streetwriters.identity.users.reset_password")] + Task ResetPasswordAsync(string userId, string newPassword); + [WampProcedure("co.streetwriters.identity.users.clear_sessions")] + Task ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken); } } \ No newline at end of file diff --git a/Streetwriters.Identity/Controllers/AccountController.cs b/Streetwriters.Identity/Controllers/AccountController.cs index 4149da6..f759809 100644 --- a/Streetwriters.Identity/Controllers/AccountController.cs +++ b/Streetwriters.Identity/Controllers/AccountController.cs @@ -25,6 +25,7 @@ using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using AspNetCore.Identity.Mongo.Model; +using IdentityServer4.Extensions; using IdentityServer4.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -97,12 +98,12 @@ namespace Streetwriters.Identity.Controllers } case TokenType.RESET_PASSWORD: { - // if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code)) - return BadRequest("Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience."); + if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code)) + return BadRequest("Invalid token."); - // var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode"); - // var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}"; - // return RedirectPermanent(redirectUrl); + var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode"); + var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}"; + return RedirectPermanent(redirectUrl); } default: return BadRequest("Invalid type."); @@ -149,22 +150,22 @@ namespace Streetwriters.Identity.Controllers [EnableRateLimiting("strict")] public async Task ResetUserPassword([FromForm] ResetPasswordForm form) { - return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." }); - // var client = Clients.FindClientById(form.ClientId); - // if (client == null) return BadRequest("Invalid client_id."); - // var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found."); - // if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok(); + var client = Clients.FindClientById(form.ClientId); + if (client == null) return BadRequest("Invalid client_id."); - // var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword"); - // var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD); - // #if (DEBUG || STAGING) - // return Ok(callbackUrl); - // #else - // logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl); - // await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client); - // return Ok(); - // #endif + var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found."); + if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok(); + + var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword"); + var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD); +#if (DEBUG || STAGING) + return Ok(callbackUrl); +#else + logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl); + await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client); + return Ok(); +#endif } [HttpPost("logout")] @@ -249,36 +250,6 @@ namespace Streetwriters.Identity.Controllers } return BadRequest(result.Errors.ToErrors()); } - case "change_password": - { - return BadRequest(new { error = "Password change is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." }); - // ArgumentNullException.ThrowIfNull(form.OldPassword); - // ArgumentNullException.ThrowIfNull(form.NewPassword); - // var result = await UserManager.ChangePasswordAsync(user, form.OldPassword, form.NewPassword); - // if (result.Succeeded) - // { - // await SendLogoutMessageAsync(user.Id.ToString(), "Password changed."); - // return Ok(); - // } - // return BadRequest(result.Errors.ToErrors()); - } - case "reset_password": - { - return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." }); - // ArgumentNullException.ThrowIfNull(form.NewPassword); - // var result = await UserManager.RemovePasswordAsync(user); - // if (result.Succeeded) - // { - // await MFAService.ResetMFAAsync(user); - // result = await UserManager.AddPasswordAsync(user, form.NewPassword); - // if (result.Succeeded) - // { - // await SendLogoutMessageAsync(user.Id.ToString(), "Password reset."); - // return Ok(); - // } - // } - // return BadRequest(result.Errors.ToErrors()); - } case "change_marketing_consent": { var claimType = $"{client.Id}:marketing_consent"; @@ -297,40 +268,14 @@ namespace Streetwriters.Identity.Controllers [HttpPost("sessions/clear")] public async Task ClearUserSessions([FromQuery] bool all, [FromForm] string? refresh_token) { - var client = Clients.FindClientById(User.FindFirstValue("client_id")); - if (client == null) return BadRequest("Invalid client_id."); - - var user = await UserManager.GetUserAsync(User) ?? throw new Exception("User not found."); - if (!await UserService.IsUserValidAsync(UserManager, user, client.Id)) return BadRequest($"Unable to find user with ID '{user.Id}'."); - var jti = User.FindFirstValue("jti"); - - var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter - { - ClientId = client.Id, - SubjectId = user.Id.ToString() - }); - string? refreshTokenKey = refresh_token != null ? GetHashedKey(refresh_token, PersistedGrantTypes.RefreshToken) : null; - var removedKeys = new List(); - foreach (var grant in grants) - { - if (!all && (grant.Data.Contains(jti) || grant.Key == refreshTokenKey)) continue; - await PersistedGrantStore.RemoveAsync(grant.Key); - removedKeys.Add(grant.Key); - } - - await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys)); - await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys)); - await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys)); - await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked."); + var userId = User.GetSubjectId(); + var clientId = User.FindFirstValue("client_id"); + if (await UserAccountService.ClearSessionsAsync(userId, clientId, all, refresh_token, jti)) + await SendLogoutMessageAsync(userId, "Session revoked."); return Ok(); } - private static string GetHashedKey(string value, string grantType) - { - return (value + ":" + grantType).Sha256(); - } - private async Task SendLogoutMessageAsync(string userId, string reason) { await SendMessageAsync(userId, new Message diff --git a/Streetwriters.Identity/Services/UserAccountService.cs b/Streetwriters.Identity/Services/UserAccountService.cs index fcfe56a..d0878fb 100644 --- a/Streetwriters.Identity/Services/UserAccountService.cs +++ b/Streetwriters.Identity/Services/UserAccountService.cs @@ -1,16 +1,22 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; +using IdentityServer4; +using IdentityServer4.Stores; using Microsoft.AspNetCore.Identity; +using Streetwriters.Common; using Streetwriters.Common.Enums; using Streetwriters.Common.Interfaces; +using Streetwriters.Common.Messages; using Streetwriters.Common.Models; using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Models; namespace Streetwriters.Identity.Services { - public class UserAccountService(UserManager userManager, IMFAService mfaService) : IUserAccountService + public class UserAccountService(UserManager userManager, IMFAService mfaService, IPersistedGrantStore persistedGrantStore) : IUserAccountService { public async Task GetUserAsync(string clientId, string userId) { @@ -54,5 +60,58 @@ namespace Streetwriters.Identity.Services await userManager.DeleteAsync(user); } + + public async Task ChangePasswordAsync(string userId, string oldPassword, string newPassword) + { + var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found."); + + var result = await userManager.ChangePasswordAsync(user, oldPassword, newPassword); + return result.Succeeded; + } + + public async Task ResetPasswordAsync(string userId, string newPassword) + { + var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found."); + + var result = await userManager.RemovePasswordAsync(user); + if (!result.Succeeded) return false; + + await mfaService.ResetMFAAsync(user); + result = await userManager.AddPasswordAsync(user, newPassword); + return result.Succeeded; + } + + public async Task ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken) + { + var client = Clients.FindClientById(clientId) ?? throw new Exception("Invalid client_id."); + + var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found."); + if (!await UserService.IsUserValidAsync(userManager, user, client.Id)) throw new Exception($"Unable to find user with ID '{user.Id}'."); + + var grants = await persistedGrantStore.GetAllAsync(new PersistedGrantFilter + { + ClientId = client.Id, + SubjectId = user.Id.ToString() + }); + string? refreshTokenKey = refreshToken != null ? GetHashedKey(refreshToken, IdentityServerConstants.PersistedGrantTypes.RefreshToken) : null; + List removedKeys = []; + foreach (var grant in grants) + { + if (!all && (grant.Data.Contains(jti) || grant.Key == refreshTokenKey)) continue; + await persistedGrantStore.RemoveAsync(grant.Key); + removedKeys.Add(grant.Key); + } + + await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys)); + await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys)); + await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys)); + // await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked."); + return true; + } + + private static string GetHashedKey(string value, string grantType) + { + return (value + ":" + grantType).Sha256(); + } } } \ No newline at end of file