using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using AspNetCore.Identity.Mongo.Model; using IdentityServer4; using IdentityServer4.Stores; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Streetwriters.Common; using Streetwriters.Common.Enums; using Streetwriters.Common.Interfaces; using Streetwriters.Common.Messages; using Streetwriters.Common.Models; using Streetwriters.Common.Services; using Streetwriters.Identity.Enums; using Streetwriters.Identity.Extensions; using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Models; namespace Streetwriters.Identity.Services { public class UserAccountService(UserManager userManager, IMFAService mfaService, IPersistedGrantStore persistedGrantStore, RoleManager roleManager, EmailAddressValidator emailValidator, ITemplatedEmailSender emailSender, ITokenGenerationService tokenGenerationService, ILogger logger) : IUserAccountService { public async Task GetUserAsync(string clientId, string userId) { var user = await userManager.FindByIdAsync(userId); if (user == null || !await UserService.IsUserValidAsync(userManager, user, clientId)) return null; var claims = await userManager.GetClaimsAsync(user); var marketingConsentClaim = claims.FirstOrDefault((claim) => claim.Type == $"{clientId}:marketing_consent"); if (await userManager.IsEmailConfirmedAsync(user) && !await userManager.GetTwoFactorEnabledAsync(user)) { await mfaService.EnableMFAAsync(user, MFAMethods.Email); user = await userManager.FindByIdAsync(userId); ArgumentNullException.ThrowIfNull(user); } ArgumentNullException.ThrowIfNull(user.Email); return new UserModel { UserId = user.Id.ToString(), Email = user.Email, IsEmailConfirmed = user.EmailConfirmed, MarketingConsent = marketingConsentClaim == null, MFA = new MFAConfig { IsEnabled = user.TwoFactorEnabled, PrimaryMethod = mfaService.GetPrimaryMethod(user), SecondaryMethod = mfaService.GetSecondaryMethod(user), RemainingValidCodes = await mfaService.GetRemainingValidCodesAsync(user) } }; } public async Task DeleteUserAsync(string clientId, string userId, string password) { var user = await userManager.FindByIdAsync(userId); if (user == null || !await UserService.IsUserValidAsync(userManager, user, clientId)) return; if (!await userManager.CheckPasswordAsync(user, password)) throw new Exception("Wrong password."); 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; } public async Task CreateUserAsync(string clientId, string email, string password, string? userAgent = null) { if (Constants.DISABLE_SIGNUPS) return new SignupResponse { Errors = ["Creating new accounts is not allowed."] }; try { var client = Clients.FindClientById(clientId); if (client == null) return new SignupResponse { Errors = ["Invalid client id."] }; if (await roleManager.FindByNameAsync(clientId) == null) await roleManager.CreateAsync(new MongoRole(clientId)); // email addresses must be case-insensitive email = email.ToLowerInvariant(); if (!await emailValidator.IsEmailAddressValidAsync(email)) return new SignupResponse { Errors = ["Invalid email address."] }; var result = await userManager.CreateAsync(new User { Email = email, EmailConfirmed = Constants.IS_SELF_HOSTED, UserName = email, }, password); if (result.Succeeded) { var user = await userManager.FindByEmailAsync(email); if (user == null) return SignupResponse.Error(["User not found after creation."]); await userManager.AddToRoleAsync(user, client.Id); if (Constants.IS_SELF_HOSTED) { await userManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer")); } else { if (userAgent != null) await userManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(userAgent))); var code = await userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL); if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null) { await emailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client); } } var response = await tokenGenerationService.CreateUserTokensAsync(user, client.Id, 3600); if (response == null) return SignupResponse.Error(["Failed to generate access token."]); return new SignupResponse { AccessToken = response.AccessToken, AccessTokenLifetime = response.AccessTokenLifetime, RefreshToken = response.RefreshToken, Scope = response.Scope, UserId = user.Id.ToString() }; } var otherErrors = result.Errors .Where(e => e.Code != "DuplicateUserName" && e.Code != "DuplicateEmail") .ToErrors(); var hasDuplicate = result.Errors.Any(e => e.Code == "DuplicateUserName" || e.Code == "DuplicateEmail"); var errors = hasDuplicate ? ["Unable to create an account on this email.", .. otherErrors] : otherErrors; return SignupResponse.Error(errors); } catch (System.Exception ex) { logger.LogError(ex, "Failed to create user account for email: {Email}", email); return SignupResponse.Error(["Failed to create an account."]); } } private static string PlatformFromUserAgent(string? userAgent) { if (string.IsNullOrEmpty(userAgent)) return "unknown"; return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web"; } private static string GetHashedKey(string value, string grantType) { return (value + ":" + grantType).Sha256(); } } }