Skip to content

Commit

Permalink
Migrate creation of an Identity and registration of a Device domain a…
Browse files Browse the repository at this point in the history
…ppropriate logic from Handler to Domain (#886)

* fix: use postgres connection string

* refactore: extract methods in handler

* wip

* wip

* fix: add device id

* feat: introduce AddDevice()

* refactor: improve code structure of Handler

* fix: resolve DeviceId issue

* refactor: do not pass ApplicationUser in handler

* feat: remove prop

* refactor: extract method

* feat: add new method

* refactor: improve constructor

* refactor: improe code structure of Handler

* refactor: small improvements

* test: add new param

* refactor: improve ApplicationUser

* refactor: improve code structure

* fix: resolve foreign key constraint

* test: add communication language to integration test

* chore: fix formatting issues

* test: use new ctor

* fix: use correct language in test

* fix: update test

* refactor: small improvements

* fix: resolve errors that prevented the db population

* feat: introduce validator

* feat: use validator

* refactor: add various small improvements

* test: improve test

* refactor: use IsValid() instead of Validate()

* feat: remove IsValid()

* refactor: various small improvements

* fix: use correct type

* fix: reverte IsValid() changes

* chore: remove unused InvalidCommunicationLanguageException class

* refactor: remove unnecessary class

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Timo Notheisen <[email protected]>
  • Loading branch information
3 people authored Oct 2, 2024
1 parent 141acd7 commit 35c8a8f
Show file tree
Hide file tree
Showing 30 changed files with 163 additions and 121 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
},
"SqlDatabase": {
"Provider": "Postgres",
"ConnectionString": "User ID=adminUi;Password=Passw0rd;Server=localhost;Port=5432;Database=enmeshed;" // postgres
"ConnectionString": "User ID=postgres;Password=admin;Server=localhost;Port=5432;Database=enmeshed;" // postgres
// "ConnectionString": "Server=localhost;Database=enmeshed;User Id=adminUi;Password=Passw0rd;TrustServerCertificate=True"
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public async Task WhenAPostRequestIsSentToTheIdentitiesEndpointWithAValidSignatu
alg = CryptoExchangeAlgorithm.ECDH_X25519,
pub = identityKeyPair.PublicKey.Base64Representation
})).BytesRepresentation,
CommunicationLanguage = "en",
DevicePassword = DEVICE_PASSWORD
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ private async Task<Identity> SeedDatabaseWithIdentityWithRipeDeletionProcess()
{
var dbContext = GetService<DevicesDbContext>();

var identity = new Identity("test", TestDataGenerator.CreateRandomIdentityAddress(), [], TierId.Generate(), 1);
var identity = new Identity("test", TestDataGenerator.CreateRandomIdentityAddress(), [], TierId.Generate(), 1, CommunicationLanguage.DEFAULT_LANGUAGE);

var device = new Device(identity, CommunicationLanguage.DEFAULT_LANGUAGE);
identity.Devices.Add(device);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Text.Json.Serialization;
using Backbone.BuildingBlocks.Application.Abstractions.Exceptions;
using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.UserContext;
using Backbone.BuildingBlocks.Domain;
using Backbone.Modules.Devices.Application.Devices.DTOs;
using Backbone.Modules.Devices.Application.Infrastructure.Persistence.Repository;
using Backbone.Modules.Devices.Domain.Entities.Identities;
Expand Down Expand Up @@ -33,20 +32,17 @@ public async Task<RegisterDeviceResponse> Handle(RegisterDeviceCommand command,
var identity = await _identitiesRepository.FindByAddress(_userContext.GetAddress(), cancellationToken, track: true) ?? throw new NotFoundException(nameof(Identity));

await _challengeValidator.Validate(command.SignedChallenge, PublicKey.FromBytes(identity.PublicKey));

_logger.LogTrace("Successfully validated challenge.");

var communicationLanguageResult = CommunicationLanguage.Create(command.CommunicationLanguage);
if (communicationLanguageResult.IsFailure)
throw new DomainException(communicationLanguageResult.Error);

var user = new ApplicationUser(identity, communicationLanguageResult.Value, _userContext.GetDeviceId());
var newDevice = identity.AddDevice(communicationLanguageResult.Value, _userContext.GetDeviceId());

await _identitiesRepository.AddUser(user, command.DevicePassword);
await _identitiesRepository.UpdateWithNewDevice(identity, command.DevicePassword);

_logger.CreatedDevice();

return new RegisterDeviceResponse(user);
return new RegisterDeviceResponse(newDevice.User);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Backbone.BuildingBlocks.Application.FluentValidation;
using Backbone.Modules.Devices.Application.Devices.DTOs.Validators;
using Backbone.Modules.Devices.Domain.Entities.Identities;
using FluentValidation;

namespace Backbone.Modules.Devices.Application.Devices.Commands.RegisterDevice;
Expand All @@ -10,5 +11,6 @@ public RegisterDeviceCommandValidator()
{
RuleFor(c => c.DevicePassword).DetailedNotEmpty();
RuleFor(c => c.SignedChallenge).DetailedNotEmpty().SetValidator(new SignedChallengeDTOValidator());
RuleFor(c => c.CommunicationLanguage).Valid(CommunicationLanguage.Validate);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
using Backbone.Modules.Devices.Domain.Entities.Identities;

namespace Backbone.Modules.Devices.Application.Identities.Commands.CreateIdentity;

public class CreateIdentityResponse
{
public required string Address { get; set; }
public required DateTime CreatedAt { get; set; }
public required CreateIdentityResponseDevice Device { get; set; }
public CreateIdentityResponse(Identity identity)
{
Address = identity.Address;
CreatedAt = identity.CreatedAt;
Device = new CreateIdentityResponseDevice
{
Id = identity.Devices.First().Id,
Username = identity.Devices.First().User.UserName!,
CreatedAt = identity.Devices.First().CreatedAt
};
}

public string Address { get; set; }
public DateTime CreatedAt { get; set; }
public CreateIdentityResponseDevice Device { get; set; }
}

public class CreateIdentityResponseDevice
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Backbone.BuildingBlocks.Application.Abstractions.Exceptions;
using Backbone.BuildingBlocks.Domain;
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Devices.Application.Devices.DTOs;
using Backbone.Modules.Devices.Application.Infrastructure.Persistence.Repository;
Expand Down Expand Up @@ -31,51 +30,48 @@ public Handler(ChallengeValidator challengeValidator, ILogger<Handler> logger, I

public async Task<CreateIdentityResponse> Handle(CreateIdentityCommand command, CancellationToken cancellationToken)
{
var publicKey = PublicKey.FromBytes(command.IdentityPublicKey);

await _challengeValidator.Validate(command.SignedChallenge, publicKey);

var publicKey = await ValidateChallenge(command);
_logger.LogTrace("Challenge successfully validated.");

var address = IdentityAddress.Create(publicKey.Key, _applicationOptions.DidDomainName);

var address = await CreateIdentityAddress(publicKey, cancellationToken);
_logger.LogTrace("Address created.");

var addressAlreadyExists = await _identitiesRepository.Exists(address, cancellationToken);
var newIdentity = await CreateNewIdentity(command, cancellationToken, address);
await _identitiesRepository.Add(newIdentity, command.DevicePassword);
_logger.CreatedIdentity();

if (addressAlreadyExists)
throw new OperationFailedException(ApplicationErrors.Devices.AddressAlreadyExists());
return new CreateIdentityResponse(newIdentity);
}

private async Task<Identity> CreateNewIdentity(CreateIdentityCommand command, CancellationToken cancellationToken, IdentityAddress address)
{
var client = await _oAuthClientsRepository.Find(command.ClientId, cancellationToken) ?? throw new NotFoundException(nameof(OAuthClient));

var clientIdentityCount = await _identitiesRepository.CountByClientId(command.ClientId, cancellationToken);

if (clientIdentityCount >= client.MaxIdentities)
throw new OperationFailedException(ApplicationErrors.Devices.ClientReachedIdentitiesLimit());

var newIdentity = new Identity(command.ClientId, address, command.IdentityPublicKey, client.DefaultTier, command.IdentityVersion);

var communicationLanguageResult = CommunicationLanguage.Create(command.CommunicationLanguage);
if (communicationLanguageResult.IsFailure)
throw new DomainException(communicationLanguageResult.Error);

var user = new ApplicationUser(newIdentity, communicationLanguageResult.Value);
return new Identity(client.ClientId, address, command.IdentityPublicKey, client.DefaultTier, command.IdentityVersion, communicationLanguageResult.Value);
}

await _identitiesRepository.AddUser(user, command.DevicePassword);
private async Task<IdentityAddress> CreateIdentityAddress(PublicKey publicKey, CancellationToken cancellationToken)
{
var address = IdentityAddress.Create(publicKey.Key, _applicationOptions.DidDomainName);

_logger.CreatedIdentity();
var addressAlreadyExists = await _identitiesRepository.Exists(address, cancellationToken);
if (addressAlreadyExists)
throw new OperationFailedException(ApplicationErrors.Devices.AddressAlreadyExists());

return address;
}

return new CreateIdentityResponse
{
Address = address,
CreatedAt = newIdentity.CreatedAt,
Device = new CreateIdentityResponseDevice
{
Id = user.DeviceId,
Username = user.UserName!,
CreatedAt = user.Device.CreatedAt
}
};
private async Task<PublicKey> ValidateChallenge(CreateIdentityCommand command)
{
var publicKey = PublicKey.FromBytes(command.IdentityPublicKey);
await _challengeValidator.Validate(command.SignedChallenge, publicKey);
return publicKey;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Backbone.BuildingBlocks.Application.FluentValidation;
using Backbone.Modules.Devices.Application.Devices.DTOs.Validators;
using Backbone.Modules.Devices.Domain.Entities.Identities;
using FluentValidation;

namespace Backbone.Modules.Devices.Application.Identities.Commands.CreateIdentity;
Expand All @@ -12,5 +13,6 @@ public CreateIdentityCommandValidator()
RuleFor(c => c.IdentityPublicKey).DetailedNotEmpty();
RuleFor(c => c.DevicePassword).DetailedNotEmpty();
RuleFor(c => c.SignedChallenge).DetailedNotEmpty().SetValidator(new SignedChallengeDTOValidator());
RuleFor(c => c.CommunicationLanguage).Valid(CommunicationLanguage.Validate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.UserContext;
using Backbone.BuildingBlocks.Application.PushNotifications;
using Backbone.BuildingBlocks.Domain;
using Backbone.BuildingBlocks.Infrastructure.Exceptions;
using Backbone.Modules.Devices.Application.Infrastructure.Persistence.Repository;
using Backbone.Modules.Devices.Application.Infrastructure.PushNotifications.DeletionProcess;
using Backbone.Modules.Devices.Domain;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,8 @@ public interface IIdentitiesRepository
Task<IEnumerable<Identity>> Find(Expression<Func<Identity, bool>> filter, CancellationToken cancellationToken, bool track = false);
Task Delete(Expression<Func<Identity, bool>> filter, CancellationToken cancellationToken);

#endregion

#region Users

Task AddUser(ApplicationUser user, string password);
Task Add(Identity identity, string password);
Task UpdateWithNewDevice(Identity identity, string password);

#endregion

Expand Down
Original file line number Diff line number Diff line change
@@ -1,59 +1,32 @@
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Devices.Application.Infrastructure.Persistence.Database;
using Backbone.Modules.Devices.Application.Infrastructure.Persistence.Repository;
using Backbone.Modules.Devices.Domain.Entities.Identities;
using Backbone.Tooling;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;

namespace Backbone.Modules.Devices.Application.Users.Commands.SeedTestUsers;

public class Handler : IRequestHandler<SeedTestUsersCommand>
{
private readonly IPasswordHasher<ApplicationUser> _passwordHasher;
private readonly ApplicationOptions _applicationOptions;
private readonly IDevicesDbContext _dbContext;
private readonly IIdentitiesRepository _identitiesRepository;
private readonly ITiersRepository _tiersRepository;

public Handler(IDevicesDbContext context, ITiersRepository tiersRepository, IPasswordHasher<ApplicationUser> passwordHasher, IOptions<ApplicationOptions> applicationOptions)
public Handler(IIdentitiesRepository identitiesRepository, ITiersRepository tiersRepository, IOptions<ApplicationOptions> applicationOptions)
{
_dbContext = context;
_identitiesRepository = identitiesRepository;
_tiersRepository = tiersRepository;
_passwordHasher = passwordHasher;
_applicationOptions = applicationOptions.Value;
}

public async Task Handle(SeedTestUsersCommand request, CancellationToken cancellationToken)
{
var basicTier = await _tiersRepository.FindBasicTier(cancellationToken);

var user = new ApplicationUser(new Device(new Identity("test",
IdentityAddress.Create([1, 1, 1, 1, 1], _applicationOptions.DidDomainName),
[1, 1, 1, 1, 1], basicTier!.Id, 1
), CommunicationLanguage.DEFAULT_LANGUAGE))
{
SecurityStamp = Guid.NewGuid().ToString("D"),
UserName = "USRa",
NormalizedUserName = "USRA",
CreatedAt = SystemTime.UtcNow
};
user.PasswordHash = _passwordHasher.HashPassword(user, "a");
await _dbContext.Set<ApplicationUser>().AddAsync(user, cancellationToken);
var identityA = Identity.CreateTestIdentity(IdentityAddress.Create([1, 1, 1, 1, 1], _applicationOptions.DidDomainName), [1, 1, 1, 1, 1], basicTier!.Id, "USRa");
var identityB = Identity.CreateTestIdentity(IdentityAddress.Create([2, 2, 2, 2, 2], _applicationOptions.DidDomainName), [2, 2, 2, 2, 2], basicTier.Id, "USRb");

user = new ApplicationUser(new Device(new Identity("test",
IdentityAddress.Create([2, 2, 2, 2, 2], _applicationOptions.DidDomainName),
[2, 2, 2, 2, 2], basicTier.Id, 1
), CommunicationLanguage.DEFAULT_LANGUAGE))
{
SecurityStamp = Guid.NewGuid().ToString("D"),
UserName = "USRb",
NormalizedUserName = "USRB",
CreatedAt = SystemTime.UtcNow
};
user.PasswordHash = _passwordHasher.HashPassword(user, "b");
await _dbContext.Set<ApplicationUser>().AddAsync(user, cancellationToken);

await _dbContext.SaveChangesAsync(cancellationToken);
await _identitiesRepository.Add(identityA, "a");
await _identitiesRepository.Add(identityB, "b");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@ public class ApplicationUser : IdentityUser
{
private readonly Device _device;

// This constructor is required by AspnetCoreIdentity
public ApplicationUser()
{
_device = null!;
DeviceId = null!;
CreatedAt = SystemTime.UtcNow;
}

public ApplicationUser(Device device) : base(Username.New())
internal ApplicationUser(Device device, string username) : base(username)
{
_device = device;
DeviceId = null!;
DeviceId = device.Id;
CreatedAt = SystemTime.UtcNow;
}

public ApplicationUser(Identity identity, CommunicationLanguage communicationLanguage, DeviceId? createdByDevice = null) : base(Username.New())
public ApplicationUser(Device device) : base(Username.New())
{
_device = new Device(identity, communicationLanguage, createdByDevice);
DeviceId = Device.Id;

_device = device;
DeviceId = device.Id;
CreatedAt = SystemTime.UtcNow;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static Result<CommunicationLanguage, DomainError> Create(string value)
var validationResult = Validate(value);
if (validationResult != null)
return Result.Failure<CommunicationLanguage, DomainError>(validationResult);
return Result.Success<CommunicationLanguage, DomainError>(new CommunicationLanguage(value));
return new CommunicationLanguage(value);
}

public static DomainError? Validate(string value)
Expand Down
22 changes: 20 additions & 2 deletions Modules/Devices/src/Devices.Domain/Entities/Identities/Device.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,27 @@ private Device()
CommunicationLanguage = null!;
}

private Device(Identity identity, CommunicationLanguage communicationLanguage, string username)
{
Id = DeviceId.New();
CreatedAt = SystemTime.UtcNow;
CreatedByDevice = Id;
CommunicationLanguage = communicationLanguage;

User = new ApplicationUser(this, username);

Identity = identity;
IdentityAddress = null!;
}

public Device(Identity identity, CommunicationLanguage communicationLanguage, DeviceId? createdByDevice = null)
{
Id = DeviceId.New();
CreatedAt = SystemTime.UtcNow;
CreatedByDevice = createdByDevice ?? Id;
CommunicationLanguage = communicationLanguage;

User = null!; // This is just to satisfy the compiler; the property is actually set by EF core
User = new ApplicationUser(this);

// The following distinction is unfortunately necessary in order to make EF recognize that the identity already exists
if (identity.IsNew())
Expand Down Expand Up @@ -68,7 +81,7 @@ public Device(Identity identity, CommunicationLanguage communicationLanguage, De
if (IsOnboarded)
return new DomainError("error.platform.validation.device.deviceCannotBeDeleted", "The device cannot be deleted because it is already onboarded.");

if (Identity.Address != addressOfActiveIdentity)
if (IdentityAddress != addressOfActiveIdentity)
return new DomainError("error.platform.validation.device.deviceCannotBeDeleted", "You are not allowed to delete this device as it belongs to another identity.");

return null;
Expand Down Expand Up @@ -96,4 +109,9 @@ public void MarkAsDeleted(DeviceId deletedByDevice, IdentityAddress addressOfAct
DeletedAt = SystemTime.UtcNow;
DeletedByDevice = deletedByDevice;
}

public static Device CreateTestDevice(Identity identity, CommunicationLanguage communicationLanguage, string username)
{
return new Device(identity, communicationLanguage, username);
}
}
Loading

0 comments on commit 35c8a8f

Please sign in to comment.