From 500a64de180f39d5eff326b043c5ec412e60c598 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 26 Sep 2025 09:34:11 +0500 Subject: [PATCH] identity: use subscription v2 types & api --- Streetwriters.Common/Models/Subscription.cs | 182 ++++++++------- .../Controllers/SignupController.cs | 2 +- .../MessageHandlers/CreateSubscription.cs | 97 ++++---- .../MessageHandlers/CreateSubscriptionV2.cs | 49 ++++ Streetwriters.Identity/Services/MFAService.cs | 6 +- .../Services/UserService.cs | 214 +++++++++++------- Streetwriters.Identity/Startup.cs | 11 + 7 files changed, 342 insertions(+), 219 deletions(-) create mode 100644 Streetwriters.Identity/MessageHandlers/CreateSubscriptionV2.cs diff --git a/Streetwriters.Common/Models/Subscription.cs b/Streetwriters.Common/Models/Subscription.cs index 683cbea..9eab742 100644 --- a/Streetwriters.Common/Models/Subscription.cs +++ b/Streetwriters.Common/Models/Subscription.cs @@ -1,80 +1,102 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using System.ComponentModel.DataAnnotations; -using System.Runtime.Serialization; -using System.Text.Json.Serialization; -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; -using Streetwriters.Common.Enums; -using Streetwriters.Common.Interfaces; - -namespace Streetwriters.Common.Models -{ - public class Subscription : ISubscription - { - public Subscription() - { - Id = ObjectId.GenerateNewId().ToString(); - } - - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - [JsonPropertyName("id")] - public string Id { get; set; } - - [JsonPropertyName("userId")] - public string UserId { get; set; } - - [JsonIgnore] - public string OrderId { get; set; } - [JsonIgnore] - public string SubscriptionId { get; set; } - - [BsonRepresentation(BsonType.Int32)] - [JsonPropertyName("appId")] - public ApplicationType AppId { get; set; } - - [JsonPropertyName("start")] - public long StartDate { get; set; } - - [JsonPropertyName("expiry")] - public long ExpiryDate { get; set; } - - [BsonRepresentation(BsonType.Int32)] - [JsonPropertyName("provider")] - public SubscriptionProvider Provider { get; set; } - - [BsonRepresentation(BsonType.Int32)] - [JsonPropertyName("type")] - public SubscriptionType Type { get; set; } - - [JsonPropertyName("cancelURL")] - public string CancelURL { get; set; } - - [JsonPropertyName("updateURL")] - public string UpdateURL { get; set; } - - [JsonPropertyName("productId")] - public string ProductId { get; set; } - - [JsonIgnore] - public int TrialExtensionCount { get; set; } - } -} +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Interfaces; + +namespace Streetwriters.Common.Models +{ + public class Subscription : ISubscription + { + public Subscription() + { + Id = ObjectId.GenerateNewId().ToString(); + } + + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("userId")] + public string UserId { get; set; } + + [JsonIgnore] + public string OrderId { get; set; } + [JsonIgnore] + public string SubscriptionId { get; set; } + + [BsonRepresentation(BsonType.Int32)] + [JsonPropertyName("appId")] + public ApplicationType AppId { get; set; } + + [JsonPropertyName("start")] + public long StartDate { get; set; } + + [JsonPropertyName("expiry")] + public long ExpiryDate { get; set; } + + [BsonRepresentation(BsonType.Int32)] + [JsonPropertyName("provider")] + public SubscriptionProvider Provider { get; set; } + + [BsonRepresentation(BsonType.Int32)] + [JsonPropertyName("type")] + [Obsolete("Use SubscriptionPlan and SubscriptionStatus instead.")] + public SubscriptionType Type { get; set; } + + [JsonPropertyName("cancelURL")] + public string CancelURL { get; set; } + + [JsonPropertyName("updateURL")] + public string UpdateURL { get; set; } + + [JsonPropertyName("googlePurchaseToken")] + public string? GooglePurchaseToken { get; set; } + + [JsonPropertyName("productId")] + public string ProductId { get; set; } + + [JsonIgnore] + public int TrialExtensionCount { get; set; } + + [JsonPropertyName("trialExpiry")] + public long TrialExpiryDate { get; set; } + + [JsonPropertyName("trialsAvailed")] + public SubscriptionPlan[] TrialsAvailed { get; set; } + + [JsonPropertyName("updatedAt")] + public long UpdatedAt { get; set; } + + [BsonRepresentation(BsonType.Int32)] + [JsonPropertyName("plan")] + public SubscriptionPlan Plan { get; set; } + + [BsonRepresentation(BsonType.Int32)] + [JsonPropertyName("status")] + public SubscriptionStatus Status { get; set; } + } +} diff --git a/Streetwriters.Identity/Controllers/SignupController.cs b/Streetwriters.Identity/Controllers/SignupController.cs index 2dedaf9..2fa5643 100644 --- a/Streetwriters.Identity/Controllers/SignupController.cs +++ b/Streetwriters.Identity/Controllers/SignupController.cs @@ -109,7 +109,7 @@ namespace Streetwriters.Identity.Controllers await UserManager.AddToRoleAsync(user, client.Id); if (Constants.IS_SELF_HOSTED) { - await UserManager.AddClaimAsync(user, UserService.SubscriptionTypeToClaim(client.Id, Common.Enums.SubscriptionType.PREMIUM)); + await UserManager.AddClaimAsync(user, UserService.SubscriptionPlanToClaim(client.Id, SubscriptionPlan.BELIEVER)); } else { diff --git a/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs b/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs index 4bd4e9e..53feb52 100644 --- a/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs +++ b/Streetwriters.Identity/MessageHandlers/CreateSubscription.cs @@ -1,49 +1,50 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using System.Threading.Tasks; -using Streetwriters.Common.Messages; -using Streetwriters.Common.Models; -using Streetwriters.Common; -using Microsoft.AspNetCore.Identity; -using System.Security.Claims; -using System.Linq; -using Streetwriters.Identity.Services; - -namespace Streetwriters.Identity.MessageHandlers -{ - public class CreateSubscription - { - public static async Task Process(CreateSubscriptionMessage message, UserManager userManager) - { - var user = await userManager.FindByIdAsync(message.UserId); - var client = Clients.FindClientByAppId(message.AppId); - if (client == null || user == null) return; - - IdentityUserClaim statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == UserService.GetClaimKey(client.Id)); - Claim subscriptionClaim = UserService.SubscriptionTypeToClaim(client.Id, message.Type); - if (statusClaim?.ClaimValue == subscriptionClaim.Value) return; - if (statusClaim != null) - await userManager.ReplaceClaimAsync(user, statusClaim.ToClaim(), subscriptionClaim); - else - await userManager.AddClaimAsync(user, subscriptionClaim); - } - - } +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Threading.Tasks; +using Streetwriters.Common.Messages; +using Streetwriters.Common.Models; +using Streetwriters.Common; +using Microsoft.AspNetCore.Identity; +using System.Security.Claims; +using System.Linq; +using Streetwriters.Identity.Services; + +namespace Streetwriters.Identity.MessageHandlers +{ + public class CreateSubscription + { + public static async Task Process(CreateSubscriptionMessage message, UserManager userManager) + { + var user = await userManager.FindByIdAsync(message.UserId); + var client = Clients.FindClientByAppId(message.AppId); + if (client == null || user == null) return; + + IdentityUserClaim statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == UserService.GetClaimKey(client.Id)); + Claim subscriptionClaim = UserService.SubscriptionTypeToClaim(client.Id, message.Type); + if (statusClaim?.ClaimValue == subscriptionClaim.Value) return; + if (statusClaim != null) + await userManager.ReplaceClaimAsync(user, statusClaim.ToClaim(), subscriptionClaim); + // we no longer accept legacy subscriptions. + // else + // await userManager.AddClaimAsync(user, subscriptionClaim); + } + + } } \ No newline at end of file diff --git a/Streetwriters.Identity/MessageHandlers/CreateSubscriptionV2.cs b/Streetwriters.Identity/MessageHandlers/CreateSubscriptionV2.cs new file mode 100644 index 0000000..ac845eb --- /dev/null +++ b/Streetwriters.Identity/MessageHandlers/CreateSubscriptionV2.cs @@ -0,0 +1,49 @@ +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Threading.Tasks; +using Streetwriters.Common.Messages; +using Streetwriters.Common.Models; +using Streetwriters.Common; +using Microsoft.AspNetCore.Identity; +using System.Security.Claims; +using System.Linq; +using Streetwriters.Identity.Services; + +namespace Streetwriters.Identity.MessageHandlers +{ + public class CreateSubscriptionV2 + { + public static async Task Process(CreateSubscriptionMessageV2 message, UserManager userManager) + { + var user = await userManager.FindByIdAsync(message.UserId); + var client = Clients.FindClientByAppId(message.AppId); + if (client == null || user == null) return; + + IdentityUserClaim statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == UserService.GetClaimKey(client.Id)); + Claim subscriptionClaim = UserService.SubscriptionPlanToClaim(client.Id, message.Plan); + if (statusClaim?.ClaimValue == subscriptionClaim.Value) return; + if (statusClaim != null) + await userManager.ReplaceClaimAsync(user, statusClaim.ToClaim(), subscriptionClaim); + else + await userManager.AddClaimAsync(user, subscriptionClaim); + } + + } +} \ No newline at end of file diff --git a/Streetwriters.Identity/Services/MFAService.cs b/Streetwriters.Identity/Services/MFAService.cs index 11603d0..834f8b8 100644 --- a/Streetwriters.Identity/Services/MFAService.cs +++ b/Streetwriters.Identity/Services/MFAService.cs @@ -169,10 +169,12 @@ namespace Streetwriters.Identity.Services if ((method != MFAMethods.Email && method != MFAMethods.SMS) || !IsValidMFAMethod(method)) throw new Exception("Invalid method."); + var userPlan = UserService.GetUserSubscriptionPlan(client.Id, user); if (isSetup && method == MFAMethods.SMS && - !UserService.IsUserPremium(client.Id, user)) - throw new Exception("Due to the high costs of SMS, currently 2FA via SMS is only available for Pro users."); + !UserService.IsUserPremium(client.Id, user) && + userPlan != SubscriptionPlan.BELIEVER && userPlan != SubscriptionPlan.PRO) + throw new Exception("Due to the high costs of SMS, 2FA via SMS is only available on Pro & Believer plans."); // if (!user.EmailConfirmed) throw new Exception("Please confirm your email before activating 2FA by email."); await GetAuthenticatorDetailsAsync(user, client); diff --git a/Streetwriters.Identity/Services/UserService.cs b/Streetwriters.Identity/Services/UserService.cs index e900479..056c941 100644 --- a/Streetwriters.Identity/Services/UserService.cs +++ b/Streetwriters.Identity/Services/UserService.cs @@ -1,89 +1,127 @@ -/* -This file is part of the Notesnook Sync Server project (https://notesnook.com/) - -Copyright (C) 2023 Streetwriters (Private) Limited - -This program is free software: you can redistribute it and/or modify -it under the terms of the Affero GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -Affero GNU General Public License for more details. - -You should have received a copy of the Affero GNU General Public License -along with this program. If not, see . -*/ - -using System.Linq; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Identity; -using Streetwriters.Common.Enums; -using Streetwriters.Common.Models; - -namespace Streetwriters.Identity.Services -{ - public class UserService - { - public static SubscriptionType GetUserSubscriptionStatus(string clientId, User user) - { - var claimKey = GetClaimKey(clientId); - var status = user.Claims.FirstOrDefault((c) => c.ClaimType == claimKey).ClaimValue; - switch (status) - { - case "basic": - return SubscriptionType.BASIC; - case "trial": - return SubscriptionType.TRIAL; - case "premium": - return SubscriptionType.PREMIUM; - case "premium_canceled": - return SubscriptionType.PREMIUM_CANCELED; - case "premium_expired": - return SubscriptionType.PREMIUM_EXPIRED; - default: - return SubscriptionType.BASIC; - } - } - - - - public static bool IsUserPremium(string clientId, User user) - { - var status = GetUserSubscriptionStatus(clientId, user); - return status == SubscriptionType.PREMIUM || status == SubscriptionType.PREMIUM_CANCELED; - } - - public static Claim SubscriptionTypeToClaim(string clientId, SubscriptionType type) - { - var claimKey = GetClaimKey(clientId); - switch (type) - { - case SubscriptionType.BASIC: - return new Claim(claimKey, "basic"); - case SubscriptionType.TRIAL: - return new Claim(claimKey, "trial"); - case SubscriptionType.PREMIUM: - return new Claim(claimKey, "premium"); - case SubscriptionType.PREMIUM_CANCELED: - return new Claim(claimKey, "premium_canceled"); - case SubscriptionType.PREMIUM_EXPIRED: - return new Claim(claimKey, "premium_expired"); - } - return null; - } - - public static string GetClaimKey(string clientId) - { - return $"{clientId}:status"; - } - - public static async Task IsUserValidAsync(UserManager userManager, User user, string clientId) - { - return user != null && await userManager.IsInRoleAsync(user, clientId); - } - } +/* +This file is part of the Notesnook Sync Server project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the Affero GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +Affero GNU General Public License for more details. + +You should have received a copy of the Affero GNU General Public License +along with this program. If not, see . +*/ + +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Streetwriters.Common.Enums; +using Streetwriters.Common.Models; + +namespace Streetwriters.Identity.Services +{ + public class UserService + { + public static SubscriptionType GetUserSubscriptionStatus(string clientId, User user) + { + var claimKey = GetClaimKey(clientId); + var status = user.Claims.FirstOrDefault((c) => c.ClaimType == claimKey).ClaimValue; + switch (status) + { + case "basic": + return SubscriptionType.BASIC; + case "trial": + return SubscriptionType.TRIAL; + case "premium": + return SubscriptionType.PREMIUM; + case "premium_canceled": + return SubscriptionType.PREMIUM_CANCELED; + case "premium_expired": + return SubscriptionType.PREMIUM_EXPIRED; + default: + return SubscriptionType.BASIC; + } + } + + public static SubscriptionPlan GetUserSubscriptionPlan(string clientId, User user) + { + var claimKey = GetClaimKey(clientId); + var status = user.Claims.FirstOrDefault((c) => c.ClaimType == claimKey).ClaimValue; + switch (status) + { + case "free": + return SubscriptionPlan.FREE; + case "believer": + return SubscriptionPlan.BELIEVER; + case "education": + return SubscriptionPlan.EDUCATION; + case "essential": + return SubscriptionPlan.ESSENTIAL; + case "pro": + return SubscriptionPlan.PRO; + default: + return SubscriptionPlan.FREE; + } + } + + public static bool IsUserPremium(string clientId, User user) + { + var status = GetUserSubscriptionStatus(clientId, user); + return status == SubscriptionType.PREMIUM || status == SubscriptionType.PREMIUM_CANCELED; + } + + public static Claim SubscriptionTypeToClaim(string clientId, SubscriptionType type) + { + var claimKey = GetClaimKey(clientId); + switch (type) + { + case SubscriptionType.BASIC: + return new Claim(claimKey, "basic"); + case SubscriptionType.TRIAL: + return new Claim(claimKey, "trial"); + case SubscriptionType.PREMIUM: + return new Claim(claimKey, "premium"); + case SubscriptionType.PREMIUM_CANCELED: + return new Claim(claimKey, "premium_canceled"); + case SubscriptionType.PREMIUM_EXPIRED: + return new Claim(claimKey, "premium_expired"); + } + return null; + } + + public static Claim SubscriptionPlanToClaim(string clientId, SubscriptionPlan plan) + { + var claimKey = GetClaimKey(clientId); + switch (plan) + { + case SubscriptionPlan.FREE: + return new Claim(claimKey, "free"); + case SubscriptionPlan.BELIEVER: + return new Claim(claimKey, "believer"); + case SubscriptionPlan.EDUCATION: + return new Claim(claimKey, "education"); + case SubscriptionPlan.ESSENTIAL: + return new Claim(claimKey, "essential"); + case SubscriptionPlan.PRO: + return new Claim(claimKey, "pro"); + } + return null; + } + + public static string GetClaimKey(string clientId) + { + return $"{clientId}:status"; + } + + public static async Task IsUserValidAsync(UserManager userManager, User user, string clientId) + { + return user != null && await userManager.IsInRoleAsync(user, clientId); + } + } } \ No newline at end of file diff --git a/Streetwriters.Identity/Startup.cs b/Streetwriters.Identity/Startup.cs index 531324c..a45fde3 100644 --- a/Streetwriters.Identity/Startup.cs +++ b/Streetwriters.Identity/Startup.cs @@ -244,6 +244,17 @@ namespace Streetwriters.Identity await MessageHandlers.CreateSubscription.Process(message, userManager); } }); + + realm.Subscribe(SubscriptionServerTopics.CreateSubscriptionV2Topic, async (CreateSubscriptionMessageV2 message) => + { + using (var serviceScope = app.ApplicationServices.CreateScope()) + { + var services = serviceScope.ServiceProvider; + var userManager = services.GetRequiredService>(); + await MessageHandlers.CreateSubscriptionV2.Process(message, userManager); + } + }); + realm.Subscribe(SubscriptionServerTopics.DeleteSubscriptionTopic, async (DeleteSubscriptionMessage message) => { using (var serviceScope = app.ApplicationServices.CreateScope())