2023-02-18 08:52:17 +01:00
|
|
|
using Microsoft.Extensions.Localization;
|
|
|
|
|
|
|
|
|
|
using MongoDB.Driver;
|
|
|
|
|
using MongoDB.Entities;
|
|
|
|
|
|
2023-02-19 00:43:43 +01:00
|
|
|
using PrivaPub.ClientModels;
|
|
|
|
|
using PrivaPub.ClientModels.User;
|
|
|
|
|
using PrivaPub.Models;
|
|
|
|
|
using PrivaPub.Models.User;
|
|
|
|
|
using PrivaPub.Resources;
|
2026-10-04 03:16:59 +02:00
|
|
|
using PrivaPub.Infrastructure.Jobs;
|
|
|
|
|
using PrivaPub.Models.Jobs;
|
2023-02-19 00:43:43 +01:00
|
|
|
using PrivaPub.StaticServices;
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
using System.Globalization;
|
2026-10-04 03:16:59 +02:00
|
|
|
using System.Text.Json;
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
#pragma warning disable 8603
|
|
|
|
|
#pragma warning disable 8625
|
|
|
|
|
|
2023-02-19 00:43:43 +01:00
|
|
|
namespace PrivaPub.Services
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
|
|
|
|
public class RootUsersService : IRootUsersService
|
|
|
|
|
{
|
|
|
|
|
readonly DbEntities DbEntities;
|
|
|
|
|
readonly IPasswordHasher PasswordHasher;
|
|
|
|
|
readonly IStringLocalizer Localizer;
|
|
|
|
|
readonly ILogger<RootUsersService> Logger;
|
|
|
|
|
readonly AppConfigurationService AppConfigurationService;
|
2026-10-04 03:16:59 +02:00
|
|
|
readonly IJobQueue Jobs;
|
|
|
|
|
readonly IRootSessions Sessions;
|
|
|
|
|
readonly IRootRemoval Removal;
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
public RootUsersService(
|
2026-10-04 03:16:59 +02:00
|
|
|
IJobQueue jobs,
|
|
|
|
|
IRootSessions sessions,
|
|
|
|
|
IRootRemoval removal,
|
2023-02-18 08:52:17 +01:00
|
|
|
IStringLocalizer<GenericRes> localizer,
|
|
|
|
|
ILogger<RootUsersService> logger,
|
|
|
|
|
IPasswordHasher passwordHasher,
|
|
|
|
|
DbEntities dbEntities,
|
2026-10-17 02:56:57 +02:00
|
|
|
AppConfigurationService appConfigurationService)
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
Jobs = jobs;
|
|
|
|
|
Sessions = sessions;
|
|
|
|
|
Removal = removal;
|
2023-02-18 08:52:17 +01:00
|
|
|
DbEntities = dbEntities;
|
|
|
|
|
PasswordHasher = passwordHasher;
|
|
|
|
|
Localizer = localizer;
|
|
|
|
|
Logger = logger;
|
|
|
|
|
AppConfigurationService = appConfigurationService;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
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)));
|
|
|
|
|
|
2026-10-17 02:56:57 +02:00
|
|
|
public async Task<WebResult> SignUpAsync(LoginForm signUpForm)
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
|
|
|
|
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();
|
2026-10-01 08:05:56 +02:00
|
|
|
newUser.Settings = new RootUserSettings
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
|
|
|
|
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
|
|
|
|
|
};
|
2026-10-01 08:05:56 +02:00
|
|
|
await DB.Default.SaveAsync(newUser);
|
2023-02-18 08:52:17 +01:00
|
|
|
|
2026-10-01 08:05:56 +02:00
|
|
|
result.Data = (newUser, ToViewSettings(newUser.Settings));
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 02:56:57 +02:00
|
|
|
public async Task<WebResult> LoginAsync(LoginForm loginForm)
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
// 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.
|
2023-02-18 08:52:17 +01:00
|
|
|
loginForm.UserName = loginForm.UserName.ToLower();
|
2026-10-04 03:16:59 +02:00
|
|
|
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]);
|
2023-02-18 08:52:17 +01:00
|
|
|
if (user.IsBanned)
|
|
|
|
|
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
|
2026-10-17 03:18:19 +02:00
|
|
|
// only a correct password learns that two-factor is on: it is asked for with a 428
|
|
|
|
|
if (user.Totp?.EnabledAt != default)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(loginForm.OneTimeCode))
|
|
|
|
|
return result.Invalidate(Localizer[TwoFactor.CodeNeeded], StatusCodes.Status428PreconditionRequired);
|
|
|
|
|
if (!await TwoFactor.Consume(user, loginForm.OneTimeCode, CancellationToken.None))
|
|
|
|
|
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
|
|
|
|
|
}
|
2023-02-18 08:52:17 +01:00
|
|
|
|
2026-10-17 03:13:16 +02:00
|
|
|
// hashed again at today's iterations; matched on the old hash, so two sign-ins at once never undo each other
|
2023-02-18 08:52:17 +01:00
|
|
|
if (needsUpgrade)
|
2026-10-17 03:13:16 +02:00
|
|
|
{
|
|
|
|
|
var oldHash = user.HashedPassword;
|
|
|
|
|
user.HashedPassword = PasswordHasher.Hash(loginForm.Password);
|
|
|
|
|
await DB.Default.Update<RootUser>()
|
|
|
|
|
.Match(u => u.ID == user.ID && u.HashedPassword == oldHash)
|
|
|
|
|
.Modify(u => u.HashedPassword, user.HashedPassword)
|
|
|
|
|
.ExecuteAsync();
|
|
|
|
|
}
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
|
2026-10-01 08:05:56 +02:00
|
|
|
var userSettings = (ViewUserSettings)userSettingsResult.Data;
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
result.Data = (user, userSettings);
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 08:05:56 +02:00
|
|
|
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
|
|
|
|
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();
|
2026-10-01 08:05:56 +02:00
|
|
|
_ = await DB.Default.Update<RootUser>()
|
|
|
|
|
.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)
|
2026-10-17 03:13:16 +02:00
|
|
|
.Modify(u => u.Settings.SavedAt, DateTime.UtcNow)
|
2026-10-01 08:05:56 +02:00
|
|
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
2023-02-18 08:52:17 +01:00
|
|
|
.ExecuteAsync();
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-01 08:05:56 +02:00
|
|
|
var user = await DbEntities.RootUsers.Match(u => u.ID == userId && u.DeletedAt == null).ExecuteFirstAsync();
|
2023-02-18 08:52:17 +01:00
|
|
|
|
|
|
|
|
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."]);
|
|
|
|
|
|
2026-10-17 03:13:16 +02:00
|
|
|
await DB.Default.Update<RootUser>().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));
|
2023-02-18 08:52:17 +01:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> RemoveUserAsync(UsersIds usersIds)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
var removed = 0;
|
|
|
|
|
foreach (var id in usersIds.UserIdList.Distinct())
|
|
|
|
|
if (await Removal.Remove(id, CancellationToken.None))
|
|
|
|
|
removed++;
|
|
|
|
|
if (removed == 0)
|
2023-02-18 08:52:17 +01:00
|
|
|
return result.Invalidate(Localizer["User already deleted."]);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
// A root deletes itself only with its password, so a stolen session cannot.
|
|
|
|
|
public async Task<WebResult> 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-18 08:52:17 +01:00
|
|
|
public async Task<WebResult> BanUserAsync(UsersIds usersIds)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-01 08:05:56 +02:00
|
|
|
await DB.Default.Update<RootUser>()
|
2023-02-18 08:52:17 +01:00
|
|
|
.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)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> UnbanUserAsync(UsersIds usersIds)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-01 08:05:56 +02:00
|
|
|
await DB.Default.Update<RootUser>()
|
2023-02-18 08:52:17 +01:00
|
|
|
.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)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-01 08:05:56 +02:00
|
|
|
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)
|
2023-02-18 08:52:17 +01:00
|
|
|
{
|
2026-10-01 08:05:56 +02:00
|
|
|
user.Settings.ThemeIsDarkMode = loginForm.ThemeIsDarkMode;
|
|
|
|
|
await DB.Default.Update<RootUser>()
|
|
|
|
|
.MatchID(userId)
|
|
|
|
|
.Modify(u => u.Settings.ThemeIsDarkMode, loginForm.ThemeIsDarkMode)
|
|
|
|
|
.ExecuteAsync();
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-01 08:05:56 +02:00
|
|
|
result.Data = ToViewSettings(user.Settings);
|
2023-02-18 08:52:17 +01:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
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.
|
2023-02-18 08:52:17 +01:00
|
|
|
public async Task<WebResult> SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
|
|
|
|
|
var user = passwordRecoveryForm.IsEmailDisabled
|
|
|
|
|
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
|
2026-10-17 03:13:16 +02:00
|
|
|
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.IsEmailValidated && u.DeletedAt == null).ExecuteFirstAsync();
|
2026-10-04 03:16:59 +02:00
|
|
|
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
|
|
|
|
|
dedupeKey: default, CancellationToken.None);
|
|
|
|
|
result.Data = Localizer[RecoverySent].Value;
|
2023-02-18 08:52:17 +01:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
Task<EmailRecovery> LiveRecovery(string recoveryCode)
|
|
|
|
|
{
|
|
|
|
|
var hash = RecoveryJob.Hash(recoveryCode);
|
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
|
return DbEntities.EmailRecoveries.Match(er => er.CodeHash == hash && er.ExpiresAt > now).ExecuteFirstAsync();
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-18 08:52:17 +01:00
|
|
|
public async Task<WebResult> IsValidRecoveryCode(string recoveryCode)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
result.Data = await LiveRecovery(recoveryCode) != default;
|
2023-02-18 08:52:17 +01:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
// A recovered password ends every session the root had: whoever had them may be why it was recovered.
|
2023-02-18 08:52:17 +01:00
|
|
|
public async Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-10-04 03:16:59 +02:00
|
|
|
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)
|
2023-02-18 08:52:17 +01:00
|
|
|
return result.Invalidate(Localizer["Invalid recovery code."], StatusCodes.Status404NotFound);
|
|
|
|
|
|
2026-10-04 03:16:59 +02:00
|
|
|
await DB.Default.Update<RootUser>().MatchID(user.ID)
|
|
|
|
|
.Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword))
|
|
|
|
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
2023-02-18 08:52:17 +01:00
|
|
|
.ExecuteAsync();
|
2026-10-04 03:16:59 +02:00
|
|
|
await DB.Default.DeleteAsync<EmailRecovery>(er => er.RootUserId == user.ID);
|
|
|
|
|
await Sessions.Revoke(user.ID, CancellationToken.None);
|
2023-02-18 08:52:17 +01:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()");
|
2026-10-01 10:58:39 +02:00
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 08:05:56 +02:00
|
|
|
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,
|
2026-10-17 03:13:16 +02:00
|
|
|
ThemeIsLightGray = settings.ThemeIsLightGray,
|
|
|
|
|
SavedAt = settings.SavedAt
|
2026-10-01 08:05:56 +02:00
|
|
|
};
|
2026-10-17 03:13:16 +02:00
|
|
|
|
|
|
|
|
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<WebResult> 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<EmailVerification>().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,
|
2026-10-17 03:18:19 +02:00
|
|
|
CreatedAt = user.CreatedAt,
|
|
|
|
|
TwoFactorEnabled = user.Totp?.EnabledAt != default,
|
|
|
|
|
RecoveryCodesLeft = user.Totp?.EnabledAt != default ? user.Totp.RecoveryCodeHashes.Count : 0
|
2026-10-17 03:13:16 +02:00
|
|
|
};
|
|
|
|
|
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<WebResult> 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<EmailVerification>(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<WebResult> VerifyEmailAsync(string rootId, string code)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
|
var pending = await DB.Default.Find<EmailVerification>().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<EmailVerification>(pending.ID);
|
|
|
|
|
else
|
|
|
|
|
await DB.Default.Update<EmailVerification>().MatchID(pending.ID).Modify(b => b.Inc(v => v.Attempts, 1)).ExecuteAsync();
|
|
|
|
|
return result.Invalidate(Localizer[EmailCodeInvalid]);
|
|
|
|
|
}
|
|
|
|
|
await DB.Default.DeleteAsync<EmailVerification>(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<RootUser>().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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 03:18:19 +02:00
|
|
|
// Two-factor sign-in (TwoFactor): set up with the password, turned on by the app's first code (which also proves the
|
|
|
|
|
// app holds the secret), turned off with the password and a code. Turning it on ends every other session.
|
|
|
|
|
public async Task<WebResult> TotpSetupAsync(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);
|
|
|
|
|
if (user.Totp?.EnabledAt != default)
|
|
|
|
|
return result.Invalidate(Localizer["Two-factor sign-in is on already: turn it off first."], StatusCodes.Status409Conflict);
|
|
|
|
|
|
|
|
|
|
var secret = TwoFactor.NewSecret();
|
|
|
|
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
|
|
|
|
.Modify(u => u.Totp, new RootTotp { PendingSecret = secret, PendingSince = DateTime.UtcNow })
|
|
|
|
|
.ExecuteAsync();
|
|
|
|
|
var uri = TwoFactor.OtpAuthUri(secret, user.UserName);
|
|
|
|
|
result.Data = new ViewTotpSetup { Secret = secret, OtpAuthUri = uri, QrDataUri = TwoFactor.QrDataUri(uri) };
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpSetupAsync)}()");
|
|
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> TotpEnableAsync(string rootId, string code)
|
|
|
|
|
{
|
|
|
|
|
var result = new WebResult();
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
|
|
|
|
|
if (user?.Totp is not { EnabledAt: null, PendingSecret: not null } pending || pending.PendingSince < DateTime.UtcNow.AddHours(-1))
|
|
|
|
|
return result.Invalidate(Localizer["Set up two-factor sign-in again: that setup expired."], StatusCodes.Status409Conflict);
|
|
|
|
|
if (TwoFactor.Step(pending.PendingSecret, code) is not { } step)
|
|
|
|
|
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
|
|
|
|
|
|
|
|
|
|
var codes = TwoFactor.NewRecoveryCodes();
|
|
|
|
|
await DB.Default.Update<RootUser>().MatchID(rootId)
|
|
|
|
|
.Modify(u => u.Totp, new RootTotp
|
|
|
|
|
{
|
|
|
|
|
Secret = pending.PendingSecret,
|
|
|
|
|
EnabledAt = DateTime.UtcNow,
|
|
|
|
|
LastStep = step,
|
|
|
|
|
RecoveryCodeHashes = codes.Select(TwoFactor.HashRecovery).ToList()
|
|
|
|
|
})
|
|
|
|
|
.ExecuteAsync();
|
|
|
|
|
await Sessions.Revoke(rootId, CancellationToken.None);
|
|
|
|
|
user = await DbEntities.RootUsers.MatchID(rootId).ExecuteFirstAsync();
|
|
|
|
|
result.Data = (user, ToViewSettings(user.Settings), codes);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpEnableAsync)}()");
|
|
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> TotpDisableAsync(string rootId, string password, string code)
|
|
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
if (user.Totp?.EnabledAt == default)
|
|
|
|
|
return await AccountAsync(rootId);
|
|
|
|
|
if (!await TwoFactor.Consume(user, code, CancellationToken.None))
|
|
|
|
|
return result.Invalidate(Localizer[TwoFactor.CodeInvalid]);
|
|
|
|
|
|
|
|
|
|
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.Totp, null).ExecuteAsync();
|
|
|
|
|
return await AccountAsync(rootId);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(TotpDisableAsync)}()");
|
|
|
|
|
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 03:13:16 +02:00
|
|
|
public async Task<WebResult> 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<RootUser>().MatchID(rootId)
|
|
|
|
|
.Modify(u => u.Email, null)
|
|
|
|
|
.Modify(u => u.IsEmailValidated, false)
|
|
|
|
|
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
|
|
|
|
.ExecuteAsync();
|
|
|
|
|
await DB.Default.DeleteAsync<EmailVerification>(v => v.RootUserId == rootId);
|
|
|
|
|
await DB.Default.DeleteAsync<EmailRecovery>(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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-02-18 08:52:17 +01:00
|
|
|
}
|
2026-10-01 08:05:56 +02:00
|
|
|
}
|