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

Commit 180ce7b

Browse files
committed
#270 - Reset passwords.
1 parent c977e72 commit 180ce7b

46 files changed

Lines changed: 915 additions & 229 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ public async Task ActivateUser(ActivateUserRequest request)
4141
public async Task ChangePassword(ChangePasswordRequest request)
4242
=> await Post("/api/users/change-password", request);
4343

44+
public async Task ForgotPassword(ForgotPasswordRequest request)
45+
=> await Post("/api/users/forgot-password", request);
46+
47+
public async Task ResetPassword(ResetPasswordRequest request)
48+
=> await Post("/api/users/reset-password", request);
49+
4450
public async Task<IReadOnlyList<AccessTokenResponse>?> GetAccessTokens()
4551
=> await Get<IReadOnlyList<AccessTokenResponse>>("/api/users/access-tokens");
4652

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 Bogus;
5+
using Pyro.ApiTests.Clients;
6+
using Pyro.Contracts.Requests.Identity;
7+
8+
namespace Pyro.ApiTests.Tests;
9+
10+
public class ResetPasswordTests
11+
{
12+
private Faker faker;
13+
private IdentityClient client;
14+
private string login;
15+
private string email;
16+
17+
[OneTimeSetUp]
18+
public async Task SetUp()
19+
{
20+
faker = new Faker();
21+
client = new IdentityClient(Api.BaseAddress);
22+
await client.Login();
23+
24+
login = faker.Random.Hash(32);
25+
email = faker.Internet.Email();
26+
var createUserRequest = new CreateUserRequest(login, email, ["User"]);
27+
var user = await client.CreateUser(createUserRequest);
28+
Assert.That(user, Is.Not.Null);
29+
30+
var message = Api.Smtp.WaitForMessage(x => x.To == email) ??
31+
throw new InvalidOperationException("The message was not found.");
32+
var token = message.GetToken();
33+
var password = faker.Random.Hash();
34+
var activateUserRequest = new ActivateUserRequest(token, password);
35+
await client.ActivateUser(activateUserRequest);
36+
}
37+
38+
[OneTimeTearDown]
39+
public void TearDown()
40+
{
41+
client.Dispose();
42+
}
43+
44+
[Test]
45+
public async Task Tests()
46+
{
47+
await client.ForgotPassword(new ForgotPasswordRequest(login));
48+
var message = Api.Smtp.WaitForMessage(x => x.To == email) ??
49+
throw new InvalidOperationException("The message was not found.");
50+
var token = message.GetToken();
51+
52+
var password = faker.Random.Hash();
53+
await client.ResetPassword(new ResetPasswordRequest(token, password));
54+
55+
await client.Logout();
56+
await client.Login(login, password);
57+
}
58+
}
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 ForgotPasswordRequest(string Login);
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 ResetPasswordRequest(string Token, string Password);

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,130 @@ public void ChangePassword()
403403
Assert.That(user.Password, Is.Not.EqualTo(passwordHash));
404404
Assert.That(user.Salt, Is.Not.EqualTo(salt));
405405
Assert.That(user.PasswordExpiresAt, Is.EqualTo(currentDateTime.AddDays(90)));
406+
Assert.That(user.AuthenticationTokens, Is.Empty);
407+
Assert.That(user.AccessTokens, Is.Empty);
408+
Assert.That(user.OneTimePasswords, Is.Empty);
409+
});
410+
}
411+
412+
[Test]
413+
public void ResetPasswordWithTokenIsNull()
414+
{
415+
var user = new User
416+
{
417+
Login = "test",
418+
DisplayName = "test",
419+
Email = "test@localhost.local",
420+
};
421+
422+
var timeProvider = Substitute.For<TimeProvider>();
423+
var passwordService = Substitute.For<IPasswordService>();
424+
425+
Assert.Throws<ArgumentNullException>(() =>
426+
user.ResetPassword(timeProvider, passwordService, null!, string.Empty));
427+
}
428+
429+
[Test]
430+
public void ResetPasswordWithExpiredToken()
431+
{
432+
var currentTime = DateTimeOffset.UtcNow;
433+
434+
var user = new User
435+
{
436+
Login = "test",
437+
DisplayName = "test",
438+
Email = "test@localhost.local",
439+
};
440+
var oneTimePassword = new OneTimePassword
441+
{
442+
Token = "token",
443+
ExpiresAt = currentTime.AddDays(-1),
444+
Purpose = OneTimePasswordPurpose.PasswordReset,
445+
User = user,
446+
};
447+
user.AddOneTimePassword(oneTimePassword);
448+
449+
var timeProvider = Substitute.For<TimeProvider>();
450+
timeProvider
451+
.GetUtcNow()
452+
.Returns(currentTime);
453+
454+
var passwordService = Substitute.For<IPasswordService>();
455+
456+
Assert.Throws<DomainException>(() =>
457+
user.ResetPassword(timeProvider, passwordService, oneTimePassword, string.Empty));
458+
}
459+
460+
[Test]
461+
public void ResetPasswordWithLockedUser()
462+
{
463+
var currentTime = DateTimeOffset.UtcNow;
464+
465+
var user = new User
466+
{
467+
Login = "test",
468+
DisplayName = "test",
469+
Email = "test@localhost.local",
470+
};
471+
var oneTimePassword = new OneTimePassword
472+
{
473+
Token = "token",
474+
ExpiresAt = currentTime.AddDays(1),
475+
Purpose = OneTimePasswordPurpose.PasswordReset,
476+
User = user,
477+
};
478+
user.AddOneTimePassword(oneTimePassword);
479+
user.Lock();
480+
481+
var timeProvider = Substitute.For<TimeProvider>();
482+
timeProvider
483+
.GetUtcNow()
484+
.Returns(currentTime);
485+
486+
var passwordService = Substitute.For<IPasswordService>();
487+
488+
Assert.Throws<DomainException>(() =>
489+
user.ResetPassword(timeProvider, passwordService, oneTimePassword, string.Empty));
490+
}
491+
492+
[Test]
493+
public void ResetPassword()
494+
{
495+
var currentTime = DateTimeOffset.UtcNow;
496+
const string password = "12345678";
497+
498+
var user = new User
499+
{
500+
Login = "test",
501+
DisplayName = "test",
502+
Email = "test@localhost.local",
503+
};
504+
var oneTimePassword = new OneTimePassword
505+
{
506+
Token = "token",
507+
ExpiresAt = currentTime.AddDays(1),
508+
Purpose = OneTimePasswordPurpose.PasswordReset,
509+
User = user,
510+
};
511+
user.AddOneTimePassword(oneTimePassword);
512+
513+
var timeProvider = Substitute.For<TimeProvider>();
514+
timeProvider
515+
.GetUtcNow()
516+
.Returns(currentTime);
517+
518+
var passwordService = Substitute.For<IPasswordService>();
519+
passwordService
520+
.GeneratePasswordHash(password)
521+
.Returns((new byte[64], new byte[16]));
522+
523+
user.ResetPassword(timeProvider, passwordService, oneTimePassword, password);
524+
525+
Assert.Multiple(() =>
526+
{
527+
Assert.That(user.Password, Is.Not.Null);
528+
Assert.That(user.Salt, Is.Not.Null);
529+
Assert.That(user.OneTimePasswords, Is.Empty);
406530
});
407531
}
408532
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 System.Text.Encodings.Web;
5+
using FluentValidation;
6+
using MediatR;
7+
using Microsoft.Extensions.Options;
8+
using Pyro.Domain.Shared;
9+
using Pyro.Domain.Shared.Email;
10+
using Pyro.Domain.Shared.Exceptions;
11+
12+
namespace Pyro.Domain.Identity.Commands;
13+
14+
public record ForgotPassword(string Login) : IRequest;
15+
16+
public class ForgotPasswordValidator : AbstractValidator<ForgotPassword>
17+
{
18+
public ForgotPasswordValidator()
19+
{
20+
RuleFor(x => x.Login)
21+
.NotEmpty()
22+
.MaximumLength(32)
23+
.Matches(@"^[a-zA-Z0-9_\-]*$");
24+
}
25+
}
26+
27+
public class ForgotPasswordHandler : IRequestHandler<ForgotPassword>
28+
{
29+
private readonly IUserRepository repository;
30+
private readonly IPasswordService passwordService;
31+
private readonly IEmailService emailService;
32+
private readonly EmailServiceOptions emailServiceOptions;
33+
private readonly ServiceOptions serviceOptions;
34+
private readonly UrlEncoder urlEncoder;
35+
36+
public ForgotPasswordHandler(
37+
IUserRepository repository,
38+
IPasswordService passwordService,
39+
IEmailService emailService,
40+
IOptions<EmailServiceOptions> emailServiceOptions,
41+
IOptions<ServiceOptions> serviceOptions,
42+
UrlEncoder urlEncoder)
43+
{
44+
this.repository = repository;
45+
this.passwordService = passwordService;
46+
this.emailService = emailService;
47+
this.emailServiceOptions = emailServiceOptions.Value;
48+
this.serviceOptions = serviceOptions.Value;
49+
this.urlEncoder = urlEncoder;
50+
}
51+
52+
public async Task Handle(ForgotPassword request, CancellationToken cancellationToken = default)
53+
{
54+
var user = await repository.GetUserByLogin(request.Login, cancellationToken) ??
55+
throw new NotFoundException($"User (Login: {request.Login}) not found");
56+
57+
var oneTimePassword = passwordService.GeneratePasswordResetTokenFor(user);
58+
59+
var inviteLink = new UriBuilder(serviceOptions.PublicUrl!)
60+
{
61+
Path = "/reset-password",
62+
Query = $"token={urlEncoder.Encode(oneTimePassword.Token)}",
63+
}
64+
.Uri
65+
.ToString();
66+
var body = $"""
67+
Hello {user.DisplayName}!
68+
69+
You have requested to reset your password. Please use the following link to reset your password: <a href="{inviteLink}">Reset Password</a>. If you did not request to reset your password, please ignore this email.
70+
71+
Thank you, Pyro.
72+
""";
73+
var message = new EmailMessage(
74+
new EmailAddress("No Reply", $"no-reply@{emailServiceOptions.Domain}"),
75+
new EmailAddress(user.DisplayName, user.Email),
76+
"Reset your password",
77+
body);
78+
await emailService.SendEmail(message, cancellationToken);
79+
}
80+
}

