diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index daa350e32..4ffdb019b 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -18,222 +18,243 @@ using BTCPayServer.Services.Stores; namespace BTCPayServer.Controllers { - [Authorize] - [Route("[controller]/[action]")] - public class AccountController : Controller - { - private readonly UserManager _userManager; - private readonly SignInManager _signInManager; - private readonly IEmailSender _emailSender; - private readonly ILogger _logger; + [Authorize] + [Route("[controller]/[action]")] + public class AccountController : Controller + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IEmailSender _emailSender; + private readonly ILogger _logger; StoreRepository storeRepository; RoleManager _RoleManager; + SettingsRepository _SettingsRepository; public AccountController( - UserManager userManager, + UserManager userManager, RoleManager roleManager, StoreRepository storeRepository, SignInManager signInManager, - IEmailSender emailSender, - ILogger logger) - { + IEmailSender emailSender, + SettingsRepository settingsRepository, + ILogger logger) + { this.storeRepository = storeRepository; - _userManager = userManager; - _signInManager = signInManager; - _emailSender = emailSender; - _logger = logger; + _userManager = userManager; + _signInManager = signInManager; + _emailSender = emailSender; + _logger = logger; _RoleManager = roleManager; - + _SettingsRepository = settingsRepository; } - [TempData] - public string ErrorMessage { get; set; } + [TempData] + public string ErrorMessage + { + get; set; + } - [HttpGet] - [AllowAnonymous] - public async Task Login(string returnUrl = null) - { - // Clear the existing external cookie to ensure a clean login process - await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + [HttpGet] + [AllowAnonymous] + public async Task Login(string returnUrl = null) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); - ViewData["ReturnUrl"] = returnUrl; - return View(); - } + ViewData["ReturnUrl"] = returnUrl; + return View(); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task Login(LoginViewModel model, string returnUrl = null) - { - ViewData["ReturnUrl"] = returnUrl; - if (ModelState.IsValid) - { - // This doesn't count login failures towards account lockout - // To enable password failures to trigger account lockout, set lockoutOnFailure: true - var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false); - if (result.Succeeded) - { - _logger.LogInformation("User logged in."); - return RedirectToLocal(returnUrl); - } - if (result.RequiresTwoFactor) - { - return RedirectToAction(nameof(LoginWith2fa), new { returnUrl, model.RememberMe }); - } - if (result.IsLockedOut) - { - _logger.LogWarning("User account locked out."); - return RedirectToAction(nameof(Lockout)); - } - else - { - ModelState.AddModelError(string.Empty, "Invalid login attempt."); - return View(model); - } - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task Login(LoginViewModel model, string returnUrl = null) + { + ViewData["ReturnUrl"] = returnUrl; + if(ModelState.IsValid) + { + // Require the user to have a confirmed email before they can log on. + var user = await _userManager.FindByEmailAsync(model.Email); + if(user != null) + { + if(user.RequiresEmailConfirmation && !await _userManager.IsEmailConfirmedAsync(user)) + { + ModelState.AddModelError(string.Empty, + "You must have a confirmed email to log in."); + return View(model); + } + } + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false); + if(result.Succeeded) + { + _logger.LogInformation("User logged in."); + return RedirectToLocal(returnUrl); + } + if(result.RequiresTwoFactor) + { + return RedirectToAction(nameof(LoginWith2fa), new + { + returnUrl, + model.RememberMe + }); + } + if(result.IsLockedOut) + { + _logger.LogWarning("User account locked out."); + return RedirectToAction(nameof(Lockout)); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid login attempt."); + return View(model); + } + } - // If we got this far, something failed, redisplay form - return View(model); - } + // If we got this far, something failed, redisplay form + return View(model); + } - [HttpGet] - [AllowAnonymous] - public async Task LoginWith2fa(bool rememberMe, string returnUrl = null) - { - // Ensure the user has gone through the username & password screen first - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + [HttpGet] + [AllowAnonymous] + public async Task LoginWith2fa(bool rememberMe, string returnUrl = null) + { + // Ensure the user has gone through the username & password screen first + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - throw new ApplicationException($"Unable to load two-factor authentication user."); - } + if(user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } - var model = new LoginWith2faViewModel { RememberMe = rememberMe }; - ViewData["ReturnUrl"] = returnUrl; + var model = new LoginWith2faViewModel { RememberMe = rememberMe }; + ViewData["ReturnUrl"] = returnUrl; - return View(model); - } + return View(model); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task LoginWith2fa(LoginWith2faViewModel model, bool rememberMe, string returnUrl = null) - { - if (!ModelState.IsValid) - { - return View(model); - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LoginWith2fa(LoginWith2faViewModel model, bool rememberMe, string returnUrl = null) + { + if(!ModelState.IsValid) + { + return View(model); + } - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); - } + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if(user == null) + { + throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } - var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty); + var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty); - var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine); + var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine); - if (result.Succeeded) - { - _logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id); - return RedirectToLocal(returnUrl); - } - else if (result.IsLockedOut) - { - _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); - return RedirectToAction(nameof(Lockout)); - } - else - { - _logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id); - ModelState.AddModelError(string.Empty, "Invalid authenticator code."); - return View(); - } - } + if(result.Succeeded) + { + _logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id); + return RedirectToLocal(returnUrl); + } + else if(result.IsLockedOut) + { + _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); + return RedirectToAction(nameof(Lockout)); + } + else + { + _logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id); + ModelState.AddModelError(string.Empty, "Invalid authenticator code."); + return View(); + } + } - [HttpGet] - [AllowAnonymous] - public async Task LoginWithRecoveryCode(string returnUrl = null) - { - // Ensure the user has gone through the username & password screen first - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - throw new ApplicationException($"Unable to load two-factor authentication user."); - } + [HttpGet] + [AllowAnonymous] + public async Task LoginWithRecoveryCode(string returnUrl = null) + { + // Ensure the user has gone through the username & password screen first + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if(user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } - ViewData["ReturnUrl"] = returnUrl; + ViewData["ReturnUrl"] = returnUrl; - return View(); - } + return View(); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task LoginWithRecoveryCode(LoginWithRecoveryCodeViewModel model, string returnUrl = null) - { - if (!ModelState.IsValid) - { - return View(model); - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task LoginWithRecoveryCode(LoginWithRecoveryCodeViewModel model, string returnUrl = null) + { + if(!ModelState.IsValid) + { + return View(model); + } - var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); - if (user == null) - { - throw new ApplicationException($"Unable to load two-factor authentication user."); - } + var user = await _signInManager.GetTwoFactorAuthenticationUserAsync(); + if(user == null) + { + throw new ApplicationException($"Unable to load two-factor authentication user."); + } - var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty); + var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty); - var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); + var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); - if (result.Succeeded) - { - _logger.LogInformation("User with ID {UserId} logged in with a recovery code.", user.Id); - return RedirectToLocal(returnUrl); - } - if (result.IsLockedOut) - { - _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); - return RedirectToAction(nameof(Lockout)); - } - else - { - _logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id); - ModelState.AddModelError(string.Empty, "Invalid recovery code entered."); - return View(); - } - } + if(result.Succeeded) + { + _logger.LogInformation("User with ID {UserId} logged in with a recovery code.", user.Id); + return RedirectToLocal(returnUrl); + } + if(result.IsLockedOut) + { + _logger.LogWarning("User with ID {UserId} account locked out.", user.Id); + return RedirectToAction(nameof(Lockout)); + } + else + { + _logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id); + ModelState.AddModelError(string.Empty, "Invalid recovery code entered."); + return View(); + } + } - [HttpGet] - [AllowAnonymous] - public IActionResult Lockout() - { - return View(); - } + [HttpGet] + [AllowAnonymous] + public IActionResult Lockout() + { + return View(); + } - [HttpGet] - [AllowAnonymous] - public IActionResult Register(string returnUrl = null) - { - ViewData["ReturnUrl"] = returnUrl; - return View(); - } + [HttpGet] + [AllowAnonymous] + public IActionResult Register(string returnUrl = null) + { + ViewData["ReturnUrl"] = returnUrl; + return View(); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task Register(RegisterViewModel model, string returnUrl = null) - { - ViewData["ReturnUrl"] = returnUrl; - if (ModelState.IsValid) - { - var user = new ApplicationUser { UserName = model.Email, Email = model.Email }; - var result = await _userManager.CreateAsync(user, model.Password); - if (result.Succeeded) - { - _logger.LogInformation("User created a new account with password."); + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task Register(RegisterViewModel model, string returnUrl = null) + { + ViewData["ReturnUrl"] = returnUrl; + if(ModelState.IsValid) + { + var policies = await _SettingsRepository.GetSettingAsync() ?? new PoliciesSettings(); + var user = new ApplicationUser { UserName = model.Email, Email = model.Email, RequiresEmailConfirmation = policies.RequiresConfirmedEmail }; + var result = await _userManager.CreateAsync(user, model.Password); + if(result.Succeeded) + { + _logger.LogInformation("User created a new account with password."); var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin); if(admin.Count == 0) @@ -246,17 +267,25 @@ namespace BTCPayServer.Controllers var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme); RegisteredUserId = user.Id; - await _emailSender.SendEmailConfirmationAsync(model.Email, callbackUrl); - await _signInManager.SignInAsync(user, isPersistent: false); - _logger.LogInformation("User created a new account with password."); - return RedirectToLocal(returnUrl); - } - AddErrors(result); - } + await _emailSender.SendEmailConfirmationAsync(model.Email, callbackUrl); + if(!policies.RequiresConfirmedEmail) + { + await _signInManager.SignInAsync(user, isPersistent: false); + return RedirectToLocal(returnUrl); + } + else + { + TempData["StatusMessage"] = "Account created, please confirm your email"; + return View(); + } + _logger.LogInformation("User created a new account with password."); + } + AddErrors(result); + } - // If we got this far, something failed, redisplay form - return View(model); - } + // If we got this far, something failed, redisplay form + return View(model); + } /// /// Test property @@ -267,222 +296,225 @@ namespace BTCPayServer.Controllers } [HttpGet] - public async Task Logout() - { - await _signInManager.SignOutAsync(); - _logger.LogInformation("User logged out."); - return RedirectToAction(nameof(HomeController.Index), "Home"); - } + public async Task Logout() + { + await _signInManager.SignOutAsync(); + _logger.LogInformation("User logged out."); + return RedirectToAction(nameof(HomeController.Index), "Home"); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public IActionResult ExternalLogin(string provider, string returnUrl = null) - { - // Request a redirect to the external login provider. - var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account", new { returnUrl }); - var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); - return Challenge(properties, provider); - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public IActionResult ExternalLogin(string provider, string returnUrl = null) + { + // Request a redirect to the external login provider. + var redirectUrl = Url.Action(nameof(ExternalLoginCallback), "Account", new + { + returnUrl + }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + return Challenge(properties, provider); + } - [HttpGet] - [AllowAnonymous] - public async Task ExternalLoginCallback(string returnUrl = null, string remoteError = null) - { - if (remoteError != null) - { - ErrorMessage = $"Error from external provider: {remoteError}"; - return RedirectToAction(nameof(Login)); - } - var info = await _signInManager.GetExternalLoginInfoAsync(); - if (info == null) - { - return RedirectToAction(nameof(Login)); - } + [HttpGet] + [AllowAnonymous] + public async Task ExternalLoginCallback(string returnUrl = null, string remoteError = null) + { + if(remoteError != null) + { + ErrorMessage = $"Error from external provider: {remoteError}"; + return RedirectToAction(nameof(Login)); + } + var info = await _signInManager.GetExternalLoginInfoAsync(); + if(info == null) + { + return RedirectToAction(nameof(Login)); + } - // Sign in the user with this external login provider if the user already has a login. - var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); - if (result.Succeeded) - { - _logger.LogInformation("User logged in with {Name} provider.", info.LoginProvider); - return RedirectToLocal(returnUrl); - } - if (result.IsLockedOut) - { - return RedirectToAction(nameof(Lockout)); - } - else - { - // If the user does not have an account, then ask the user to create an account. - ViewData["ReturnUrl"] = returnUrl; - ViewData["LoginProvider"] = info.LoginProvider; - var email = info.Principal.FindFirstValue(ClaimTypes.Email); - return View("ExternalLogin", new ExternalLoginViewModel { Email = email }); - } - } + // Sign in the user with this external login provider if the user already has a login. + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); + if(result.Succeeded) + { + _logger.LogInformation("User logged in with {Name} provider.", info.LoginProvider); + return RedirectToLocal(returnUrl); + } + if(result.IsLockedOut) + { + return RedirectToAction(nameof(Lockout)); + } + else + { + // If the user does not have an account, then ask the user to create an account. + ViewData["ReturnUrl"] = returnUrl; + ViewData["LoginProvider"] = info.LoginProvider; + var email = info.Principal.FindFirstValue(ClaimTypes.Email); + return View("ExternalLogin", new ExternalLoginViewModel { Email = email }); + } + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task ExternalLoginConfirmation(ExternalLoginViewModel model, string returnUrl = null) - { - if (ModelState.IsValid) - { - // Get the information about the user from the external login provider - var info = await _signInManager.GetExternalLoginInfoAsync(); - if (info == null) - { - throw new ApplicationException("Error loading external login information during confirmation."); - } - var user = new ApplicationUser { UserName = model.Email, Email = model.Email }; - var result = await _userManager.CreateAsync(user); - if (result.Succeeded) - { - result = await _userManager.AddLoginAsync(user, info); - if (result.Succeeded) - { - await _signInManager.SignInAsync(user, isPersistent: false); - _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider); - return RedirectToLocal(returnUrl); - } - } - AddErrors(result); - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task ExternalLoginConfirmation(ExternalLoginViewModel model, string returnUrl = null) + { + if(ModelState.IsValid) + { + // Get the information about the user from the external login provider + var info = await _signInManager.GetExternalLoginInfoAsync(); + if(info == null) + { + throw new ApplicationException("Error loading external login information during confirmation."); + } + var user = new ApplicationUser { UserName = model.Email, Email = model.Email }; + var result = await _userManager.CreateAsync(user); + if(result.Succeeded) + { + result = await _userManager.AddLoginAsync(user, info); + if(result.Succeeded) + { + await _signInManager.SignInAsync(user, isPersistent: false); + _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider); + return RedirectToLocal(returnUrl); + } + } + AddErrors(result); + } - ViewData["ReturnUrl"] = returnUrl; - return View(nameof(ExternalLogin), model); - } + ViewData["ReturnUrl"] = returnUrl; + return View(nameof(ExternalLogin), model); + } - [HttpGet] - [AllowAnonymous] - public async Task ConfirmEmail(string userId, string code) - { - if (userId == null || code == null) - { - return RedirectToAction(nameof(HomeController.Index), "Home"); - } - 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); - return View(result.Succeeded ? "ConfirmEmail" : "Error"); - } + [HttpGet] + [AllowAnonymous] + public async Task ConfirmEmail(string userId, string code) + { + if(userId == null || code == null) + { + return RedirectToAction(nameof(HomeController.Index), "Home"); + } + 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); + return View(result.Succeeded ? "ConfirmEmail" : "Error"); + } - [HttpGet] - [AllowAnonymous] - public IActionResult ForgotPassword() - { - return View(); - } + [HttpGet] + [AllowAnonymous] + public IActionResult ForgotPassword() + { + return View(); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task ForgotPassword(ForgotPasswordViewModel model) - { - if (ModelState.IsValid) - { - var user = await _userManager.FindByEmailAsync(model.Email); - if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) - { - // Don't reveal that the user does not exist or is not confirmed - return RedirectToAction(nameof(ForgotPasswordConfirmation)); - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task ForgotPassword(ForgotPasswordViewModel model) + { + if(ModelState.IsValid) + { + var user = await _userManager.FindByEmailAsync(model.Email); + if(user == null || !(await _userManager.IsEmailConfirmedAsync(user))) + { + // Don't reveal that the user does not exist or is not confirmed + return RedirectToAction(nameof(ForgotPasswordConfirmation)); + } - // For more information on how to enable account confirmation and password reset please - // visit https://go.microsoft.com/fwlink/?LinkID=532713 - var code = await _userManager.GeneratePasswordResetTokenAsync(user); - var callbackUrl = Url.ResetPasswordCallbackLink(user.Id, code, Request.Scheme); - await _emailSender.SendEmailAsync(model.Email, "Reset Password", - $"Please reset your password by clicking here: link"); - return RedirectToAction(nameof(ForgotPasswordConfirmation)); - } + // For more information on how to enable account confirmation and password reset please + // visit https://go.microsoft.com/fwlink/?LinkID=532713 + var code = await _userManager.GeneratePasswordResetTokenAsync(user); + var callbackUrl = Url.ResetPasswordCallbackLink(user.Id, code, Request.Scheme); + await _emailSender.SendEmailAsync(model.Email, "Reset Password", + $"Please reset your password by clicking here: link"); + return RedirectToAction(nameof(ForgotPasswordConfirmation)); + } - // If we got this far, something failed, redisplay form - return View(model); - } + // If we got this far, something failed, redisplay form + return View(model); + } - [HttpGet] - [AllowAnonymous] - public IActionResult ForgotPasswordConfirmation() - { - return View(); - } + [HttpGet] + [AllowAnonymous] + public IActionResult ForgotPasswordConfirmation() + { + return View(); + } - [HttpGet] - [AllowAnonymous] - public IActionResult ResetPassword(string code = null) - { - if (code == null) - { - throw new ApplicationException("A code must be supplied for password reset."); - } - var model = new ResetPasswordViewModel { Code = code }; - return View(model); - } + [HttpGet] + [AllowAnonymous] + public IActionResult ResetPassword(string code = null) + { + if(code == null) + { + throw new ApplicationException("A code must be supplied for password reset."); + } + var model = new ResetPasswordViewModel { Code = code }; + return View(model); + } - [HttpPost] - [AllowAnonymous] - [ValidateAntiForgeryToken] - public async Task ResetPassword(ResetPasswordViewModel model) - { - if (!ModelState.IsValid) - { - return View(model); - } - var user = await _userManager.FindByEmailAsync(model.Email); - if (user == null) - { - // Don't reveal that the user does not exist - return RedirectToAction(nameof(ResetPasswordConfirmation)); - } - var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password); - if (result.Succeeded) - { - return RedirectToAction(nameof(ResetPasswordConfirmation)); - } - AddErrors(result); - return View(); - } + [HttpPost] + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task ResetPassword(ResetPasswordViewModel model) + { + if(!ModelState.IsValid) + { + return View(model); + } + var user = await _userManager.FindByEmailAsync(model.Email); + if(user == null) + { + // Don't reveal that the user does not exist + return RedirectToAction(nameof(ResetPasswordConfirmation)); + } + var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password); + if(result.Succeeded) + { + return RedirectToAction(nameof(ResetPasswordConfirmation)); + } + AddErrors(result); + return View(); + } - [HttpGet] - [AllowAnonymous] - public IActionResult ResetPasswordConfirmation() - { - return View(); - } + [HttpGet] + [AllowAnonymous] + public IActionResult ResetPasswordConfirmation() + { + return View(); + } - [HttpGet] - public IActionResult AccessDenied() - { - return View(); - } + [HttpGet] + public IActionResult AccessDenied() + { + return View(); + } - #region Helpers + #region Helpers - private void AddErrors(IdentityResult result) - { - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - } + private void AddErrors(IdentityResult result) + { + foreach(var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } - private IActionResult RedirectToLocal(string returnUrl) - { - if (Url.IsLocalUrl(returnUrl)) - { - return Redirect(returnUrl); - } - else - { - return RedirectToAction(nameof(HomeController.Index), "Home"); - } - } + private IActionResult RedirectToLocal(string returnUrl) + { + if(Url.IsLocalUrl(returnUrl)) + { + return Redirect(returnUrl); + } + else + { + return RedirectToAction(nameof(HomeController.Index), "Home"); + } + } - #endregion - } + #endregion + } } diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index c113d4d66..2acd50eb7 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -32,6 +32,7 @@ using BTCPayServer.Services.Stores; using BTCPayServer.Servcices.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Wallets; +using BTCPayServer.Validations; namespace BTCPayServer.Controllers { @@ -72,16 +73,6 @@ namespace BTCPayServer.Controllers _FeeProvider = feeProvider ?? throw new ArgumentNullException(nameof(feeProvider)); } - static Regex _Email; - bool IsEmail(string str) - { - if(String.IsNullOrWhiteSpace(str)) - return false; - if(_Email == null) - _Email = new Regex("^((([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+(\\.([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(\\\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.)+(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.?$", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled, TimeSpan.FromSeconds(2.0)); - return _Email.IsMatch(str); - } - private async Task> CreateInvoiceCore(Invoice invoice, StoreData store) { var derivationStrategy = store.DerivationStrategy; @@ -99,7 +90,7 @@ namespace BTCPayServer.Controllers entity.FullNotifications = invoice.FullNotifications; entity.NotificationURL = notificationUri?.AbsoluteUri; entity.BuyerInformation = Map(invoice); - entity.RefundMail = IsEmail(entity?.BuyerInformation?.BuyerEmail) ? entity.BuyerInformation.BuyerEmail : null; + entity.RefundMail = EmailValidator.IsEmail(entity?.BuyerInformation?.BuyerEmail) ? entity.BuyerInformation.BuyerEmail : null; entity.ProductInformation = Map(invoice); entity.RedirectURL = invoice.RedirectURL ?? store.StoreWebsite; entity.Status = "new"; diff --git a/BTCPayServer/Controllers/ServerController.cs b/BTCPayServer/Controllers/ServerController.cs index 493d07428..b78cc1971 100644 --- a/BTCPayServer/Controllers/ServerController.cs +++ b/BTCPayServer/Controllers/ServerController.cs @@ -1,23 +1,31 @@ using BTCPayServer.Models; using BTCPayServer.Models.ServerViewModels; +using BTCPayServer.Services; +using BTCPayServer.Services.Mails; +using BTCPayServer.Validations; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Net; +using System.Net.Mail; using System.Threading.Tasks; namespace BTCPayServer.Controllers { - [Authorize(Roles=Roles.ServerAdmin)] + [Authorize(Roles = Roles.ServerAdmin)] public class ServerController : Controller { private UserManager _UserManager; + SettingsRepository _SettingsRepository; - public ServerController(UserManager userManager) + public ServerController(UserManager userManager, SettingsRepository settingsRepository) { _UserManager = userManager; + _SettingsRepository = settingsRepository; } [Route("server/users")] @@ -32,5 +40,58 @@ namespace BTCPayServer.Controllers }).ToList(); return View(users); } + + [Route("server/emails")] + public async Task Emails() + { + var data = (await _SettingsRepository.GetSettingAsync()) ?? new EmailSettings(); + return View(new EmailsViewModel() { Settings = data }); + } + + [Route("server/policies")] + public async Task Policies() + { + var data = (await _SettingsRepository.GetSettingAsync()) ?? new PoliciesSettings(); + return View(data); + } + [Route("server/policies")] + [HttpPost] + public async Task Policies(PoliciesSettings settings) + { + await _SettingsRepository.UpdateSetting(settings); + TempData["StatusMessage"] = "Policies upadated successfully"; + return View(settings); + } + + [Route("server/emails")] + [HttpPost] + public async Task Emails(EmailsViewModel model, string command) + { + if(command == "Test") + { + if(!ModelState.IsValid) + return View(model); + try + { + var client = model.Settings.CreateSmtpClient(); + await client.SendMailAsync(model.Settings.From, model.TestEmail, "BTCPay test", "BTCPay test"); + model.StatusMessage = "Email sent to " + model.TestEmail + ", please, verify you received it"; + } + catch(Exception ex) + { + model.StatusMessage = "Error: " + ex.Message; + } + return View(model); + } + else + { + ModelState.Remove(nameof(model.TestEmail)); + if(!ModelState.IsValid) + return View(model); + await _SettingsRepository.UpdateSetting(model.Settings); + model.StatusMessage = "Email settings saved"; + return View(model); + } + } } } diff --git a/BTCPayServer/Data/ApplicationDbContext.cs b/BTCPayServer/Data/ApplicationDbContext.cs index 487baccc4..f97122d93 100644 --- a/BTCPayServer/Data/ApplicationDbContext.cs +++ b/BTCPayServer/Data/ApplicationDbContext.cs @@ -45,6 +45,11 @@ namespace BTCPayServer.Data get; set; } + public DbSet Settings + { + get; set; + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var options = optionsBuilder.Options.FindExtension(); diff --git a/BTCPayServer/Data/SettingData.cs b/BTCPayServer/Data/SettingData.cs new file mode 100644 index 000000000..e6a783613 --- /dev/null +++ b/BTCPayServer/Data/SettingData.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Data +{ + public class SettingData + { + public string Id + { + get; set; + } + + public string Value + { + get; set; + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 1d30040d0..d07018f15 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -95,6 +95,7 @@ namespace BTCPayServer.Hosting var path = Path.Combine(provider.GetRequiredService().DataDir, "sqllite.db"); o.UseSqlite("Data Source=" + path); }); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => o.GetRequiredService>().Value); services.TryAddSingleton, BTCPayServerConfigureOptions>(); diff --git a/BTCPayServer/Migrations/20170926073744_Settings.Designer.cs b/BTCPayServer/Migrations/20170926073744_Settings.Designer.cs new file mode 100644 index 000000000..8f27c3b37 --- /dev/null +++ b/BTCPayServer/Migrations/20170926073744_Settings.Designer.cs @@ -0,0 +1,369 @@ +// +using BTCPayServer.Data; +using BTCPayServer.Servcices.Invoices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.Internal; +using System; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20170926073744_Settings")] + partial class Settings + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Created"); + + b.Property("CustomerEmail"); + + b.Property("ExceptionStatus"); + + b.Property("ItemCode"); + + b.Property("OrderId"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("RefundAddresses"); + }); + + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Value"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DerivationStrategy"); + + b.Property("SpeedPolicy"); + + b.Property("StoreCertificate"); + + b.Property("StoreName"); + + b.Property("StoreWebsite"); + + b.HasKey("Id"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.Property("ApplicationUserId"); + + b.Property("StoreDataId"); + + b.Property("Role"); + + b.HasKey("ApplicationUserId", "StoreDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("UserStore"); + }); + + modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Email") + .HasMaxLength(256); + + b.Property("EmailConfirmed"); + + b.Property("LockoutEnabled"); + + b.Property("LockoutEnd"); + + b.Property("NormalizedEmail") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasMaxLength(256); + + b.Property("PasswordHash"); + + b.Property("PhoneNumber"); + + b.Property("PhoneNumberConfirmed"); + + b.Property("SecurityStamp"); + + b.Property("TwoFactorEnabled"); + + b.Property("UserName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Name") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("RoleId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider"); + + b.Property("ProviderKey"); + + b.Property("ProviderDisplayName"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId"); + + b.Property("RoleId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany() + .HasForeignKey("StoreDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Payments") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("RefundAddresses") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser") + .WithMany("UserStores") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("UserStores") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BTCPayServer/Migrations/20170926073744_Settings.cs b/BTCPayServer/Migrations/20170926073744_Settings.cs new file mode 100644 index 000000000..60d8f3fde --- /dev/null +++ b/BTCPayServer/Migrations/20170926073744_Settings.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; +using System.Collections.Generic; + +namespace BTCPayServer.Migrations +{ + public partial class Settings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Settings"); + } + } +} diff --git a/BTCPayServer/Migrations/20170926084408_RequiresEmailConfirmation.Designer.cs b/BTCPayServer/Migrations/20170926084408_RequiresEmailConfirmation.Designer.cs new file mode 100644 index 000000000..c9c90ec1f --- /dev/null +++ b/BTCPayServer/Migrations/20170926084408_RequiresEmailConfirmation.Designer.cs @@ -0,0 +1,371 @@ +// +using BTCPayServer.Data; +using BTCPayServer.Servcices.Invoices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.Internal; +using System; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20170926084408_RequiresEmailConfirmation")] + partial class RequiresEmailConfirmation + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Created"); + + b.Property("CustomerEmail"); + + b.Property("ExceptionStatus"); + + b.Property("ItemCode"); + + b.Property("OrderId"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("RefundAddresses"); + }); + + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Value"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DerivationStrategy"); + + b.Property("SpeedPolicy"); + + b.Property("StoreCertificate"); + + b.Property("StoreName"); + + b.Property("StoreWebsite"); + + b.HasKey("Id"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.Property("ApplicationUserId"); + + b.Property("StoreDataId"); + + b.Property("Role"); + + b.HasKey("ApplicationUserId", "StoreDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("UserStore"); + }); + + modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Email") + .HasMaxLength(256); + + b.Property("EmailConfirmed"); + + b.Property("LockoutEnabled"); + + b.Property("LockoutEnd"); + + b.Property("NormalizedEmail") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasMaxLength(256); + + b.Property("PasswordHash"); + + b.Property("PhoneNumber"); + + b.Property("PhoneNumberConfirmed"); + + b.Property("RequiresEmailConfirmation"); + + b.Property("SecurityStamp"); + + b.Property("TwoFactorEnabled"); + + b.Property("UserName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Name") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("RoleId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider"); + + b.Property("ProviderKey"); + + b.Property("ProviderDisplayName"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId"); + + b.Property("RoleId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany() + .HasForeignKey("StoreDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Payments") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("RefundAddresses") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser") + .WithMany("UserStores") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("UserStores") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BTCPayServer/Migrations/20170926084408_RequiresEmailConfirmation.cs b/BTCPayServer/Migrations/20170926084408_RequiresEmailConfirmation.cs new file mode 100644 index 000000000..23592089b --- /dev/null +++ b/BTCPayServer/Migrations/20170926084408_RequiresEmailConfirmation.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; +using System.Collections.Generic; + +namespace BTCPayServer.Migrations +{ + public partial class RequiresEmailConfirmation : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequiresEmailConfirmation", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RequiresEmailConfirmation", + table: "AspNetUsers"); + } + } +} diff --git a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs index 0707416ac..457503c0d 100644 --- a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using BTCPayServer.Data; +using BTCPayServer.Servcices.Invoices; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -79,6 +80,18 @@ namespace BTCPayServer.Migrations b.ToTable("RefundAddresses"); }); + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Value"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => { b.Property("Id") @@ -145,6 +158,8 @@ namespace BTCPayServer.Migrations b.Property("PhoneNumberConfirmed"); + b.Property("RequiresEmailConfirmation"); + b.Property("SecurityStamp"); b.Property("TwoFactorEnabled"); diff --git a/BTCPayServer/Models/ApplicationUser.cs b/BTCPayServer/Models/ApplicationUser.cs index 8f3203ee7..19440a4c9 100644 --- a/BTCPayServer/Models/ApplicationUser.cs +++ b/BTCPayServer/Models/ApplicationUser.cs @@ -15,5 +15,10 @@ namespace BTCPayServer.Models get; set; } + + public bool RequiresEmailConfirmation + { + get; set; + } } } diff --git a/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs b/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs new file mode 100644 index 000000000..7abed7697 --- /dev/null +++ b/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs @@ -0,0 +1,29 @@ +using BTCPayServer.Services.Mails; +using Microsoft.AspNetCore.Mvc.Rendering; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.ServerViewModels +{ + public class EmailsViewModel + { + public string StatusMessage + { + get; set; + } + public EmailSettings Settings + { + get; set; + } + + [Required] + [EmailAddress] + public string TestEmail + { + get; set; + } + } +} diff --git a/BTCPayServer/Services/Mails/EmailSender.cs b/BTCPayServer/Services/Mails/EmailSender.cs index d91753491..e6ec7c25e 100644 --- a/BTCPayServer/Services/Mails/EmailSender.cs +++ b/BTCPayServer/Services/Mails/EmailSender.cs @@ -1,17 +1,40 @@ -using System; +using Hangfire; +using System; using System.Collections.Generic; using System.Linq; +using System.Net.Mail; using System.Threading.Tasks; namespace BTCPayServer.Services.Mails { - // This class is used by the application to send email for account confirmation and password reset. - // For more details see https://go.microsoft.com/fwlink/?LinkID=532713 - public class EmailSender : IEmailSender - { - public Task SendEmailAsync(string email, string subject, string message) - { - return Task.CompletedTask; - } - } + // This class is used by the application to send email for account confirmation and password reset. + // For more details see https://go.microsoft.com/fwlink/?LinkID=532713 + public class EmailSender : IEmailSender + { + IBackgroundJobClient _JobClient; + SettingsRepository _Repository; + public EmailSender(IBackgroundJobClient jobClient, SettingsRepository repository) + { + if(jobClient == null) + throw new ArgumentNullException(nameof(jobClient)); + _JobClient = jobClient; + _Repository = repository; + } + public Task SendEmailAsync(string email, string subject, string message) + { + _JobClient.Schedule(() => SendMailCore(email, subject, message), TimeSpan.Zero); + return Task.CompletedTask; + } + + public async Task SendMailCore(string email, string subject, string message) + { + var settings = await _Repository.GetSettingAsync(); + if(settings == null) + throw new InvalidOperationException("Email settings not configured"); + var smtp = settings.CreateSmtpClient(); + MailMessage mail = new MailMessage(settings.From, email, subject, message); + mail.IsBodyHtml = true; + await smtp.SendMailAsync(mail); + } + } } diff --git a/BTCPayServer/Services/Mails/EmailSettings.cs b/BTCPayServer/Services/Mails/EmailSettings.cs new file mode 100644 index 000000000..45abc6548 --- /dev/null +++ b/BTCPayServer/Services/Mails/EmailSettings.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Net.Mail; +using System.Threading.Tasks; + +namespace BTCPayServer.Services.Mails +{ + public class EmailSettings + { + [Required] + public string Server + { + get; set; + } + + [Required] + public int? Port + { + get; set; + } + + [Required] + public String Login + { + get; set; + } + + [Required] + public String Password + { + get; set; + } + + [EmailAddress] + public string From + { + get; set; + } + + public bool EnableSSL + { + get; set; + } + + public SmtpClient CreateSmtpClient() + { + SmtpClient client = new SmtpClient(Server, Port.Value); + client.EnableSsl = true; + client.UseDefaultCredentials = false; + client.Credentials = new NetworkCredential(Login, Password); + client.DeliveryMethod = SmtpDeliveryMethod.Network; + client.Timeout = 10000; + return client; + } + } +} diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs new file mode 100644 index 000000000..ad10a78c4 --- /dev/null +++ b/BTCPayServer/Services/PoliciesSettings.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Services +{ + public class PoliciesSettings + { + public bool RequiresConfirmedEmail + { + get; set; + } + } +} diff --git a/BTCPayServer/Services/SettingsRepository.cs b/BTCPayServer/Services/SettingsRepository.cs new file mode 100644 index 000000000..30d4d9235 --- /dev/null +++ b/BTCPayServer/Services/SettingsRepository.cs @@ -0,0 +1,66 @@ +using BTCPayServer.Data; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using BTCPayServer.Models; +using Microsoft.EntityFrameworkCore.Infrastructure.Internal; +using Newtonsoft.Json; + +namespace BTCPayServer.Services +{ + public class SettingsRepository + { + private ApplicationDbContextFactory _ContextFactory; + public SettingsRepository(ApplicationDbContextFactory contextFactory) + { + _ContextFactory = contextFactory; + } + + public async Task GetSettingAsync() + { + var name = typeof(T).FullName; + using(var ctx = _ContextFactory.CreateContext()) + { + var data = await ctx.Settings.Where(s => s.Id == name).FirstOrDefaultAsync(); + if(data == null) + return default(T); + return Deserialize(data.Value); + } + } + + public async Task UpdateSetting(T obj) + { + var name = obj.GetType().FullName; + using(var ctx = _ContextFactory.CreateContext()) + { + var settings = new SettingData(); + settings.Id = name; + settings.Value = Serialize(obj); + ctx.Attach(settings); + ctx.Entry(settings).State = EntityState.Modified; + try + { + await ctx.SaveChangesAsync(); + } + catch(DbUpdateException) + { + ctx.Entry(settings).State = EntityState.Added; + await ctx.SaveChangesAsync(); + } + } + } + + private T Deserialize(string value) + { + return JsonConvert.DeserializeObject(value); + } + + private string Serialize(T obj) + { + return JsonConvert.SerializeObject(obj); + } + } +} diff --git a/BTCPayServer/Validations/EmailValidator.cs b/BTCPayServer/Validations/EmailValidator.cs new file mode 100644 index 000000000..2ef40ea38 --- /dev/null +++ b/BTCPayServer/Validations/EmailValidator.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace BTCPayServer.Validations +{ + public class EmailValidator + { + static Regex _Email; + public static bool IsEmail(string str) + { + if(String.IsNullOrWhiteSpace(str)) + return false; + if(_Email == null) + _Email = new Regex("^((([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+(\\.([a-z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(\\\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|\\d|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.)+(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])|(([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])([a-z]|\\d|-|\\.|_|~|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])*([a-z]|[\\u00A0-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFEF])))\\.?$", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture | RegexOptions.Compiled, TimeSpan.FromSeconds(2.0)); + return _Email.IsMatch(str); + } + } +} diff --git a/BTCPayServer/Views/Account/ConfirmEmail.cshtml b/BTCPayServer/Views/Account/ConfirmEmail.cshtml index 81125a426..d73d4097f 100644 --- a/BTCPayServer/Views/Account/ConfirmEmail.cshtml +++ b/BTCPayServer/Views/Account/ConfirmEmail.cshtml @@ -1,10 +1,14 @@ @{ - ViewData["Title"] = "Confirm email"; + ViewData["Title"] = "Confirm email"; } -

@ViewData["Title"]

-
-

- Thank you for confirming your email. -

-
+
+
+ +
+
+ @Html.Partial("_StatusMessage", "Thank you for confirming your email.") +
+
+
+
\ No newline at end of file diff --git a/BTCPayServer/Views/Account/Register.cshtml b/BTCPayServer/Views/Account/Register.cshtml index 9d83a3da0..6e58d603e 100644 --- a/BTCPayServer/Views/Account/Register.cshtml +++ b/BTCPayServer/Views/Account/Register.cshtml @@ -5,6 +5,11 @@
+
+
+ @Html.Partial("_StatusMessage", TempData["StatusMessage"]) +
+

@ViewData["Title"]

diff --git a/BTCPayServer/Views/Server/Emails.cshtml b/BTCPayServer/Views/Server/Emails.cshtml new file mode 100644 index 000000000..70967380b --- /dev/null +++ b/BTCPayServer/Views/Server/Emails.cshtml @@ -0,0 +1,67 @@ +@model EmailsViewModel +@{ + ViewData["Title"] = ServerNavPages.Emails; + ViewData.AddActivePage(ServerNavPages.Emails); +} + + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", Model.StatusMessage) + + +
+
+
+
+
+
+
+
+
+ + + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} \ No newline at end of file diff --git a/BTCPayServer/Views/Server/Policies.cshtml b/BTCPayServer/Views/Server/Policies.cshtml new file mode 100644 index 000000000..a3b59661c --- /dev/null +++ b/BTCPayServer/Views/Server/Policies.cshtml @@ -0,0 +1,29 @@ +@model BTCPayServer.Services.PoliciesSettings +@{ + ViewData["Title"] = ServerNavPages.Emails; + ViewData.AddActivePage(ServerNavPages.Emails); +} + + +

@ViewData["Title"]

+@Html.Partial("_StatusMessage", TempData["StatusMessage"]) +
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} \ No newline at end of file diff --git a/BTCPayServer/Views/Server/ServerNavPages.cs b/BTCPayServer/Views/Server/ServerNavPages.cs index 314d00cf2..d1fceb646 100644 --- a/BTCPayServer/Views/Server/ServerNavPages.cs +++ b/BTCPayServer/Views/Server/ServerNavPages.cs @@ -14,9 +14,13 @@ namespace BTCPayServer.Views.Server public static string Users => "Users"; + public static string Emails => "Email server"; + public static string Policies => "Policies"; public static string Hangfire => "Hangfire"; public static string UsersNavClass(ViewContext viewContext) => PageNavClass(viewContext, Users); + public static string EmailsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Emails); + public static string PoliciesNavClass(ViewContext viewContext) => PageNavClass(viewContext, Policies); public static string HangfireNavClass(ViewContext viewContext) => PageNavClass(viewContext, Hangfire); public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); diff --git a/BTCPayServer/Views/Server/_Nav.cshtml b/BTCPayServer/Views/Server/_Nav.cshtml index 600af243e..7531a783e 100644 --- a/BTCPayServer/Views/Server/_Nav.cshtml +++ b/BTCPayServer/Views/Server/_Nav.cshtml @@ -2,6 +2,8 @@