Skip to content
This repository was archived by the owner on Jan 23, 2025. It is now read-only.

Commit eddd5b4

Browse files
committed
#267. Change password on profile page.
1 parent 0c85171 commit eddd5b4

24 files changed

Lines changed: 372 additions & 39 deletions

File tree

Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ public async Task UnlockUser(string login)
3838
public async Task ActivateUser(ActivateUserRequest request)
3939
=> await Post($"/api/users/activate", request);
4040

41+
public async Task ChangePassword(ChangePasswordRequest request)
42+
=> await Post("/api/users/change-password", request);
43+
4144
public async Task<IReadOnlyList<AccessTokenResponse>?> GetAccessTokens()
4245
=> await Get<IReadOnlyList<AccessTokenResponse>>("/api/users/access-tokens");
4346

Pyro.Api/Pyro.ApiTests/Tests/UserTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,36 @@ public async Task CreateGetUpdateUser()
9191
});
9292
}
9393

94+
[Test]
95+
public async Task ChangePassword()
96+
{
97+
var login = faker.Internet.Email();
98+
var createRequest = new CreateUserRequest(
99+
login,
100+
["Admin"]);
101+
await client.CreateUser(createRequest);
102+
103+
var message = Api.Smtp.WaitForMessage(x => x.To == login) ??
104+
throw new InvalidOperationException("The message was not found.");
105+
var token = message.GetToken();
106+
var password = faker.Random.Hash();
107+
var activateUserRequest = new ActivateUserRequest(token, password);
108+
await client.ActivateUser(activateUserRequest);
109+
110+
using var identityClient = new IdentityClient(Api.BaseAddress);
111+
await identityClient.Login(login, password);
112+
113+
var newPassword = faker.Random.Hash();
114+
var changePasswordRequest = new ChangePasswordRequest(password, newPassword);
115+
await identityClient.ChangePassword(changePasswordRequest);
116+
await identityClient.Logout();
117+
118+
await identityClient.Login(login, newPassword);
119+
var user = identityClient.GetUser(login);
120+
121+
Assert.That(user, Is.Not.Null);
122+
}
123+
94124
[Test]
95125
public async Task CreateGetDeleteAccessToken()
96126
{
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
2+
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.
3+
4+
namespace Pyro.Contracts.Requests.Identity;
5+
6+
public record ChangePasswordRequest(string OldPassword, string NewPassword);

Pyro.Api/Pyro.Domain.Identity.UnitTests/Models/UserTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,44 @@ public void ActivateUser()
270270
Assert.That(user.OneTimePasswords, Is.Empty);
271271
});
272272
}
273+
274+
[Test]
275+
public void ChangeIncorrectPassword()
276+
{
277+
const string oldPassword = "password";
278+
279+
var passwordService = new PasswordService(TimeProvider.System);
280+
var (passwordHash, salt) = passwordService.GeneratePasswordHash(oldPassword);
281+
var user = new User
282+
{
283+
Login = "test@localhost.local",
284+
Password = passwordHash,
285+
Salt = salt,
286+
};
287+
288+
Assert.Throws<DomainException>(() => user.ChangePassword(passwordService, "incorrect", "newPassword"));
289+
}
290+
291+
[Test]
292+
public void ChangePassword()
293+
{
294+
const string oldPassword = "password";
295+
296+
var passwordService = new PasswordService(TimeProvider.System);
297+
var (passwordHash, salt) = passwordService.GeneratePasswordHash(oldPassword);
298+
var user = new User
299+
{
300+
Login = "test@localhost.local",
301+
Password = passwordHash,
302+
Salt = salt,
303+
};
304+
305+
user.ChangePassword(passwordService, oldPassword, "newPassword");
306+
307+
Assert.Multiple(() =>
308+
{
309+
Assert.That(user.Password, Is.Not.EqualTo(passwordHash));
310+
Assert.That(user.Salt, Is.Not.EqualTo(salt));
311+
});
312+
}
273313
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
2+
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.
3+
4+
using FluentValidation;
5+
using MediatR;
6+
using Pyro.Domain.Shared.CurrentUserProvider;
7+
using Pyro.Domain.Shared.Exceptions;
8+
9+
namespace Pyro.Domain.Identity.Commands;
10+
11+
public record ChangePassword(string OldPassword, string NewPassword) : IRequest;
12+
13+
public class ChangePasswordValidator : AbstractValidator<ChangePassword>
14+
{
15+
public ChangePasswordValidator()
16+
{
17+
RuleFor(x => x.OldPassword)
18+
.NotEmpty();
19+
20+
RuleFor(x => x.NewPassword)
21+
.NotEmpty()
22+
.MinimumLength(8);
23+
}
24+
}
25+
26+
public class ChangePasswordHandler : IRequestHandler<ChangePassword>
27+
{
28+
private readonly ICurrentUserProvider currentUserProvider;
29+
private readonly IUserRepository userRepository;
30+
private readonly IPasswordService passwordService;
31+
32+
public ChangePasswordHandler(
33+
ICurrentUserProvider currentUserProvider,
34+
IUserRepository userRepository,
35+
IPasswordService passwordService)
36+
{
37+
this.currentUserProvider = currentUserProvider;
38+
this.userRepository = userRepository;
39+
this.passwordService = passwordService;
40+
}
41+
42+
public async Task Handle(ChangePassword request, CancellationToken cancellationToken)
43+
{
44+
var currentUser = currentUserProvider.GetCurrentUser();
45+
var user = await userRepository.GetUserById(currentUser.Id, cancellationToken) ??
46+
throw new NotFoundException($"The user (Id: {currentUser.Id}) not found");
47+
48+
user.ChangePassword(passwordService, request.OldPassword, request.NewPassword);
49+
}
50+
}

