diff --git a/BTCPayServer.Client/BTCPayServerClient.Users.cs b/BTCPayServer.Client/BTCPayServerClient.Users.cs index d320c3792..0879bd7ce 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Users.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Users.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Client.Models; @@ -11,5 +12,12 @@ namespace BTCPayServer.Client var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users/me"), token); return await HandleResponse(response); } + + public virtual async Task CreateUser(CreateApplicationUserRequest request, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users", null, request, HttpMethod.Post), token); + return await HandleResponse(response); + } } } diff --git a/BTCPayServer.Client/BTCPayServerClient.cs b/BTCPayServer.Client/BTCPayServerClient.cs index a8f97bd30..a855e1028 100644 --- a/BTCPayServer.Client/BTCPayServerClient.cs +++ b/BTCPayServer.Client/BTCPayServerClient.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -64,7 +65,7 @@ namespace BTCPayServer.Client var request = CreateHttpRequest(path, queryPayload, method); if (typeof(T).IsPrimitive || !EqualityComparer.Default.Equals(bodyPayload, default(T))) { - request.Content = new StringContent(JsonSerializer.Serialize(bodyPayload, _serializerOptions)); + request.Content = new StringContent(JsonSerializer.Serialize(bodyPayload, _serializerOptions), Encoding.UTF8, "application/json"); } return request; diff --git a/BTCPayServer.Client/Models/ApplicationUserData.cs b/BTCPayServer.Client/Models/ApplicationUserData.cs index 6965271f8..6a88944b4 100644 --- a/BTCPayServer.Client/Models/ApplicationUserData.cs +++ b/BTCPayServer.Client/Models/ApplicationUserData.cs @@ -2,7 +2,41 @@ namespace BTCPayServer.Client.Models { public class ApplicationUserData { + /// + /// the id of the user + /// public string Id { get; set; } + /// + /// the email AND username of the user + /// public string Email { get; set; } + /// + /// Whether the user has verified their email + /// + public bool EmailConfirmed { get; set; } + /// + /// whether the user needed to verify their email on account creation + /// + public bool RequiresEmailConfirmation { get; set; } + } + + public class CreateApplicationUserRequest + { + /// + /// the email AND username of the new user + /// + public string Email { get; set; } + /// + /// password of the new user + /// + public string Password { get; set; } + /// + /// Whether this user is an administrator. If left null and there are no admins in the system, the user will be created as an admin. + /// + public bool? IsAdministrator { get; set; } + /// + /// If the server requires email confirmation, this allows you to set the account as confirmed from the start + /// + public bool? EmailConfirmed { get; set; } } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 890f1e902..511178ca8 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -1,13 +1,17 @@ +using System; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using BTCPayServer.Client; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; +using BTCPayServer.Controllers.RestApi.Users; using BTCPayServer.Tests.Logging; using Microsoft.AspNet.SignalR.Client; using Microsoft.AspNetCore.Mvc; using Xunit; using Xunit.Abstractions; +using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; namespace BTCPayServer.Tests { @@ -72,6 +76,36 @@ namespace BTCPayServer.Tests await Assert.ThrowsAsync(async () => await clientInsufficient.GetCurrentUser()); await clientServer.GetCurrentUser(); + + + await Assert.ThrowsAsync(async () => await clientInsufficient.CreateUser(new CreateApplicationUserRequest() + { + Email = $"{Guid.NewGuid()}@g.com", + Password = Guid.NewGuid().ToString() + }) ); + + var newUser = await clientServer.CreateUser(new CreateApplicationUserRequest() + { + Email = $"{Guid.NewGuid()}@g.com", Password = Guid.NewGuid().ToString() + }); + Assert.NotNull(newUser); + + await Assert.ThrowsAsync(async () => await clientServer.CreateUser(new CreateApplicationUserRequest() + { + Email = $"{Guid.NewGuid()}", + Password = Guid.NewGuid().ToString() + }) ); + + await Assert.ThrowsAsync(async () => await clientServer.CreateUser(new CreateApplicationUserRequest() + { + Email = $"{Guid.NewGuid()}@g.com", + }) ); + + await Assert.ThrowsAsync(async () => await clientServer.CreateUser(new CreateApplicationUserRequest() + { + Password = Guid.NewGuid().ToString() + }) ); + } } } diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index acaac444f..d4c0fb68a 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -23,6 +23,7 @@ using BTCPayServer.U2F.Models; using Newtonsoft.Json; using NicolasDorier.RateLimits; using BTCPayServer.Data; +using BTCPayServer.Events; using U2F.Core.Exceptions; namespace BTCPayServer.Controllers @@ -40,6 +41,7 @@ namespace BTCPayServer.Controllers Configuration.BTCPayServerOptions _Options; private readonly BTCPayServerEnvironment _btcPayServerEnvironment; public U2FService _u2FService; + private readonly EventAggregator _eventAggregator; ILogger _logger; public AccountController( @@ -51,7 +53,8 @@ namespace BTCPayServer.Controllers SettingsRepository settingsRepository, Configuration.BTCPayServerOptions options, BTCPayServerEnvironment btcPayServerEnvironment, - U2FService u2FService) + U2FService u2FService, + EventAggregator eventAggregator) { this.storeRepository = storeRepository; _userManager = userManager; @@ -62,6 +65,7 @@ namespace BTCPayServer.Controllers _Options = options; _btcPayServerEnvironment = btcPayServerEnvironment; _u2FService = u2FService; + _eventAggregator = eventAggregator; _logger = Logs.PayServer; } @@ -439,7 +443,6 @@ namespace BTCPayServer.Controllers if (result.Succeeded) { var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin); - Logs.PayServer.LogInformation($"A new user just registered {user.Email} {(admin.Count == 0 ? "(admin)" : "")}"); if (admin.Count == 0 || (model.IsAdmin && _Options.AllowAdminRegistration)) { await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin)); @@ -456,11 +459,14 @@ namespace BTCPayServer.Controllers RegisteredAdmin = true; } - var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme); + _eventAggregator.Publish(new UserRegisteredEvent() + { + Request = Request, + User = user, + Admin = RegisteredAdmin + }); RegisteredUserId = user.Id; - _EmailSenderFactory.GetEmailSender().SendEmailConfirmation(model.Email, callbackUrl); if (!policies.RequiresConfirmedEmail) { if (logon) diff --git a/BTCPayServer/Controllers/ManageController.cs b/BTCPayServer/Controllers/ManageController.cs index 357f96e8e..5fdaae147 100644 --- a/BTCPayServer/Controllers/ManageController.cs +++ b/BTCPayServer/Controllers/ManageController.cs @@ -20,6 +20,7 @@ using BTCPayServer.Security; using BTCPayServer.U2F; using BTCPayServer.Data; using BTCPayServer.Security.APIKeys; +using Microsoft.AspNetCore.Routing; namespace BTCPayServer.Controllers @@ -38,6 +39,7 @@ namespace BTCPayServer.Controllers private readonly BTCPayServerEnvironment _btcPayServerEnvironment; private readonly APIKeyRepository _apiKeyRepository; private readonly IAuthorizationService _authorizationService; + private readonly LinkGenerator _linkGenerator; StoreRepository _StoreRepository; @@ -54,7 +56,8 @@ namespace BTCPayServer.Controllers U2FService u2FService, BTCPayServerEnvironment btcPayServerEnvironment, APIKeyRepository apiKeyRepository, - IAuthorizationService authorizationService + IAuthorizationService authorizationService, + LinkGenerator linkGenerator ) { _userManager = userManager; @@ -67,6 +70,7 @@ namespace BTCPayServer.Controllers _btcPayServerEnvironment = btcPayServerEnvironment; _apiKeyRepository = apiKeyRepository; _authorizationService = authorizationService; + _linkGenerator = linkGenerator; _StoreRepository = storeRepository; } @@ -156,7 +160,7 @@ namespace BTCPayServer.Controllers } var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme); + var callbackUrl = _linkGenerator.EmailConfirmationLink(user.Id, code, Request.Scheme, Request.HttpContext); var email = user.Email; _EmailSenderFactory.GetEmailSender().SendEmailConfirmation(email, callbackUrl); TempData[WellKnownTempData.SuccessMessage] = "Verification email sent. Please check your email."; diff --git a/BTCPayServer/Controllers/RestApi/Users/UsersController.cs b/BTCPayServer/Controllers/RestApi/Users/UsersController.cs index f0bce9ec3..c8a81d7e4 100644 --- a/BTCPayServer/Controllers/RestApi/Users/UsersController.cs +++ b/BTCPayServer/Controllers/RestApi/Users/UsersController.cs @@ -1,12 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Threading.Tasks; using BTCPayServer.Client.Models; +using BTCPayServer.Configuration; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Hosting.OpenApi; using BTCPayServer.Security; +using BTCPayServer.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using NSwag.Annotations; namespace BTCPayServer.Controllers.RestApi.Users @@ -18,14 +24,24 @@ namespace BTCPayServer.Controllers.RestApi.Users public class UsersController : ControllerBase { private readonly UserManager _userManager; + private readonly BTCPayServerOptions _btcPayServerOptions; + private readonly RoleManager _roleManager; + private readonly SettingsRepository _settingsRepository; + private readonly EventAggregator _eventAggregator; - public UsersController(UserManager userManager) + public UsersController(UserManager userManager, BTCPayServerOptions btcPayServerOptions, + RoleManager roleManager, SettingsRepository settingsRepository, + EventAggregator eventAggregator) { _userManager = userManager; + _btcPayServerOptions = btcPayServerOptions; + _roleManager = roleManager; + _settingsRepository = settingsRepository; + _eventAggregator = eventAggregator; } - + [OpenApiOperation("Get current user information", "View information about the current user")] - [SwaggerResponse(StatusCodes.Status200OK, typeof(ApiKeyData), + [SwaggerResponse(StatusCodes.Status200OK, typeof(ApplicationUserData), Description = "Information about the current user")] [Authorize(Policy = Policies.CanModifyProfile.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)] [HttpGet("~/api/v1/users/me")] @@ -34,14 +50,82 @@ namespace BTCPayServer.Controllers.RestApi.Users var user = await _userManager.GetUserAsync(User); return FromModel(user); } - + + [OpenApiOperation("Create user", "Create a new user")] + [SwaggerResponse(StatusCodes.Status201Created, typeof(ApplicationUserData), + Description = "Information about the new user")] + [SwaggerResponse(StatusCodes.Status422UnprocessableEntity, typeof(ValidationProblemDetails), + Description = "A list of validation errors that occurred")] + [SwaggerResponse(StatusCodes.Status400BadRequest, typeof(ValidationProblemDetails), + Description = "A list of errors that occurred when creating the user")] + [Authorize(Policy = Policies.CanCreateUser.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)] + [HttpPost("~/api/v1/users")] + public async Task> CreateUser(CreateApplicationUserRequest request) + { + var policies = await _settingsRepository.GetSettingAsync() ?? new PoliciesSettings(); + var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any(); + var admin = request.IsAdministrator.GetValueOrDefault(!anyAdmin); + var user = new ApplicationUser + { + UserName = request.Email, + Email = request.Email, + RequiresEmailConfirmation = policies.RequiresConfirmedEmail, + EmailConfirmed = request.EmailConfirmed.GetValueOrDefault(false) + }; + var identityResult = await _userManager.CreateAsync(user); + if (!identityResult.Succeeded) + { + AddErrors(identityResult); + return BadRequest(new ValidationProblemDetails(ModelState)); + } + else if (admin) + { + await _roleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin)); + await _userManager.AddToRoleAsync(user, Roles.ServerAdmin); + } + + _eventAggregator.Publish(new UserRegisteredEvent() {Request = Request, User = user, Admin = admin}); + + return CreatedAtAction("", user); + } + private static ApplicationUserData FromModel(ApplicationUser data) { return new ApplicationUserData() { Id = data.Id, - Email = data.Email + Email = data.Email, + EmailConfirmed = data.EmailConfirmed, + RequiresEmailConfirmation = data.RequiresEmailConfirmation }; } + + private void AddErrors(IdentityResult result) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + } + + [ModelMetadataType(typeof(CreateApplicationUserRequestMetadata))] + public class CreateApplicationUserRequest : BTCPayServer.Client.Models.CreateApplicationUserRequest + { + + } + + public class CreateApplicationUserRequestMetadata + { + [Required] + [EmailAddress] + [Display(Name = "Email")] + public string Email { get; set; } + + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "Password")] + public string Password { get; set; } } } diff --git a/BTCPayServer/Events/UserRegisteredEvent.cs b/BTCPayServer/Events/UserRegisteredEvent.cs new file mode 100644 index 000000000..0b14857c6 --- /dev/null +++ b/BTCPayServer/Events/UserRegisteredEvent.cs @@ -0,0 +1,12 @@ +using BTCPayServer.Data; +using Microsoft.AspNetCore.Http; + +namespace BTCPayServer.Events +{ + public class UserRegisteredEvent + { + public ApplicationUser User { get; set; } + public HttpRequest Request { get; set; } + public bool Admin { get; set; } + } +} diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index 1167db26e..02b543c9d 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -1,20 +1,19 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.Mvc { public static class UrlHelperExtensions { - public static string EmailConfirmationLink(this IUrlHelper urlHelper, string userId, string code, string scheme) + public static string EmailConfirmationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HttpContext context) { - return urlHelper.Action( - action: nameof(AccountController.ConfirmEmail), - controller: "Account", - values: new { userId, code }, - protocol: scheme); + return urlHelper.GetUriByAction(context, nameof(AccountController.ConfirmEmail), "Account", + new {userId, code}, scheme); } public static string ResetPasswordCallbackLink(this IUrlHelper urlHelper, string userId, string code, string scheme) diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs new file mode 100644 index 000000000..246370562 --- /dev/null +++ b/BTCPayServer/HostedServices/UserEventHostedService.cs @@ -0,0 +1,53 @@ +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Logging; +using BTCPayServer.Services; +using BTCPayServer.Services.Mails; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.HostedServices +{ + public class UserEventHostedService : EventHostedServiceBase + { + private readonly UserManager _userManager; + private readonly EmailSenderFactory _emailSenderFactory; + private readonly LinkGenerator _generator; + + public UserEventHostedService(EventAggregator eventAggregator, UserManager userManager, + EmailSenderFactory emailSenderFactory, LinkGenerator generator) : base(eventAggregator) + { + _userManager = userManager; + _emailSenderFactory = emailSenderFactory; + _generator = generator; + } + + protected override void SubscibeToEvents() + { + Subscribe(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + switch (evt) + { + case UserRegisteredEvent userRegisteredEvent: + Logs.PayServer.LogInformation($"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}"); + if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation) + { + var code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User); + var callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code, userRegisteredEvent.Request.Scheme, userRegisteredEvent.Request.HttpContext); + + _emailSenderFactory.GetEmailSender() + .SendEmailConfirmation(userRegisteredEvent.User.Email, callbackUrl); + } + break; + } + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 602bd5343..07d8a439f 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -204,6 +204,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 61f0cdf06..1ba9f873c 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -72,6 +72,16 @@ namespace BTCPayServer.Hosting // ScriptSrc = "'self' 'unsafe-inline'" //}); }) + .ConfigureApiBehaviorOptions(options => + { + var builtInFactory = options.InvalidModelStateResponseFactory; + + options.InvalidModelStateResponseFactory = context => + { + context.HttpContext.Response.StatusCode = (int)HttpStatusCode.UnprocessableEntity; + return builtInFactory(context); + }; + }) .AddNewtonsoftJson() #if DEBUG .AddRazorRuntimeCompilation() diff --git a/BTCPayServer/Models/AccountViewModels/RegisterViewModel.cs b/BTCPayServer/Models/AccountViewModels/RegisterViewModel.cs index c59e7eccf..a865c4387 100644 --- a/BTCPayServer/Models/AccountViewModels/RegisterViewModel.cs +++ b/BTCPayServer/Models/AccountViewModels/RegisterViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs b/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs index 71c27e698..391769361 100644 --- a/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs +++ b/BTCPayServer/Security/APIKeys/APIKeyAuthorizationHandler.cs @@ -29,6 +29,13 @@ namespace BTCPayServer.Security.APIKeys protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement) { + //if it is a create user request, and the auth is not specified, and there are no admins in the system: authorize + if (context.User.Identity.AuthenticationType == null && requirement.Policy == Policies.CanCreateUser.Key && + !(await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any()) + { + context.Succeed(requirement); + } + if (context.User.Identity.AuthenticationType != APIKeyConstants.AuthenticationType) return; @@ -67,6 +74,7 @@ namespace BTCPayServer.Security.APIKeys } break; + case Policies.CanCreateUser.Key: case Policies.CanModifyServerSettings.Key: if (!context.HasPermissions(Permissions.ServerManagement)) break; diff --git a/BTCPayServer/Security/Policies.cs b/BTCPayServer/Security/Policies.cs index 9ba065d07..5e48517c1 100644 --- a/BTCPayServer/Security/Policies.cs +++ b/BTCPayServer/Security/Policies.cs @@ -13,6 +13,7 @@ namespace BTCPayServer.Security options.AddPolicy(CanModifyServerSettings.Key); options.AddPolicy(CanModifyServerSettings.Key); options.AddPolicy(CanModifyProfile.Key); + options.AddPolicy(CanCreateUser.Key); return options; } @@ -46,5 +47,10 @@ namespace BTCPayServer.Security { public const string Key = "btcpay.store.cangetrates"; } + + public class CanCreateUser + { + public const string Key = "btcpay.store.cancreateuser"; + } } } diff --git a/BTCPayServer/Services/Mails/EmailSenderFactory.cs b/BTCPayServer/Services/Mails/EmailSenderFactory.cs index 12c288350..4f88bdd4b 100644 --- a/BTCPayServer/Services/Mails/EmailSenderFactory.cs +++ b/BTCPayServer/Services/Mails/EmailSenderFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Threading.Tasks; using BTCPayServer.Services.Stores;