Feature: Plugin can extend whether an account can login or not

This commit is contained in:
Nicolas Dorier
2025-11-14 15:56:14 +09:00
parent a914d798f3
commit 41f5588257
13 changed files with 440 additions and 288 deletions

View File

@@ -19,7 +19,7 @@ namespace BTCPayServer.Data
public List<APIKeyData> APIKeys { get; set; }
public DateTimeOffset? Created { get; set; }
public string DisabledNotifications { get; set; }
public List<NotificationData> Notifications { get; set; }
public List<UserStore> UserStores { get; set; }
public List<Fido2Credential> Fido2Credentials { get; set; }

View File

@@ -444,7 +444,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
// User shouldn't be deleted if it's the only admin
if (await _userService.IsUserTheOnlyOneAdmin(user))
if (await _userService.IsUserTheOnlyOneAdmin(new (user, baseUrl: Request.GetRequestBaseUrl())))
{
return Forbid(AuthenticationSchemes.GreenfieldBasic);
}

View File

@@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
@@ -31,60 +32,32 @@ using NicolasDorier.RateLimits;
namespace BTCPayServer.Controllers
{
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIAccountController : Controller
public class UIAccountController(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
SignInManager<ApplicationUser> signInManager,
PoliciesSettings policiesSettings,
SettingsRepository settingsRepository,
Configuration.BTCPayServerOptions options,
BTCPayServerEnvironment btcPayServerEnvironment,
EventAggregator eventAggregator,
Fido2Service fido2Service,
UserLoginCodeService userLoginCodeService,
LnurlAuthService lnurlAuthService,
EmailSenderFactory emailSenderFactory,
CallbackGenerator callbackGenerator,
IStringLocalizer stringLocalizer,
ViewLocalizer viewLocalizer,
UserService userService,
Logs logs)
: Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
readonly RoleManager<IdentityRole> _RoleManager;
readonly Configuration.BTCPayServerOptions _Options;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
readonly SettingsRepository _SettingsRepository;
private readonly Fido2Service _fido2Service;
private readonly LnurlAuthService _lnurlAuthService;
private readonly CallbackGenerator _callbackGenerator;
private readonly UserLoginCodeService _userLoginCodeService;
private readonly EventAggregator _eventAggregator;
readonly ILogger _logger;
readonly ILogger _logger = logs.PayServer;
public PoliciesSettings PoliciesSettings { get; }
public EmailSenderFactory EmailSenderFactory { get; }
public IStringLocalizer StringLocalizer { get; }
public Logs Logs { get; }
public UIAccountController(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
SignInManager<ApplicationUser> signInManager,
PoliciesSettings policiesSettings,
SettingsRepository settingsRepository,
Configuration.BTCPayServerOptions options,
BTCPayServerEnvironment btcPayServerEnvironment,
EventAggregator eventAggregator,
Fido2Service fido2Service,
UserLoginCodeService userLoginCodeService,
LnurlAuthService lnurlAuthService,
EmailSenderFactory emailSenderFactory,
CallbackGenerator callbackGenerator,
IStringLocalizer stringLocalizer,
Logs logs)
{
_userManager = userManager;
_signInManager = signInManager;
PoliciesSettings = policiesSettings;
_SettingsRepository = settingsRepository;
_RoleManager = roleManager;
_Options = options;
_btcPayServerEnvironment = btcPayServerEnvironment;
_fido2Service = fido2Service;
_lnurlAuthService = lnurlAuthService;
EmailSenderFactory = emailSenderFactory;
_callbackGenerator = callbackGenerator;
_userLoginCodeService = userLoginCodeService;
_eventAggregator = eventAggregator;
_logger = logs.PayServer;
Logs = logs;
StringLocalizer = stringLocalizer;
}
public PoliciesSettings PoliciesSettings { get; } = policiesSettings;
public EmailSenderFactory EmailSenderFactory { get; } = emailSenderFactory;
public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
public Logs Logs { get; } = logs;
[TempData]
public string ErrorMessage
@@ -152,31 +125,31 @@ namespace BTCPayServer.Controllers
if (!string.IsNullOrEmpty(loginCode))
{
var code = loginCode.Split(';').First();
var userId = _userLoginCodeService.Verify(code);
var userId = userLoginCodeService.Verify(code);
if (userId is null)
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Login code was invalid"].Value;
return await Login(returnUrl);
}
var user = await _userManager.FindByIdAsync(userId);
if (!UserService.TryCanLogin(user, out var message))
var user = await userManager.FindByIdAsync(userId);
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
TempData.SetStatusLoginResult(loginContext);
return await Login(returnUrl);
}
_logger.LogInformation("User {Email} logged in with a login code", user!.Email);
await _signInManager.SignInAsync(user, false, "LoginCode");
await signInManager.SignInAsync(user, false, "LoginCode");
return RedirectToLocal(returnUrl);
}
return await Login(returnUrl);
}
private UserService.CanLoginContext CreateLoginContext(ApplicationUser user)
=> new(user, StringLocalizer, viewLocalizer, this.HttpContext.Request.GetRequestBaseUrl());
[HttpPost("/login")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
@@ -192,30 +165,27 @@ namespace BTCPayServer.Controllers
if (ModelState.IsValid)
{
// Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
var user = await _userManager.FindByEmailAsync(model.Email);
var user = await userManager.FindByEmailAsync(model.Email);
var errorMessage = StringLocalizer["Invalid login attempt."].Value;
if (!UserService.TryCanLogin(user, out var message))
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
TempData.SetStatusLoginResult(loginContext);
return View(model);
}
var fido2Devices = await _fido2Service.HasCredentials(user!.Id);
var lnurlAuthCredentials = await _lnurlAuthService.HasCredentials(user.Id);
if (!await _userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
var fido2Devices = await fido2Service.HasCredentials(user!.Id);
var lnurlAuthCredentials = await lnurlAuthService.HasCredentials(user.Id);
if (!await userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
{
if (await _userManager.CheckPasswordAsync(user, model.Password))
if (await userManager.CheckPasswordAsync(user, model.Password))
{
LoginWith2faViewModel twoFModel = null;
if (user.TwoFactorEnabled)
{
// we need to do an actual sign in attempt so that 2fa can function in next step
await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
await signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
twoFModel = new LoginWith2faViewModel
{
RememberMe = model.RememberMe
@@ -230,12 +200,12 @@ namespace BTCPayServer.Controllers
});
}
await _userManager.AccessFailedAsync(user);
await userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, errorMessage!);
return View(model);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
var result = await signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
_logger.LogInformation("User {Email} logged in", user.Email);
@@ -267,9 +237,9 @@ namespace BTCPayServer.Controllers
private async Task<LoginWithFido2ViewModel> BuildFido2ViewModel(bool rememberMe, ApplicationUser user)
{
if (_btcPayServerEnvironment.IsSecure(HttpContext))
if (btcPayServerEnvironment.IsSecure(HttpContext))
{
var r = await _fido2Service.RequestLogin(user.Id);
var r = await fido2Service.RequestLogin(user.Id);
if (r is null)
{
return null;
@@ -286,9 +256,9 @@ namespace BTCPayServer.Controllers
private async Task<LoginWithLNURLAuthViewModel> BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user)
{
if (_btcPayServerEnvironment.IsSecure(HttpContext))
if (btcPayServerEnvironment.IsSecure(HttpContext))
{
var r = await _lnurlAuthService.RequestLogin(user.Id);
var r = await lnurlAuthService.RequestLogin(user.Id);
if (r is null)
{
return null;
@@ -297,7 +267,7 @@ namespace BTCPayServer.Controllers
{
RememberMe = rememberMe,
UserId = user.Id,
LNURLEndpoint = new Uri(_callbackGenerator.ForLNUrlAuth(user, r, Request))
LNURLEndpoint = new Uri(callbackGenerator.ForLNUrlAuth(user, r, Request))
};
}
return null;
@@ -315,25 +285,22 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = StringLocalizer["Invalid login attempt."].Value;
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (!UserService.TryCanLogin(user, out var message))
var user = await userManager.FindByIdAsync(viewModel.UserId);
var loggingContext = CreateLoginContext(user);
if (!await userService.CanLogin(loggingContext))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
TempData.SetStatusLoginResult(loggingContext);
return RedirectToAction("Login");
}
try
{
var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1"));
if (_lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out var storedk1) &&
if (lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out var storedk1) &&
storedk1.SequenceEqual(k1))
{
_lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
await signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
return RedirectToLocal(returnUrl);
}
@@ -349,7 +316,7 @@ namespace BTCPayServer.Controllers
}
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
LoginWithFido2ViewModel = await fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = viewModel,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
@@ -372,22 +339,19 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (!UserService.TryCanLogin(user, out var message))
var user = await userManager.FindByIdAsync(viewModel.UserId);
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
TempData.SetStatusLoginResult(loginContext);
return RedirectToAction("Login");
}
try
{
if (await _fido2Service.CompleteLogin(viewModel.UserId, System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(viewModel.Response)))
if (await fido2Service.CompleteLogin(viewModel.UserId, System.Text.Json.JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(viewModel.Response)))
{
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
await signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User {Email} logged in with FIDO2", user.Email);
return RedirectToLocal(returnUrl);
}
@@ -405,7 +369,7 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = viewModel,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = await lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel
@@ -425,7 +389,7 @@ namespace BTCPayServer.Controllers
}
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
@@ -436,8 +400,8 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = await fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
@@ -456,23 +420,16 @@ namespace BTCPayServer.Controllers
return View(model);
}
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
TempData.SetStatusLoginResult(loginContext);
return View(model);
}
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
var result = await signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
if (result.Succeeded)
{
_logger.LogInformation("User {Email} logged in with 2FA", user.Email);
@@ -484,8 +441,8 @@ namespace BTCPayServer.Controllers
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = await fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
@@ -499,7 +456,7 @@ namespace BTCPayServer.Controllers
}
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
@@ -525,23 +482,16 @@ namespace BTCPayServer.Controllers
return View(model);
}
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
var user = await signInManager.GetTwoFactorAuthenticationUserAsync();
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
throw new ApplicationException("Unable to load two-factor authentication user.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
TempData.SetStatusLoginResult(loginContext);
return View(model);
}
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
var result = await signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
if (result.Succeeded)
{
_logger.LogInformation("User {Email} logged in with a recovery code", user.Email);
@@ -592,14 +542,14 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
ViewData["Logon"] = logon.ToString(CultureInfo.InvariantCulture).ToLowerInvariant();
var policies = await _SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
var policies = await settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
if (ModelState.IsValid)
{
var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
var isFirstAdmin = !anyAdmin || (model.IsAdmin && _Options.CheatMode);
var anyAdmin = (await userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
var isFirstAdmin = !anyAdmin || (model.IsAdmin && options.CheatMode);
var user = new ApplicationUser
{
UserName = model.Email,
@@ -609,21 +559,21 @@ namespace BTCPayServer.Controllers
Created = DateTimeOffset.UtcNow,
Approved = isFirstAdmin // auto-approve first admin
};
var result = await _userManager.CreateAsync(user, model.Password);
var result = await userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
if (isFirstAdmin)
{
await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
await _userManager.AddToRoleAsync(user, Roles.ServerAdmin);
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
await roleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
await userManager.AddToRoleAsync(user, Roles.ServerAdmin);
var settings = await settingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
settings.FirstRun = false;
await _SettingsRepository.UpdateSetting(settings);
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
await settingsRepository.UpdateSetting(settings);
await settingsRepository.FirstAdminRegistered(policies, options.UpdateUrl != null, options.DisableRegistration, Logs);
RegisteredAdmin = true;
}
_eventAggregator.Publish(await UserEvent.Registered.Create(user, _callbackGenerator, Request));
eventAggregator.Publish(await UserEvent.Registered.Create(user, callbackGenerator, Request));
RegisteredUserId = user.Id;
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["Account created."].Value;
@@ -643,7 +593,7 @@ namespace BTCPayServer.Controllers
}
if (logon)
{
await _signInManager.SignInAsync(user, isPersistent: false);
await signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation("User {Email} logged in", user.Email);
return RedirectToLocal(returnUrl);
}
@@ -665,9 +615,9 @@ namespace BTCPayServer.Controllers
[HttpGet("/logout")]
public async Task<IActionResult> Logout()
{
var userId = _signInManager.UserManager.GetUserId(HttpContext.User);
var user = await _userManager.FindByIdAsync(userId);
await _signInManager.SignOutAsync();
var userId = signInManager.UserManager.GetUserId(HttpContext.User);
var user = await userManager.FindByIdAsync(userId);
await signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie();
_logger.LogInformation("User {Email} logged out", user!.Email);
return RedirectToAction(nameof(Login));
@@ -681,19 +631,19 @@ namespace BTCPayServer.Controllers
{
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
}
var user = await _userManager.FindByIdAsync(userId);
var user = await userManager.FindByIdAsync(userId);
if (user == null)
{
throw new ApplicationException($"Unable to load user with ID '{userId}'.");
}
var result = await _userManager.ConfirmEmailAsync(user, code);
var result = await userManager.ConfirmEmailAsync(user, code);
if (result.Succeeded)
{
var approvalLink = _callbackGenerator.ForApproval(user, Request);
_eventAggregator.Publish(new UserEvent.ConfirmedEmail(user, approvalLink));
var approvalLink = callbackGenerator.ForApproval(user, Request);
eventAggregator.Publish(new UserEvent.ConfirmedEmail(user, approvalLink));
var hasPassword = await _userManager.HasPasswordAsync(user);
var hasPassword = await userManager.HasPasswordAsync(user);
if (hasPassword)
{
TempData.SetStatusMessageModel(new StatusMessageModel
@@ -730,14 +680,14 @@ namespace BTCPayServer.Controllers
{
if (ModelState.IsValid && await EmailSenderFactory.IsComplete())
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (!UserService.TryCanLogin(user, out _))
var user = await userManager.FindByEmailAsync(model.Email);
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext))
{
// Don't reveal that the user does not exist or is not confirmed
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
var callbackUri = await _callbackGenerator.ForPasswordReset(user, Request);
_eventAggregator.Publish(new UserEvent.PasswordResetRequested(user, callbackUri));
var callbackUri = await callbackGenerator.ForPasswordReset(user, Request);
eventAggregator.Publish(new UserEvent.PasswordResetRequested(user, callbackUri));
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
@@ -761,8 +711,8 @@ namespace BTCPayServer.Controllers
throw new ApplicationException("A code must be supplied for this action.");
}
var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId);
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
var user = string.IsNullOrEmpty(userId) ? null : await userManager.FindByIdAsync(userId);
var hasPassword = user != null && await userManager.HasPasswordAsync(user);
if (!string.IsNullOrEmpty(userId))
{
email = user?.Email;
@@ -787,17 +737,17 @@ namespace BTCPayServer.Controllers
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
var needsInitialPassword = user != null && !await _userManager.HasPasswordAsync(user);
// Let unapproved users set a password. Otherwise, don't reveal that the user does not exist.
if (!UserService.TryCanLogin(user, out var message) && !needsInitialPassword || user == null)
{
_logger.LogWarning("User {Email} tried to reset password, but failed: {Message}", user?.Email ?? "(NO EMAIL)", message);
var user = await userManager.FindByEmailAsync(model.Email);
if (user is null)
return RedirectToAction(nameof(Login));
var hasPassword = await userManager.HasPasswordAsync(user);
var needsInitialPassword = !await userManager.HasPasswordAsync(user);
// Let unapproved users set a password. Otherwise, don't reveal that the user does not exist.
var loginContext = CreateLoginContext(user);
if (!await userService.CanLogin(loginContext) && !needsInitialPassword)
return RedirectToAction(nameof(Login));
}
var result = await _userManager.ResetPasswordAsync(user!, model.Code, model.Password);
var result = await userManager.ResetPasswordAsync(user!, model.Code, model.Password);
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel
@@ -809,12 +759,12 @@ namespace BTCPayServer.Controllers
});
// see if we can sign in user after accepting an invitation and setting the password
if (needsInitialPassword && UserService.TryCanLogin(user, out _))
loginContext = CreateLoginContext(user);
if (needsInitialPassword && await userService.CanLogin(loginContext))
{
var signInResult = await _signInManager.PasswordSignInAsync(user.Email!, model.Password, true, true);
var signInResult = await signInManager.PasswordSignInAsync(user.Email!, model.Password, true, true);
if (signInResult.Succeeded)
{
_logger.LogInformation("User {Email} logged in", user.Email);
return RedirectToLocal(returnUrl);
}
}
@@ -822,7 +772,7 @@ namespace BTCPayServer.Controllers
}
AddErrors(result);
model.HasPassword = await _userManager.HasPasswordAsync(user);
model.HasPassword = await userManager.HasPasswordAsync(user);
return View(model);
}
@@ -835,14 +785,14 @@ namespace BTCPayServer.Controllers
return NotFound();
}
var user = await _userManager.FindByInvitationTokenAsync<ApplicationUser>(userId, Uri.UnescapeDataString(code));
var user = await userManager.FindByInvitationTokenAsync<ApplicationUser>(userId, Uri.UnescapeDataString(code));
if (user == null)
{
return NotFound();
}
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
var requiresSetPassword = !await userManager.HasPasswordAsync(user);
if (requiresEmailConfirmation)
{
return await RedirectToConfirmEmail(user);
@@ -868,13 +818,13 @@ namespace BTCPayServer.Controllers
private async Task<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
{
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
return RedirectToAction(nameof(ConfirmEmail), new { userId = user.Id, code });
}
private async Task<IActionResult> RedirectToSetPassword(ApplicationUser user)
{
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
var code = await userManager.GeneratePasswordResetTokenAsync(user);
return RedirectToAction(nameof(SetPassword), new { userId = user.Id, email = user.Email, code });
}
@@ -912,7 +862,7 @@ namespace BTCPayServer.Controllers
private bool CanLoginOrRegister()
{
return _btcPayServerEnvironment.IsDeveloping || _btcPayServerEnvironment.IsSecure(HttpContext);
return btcPayServerEnvironment.IsDeveloping || btcPayServerEnvironment.IsSecure(HttpContext);
}
private void SetInsecureFlags()

View File

@@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -296,7 +297,8 @@ namespace BTCPayServer.Controllers
var roles = await _UserManager.GetRolesAsync(user);
if (Roles.HasServerAdmin(roles))
{
if (await _userService.IsUserTheOnlyOneAdmin(user))
var loginContext = CreateLoginContext(user);
if (await _userService.IsUserTheOnlyOneAdmin(loginContext))
{
return View("Confirm", new ConfirmModel(StringLocalizer["Delete admin"],
$"Unable to proceed: As the user <strong>{Html.Encode(user.Email)}</strong> is the last enabled admin, it cannot be removed."));
@@ -330,7 +332,8 @@ namespace BTCPayServer.Controllers
if (user == null)
return NotFound();
if (!enable && await _userService.IsUserTheOnlyOneAdmin(user))
var loginContext = CreateLoginContext(user);
if (!enable && await _userService.IsUserTheOnlyOneAdmin(loginContext))
{
return View("Confirm", new ConfirmModel(StringLocalizer["Disable admin"],
$"Unable to proceed: As the user <strong>{Html.Encode(user.Email)}</strong> is the last enabled admin, it cannot be disabled."));
@@ -344,7 +347,8 @@ namespace BTCPayServer.Controllers
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
if (!enable && await _userService.IsUserTheOnlyOneAdmin(user))
var loginContext = CreateLoginContext(user);
if (!enable && await _userService.IsUserTheOnlyOneAdmin(loginContext))
{
TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["User was the last enabled admin and could not be disabled."].Value;
return RedirectToAction(nameof(ListUsers));
@@ -357,6 +361,11 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ListUsers));
}
private UserService.CanLoginContext CreateLoginContext(ApplicationUser user)
{
return new UserService.CanLoginContext(user, StringLocalizer, ViewLocalizer, Request.GetRequestBaseUrl());
}
[HttpGet("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUser(string userId, bool approved)
{

View File

@@ -29,6 +29,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Hosting;
@@ -70,6 +71,7 @@ namespace BTCPayServer.Controllers
private readonly LocalizerService _localizer;
private readonly EmailSenderFactory _emailSenderFactory;
public IStringLocalizer StringLocalizer { get; }
public ViewLocalizer ViewLocalizer { get; }
public UIServerController(
UserManager<ApplicationUser> userManager,
@@ -98,6 +100,7 @@ namespace BTCPayServer.Controllers
TransactionLinkProviders transactionLinkProviders,
LocalizerService localizer,
IStringLocalizer stringLocalizer,
ViewLocalizer viewLocalizer,
BTCPayServerEnvironment environment
)
{
@@ -128,6 +131,7 @@ namespace BTCPayServer.Controllers
_localizer = localizer;
Environment = environment;
StringLocalizer = stringLocalizer;
ViewLocalizer = viewLocalizer;
}
[HttpGet("server/stores")]

View File

@@ -11,10 +11,13 @@ using System.Net.WebSockets;
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Configuration;
@@ -36,6 +39,7 @@ using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -858,6 +862,23 @@ namespace BTCPayServer
public static string RemoveUserInfo(this Uri uri)
=> string.IsNullOrEmpty(uri.UserInfo) ? uri.ToString() : uri.ToString().Replace(uri.UserInfo, "***");
public static void SetStatusLoginResult(this ITempDataDictionary tempData, UserService.CanLoginContext loginContext)
{
if (!loginContext.Failures.Any())
throw new InvalidOperationException("No login failure found");
var model = new StatusMessageModel()
{
Severity = loginContext._user is null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning
};
var failures =
loginContext
.Failures
.Select(f => f.Html is null ? HtmlEncoder.Default.Encode(f.Text.Value) : f.Html.Value)
.ToArray();
model.Html = string.Join("<br/>", failures);
tempData.SetStatusMessageModel(model);
}
public static DataDirectories Configure(this DataDirectories dataDirectories, IConfiguration configuration)
{
var networkType = DefaultConfiguration.GetNetworkType(configuration);

View File

@@ -181,6 +181,8 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<EventAggregator>();
services.TryAddSingleton<PaymentRequestService>();
services.TryAddSingleton<UserService>();
services.AddSingleton<UserService.LoginExtension, UserService.DefaultLoginExtension>();
services.TryAddSingleton<UriResolver>();
services.TryAddSingleton<WalletHistogramService>();
services.TryAddSingleton<LightningHistogramService>();

View File

@@ -0,0 +1,31 @@
#nullable enable
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.Extensions.Localization;
namespace BTCPayServer;
public class NullStringLocalizer : IStringLocalizer
{
public static readonly NullStringLocalizer Instance = new();
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Array.Empty<LocalizedString>();
public LocalizedString this[string name] => new(name, name);
public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name));
}
public class NullViewLocalizer : IViewLocalizer
{
public static readonly NullViewLocalizer Instance = new();
public LocalizedString GetString(string name) => new(name, name);
public LocalizedString GetString(string name, params object[] arguments) => new(name, string.Format(name));
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Array.Empty<LocalizedString>();
public LocalizedHtmlString this[string name] => new(name, name);
public LocalizedHtmlString this[string name, params object[] arguments] => new(name, string.Format(name));
}

View File

@@ -188,7 +188,7 @@
class="new-subscriber btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#newSubscriberModal">
Add subsriber
Add subscriber
</a>
}
</div>

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Services;
@@ -12,28 +13,19 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace BTCPayServer.Security.Greenfield
{
public class APIKeysAuthenticationHandler : AuthenticationHandler<GreenfieldAuthenticationOptions>
public class APIKeysAuthenticationHandler(
APIKeyRepository apiKeyRepository,
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<GreenfieldAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
UserService userService,
UserManager<ApplicationUser> userManager)
: AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
{
private readonly APIKeyRepository _apiKeyRepository;
private readonly IOptionsMonitor<IdentityOptions> _identityOptions;
private readonly UserManager<ApplicationUser> _userManager;
public APIKeysAuthenticationHandler(
APIKeyRepository apiKeyRepository,
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<GreenfieldAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
UserManager<ApplicationUser> userManager) : base(options, logger, encoder)
{
_apiKeyRepository = apiKeyRepository;
_identityOptions = identityOptions;
_userManager = userManager;
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
// This one deserve some explanation...
@@ -45,27 +37,25 @@ namespace BTCPayServer.Security.Greenfield
// the login page.
// But this isn't what we want when we call the API programmatically, instead we want an error 401 with a json error message.
// This hack modify a request's header to trick the CookieAuthenticationHandler to not do a redirection.
if (!Request.Headers.Accept.Any(s => s is string && s.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)))
if (!Request.Headers.Accept.Any(s => s != null && s.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)))
Request.Headers.XRequestedWith = new Microsoft.Extensions.Primitives.StringValues("XMLHttpRequest");
return base.HandleChallengeAsync(properties);
}
private bool IsJson(string contentType)
{
return contentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) is true;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey))
return AuthenticateResult.NoResult();
var key = await _apiKeyRepository.GetKey(apiKey, true);
if (!UserService.TryCanLogin(key?.User, out var error))
var key = await apiKeyRepository.GetKey(apiKey, true);
var loggingContext = new UserService.CanLoginContext(key?.User, baseUrl: Request.GetRequestBaseUrl());
if (!await userService.CanLogin(loggingContext) || key is null)
{
return AuthenticateResult.Fail($"ApiKey authentication failed: {error}");
return AuthenticateResult.Fail($"ApiKey authentication failed: {loggingContext.Failures[0].Text.Value}");
}
var claims = new List<Claim> { new (_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) };
claims.AddRange((await _userManager.GetRolesAsync(key.User)).Select(s => new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
var claims = new List<Claim> { new (identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) };
claims.AddRange((await userManager.GetRolesAsync(key.User)).Select(s => new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
claims.AddRange(Permission.ToPermissions(key.GetBlob()?.Permissions ?? Array.Empty<string>()).Select(permission =>
new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString())));
return AuthenticateResult.Success(new AuthenticationTicket(

View File

@@ -5,6 +5,7 @@ using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Services;
@@ -16,25 +17,16 @@ using Microsoft.Extensions.Options;
namespace BTCPayServer.Security.Greenfield
{
public class BasicAuthenticationHandler : AuthenticationHandler<GreenfieldAuthenticationOptions>
public class BasicAuthenticationHandler(
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<GreenfieldAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
SignInManager<ApplicationUser> signInManager,
UserService UserService,
UserManager<ApplicationUser> userManager)
: AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
{
private readonly IOptionsMonitor<IdentityOptions> _identityOptions;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
public BasicAuthenticationHandler(
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<GreenfieldAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager) : base(options, logger, encoder)
{
_identityOptions = identityOptions;
_signInManager = signInManager;
_userManager = userManager;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
string authHeader = Context.Request.Headers["Authorization"];
@@ -58,30 +50,29 @@ namespace BTCPayServer.Security.Greenfield
"Basic authentication header was not in a correct format. (username:password encoded in base64)");
}
var result = await _signInManager.PasswordSignInAsync(username, password, true, true);
if (!result.Succeeded)
return AuthenticateResult.Fail(result.ToString());
var user = await _userManager.Users
var user = await userManager.Users
.Include(applicationUser => applicationUser.Fido2Credentials)
.FirstOrDefaultAsync(applicationUser =>
applicationUser.NormalizedUserName == _userManager.NormalizeName(username));
if (!UserService.TryCanLogin(user, out var error))
applicationUser.NormalizedUserName == userManager.NormalizeName(username));
var loggingContext = new UserService.CanLoginContext(user, baseUrl: Request.GetRequestBaseUrl());
if (!await UserService.CanLogin(loggingContext))
{
return AuthenticateResult.Fail($"Basic authentication failed: {error}");
return AuthenticateResult.Fail($"Basic authentication failed: {loggingContext.Failures[0].Text.Value}");
}
if (user.Fido2Credentials.Any())
{
return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled.");
}
var result = await signInManager.CheckPasswordSignInAsync(user, password, true);
if (!result.Succeeded)
return AuthenticateResult.Fail(result.ToString());
var claims = new List<Claim>()
{
new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, user.Id),
new Claim(identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, user.Id),
new Claim(GreenfieldConstants.ClaimTypes.Permission,
Permission.Create(Policies.Unrestricted).ToString())
};
claims.AddRange((await _userManager.GetRolesAsync(user)).Select(s => new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
claims.AddRange((await userManager.GetRolesAsync(user)).Select(s => new Claim(identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
return AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims, GreenfieldConstants.AuthenticationType)),

View File

@@ -18,8 +18,8 @@ namespace BTCPayServer.Services
"'Anyone can invoice' is turned off": "",
"{0} Archived Store": "",
"{0} Archived Stores": "",
"{0} event could not be delivered. Error message received: {1}": "",
"{0} event delivered successfully! Delivery ID is {1}": "",
"{0} day": "",
"{0} days": "",
"{0} files were added. {1} files had invalid names": "",
"{0} for {1} or {2}": "",
"{0} invoice archived.": "",
@@ -45,6 +45,7 @@ namespace BTCPayServer.Services
"{0} Wallet": "",
"{0} Wallet Labels": "",
"{0} Wallet Settings": "",
"@submitLabel": "",
"<code>itemcode:code</code> for filtering a specific type of item purchased through the pos or crowdfund apps": "",
"<code>orderid:id</code> for filtering a specific order": "",
"<span class=\"currency\">{0}</span> closing channels": "",
@@ -57,9 +58,12 @@ namespace BTCPayServer.Services
"<span class=\"currency\">{0}</span> reserved": "",
"<span class=\"currency\">{0}</span> unconfirmed": "",
"<strong>Never</strong> trust anything but <code>id</code>, <strong>ignore</strong> the other fields completely, an attacker can spoof those, they are present only for backward compatibility reason:": "",
"1 day": "",
"24 Hours": "",
"2FA and U2F/FIDO2 and LNURL-Auth Authentication Methods are not available. Please go to the https endpoint.": "",
"3 Days": "",
"30 days": "",
"7 days": "",
"7 Days": "",
"A \"Contact Us\" button with this link will be shown on the checkout page. Can contain the placeholders <code>{OrderId}</code> and <code>{InvoiceId}</code>. Can be any valid URI, such as a website, email, and Nostr.": "",
"A <code>POST</code> callback will be sent to the specified <code>notificationUrl</code> (for on-chain transactions when there are sufficient confirmations):": "",
@@ -70,8 +74,10 @@ namespace BTCPayServer.Services
"A malicious actor with access to this QR Code could steal the funds on your lightning wallet.": "",
"A new payout is approved and awaiting payment": "",
"A new payout is awaiting for approval": "",
"A payment request with reference ID \"{0}\" already exists for this store.": "",
"A payment that was made to an approved payout by an external wallet is waiting for your confirmation.": "",
"A permission to the camera is needed to scan the QR code. Please grant the browser access and then retry.": "",
"A Postgres compatible JSON Path (eg. $.Customer.Name == \"john\")": "",
"A self-hosted, open-source bitcoin payment processor.": "",
"Access to vault granted by owner.": "",
"Access Tokens": "",
@@ -86,6 +92,8 @@ namespace BTCPayServer.Services
"Action canceled by user": "",
"Actions": "",
"Active": "",
"Active Members": "",
"Active Subscribers": "",
"Add": "",
"Add additional fee (network fee) to invoice …": "",
"Add Address": "",
@@ -99,9 +107,12 @@ namespace BTCPayServer.Services
"Add mapped value": "",
"Add Option": "",
"Add or customize translations": "",
"Add plan": "",
"Add Plan": "",
"Add plugin manually": "",
"Add Role": "",
"Add Service": "",
"Add subscriber": "",
"Add User": "",
"Add Webhook": "",
"Additional Actions": "",
@@ -151,9 +162,11 @@ namespace BTCPayServer.Services
"An unexpected error happened: {0}": "",
"Animation": "",
"Any amount": "",
"Any application using the API key will immediately lose access.": "",
"Any uploaded files are being saved on the same machine that hosts BTCPay; please pay attention to your storage space.": "",
"API": "",
"API authentication docs": "",
"API ID": "",
"API Key": "",
"API key generated!": "",
"API Key removed": "",
@@ -205,6 +218,7 @@ namespace BTCPayServer.Services
"Backend's language": "",
"Balance": "",
"Batch size": "",
"BCC": "",
"Before being able to upload you first need to {0}.": "",
"Before you proceed, please understand the following:": "",
"BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)": "",
@@ -236,6 +250,7 @@ namespace BTCPayServer.Services
"Can use RPC import": "",
"Cancel": "",
"Cancel Invoice": "",
"Cannot delete plan. It is currently in use by subscribers.": "",
"Cannot generate API keys while not using HTTPS or Tor": "",
"Card reset succeed": "",
"Categories": "",
@@ -243,6 +258,7 @@ namespace BTCPayServer.Services
"Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.": "",
"Caution: Enabling this option, may simplify the onboarding and spending for third-parties but carries liabilities and security risks associated to storing private keys of third parties on a server.": "",
"Caution: Enabling this option, may simplify the onboarding for third-parties but carries liabilities and security risks associated with sharing the lightning node with other users.": "",
"CC": "",
"Celebrate payment with confetti": "",
"Change": "",
"Change connection": "",
@@ -254,6 +270,7 @@ namespace BTCPayServer.Services
"Changes to the SSH settings are now permanently disabled in the BTCPay Server user interface": "",
"Changing the role of user {0} failed: {1}": "",
"Charge": "",
"Charge user": "",
"Cheat Mode: Send funds to this wallet": "",
"Check if NFC is supported and enabled on this device": "",
"Check releases on GitHub and notify when new BTCPay Server version is available": "",
@@ -299,7 +316,9 @@ namespace BTCPayServer.Services
"Config file was not in the correct format": "",
"Configure": "",
"Configure app": "",
"Configure email": "",
"Configure now": "",
"Configure offering": "",
"Configure store email settings": "",
"Configure your Pay Button, and the generated code will be displayed at the bottom of the page to copy into your project.": "",
"Configured": "",
@@ -357,15 +376,18 @@ namespace BTCPayServer.Services
"Create a new {0}": "",
"Create a new app": "",
"Create a new store": "",
"Create a new subscriber": "",
"Create a new wallet": "",
"create a separate store": "",
"Create a store": "",
"Create a store to begin accepting payments.": "",
"Create account": "",
"Create Account": "",
"Create Email Rule": "",
"Create Form": "",
"Create Invoice": "",
"Create invoice to pay custom amount": "",
"Create Invoices": "",
"Create New Token": "",
"Create Payment Request": "",
"Create pending transaction": "",
@@ -385,6 +407,9 @@ namespace BTCPayServer.Services
"Created after date:": "",
"Created before date:": "",
"Credentials": "",
"Credit": "",
"Credit user": "",
"Credits": "",
"Crowdfund": "",
"Crowdfund Behavior": "",
"Crypto": "",
@@ -409,6 +434,7 @@ namespace BTCPayServer.Services
"Custom sound file for successful payment": "",
"Custom Theme Extension Type": "",
"Custom Theme File": "",
"Customer Email": "",
"Customer Information": "",
"Customization": "",
"Customize Pay Button Text": "",
@@ -433,9 +459,12 @@ namespace BTCPayServer.Services
"DELETE": "",
"Delete Account": "",
"Delete admin": "",
"Delete API key": "",
"Delete app": "",
"Delete dictionary": "",
"Delete LND seed": "",
"Delete LND seed from server": "",
"Delete offering": "",
"Delete role": "",
"Delete store": "",
"Delete store {0}": "",
@@ -443,7 +472,6 @@ namespace BTCPayServer.Services
"Delete this store": "",
"Delete unused Docker images present on your system.": "",
"Delete user": "",
"Delete webhook": "",
"Delete Webhook": "",
"Dependencies": "",
"Dependencies not met.": "",
@@ -469,6 +497,7 @@ namespace BTCPayServer.Services
"Disable 2FA": "",
"Disable admin": "",
"Disable all notifications": "",
"Disable modification of SSH settings": "",
"Disable payment button": "",
"Disable public user registration": "",
"Disable stores from using the server's email settings as backup": "",
@@ -506,6 +535,7 @@ namespace BTCPayServer.Services
"Don't create UTXO change": "",
"Donate": "",
"Done": "",
"Downgrade": "",
"Download a two-factor authenticator app like …": "",
"Download PSBT file": "",
"Dynamic": "",
@@ -523,6 +553,8 @@ namespace BTCPayServer.Services
"Edit Item": "",
"Edit payment request": "",
"Edit Payment Request": "",
"Edit plan": "",
"Edit plan ({0})": "",
"Edit pull payment": "",
"Edit Pull Payment": "",
"Editor": "",
@@ -530,18 +562,20 @@ namespace BTCPayServer.Services
"Email": "",
"Email address": "",
"Email address is confirmed": "",
"Email Configuration": "",
"Email confirmation required": "",
"Email confirmed?": "",
"Email Notifications": "",
"Email password reset functionality is not configured for this server. Please contact the server administrator to assist with account recovery.": "",
"Email Reminder Days Before Due": "",
"Email rule successfully created": "",
"Email rule successfully deleted": "",
"Email rule successfully updated": "",
"email rules": "",
"Email rules": "",
"Email Rules": "",
"Email rules allow BTCPay Server to send customized emails from your server based on events.": "",
"Email rules allow BTCPay Server to send customized emails from your store based on events.": "",
"Email sent to {0}. Please verify you received it.": "",
"Email Server": "",
"Email server password reset": "",
"Email settings saved": "",
"Emails": "",
@@ -566,6 +600,7 @@ namespace BTCPayServer.Services
"Enable sounds on checkout page": "",
"Enable sounds on new payments": "",
"Enable tips": "",
"Enable trial": "",
"Enabled": "",
"Enabling will modify your current rate sources. This is a feature for advanced users.": "",
"End date": "",
@@ -582,12 +617,11 @@ namespace BTCPayServer.Services
"Enter the wallet seed": "",
"Enter wallet seed": "",
"Enter your extended public key": "",
"Entitlements": "",
"Error": "",
"Error updating profile": "",
"Error updating user": "",
"Error while broadcasting: {0}": "",
"Error: {0}": "",
"Event type": "",
"Example": "",
"Expiration Date": "",
"Expire": "",
@@ -673,8 +707,12 @@ namespace BTCPayServer.Services
"Get Link": "",
"Give other registered BTCPay Server users access to your store. See the {0} for granted permissions.": "",
"Go back to Javascript enabled invoice": "",
"Go to email rules": "",
"Go to top": "",
"Google Cloud Storage": "",
"Grace": "",
"Grace Period": "",
"Grace Period (days)": "",
"Greater than": "",
"Greenfield API": "",
"Greenfield API Keys": "",
@@ -704,6 +742,7 @@ namespace BTCPayServer.Services
"I have written down my recovery phrase and stored it in a secure location": "",
"I wrote down my recovery codes": "",
"Id": "",
"ID": "",
"If a translation isnt available in the new dictionary, it will be searched in the fallback.": "",
"If authorized, the generated API key will be provided to": "",
"If BTCPay Server shows you an invalid balance, {0}.<br />If some transactions appear in BTCPay Server, but are missing in another wallet, {1}.": "",
@@ -734,6 +773,7 @@ namespace BTCPayServer.Services
"In Shopify, please do the following …": "",
"In use": "",
"In-store": "",
"Inactive": "",
"Include archived": "",
"Incorrect pin code.": "",
"Increase the security of your instance by disabling the ability to change the SSH settings in this BTCPay Server instance's user interface.": "",
@@ -751,6 +791,7 @@ namespace BTCPayServer.Services
"Invalid currency pairs (should be for example: {0})": "",
"Invalid destination or payment method": "",
"Invalid email": "",
"Invalid email address or placeholder detected": "",
"Invalid login attempt.": "",
"Invalid network": "",
"Invalid passphrase confirmation": "",
@@ -844,6 +885,7 @@ namespace BTCPayServer.Services
"Logo": "",
"Logout": "",
"Logs": "",
"Mails": "",
"Maintenance": "",
"Maintenance feature requires access to SSH properly configured in BTCPay Server configuration.": "",
"Make Crowdfund Public": "",
@@ -855,6 +897,7 @@ namespace BTCPayServer.Services
"Manually enter your 12 or 24 word recovery seed.": "",
"Map specific domains to specific apps": "",
"Mapped Value": "",
"Mark as a test account": "",
"Mark as already paid": "",
"Mark as awaiting payment": "",
"Mark as invalid": "",
@@ -882,6 +925,7 @@ namespace BTCPayServer.Services
"minutes": "",
"Mirror of": "",
"Modify": "",
"Monthly revenue": "",
"More details": "",
"More information": "",
"More information...": "",
@@ -896,7 +940,10 @@ namespace BTCPayServer.Services
"Never add network fee": "",
"New {0} plugin version {1} released!": "",
"New effective fee rate": "",
"New offering": "",
"New offering created. You can now <a href='{0}' class='alert-link'>configure it.</a>": "",
"New password": "",
"New plan created": "",
"New role": "",
"New user {0} requires approval.": "",
"New user requires approval": "",
@@ -905,6 +952,7 @@ namespace BTCPayServer.Services
"New version {0} released!": "",
"Next": "",
"NFC detected.": "",
"No": "",
"No access tokens yet.": "",
"No claim made yet.": "",
"No contributions allowed after the goal has been reached": "",
@@ -912,6 +960,7 @@ namespace BTCPayServer.Services
"No deliveries for this webhook yet": "",
"No device connected.": "",
"No documentation": "",
"No email address has been configured for the Server. Configure an email address\n to\n begin sending emails.": "",
"No end date has been set": "",
"No expiry date has been set for this payment request": "",
"No invoice has been selected": "",
@@ -943,6 +992,7 @@ namespace BTCPayServer.Services
"Non-admins cannot access the User Creation API Endpoint": "",
"Non-supported state of invoice": "",
"None of the selected transaction can be fee bumped": "",
"Normal": "",
"Not all payout methods are supported": "",
"Not allowed to cancel this invoice": "",
"Not configured": "",
@@ -955,6 +1005,9 @@ namespace BTCPayServer.Services
"Notification URL": "",
"Notification URL Callbacks": "",
"Notifications": "",
"Notifications & Alerts": "",
"Offering ({0})": "",
"Offering configuration updated": "",
"Offline signing, without connecting your wallet to the internet": "",
"On-Chain Payments": "",
"On-Chain Payout Processor": "",
@@ -962,8 +1015,10 @@ namespace BTCPayServer.Services
"Only enable the payment method after user explicitly chooses it": "",
"Only meta tags are allowed in HTML headers. Your HTML code has been cleaned up accordingly.": "",
"Only process payouts when this payout sum is reached.": "",
"Only send email when the specified JSON Path exists": "",
"Only upload plugins from trusted sources.": "",
"Open in wallet": "",
"Optimistic activation": "",
"optional": "",
"Optional passphrase (BIP39)": "",
"Optional seed passphrase": "",
@@ -1023,6 +1078,7 @@ namespace BTCPayServer.Services
"Payment Method": "",
"Payment Notifications": "",
"Payment Proof": "",
"Payment received, waiting for confirmation...": "",
"Payment request \"{0}\" created successfully": "",
"Payment request \"{0}\" updated successfully": "",
"Payment Request cannot be paid as it has been archived": "",
@@ -1031,7 +1087,6 @@ namespace BTCPayServer.Services
"Payment Requests": "",
"Payment requests are persistent shareable pages that enable the receiver to pay at their convenience. Funds are paid to a payment request at the current exchange rate.": "",
"Payments": "",
"Payout": "",
"Payout Methods": "",
"Payout Processor removed": "",
"Payout Processors": "",
@@ -1048,20 +1103,26 @@ namespace BTCPayServer.Services
"Pending Approval": "",
"Pending Email Verification": "",
"Pending Invitation": "",
"Pending Transaction": "",
"percent": "",
"Percentage must be a numeric value between 0 and 100": "",
"Permanent Url": "",
"Permissions": "",
"Phase": "",
"Pin code verified.": "",
"Placeholder": "",
"Placeholders": "",
"Plan": "",
"Plan deleted": "",
"Plan edited": "",
"Plan Name": "",
"Plans": "",
"Please check that your wallet is generating the same addresses as below.": "",
"Please check your addresses and confirm.": "",
"Please check your email to reset your password.": "",
"Please configure it first.": "",
"Please consult the server log for more details.": "",
"Please enable JavaScript for this option to be available": "",
"Please enter a positive amount": "",
"Please fix errors shown in order for code generation to successfully execute.": "",
"Please make sure to also write down your passphrase.": "",
"Please note that creating a hot wallet is not supported by this instance for non administrators.": "",
@@ -1160,7 +1221,9 @@ namespace BTCPayServer.Services
"Recommended fee confirmation target blocks": "",
"Recovery Code": "",
"Recovery codes": "",
"Recurring": "",
"Recurring Goal": "",
"Recurring Type": "",
"Redeliver": "",
"Redirect invoice to redirect url automatically after paid": "",
"Redirect URL": "",
@@ -1182,15 +1245,16 @@ namespace BTCPayServer.Services
"Remember this machine": "",
"Remote plugins lookup failed. Try again later. Error: {0}": "",
"Remove": "",
"REMOVE": "",
"Remove {0} wallet": "",
"Remove Destination": "",
"Remove domain mapping": "",
"Remove email rule": "",
"Remove label": "",
"Remove Lightning Address": "",
"Remove Lightning security": "",
"Remove LNURL Auth link": "",
"Remove Mapping": "",
"Remove plan": "",
"Remove security device": "",
"Remove Store Permission": "",
"Remove store template": "",
@@ -1198,13 +1262,13 @@ namespace BTCPayServer.Services
"Remove the translation from this dictionary.": "",
"Remove wallet": "",
"Removing this user would result in the store having no owner.": "",
"Renewable": "",
"REPLACE": "",
"Replace {0} wallet": "",
"Replace template from selected store": "",
"Replace wallet": "",
"Replacements": "",
"Reporting": "",
"Request": "",
"Request contributor data on checkout": "",
"Request customer data on checkout": "",
"Request Pairing": "",
@@ -1215,6 +1279,7 @@ namespace BTCPayServer.Services
"Rescan wallet for missing transactions": "",
"rescan your wallet": "",
"Resend email": "",
"Reserved Addresses": "",
"Reserved At": "",
"Reset": "",
"RESET": "",
@@ -1231,9 +1296,12 @@ namespace BTCPayServer.Services
"Restart": "",
"Restart BTCPay Server and related services.": "",
"Restart now": "",
"Retired": "",
"Retiring": "",
"Retry": "",
"Reveal": "",
"Revoke": "",
"Revoke access token": "",
"Revoke the token": "",
"Role": "",
"Role could not be set as default": "",
@@ -1259,6 +1327,7 @@ namespace BTCPayServer.Services
"Scan the extended public key, also called \"xpub\", shown on your wallet's display.": "",
"Scan the QR Code or enter the following key into your two-factor authenticator app:": "",
"Scan the QR code to open the Point of Sale": "",
"Scan the QR code to open this page on a mobile": "",
"Scan the QR code with your Lightning wallet to link it to your user account.": "",
"Scan the QR code with your Lightning wallet to sign in.": "",
"Scan wallet QR code": "",
@@ -1271,6 +1340,7 @@ namespace BTCPayServer.Services
"Scope": "",
"Scripting": "",
"Search": "",
"Search by email, external reference, name…": "",
"Search by email...": "",
"Search by Id...": "",
"Search engines can index this site": "",
@@ -1302,16 +1372,15 @@ namespace BTCPayServer.Services
"Send": "",
"Send {0}": "",
"Send a <code>GET</code> request to <code>https://btcpay.example.com/invoices/{invoiceId}</code> with <code>Content-Type: application/json; Authorization: Basic YourLegacyAPIkey\"</code>, Legacy API key can be created with Access Tokens in Store settings": "",
"Send a test event to a webhook endpoint": "",
"Send invitation email": "",
"Send me everything": "",
"Send specific events": "",
"Send Test Email": "",
"Send test webhook": "",
"Send the email to the buyer, if email was provided to the invoice": "",
"Send verification email": "",
"Sender's Email Address": "",
"Server": "",
"Server Emails": "",
"Server IPN": "",
"Server Name": "",
"Server Settings": "",
@@ -1329,6 +1398,7 @@ namespace BTCPayServer.Services
"Set up a wallet": "",
"Set your password": "",
"Settings": "",
"Settings saved": "",
"Settings updated successfully": "",
"Settled": "",
"Settled Late": "",
@@ -1419,13 +1489,29 @@ namespace BTCPayServer.Services
"Store-level": "",
"Store: {0}": "",
"Stores": "",
"Subject": "",
"Submit": "",
"Subscriber": "",
"Subscriber '{0}' successfully created": "",
"Subscriber {0} has been charged": "",
"Subscriber {0} has been credited": "",
"Subscriber {0} is now {1}": "",
"Subscriber {0} is now suspended": "",
"Subscriber {0} is now unsuspended": "",
"Subscribers": "",
"Subscriptions": "",
"Subtotal": "",
"Subtract fees from amount": "",
"Success redirect url": "",
"Successfully planned a redelivery": "",
"Support URL": "",
"Supported by BlueWallet, Cobo Vault, Passport and Specter DIY": "",
"Supported Transaction Currencies": "",
"Suspend": "",
"Suspend Access": "",
"Suspend Subscriber": "",
"Suspended": "",
"Suspension Reason": "",
"Switch date format": "",
"Syntax error": "",
"System": "",
@@ -1452,6 +1538,7 @@ namespace BTCPayServer.Services
"The {0} offers programmatic access to your instance. You can manage your BTCPay Server (e.g. stores, invoices, users) as well as automate workflows and integrations (see {1}). For that you need the API keys, which can be generated here. Find more information in the {2}.": "",
"The <code>macaroon</code> parameter expects the HEX value, it can be obtained using this\n command:": "",
"The access key of the service is not set": "",
"The access token will be revoked. Do you wish to continue?": "",
"The admin {0} will be permanently deleted. This action will also delete all accounts, users and data associated with the server account. Are you sure?": "",
"The amount should be more than zero": "",
"The app <strong>{0}</strong> and its settings will be permanently deleted.": "",
@@ -1505,6 +1592,8 @@ namespace BTCPayServer.Services
"The payjoin receiver could not complete the payjoin: {0}": "",
"The payment request has been archived and will no longer appear in the payment request list by default again.": "",
"The payment request has been unarchived and will appear in the payment request list by default.": "",
"The plan has been started.": "",
"The plan has been started. ({0} has been refunded)": "",
"The plugin could not be downloaded. Try again later.": "",
"The previous scan completed and found <b>{0}</b> UTXOs in <b>{1}</b> (The total UTXO set size is {2})": "",
"The previous scan stopped with an error:": "",
@@ -1527,6 +1616,7 @@ namespace BTCPayServer.Services
"The store template sets defaults for all new stores. It includes rates settings, default invoice settings, checkout display settings but excludes sensitive data like access tokens, payment method settings, webhooks.": "",
"The store will be permanently deleted. This action will also delete all invoices, apps and data associated with the store.": "",
"The store will be permanently deleted. This action will also delete all invoices, apps and data associated with the store. Are you sure?": "",
"The trial has started.": "",
"The uploaded file needs to be a CSS file": "",
"The uploaded file needs to be an image": "",
"The uploaded file should be less than {0}": "",
@@ -1547,11 +1637,13 @@ namespace BTCPayServer.Services
"The webhook has been created": "",
"The webhook has been updated": "",
"Theme": "",
"There are many subscribers, use search to look for them.": "",
"There are no {0} pull payments yet.": "",
"There are no apps yet.": "",
"There are no associated permissions to the API key being requested by the application. The application cannot do anything with your BTCPay Server account other than validating your account exists.": "",
"There are no custom labels yet. You can create custom labels by assigning them to your {0}.": "",
"There are no dynamic DNS services yet.": "",
"There are no email rules for this offering.": "",
"There are no files yet.": "",
"There are no forms yet.": "",
"There are no invoices matching your criteria.": "",
@@ -1566,12 +1658,15 @@ namespace BTCPayServer.Services
"There are no recent transactions.": "",
"There are no rules yet.": "",
"There are no stores yet.": "",
"There are no subscribers.": "",
"There are no subscription plans.": "",
"There are no wallets yet. You can add wallets in the store setup.": "",
"There are no webhooks yet.": "",
"There isn't any UTXO available to bump fee with CPFP": "",
"There was an error generating your wallet: {0}": "",
"This account has been locked out because of multiple invalid login attempts. Please try again later.": "",
"This account has been locked out. Please try again": "",
"This action is permanent and will remove the ability to change the SSH settings via the BTCPay Server user interface.": "",
"This action will also delete all stores, invoices, apps and data associated with the user.": "",
"This action will delete your rate script. Are you sure to turn off rate rules scripting?": "",
"This action will modify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)": "",
@@ -1579,7 +1674,9 @@ namespace BTCPayServer.Services
"This action will permanently delete your LND seed and password. You will not be able to recover them if you don't have a backup. Are you sure?": "",
"This action will prevent {0} from accessing this store and its settings.": "",
"This action will prevent the user from accessing this store and its settings. Are you sure?": "",
"This action will remove the plan <b>{0}</b>.": "",
"This action will remove the rule with the trigger <b>{0}</b>.": "",
"This action will remove this plan. Are you sure?": "",
"This action will remove this rule. Are you sure?": "",
"This app will be removed from this store.": "",
"This can be overridden at the Store level.": "",
@@ -1589,6 +1686,7 @@ namespace BTCPayServer.Services
"This crowdfund page is not publicly viewable!": "",
"This device already signed PSBT.": "",
"This device can't sign the transaction. (The wallet keypath in your wallet settings seems incorrect)": "",
"This dictionary will be removed from this server.": "",
"This feature is disabled": "",
"This feature is only available to BTC wallets": "",
"This full node does not support rescan of the UTXO set": "",
@@ -1602,7 +1700,9 @@ namespace BTCPayServer.Services
"This is the only admin, so their role can't be removed until another admin is added.": "",
"This key, also called \"xpub\", is used to generate individual destination addresses for your invoices.": "",
"This label will be removed from this wallet and its associated transactions.": "",
"This Lightning Address will be removed.": "",
"this link": "",
"This offering will be removed from this store.": "",
"This page exposes information to connect remotely to your full node via the P2P protocol.": "",
"This page exposes information to connect remotely to your full node via the RPC protocol.": "",
"This page exposes information to use the configured BTCPay Server Configurator to modify this setup.": "",
@@ -1620,10 +1720,10 @@ namespace BTCPayServer.Services
"This transaction will change your balance:": "",
"This version of NBXplorer is not compatible. Please update to 2.5.22 or above": "",
"This webhook will be removed from this store.": "",
"This webhook will be removed from this store. Are you sure?": "",
"This will approve the user <strong>{0}</strong>.": "",
"This will send a verification email to <strong>{0}</strong>.": "",
"This will send notification mails to the recipient, as configured by the {0}.": "",
"This will send a verification email to the user.": "",
"This will send notification mails to the recipient, as configured by the <a href=\"{0}\">email rules</a>.": "",
"Threshold": "",
"Time must be at least 1": "",
"Timestamp": "",
@@ -1631,7 +1731,8 @@ namespace BTCPayServer.Services
"Tip percentage amounts (comma separated)": "",
"Tips": "",
"Title": "",
"TLS certificate security checks": "",
"TLS certificate security\n checks": "",
"To": "",
"To disable notification for a feature, kindly toggle off the specified feature.": "",
"To generate Greenfield API keys, please": "",
"To start accepting payments, set up a store.": "",
@@ -1654,7 +1755,6 @@ namespace BTCPayServer.Services
"Total Sales": "",
"Transaction": "",
"Transaction broadcasted successfully ({0})": "",
"Transaction Details": "",
"Transaction fee": "",
"Transaction fee rate:": "",
"Transaction Id": "",
@@ -1663,12 +1763,17 @@ namespace BTCPayServer.Services
"transactions": "",
"Translations": "",
"Translations are formatted as JSON; for example, <b>{0}</b> translates <b>{1}</b> to <b>{2}</b>.": "",
"Trial": "",
"Trial Period": "",
"Trial Period (days)": "",
"Trigger": "",
"Two Factor Authentication": "",
"Two-Factor Authentication": "",
"Two-Factor Authentication (2FA) is an additional measure to protect your account. In addition to your password you will be asked for a second proof on login. This can be provided by an app (such as Google or Microsoft Authenticator) or a security device (like a Yubikey or your hardware wallet supporting FIDO2).": "",
"txid, amount, comment, label": "",
"Type": "",
"Unable to create the replacement transaction ({0})": "",
"Unapprove": "",
"Unarchive": "",
"Unarchive this app": "",
"Unarchive this invoice": "",
@@ -1680,14 +1785,15 @@ namespace BTCPayServer.Services
"Unify on-chain and lightning payment URL/QR code": "",
"Uninstall": "",
"Uninstall all disabled plugins": "",
"unknown": "",
"Unknown": "",
"Unknown command": "",
"Unknown username": "",
"Unmark test account": "",
"Unnamed Store": "",
"Unread": "",
"Unsupported exchange": "",
"Unsupported hardware wallet, try to update BTCPay Server Vault": "",
"Unsuspend Access": "",
"Unusual": "",
"Update": "",
"Update Crowdfund": "",
@@ -1698,6 +1804,7 @@ namespace BTCPayServer.Services
"Update Webhook": "",
"Update your account": "",
"Updated successfully.": "",
"Upgrade": "",
"Upload": "",
"Upload a file exported from your wallet": "",
"Upload Plugin": "",
@@ -1722,8 +1829,6 @@ namespace BTCPayServer.Services
"Use the default Light or Dark Themes, or provide a custom CSS theme file below.": "",
"Use the stores default": "",
"User": "",
"User {0} accepted the invite to {1}.": "",
"User accepted invitation": "",
"User approved": "",
"User can input custom amount": "",
"User can input discount in %": "",
@@ -1733,6 +1838,7 @@ namespace BTCPayServer.Services
"User is admin": "",
"User is approved": "",
"User not found": "",
"User not found or invalid password": "",
"User removed successfully.": "",
"User successfully updated": "",
"User unapproved": "",
@@ -1790,7 +1896,6 @@ namespace BTCPayServer.Services
"We are retrieving the rate of each exchange either directly, or via CoinGecko (free).": "",
"We rejected the receiver's payjoin proposal: {0}": "",
"We will try to redeliver any failed delivery after 10 seconds, 1 minute and up to 6 times after 10 minutes": "",
"Webhook": "",
"Webhook successfully deleted": "",
"Webhooks": "",
"Webhooks allow BTCPay Server to send HTTP events related to your store to another server.": "",
@@ -1801,8 +1906,14 @@ namespace BTCPayServer.Services
"Which events would you like to trigger this webhook?": "",
"Who to send the email to. For multiple emails, separate with a comma.": "",
"With <code>DOGE_USD</code> will be expanded to <code>bitpay(DOGE_BTC) * kraken(BTC_USD)</code>. And\n <code>DOGE_CAD</code> will be expanded to <code>bitpay(DOGE_BTC) * ndax(BTC_CAD)</code>.<br />However, we advise you to write it\n that\n way to increase coverage so that <code>DOGE_BTC</code> is also supported:": "",
"Would you like to charge <span class=\"subscriber-name fw-semibold\"></span>?": "",
"Would you like to credit <span class=\"subscriber-name fw-semibold\"></span>?": "",
"Would you like to downgrade <span class=\"subscriber-name fw-semibold\"></span> to <b class=\"changePlanName\"></b> ?": "",
"Would you like to invite a new subscriber?": "",
"Would you like to proceed with suspending the following user?": "",
"Would you like to upgrade <span class=\"subscriber-name fw-semibold\"></span> to <b class=\"changePlanName\"></b>?": "",
"Yes": "",
"You are not server administrator": "",
"You are now using server's email settings": "",
"You are server administrator": "",
"You can also apply filters to your search by searching for <code>filtername:value</code>. Be sure to split your search parameters with comma. Supported filters are:": "",
"You can also share the link/LNURL or encode it in a QR code.": "",
@@ -1831,7 +1942,9 @@ namespace BTCPayServer.Services
"You have successfully signed out.": "",
"You must enable at least one payment method before creating a payout.": "",
"You must enable at least one payment method before creating a pull payment.": "",
"You must have a confirmed email to log in.": "",
"You need at least one payout method": "",
"You need to configure email settings before this feature works. <a class='alert-link configure-email' href='{0}'>Configure email settings</a>.": "",
"You need to connect to a Lightning node before adjusting its settings.": "",
"You need to restart BTCPay Server in order to update your active plugins.": "",
"You need to select a store before creating an invoice.": "",
@@ -1839,7 +1952,6 @@ namespace BTCPayServer.Services
"You need to update your version of NBXplorer": "",
"You only have 1 recovery code left.": "",
"You really should not type your seed into a device that is connected to the internet.": "",
"You received it, the BTCPay Server SMTP settings work.": "",
"Your access to BTCPay Server is over an unsecured network. If you are using the docker deployment method with NGINX and HTTPS is not available, you probably did not configure your DNS settings correctly. We disabled the register and login link so you don't leak your credentials.": "",
"Your account has been disabled. Please contact server administrator.": "",
"Your account will no longer be linked to the lightning node <strong>{0}</strong> as an option for two-factor authentication.": "",
@@ -1859,6 +1971,8 @@ namespace BTCPayServer.Services
"Your password has been set.": "",
"Your profile has been updated": "",
"Your two-factor authenticator app will provide you with a unique code.": "",
"Your user account is currently disabled.": "",
"Your user account requires approval by an admin before you can log in.": "",
"Your wallet has been generated.": ""
}
""";
@@ -1874,7 +1988,7 @@ namespace BTCPayServer.Services
/// <summary>
/// Translations which are already in the Default aren't saved into database.
/// This allows us to automatically update the english version if the translations didn't changed.
///
///
/// We only save into database the key/values that differ from Default
/// </summary>
public static Translations Default;

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@@ -11,8 +12,10 @@ using BTCPayServer.Events;
using BTCPayServer.Storage.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Services
@@ -25,6 +28,7 @@ namespace BTCPayServer.Services
private readonly EventAggregator _eventAggregator;
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly BTCPayServerSecurityStampValidator.DisabledUsers _disabledUsers;
private readonly IEnumerable<LoginExtension> _loginExtensions;
private readonly ILogger<UserService> _logger;
public UserService(
@@ -34,6 +38,7 @@ namespace BTCPayServer.Services
EventAggregator eventAggregator,
ApplicationDbContextFactory applicationDbContextFactory,
BTCPayServerSecurityStampValidator.DisabledUsers disabledUsers,
IEnumerable<LoginExtension> loginExtensions,
ILogger<UserService> logger)
{
_serviceProvider = serviceProvider;
@@ -42,6 +47,7 @@ namespace BTCPayServer.Services
_eventAggregator = eventAggregator;
_applicationDbContextFactory = applicationDbContextFactory;
_disabledUsers = disabledUsers;
_loginExtensions = loginExtensions;
_logger = logger;
}
@@ -88,40 +94,70 @@ namespace BTCPayServer.Services
};
}
private static bool IsEmailConfirmed(ApplicationUser user)
public class LoginFailure
{
return user.EmailConfirmed || !user.RequiresEmailConfirmation;
public LoginFailure(LocalizedString text)
{
ArgumentNullException.ThrowIfNull(text);
Text = text;
}
public LoginFailure(LocalizedString text, LocalizedHtmlString html) : this(text)
{
Html = html;
}
public LocalizedString Text { get; }
public LocalizedHtmlString? Html { get; }
public override string ToString() => Html?.ToString() ?? Text?.ToString() ?? "";
}
private static bool IsApproved(ApplicationUser user)
public abstract class LoginExtension
{
return user.Approved || !user.RequiresApproval;
public abstract Task Check(CanLoginContext context);
}
public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error)
public class CanLoginContext(
ApplicationUser? user,
IStringLocalizer? stringLocalizer = null,
IViewLocalizer? viewLocalizer = null,
RequestBaseUrl? baseUrl = null)
{
error = null;
if (user == null)
public CanLoginContext Clone(ApplicationUser? user) => new(user, stringLocalizer, viewLocalizer, BaseUrl);
public IStringLocalizer StringLocalizer { get; } = stringLocalizer ?? NullStringLocalizer.Instance;
public IViewLocalizer ViewLocalizer { get; } = viewLocalizer ?? NullViewLocalizer.Instance;
public RequestBaseUrl? BaseUrl { get; } = baseUrl;
internal readonly ApplicationUser? _user = user;
public ApplicationUser User => _user ?? throw new InvalidOperationException("User is not set");
public List<LoginFailure> Failures { get; } = new();
}
public async Task<bool> CanLogin(CanLoginContext context)
{
if (context._user is null)
{
error = "Invalid login attempt.";
context.Failures.Add(new LoginFailure(context.StringLocalizer["User not found or invalid password"]));
return false;
}
if (!IsEmailConfirmed(user))
foreach (var loginExtension in _loginExtensions)
{
error = "You must have a confirmed email to log in.";
return false;
await loginExtension.Check(context);
}
if (!IsApproved(user))
return !context.Failures.Any();
}
public class DefaultLoginExtension : LoginExtension
{
public override Task Check(CanLoginContext context)
{
error = "Your user account requires approval by an admin before you can log in.";
return false;
if (context.User is { EmailConfirmed: false, RequiresEmailConfirmation: true })
context.Failures.Add(new(context.StringLocalizer["You must have a confirmed email to log in."]));
if (context.User is { Approved: false, RequiresApproval: true })
context.Failures.Add(new(context.StringLocalizer["Your user account requires approval by an admin before you can log in."]));
if (context.User is { IsDisabled: true })
context.Failures.Add(new(context.StringLocalizer["Your user account is currently disabled."]));
return Task.CompletedTask;
}
if (user.IsDisabled)
{
error = "Your user account is currently disabled.";
return false;
}
return true;
}
public async Task<bool> SetUserApproval(string userId, bool approved, string loginLink)
@@ -249,21 +285,25 @@ namespace BTCPayServer.Services
}
}
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
public async Task<bool> IsUserTheOnlyOneAdmin(CanLoginContext canLoginContext)
{
using var scope = _serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roles = await userManager.GetRolesAsync(user);
var roles = await userManager.GetRolesAsync(canLoginContext.User);
if (!Roles.HasServerAdmin(roles))
{
return false;
}
var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var enabledAdminUsers = adminUsers
.Where(applicationUser => !applicationUser.IsDisabled && IsApproved(applicationUser))
.Select(applicationUser => applicationUser.Id).ToList();
var enabledAdminUsers = new List<string>();
foreach (var admin in adminUsers)
{
var loginContext = canLoginContext.Clone(admin);
if (await CanLogin(loginContext))
enabledAdminUsers.Add(admin.Id);
}
return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id);
return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(canLoginContext.User.Id);
}
}
}