Pyro.Api/Pyro.Domain.Identity/Commands/NotifyExpiringPasswords.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public async Task Handle(NotifyExpiringPasswords request, CancellationToken canc
3636
await foreach (var user in expiringUsers)
3737
{
3838
var body = $"""
39-
Hello {user.DisplayName},
39+
Hello {user.DisplayName}!
4040
4141
Your password is about to expire. The expiration date is {user.PasswordExpiresAt:yyyy-MM-dd}.
4242
Please change your password to avoid any issues.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 Microsoft.Extensions.Logging;
7+
8+
namespace Pyro.Domain.Identity.Commands;
9+
10+
public record ResetPassword(string Token, string Password) : IRequest;
11+
12+
public class ResetPasswordValidator : AbstractValidator<ResetPassword>
13+
{
14+
public ResetPasswordValidator()
15+
{
16+
RuleFor(x => x.Token)
17+
.NotEmpty();
18+
19+
RuleFor(x => x.Password)
20+
.NotEmpty()
21+
.MinimumLength(8);
22+
}
23+
}
24+
25+
public class ResetPasswordHandler : IRequestHandler<ResetPassword>
26+
{
27+
private readonly ILogger<ResetPasswordHandler> logger;
28+
private readonly IUserRepository repository;
29+
private readonly TimeProvider timeProvider;
30+
private readonly IPasswordService passwordService;
31+
32+
public ResetPasswordHandler(
33+
ILogger<ResetPasswordHandler> logger,
34+
IUserRepository repository,
35+
TimeProvider timeProvider,
36+
IPasswordService passwordService)
37+
{
38+
this.logger = logger;
39+
this.repository = repository;
40+
this.timeProvider = timeProvider;
41+
this.passwordService = passwordService;
42+
}
43+
44+
public async Task Handle(ResetPassword request, CancellationToken cancellationToken = default)
45+
{
46+
var user = await repository.GetUserByToken(request.Token, cancellationToken);
47+
var otp = user?.GetOneTimePassword(request.Token);
48+
if (user is null || otp is null)
49+
{
50+
logger.LogError("The token '{Token}' is invalid", request.Token);
51+
return;
52+
}
53+
54+
user.ResetPassword(timeProvider, passwordService, otp, request.Password);
55+
56+
logger.LogInformation("User '{Login}' password reset", user.Login);
57+
}
58+
}

Pyro.Api/Pyro.Domain.Identity/IPasswordService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ public interface IPasswordService
1414
string GeneratePassword();
1515

1616
OneTimePassword GenerateOneTimePasswordFor(User user);
17+
18+
OneTimePassword GeneratePasswordResetTokenFor(User user);
1719
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ public required IReadOnlyList<byte> Salt
2727
init => salt = [..value];
2828
}
2929

30-
public required DateTimeOffset ExpiresAt { get; init; }
30+
// TODO: remove setter
31+
public required DateTimeOffset ExpiresAt { get; set; }
3132

3233
public Guid UserId { get; init; }
34+
35+
public void Invalidate(TimeProvider timeProvider)
36+
=> ExpiresAt = timeProvider.GetUtcNow();
3337
}

0 commit comments

Comments
 (0)