using Microsoft.Extensions.Localization; using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.User; using PrivaPub.Models; using PrivaPub.Models.User; using PrivaPub.Resources; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Jobs; using PrivaPub.StaticServices; using System.Globalization; using System.Text.Json; #pragma warning disable 8603 #pragma warning disable 8625 namespace PrivaPub.Services { public class RootUsersService : IRootUsersService { readonly DbEntities DbEntities; readonly IPasswordHasher PasswordHasher; readonly IStringLocalizer Localizer; readonly ILogger Logger; readonly AppConfigurationService AppConfigurationService; readonly IJobQueue Jobs; readonly IRootSessions Sessions; readonly IRootRemoval Removal; public RootUsersService( IJobQueue jobs, IRootSessions sessions, IRootRemoval removal, IStringLocalizer localizer, ILogger logger, IPasswordHasher passwordHasher, DbEntities dbEntities, AppConfigurationService appConfigurationService) { Jobs = jobs; Sessions = sessions; Removal = removal; DbEntities = dbEntities; PasswordHasher = passwordHasher; Localizer = localizer; Logger = logger; AppConfigurationService = appConfigurationService; } public const string NoMatch = "That username and password do not match."; static string _decoy; // a hash nobody's password matches, so an unknown login costs the same hashing as a wrong password string Decoy => _decoy ??= PasswordHasher.Hash(Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16))); public async Task SignUpAsync(LoginForm signUpForm) { var result = new WebResult(); try { signUpForm.UserName = signUpForm.UserName.ToLower(); if (await DbEntities.RootUsers.Match(u => u.UserName == signUpForm.UserName).ExecuteAnyAsync()) return result.Invalidate(Localizer["Username '{0}' already taken.", signUpForm.UserName]); var signUpPasswordHashed = PasswordHasher.Hash(signUpForm.Password); var newUser = new RootUser { UserName = signUpForm.UserName, HashedPassword = signUpPasswordHashed }; var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName; var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync(); newUser.Settings = new RootUserSettings { LanguageCode = language?.International2Code ?? "en", LightThemeIndexColour = signUpForm.LightThemeIndexColour, DarkThemeIndexColour = signUpForm.DarkThemeIndexColour, IconsThemeIndexColour = signUpForm.IconsThemeIndexColour, ThemeIsDarkGray = signUpForm.ThemeIsDarkGray, ThemeIsLightGray = signUpForm.ThemeIsLightGray, PreferSystemTheming = signUpForm.PreferSystemTheming, ThemeIsDarkMode = signUpForm.ThemeIsDarkMode }; await DB.Default.SaveAsync(newUser); result.Data = (newUser, ToViewSettings(newUser.Settings)); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task LoginAsync(LoginForm loginForm) { var result = new WebResult(); try { // One answer, after the same work, whether the login is unknown, deleted or the password wrong: sign-in must not // tell anyone which logins exist. Only someone who knows the password learns the login is banned. loginForm.UserName = loginForm.UserName.ToLower(); var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null).ExecuteFirstAsync(); var (verified, needsUpgrade) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, loginForm.Password); if (user?.HashedPassword == default || !verified) return result.Invalidate(Localizer[NoMatch]); if (user.IsBanned) return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]); // hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other if (needsUpgrade) { var oldHash = user.HashedPassword; user.HashedPassword = PasswordHasher.Hash(loginForm.Password); await DB.Default.Update() .Match(u => u.ID == user.ID && u.HashedPassword == oldHash) .Modify(u => u.HashedPassword, user.HashedPassword) .ExecuteAsync(); } var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm); var userSettings = (ViewUserSettings)userSettingsResult.Data; result.Data = (user, userSettings); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId) { var result = new WebResult(); try { var isSupportedLanguage = AppConfigurationService.AppConfiguration.SupportedLanguages.Contains(userSettings.LanguageCode); if (!isSupportedLanguage) return result.Invalidate(Localizer["Language code '{0}' unsupported.", userSettings.LanguageCode]); var languageCodeExists = await DbEntities.Languages .Match(l => l.International2Code == userSettings.LanguageCode).ExecuteAnyAsync(); if (!languageCodeExists) return result.Invalidate(Localizer["Language code '{0}' doesn't exist.", userSettings.LanguageCode]); var language = await DbEntities.Languages.Match(l => l.International2Code == userSettings.LanguageCode) .ExecuteFirstAsync(); _ = await DB.Default.Update() .MatchID(userId) .Modify(u => u.Settings.LanguageCode, language.International2Code) .Modify(u => u.Settings.LightThemeIndexColour, userSettings.LightThemeIndexColour) .Modify(u => u.Settings.DarkThemeIndexColour, userSettings.DarkThemeIndexColour) .Modify(u => u.Settings.PreferSystemTheming, userSettings.PreferSystemTheming) .Modify(u => u.Settings.ThemeIsDarkMode, userSettings.ThemeIsDarkMode) .Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray) .Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray) .Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour) .Modify(u => u.Settings.SavedAt, DateTime.UtcNow) .Modify(u => u.UpdatedAt, DateTime.UtcNow) .ExecuteAsync(); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId) { var result = new WebResult(); try { var user = await DbEntities.RootUsers.Match(u => u.ID == userId && u.DeletedAt == null).ExecuteFirstAsync(); if (user == null) return result.Invalidate(Localizer["Username '{0}' not found.", userId]); var (verified, needsUpgrade) = PasswordHasher.Check(user.HashedPassword, userPasswordForm.OldPassword); if (!verified) return result.Invalidate(Localizer["Wrong password."]); await DB.Default.Update().MatchID(user.ID) .Modify(u => u.HashedPassword, PasswordHasher.Hash(userPasswordForm.NewPassword)) .Modify(u => u.UpdatedAt, DateTime.UtcNow) .ExecuteAsync(); // whoever else is signed in may be why the password changed: every session ends, and the caller gets a new one await Sessions.Revoke(user.ID, CancellationToken.None); user = await DbEntities.RootUsers.MatchID(user.ID).ExecuteFirstAsync(); result.Data = (user, ToViewSettings(user.Settings)); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task RemoveUserAsync(UsersIds usersIds) { var result = new WebResult(); try { var removed = 0; foreach (var id in usersIds.UserIdList.Distinct()) if (await Removal.Remove(id, CancellationToken.None)) removed++; if (removed == 0) return result.Invalidate(Localizer["User already deleted."]); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } // A root deletes itself only with its password, so a stolen session cannot. public async Task RemoveSelfAsync(string rootId, string password) { var result = new WebResult(); try { var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty); if (user?.HashedPassword == default || !verified) return result.Invalidate(Localizer[NoMatch], StatusCodes.Status403Forbidden); await Removal.Remove(user.ID, CancellationToken.None); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveSelfAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task BanUserAsync(UsersIds usersIds) { var result = new WebResult(); try { await DB.Default.Update() .Match(u => usersIds.UserIdList.Contains(u.ID)) .Modify(u => u.IsBanned, true) .ExecuteAsync(); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(BanUserAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task UnbanUserAsync(UsersIds usersIds) { var result = new WebResult(); try { await DB.Default.Update() .Match(u => usersIds.UserIdList.Contains(u.ID)) .Modify(u => u.IsBanned, false) .ExecuteAsync(); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UnbanUserAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task GetUserSettingsAsync(string userId, LoginForm loginForm = default) { var result = new WebResult(); try { var user = await DbEntities.RootUsers.MatchID(userId).ExecuteFirstAsync(); if (user == default) return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound); if (loginForm != default && loginForm.ThemeIsDarkMode != user.Settings.ThemeIsDarkMode) { user.Settings.ThemeIsDarkMode = loginForm.ThemeIsDarkMode; await DB.Default.Update() .MatchID(userId) .Modify(u => u.Settings.ThemeIsDarkMode, loginForm.ThemeIsDarkMode) .ExecuteAsync(); } result.Data = ToViewSettings(user.Settings); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public const string RecoverySent = "If that account has an email address, a recovery link is on its way. It works for one hour."; // The same answer, after the same work, whether the account exists, has an email or can be written to: recovery must // not tell anyone which logins or addresses exist. RecoveryJob does the rest, out of the request. public async Task SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host) { var result = new WebResult(); try { var userName = passwordRecoveryForm.UserName?.ToLowerInvariant(); var user = passwordRecoveryForm.IsEmailDisabled ? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync() : await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.IsEmailValidated && u.DeletedAt == null).ExecuteFirstAsync(); await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host, dedupeKey: default, CancellationToken.None); result.Data = Localizer[RecoverySent].Value; return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } Task LiveRecovery(string recoveryCode) { var hash = RecoveryJob.Hash(recoveryCode); var now = DateTime.UtcNow; return DbEntities.EmailRecoveries.Match(er => er.CodeHash == hash && er.ExpiresAt > now).ExecuteFirstAsync(); } public async Task IsValidRecoveryCode(string recoveryCode) { var result = new WebResult(); try { result.Data = await LiveRecovery(recoveryCode) != default; return result; } catch (Exception ex) { return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } // A recovered password ends every session the root had: whoever had them may be why it was recovered. public async Task ChangePassword(NewPasswordForm newPasswordForm) { var result = new WebResult(); try { var recovery = await LiveRecovery(newPasswordForm.RecoveryCode); var user = recovery == default ? default : await DbEntities.RootUsers.MatchID(recovery.RootUserId).Match(u => u.DeletedAt == null && !u.IsBanned).ExecuteFirstAsync(); if (user == default) return result.Invalidate(Localizer["Invalid recovery code."], StatusCodes.Status404NotFound); await DB.Default.Update().MatchID(user.ID) .Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword)) .Modify(u => u.UpdatedAt, DateTime.UtcNow) .ExecuteAsync(); await DB.Default.DeleteAsync(er => er.RootUserId == user.ID); await Sessions.Revoke(user.ID, CancellationToken.None); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } static ViewUserSettings ToViewSettings(RootUserSettings settings) => new() { LanguageCode = settings.LanguageCode, LightThemeIndexColour = settings.LightThemeIndexColour, DarkThemeIndexColour = settings.DarkThemeIndexColour, PreferSystemTheming = settings.PreferSystemTheming, ThemeIsDarkMode = settings.ThemeIsDarkMode, IconsThemeIndexColour = settings.IconsThemeIndexColour, ThemeIsDarkGray = settings.ThemeIsDarkGray, ThemeIsLightGray = settings.ThemeIsLightGray, SavedAt = settings.SavedAt }; public const string EmailCodeSent = "If that address can be used, a code is on its way to it. It works for one hour."; public const string EmailCodeInvalid = "That code is not valid."; public async Task AccountAsync(string rootId) { var result = new WebResult(); try { var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); if (user == default) return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound); var now = DateTime.UtcNow; var pending = await DB.Default.Find().Match(v => v.RootUserId == rootId && v.ExpiresAt > now).ExecuteFirstAsync(); result.Data = new ViewAccount { UserName = user.UserName, Email = user.Email, IsEmailValidated = user.IsEmailValidated, PendingEmail = pending?.Email, CreatedAt = user.CreatedAt }; return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(AccountAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } // Only with the password, so a stolen session can't move recovery to its own inbox. The answer is the same sentence // whether or not the address is free; EmailVerificationJob sends the code, or nothing when another login holds it. public async Task RequestEmailChangeAsync(string rootId, EmailChangeForm form) { var result = new WebResult(); try { var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, form.Password ?? string.Empty); if (user?.HashedPassword == default || !verified) return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden); await DB.Default.DeleteAsync(v => v.RootUserId == user.ID); var pending = new EmailVerification { RootUserId = user.ID, Email = form.Email.Trim().ToLowerInvariant(), ExpiresAt = DateTime.UtcNow + EmailVerification.Lifetime }; await DB.Default.SaveAsync(pending); await Jobs.Enqueue(JobKind.SendEmailVerification, JsonSerializer.Serialize(new EmailVerificationPayload(pending.ID)), EmailVerificationJob.Host, dedupeKey: default, CancellationToken.None); result.Data = Localizer[EmailCodeSent].Value; return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RequestEmailChangeAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } // One answer for every failure (wrong, expired, five tries spent, or the address verified by another login meanwhile) public async Task VerifyEmailAsync(string rootId, string code) { var result = new WebResult(); try { var now = DateTime.UtcNow; var pending = await DB.Default.Find().Match(v => v.RootUserId == rootId && v.ExpiresAt > now).ExecuteFirstAsync(); if (pending == default || string.IsNullOrEmpty(pending.CodeHash)) return result.Invalidate(Localizer[EmailCodeInvalid]); if (pending.CodeHash != RecoveryJob.Hash(code?.Trim())) { if (pending.Attempts + 1 >= EmailVerification.MaxAttempts) await DB.Default.DeleteAsync(pending.ID); else await DB.Default.Update().MatchID(pending.ID).Modify(b => b.Inc(v => v.Attempts, 1)).ExecuteAsync(); return result.Invalidate(Localizer[EmailCodeInvalid]); } await DB.Default.DeleteAsync(pending.ID); if (await DbEntities.RootUsers.Match(u => u.Email == pending.Email && u.IsEmailValidated && u.ID != rootId && u.DeletedAt == null).ExecuteAnyAsync()) return result.Invalidate(Localizer[EmailCodeInvalid]); await DB.Default.Update().MatchID(rootId) .Modify(u => u.Email, pending.Email) .Modify(u => u.IsEmailValidated, true) .Modify(u => u.UpdatedAt, now) .ExecuteAsync(); return await AccountAsync(rootId); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(VerifyEmailAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } public async Task RemoveEmailAsync(string rootId, string password) { var result = new WebResult(); try { var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync(); var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty); if (user?.HashedPassword == default || !verified) return result.Invalidate(Localizer["Wrong password."], StatusCodes.Status403Forbidden); await DB.Default.Update().MatchID(rootId) .Modify(u => u.Email, null) .Modify(u => u.IsEmailValidated, false) .Modify(u => u.UpdatedAt, DateTime.UtcNow) .ExecuteAsync(); await DB.Default.DeleteAsync(v => v.RootUserId == rootId); await DB.Default.DeleteAsync(r => r.RootUserId == rootId); return await AccountAsync(rootId); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveEmailAsync)}()"); return result.Invalidate(Localizer["Something went wrong."], exception: ex); } } } }