Pyro.Api/Pyro.Domain.Identity/Models/User.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public IReadOnlyList<byte> Password
4040
get => password;
4141

4242
[MemberNotNull(nameof(password))]
43-
init
43+
set
4444
{
4545
if (value is null)
4646
throw new DomainValidationException("Password cannot be null.");
@@ -57,7 +57,7 @@ public IReadOnlyList<byte> Salt
5757
get => salt;
5858

5959
[MemberNotNull(nameof(salt))]
60-
init
60+
set
6161
{
6262
if (value is null)
6363
throw new DomainValidationException("Salt cannot be null.");
@@ -143,6 +143,16 @@ public void DeleteAccessToken(string name)
143143
accessTokens.Remove(accessToken);
144144
}
145145

146+
public void ChangePassword(IPasswordService passwordService, string oldPassword, string newPassword)
147+
{
148+
if (!passwordService.VerifyPassword(oldPassword, password, salt))
149+
throw new DomainException("The old password is incorrect.");
150+
151+
var (newPasswordHash, newSalt) = passwordService.GeneratePasswordHash(newPassword);
152+
Password = newPasswordHash;
153+
Salt = newSalt;
154+
}
155+
146156
public void AddOneTimePassword(OneTimePassword oneTimePassword)
147157
{
148158
if (oneTimePasswords.Any(x => x.Token == oneTimePassword.Token))

Pyro.Api/Pyro/DtoMappings/IdentityMapper.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ public static partial class IdentityMapper
4040

4141
public static partial ActivateUser ToCommand(this ActivateUserRequest request);
4242

43+
public static partial ChangePassword ToCommand(this ChangePasswordRequest request);
44+
4345
[MapProperty([nameof(JwtTokenPair.AccessToken), nameof(Token.Value)], nameof(TokenPairResponse.AccessToken))]
4446
[MapProperty([nameof(JwtTokenPair.RefreshToken), nameof(Token.Value)], nameof(TokenPairResponse.RefreshToken))]
4547
public static partial TokenPairResponse ToResponse(this JwtTokenPair jwtTokenPair);

Pyro.Api/Pyro/Endpoints/IdentityEndpoints.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,26 @@ private static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder
182182
.WithName("Activate User")
183183
.WithOpenApi();
184184

185+
usersBuilder.MapPost("/change-password", async (
186+
IMediator mediator,
187+
UnitOfWork unitOfWork,
188+
ChangePasswordRequest request,
189+
CancellationToken cancellationToken) =>
190+
{
191+
var command = request.ToCommand();
192+
await mediator.Send(command, cancellationToken);
193+
await unitOfWork.SaveChangesAsync(cancellationToken);
194+
195+
return Results.Ok();
196+
})
197+
.Produces(200)
198+
.ProducesValidationProblem()
199+
.Produces(401)
200+
.Produces(403)
201+
.ProducesProblem(500)
202+
.WithName("Change Password")
203+
.WithOpenApi();
204+
185205
var accessTokenBuilder = usersBuilder.MapGroup("/access-tokens")
186206
.WithTags("Access Tokens");
187207

Pyro.Api/Pyro/Extensions/SpaExtensions.cs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,26 @@ namespace Pyro.Extensions;
77

88
internal static class SpaExtensions
99
{
10-
public static IServiceCollection AddSpa(this IServiceCollection services)
10+
public static IHostApplicationBuilder AddSpa(this IHostApplicationBuilder builder)
1111
{
12-
var fileServerOptions = new FileServerOptions
12+
if (!builder.Environment.IsDevelopment())
1313
{
14-
EnableDefaultFiles = true,
15-
EnableDirectoryBrowsing = false,
16-
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")),
17-
DefaultFilesOptions =
14+
var fileServerOptions = new FileServerOptions
1815
{
19-
DefaultFileNames = ["index.html"],
20-
},
21-
};
22-
services.AddSingleton(fileServerOptions);
23-
services.AddSingleton(fileServerOptions.DefaultFilesOptions);
24-
services.AddSingleton(fileServerOptions.StaticFileOptions);
25-
26-
return services;
16+
EnableDefaultFiles = true,
17+
EnableDirectoryBrowsing = false,
18+
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")),
19+
DefaultFilesOptions =
20+
{
21+
DefaultFileNames = ["index.html"],
22+
},
23+
};
24+
builder.Services.AddSingleton(fileServerOptions);
25+
builder.Services.AddSingleton(fileServerOptions.DefaultFilesOptions);
26+
builder.Services.AddSingleton(fileServerOptions.StaticFileOptions);
27+
}
28+
29+
return builder;
2730
}
2831

2932
public static IApplicationBuilder UseSpa(this IApplicationBuilder app)

Pyro.Api/Pyro/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
.AddOpenBehavior(typeof(ValidatorPipeline<,>)));
5656

5757
builder.Services.AddAuth();
58-
builder.Services.AddSpa();
58+
builder.AddSpa();
5959

6060
var app = builder.Build();
6161

0 commit comments

Comments
 (0)