Greenfield API: Create User

Slightly big PR because I started refactoring to reduce code duplication between the UI based business logic and the api one.
This commit is contained in:
Kukks
2020-03-13 11:47:22 +01:00
parent c85fb3e89f
commit e99767c7e2
16 changed files with 282 additions and 22 deletions

View File

@@ -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<ApplicationUserData>(response);
}
public virtual async Task<ApplicationUserData> CreateUser(CreateApplicationUserRequest request,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users", null, request, HttpMethod.Post), token);
return await HandleResponse<ApplicationUserData>(response);
}
}
}

View File

@@ -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<T>.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;

View File

@@ -2,7 +2,41 @@ namespace BTCPayServer.Client.Models
{
public class ApplicationUserData
{
/// <summary>
/// the id of the user
/// </summary>
public string Id { get; set; }
/// <summary>
/// the email AND username of the user
/// </summary>
public string Email { get; set; }
/// <summary>
/// Whether the user has verified their email
/// </summary>
public bool EmailConfirmed { get; set; }
/// <summary>
/// whether the user needed to verify their email on account creation
/// </summary>
public bool RequiresEmailConfirmation { get; set; }
}
public class CreateApplicationUserRequest
{
/// <summary>
/// the email AND username of the new user
/// </summary>
public string Email { get; set; }
/// <summary>
/// password of the new user
/// </summary>
public string Password { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool? IsAdministrator { get; set; }
/// <summary>
/// If the server requires email confirmation, this allows you to set the account as confirmed from the start
/// </summary>
public bool? EmailConfirmed { get; set; }
}
}

View File

@@ -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<HttpRequestException>(async () => await clientInsufficient.GetCurrentUser());
await clientServer.GetCurrentUser();
await Assert.ThrowsAsync<HttpRequestException>(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<HttpRequestException>(async () => await clientServer.CreateUser(new CreateApplicationUserRequest()
{
Email = $"{Guid.NewGuid()}",
Password = Guid.NewGuid().ToString()
}) );
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientServer.CreateUser(new CreateApplicationUserRequest()
{
Email = $"{Guid.NewGuid()}@g.com",
}) );
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientServer.CreateUser(new CreateApplicationUserRequest()
{
Password = Guid.NewGuid().ToString()
}) );
}
}
}

View File

@@ -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)

View File

@@ -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.";

View File

@@ -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<ApplicationUser> _userManager;
private readonly BTCPayServerOptions _btcPayServerOptions;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly SettingsRepository _settingsRepository;
private readonly EventAggregator _eventAggregator;
public UsersController(UserManager<ApplicationUser> userManager)
public UsersController(UserManager<ApplicationUser> userManager, BTCPayServerOptions btcPayServerOptions,
RoleManager<IdentityRole> 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")]
@@ -35,13 +51,81 @@ namespace BTCPayServer.Controllers.RestApi.Users
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<ActionResult<ApplicationUserData>> CreateUser(CreateApplicationUserRequest request)
{
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>() ?? 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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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)

View File

@@ -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<ApplicationUser> _userManager;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly LinkGenerator _generator;
public UserEventHostedService(EventAggregator eventAggregator, UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory, LinkGenerator generator) : base(eventAggregator)
{
_userManager = userManager;
_emailSenderFactory = emailSenderFactory;
_generator = generator;
}
protected override void SubscibeToEvents()
{
Subscribe<UserRegisteredEvent>();
}
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;
}
}
}
}

View File

@@ -204,6 +204,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
services.AddSingleton<IHostedService, AppHubStreamer>();
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
services.AddSingleton<IHostedService, UserEventHostedService>();
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, TorServicesHostedService>();
services.AddSingleton<IHostedService, PaymentRequestStreamer>();

View File

@@ -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()

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;

View File

@@ -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;

View File

@@ -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";
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using BTCPayServer.Services.Stores;