using MailKit.Net.Smtp; using Microsoft.Extensions.Localization; using MimeKit; using MongoDB.Driver; using MongoDB.Entities; using PasswordGenerator; 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 AuthTokenManager AuthTokenManager; 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, AuthTokenManager authTokenManager) { Jobs = jobs; Sessions = sessions; Removal = removal; DbEntities = dbEntities; AuthTokenManager = authTokenManager; 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, string invitationCode = default, bool isPasswordRequired = false) { 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); //if (!string.IsNullOrEmpty(invitationCode)) //{ // if (isPasswordRequired) // result = await DiscussionService.InviteUserToDiscussion(new PwDiscussionPreviewForm // { // InvitationCode = invitationCode, // Password = signUpForm.InvitationPassword // }, newUser.ID); // else // result = await DiscussionService.InviteUserToDiscussion(new NoPwDiscussionPreviewForm // { // InvitationCode = invitationCode, // }, newUser.ID); // if (!result.IsValid) // return result; //} 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, string invitationCode = default, bool isPasswordRequired = false) { 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]); if (needsUpgrade) result.ErrorMessage = Localizer["Needs upgrade!"]; var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm); var userSettings = (ViewUserSettings)userSettingsResult.Data; //if (!string.IsNullOrEmpty(invitationCode)) //{ // if (isPasswordRequired) // result = await DiscussionService.InviteUserToDiscussion(new PwDiscussionPreviewForm // { // InvitationCode = invitationCode, // Password = loginForm.InvitationPassword // }, user.ID); // else // result = await DiscussionService.InviteUserToDiscussion(new NoPwDiscussionPreviewForm // { // InvitationCode = invitationCode, // }, user.ID); // if (!result.IsValid) // return result; //} 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 UpdateUserAsync(UserForm userForm, string userId) { var result = new WebResult(); try { var currentUser = await DbEntities.RootUsers.Match(u => u.ID == userId).ExecuteFirstAsync(); if (!string.IsNullOrEmpty(userForm.Email) && currentUser.Email != userForm.Email) { var emailAlreadyUsed = await DbEntities.RootUsers.Match(u => u.Email == userForm.Email).ExecuteAnyAsync(); if (emailAlreadyUsed) return result.Invalidate(Localizer["Email '{0}' already taken.", userForm.Email]); } await DB.Default.Update() .Match(u => u.ID == userId) .Modify(u => u.Email, userForm.Email) .ExecuteAsync(); return result; } catch (Exception ex) { Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()"); 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.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."]); var newPasswordHashed = PasswordHasher.Hash(userPasswordForm.NewPassword); user.HashedPassword = newPasswordHashed; user.UpdatedAt = DateTime.UtcNow; await DB.Default.SaveAsync(user); 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.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 }; } }