Skip to content
This repository was archived by the owner on Jan 23, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public async Task ActivateUser(ActivateUserRequest request)
public async Task ChangePassword(ChangePasswordRequest request)
=> await Post("/api/users/change-password", request);

public async Task ForgotPassword(ForgotPasswordRequest request)
=> await Post("/api/users/forgot-password", request);

public async Task ResetPassword(ResetPasswordRequest request)
=> await Post("/api/users/reset-password", request);

public async Task<IReadOnlyList<AccessTokenResponse>?> GetAccessTokens()
=> await Get<IReadOnlyList<AccessTokenResponse>>("/api/users/access-tokens");

Expand Down
58 changes: 58 additions & 0 deletions Pyro.Api/Pyro.ApiTests/Tests/ResetPasswordTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.

using Bogus;
using Pyro.ApiTests.Clients;
using Pyro.Contracts.Requests.Identity;

namespace Pyro.ApiTests.Tests;

public class ResetPasswordTests
{
private Faker faker;
private IdentityClient client;
private string login;
private string email;

[OneTimeSetUp]
public async Task SetUp()
{
faker = new Faker();
client = new IdentityClient(Api.BaseAddress);
await client.Login();

login = faker.Random.Hash(32);
email = faker.Internet.Email();
var createUserRequest = new CreateUserRequest(login, email, ["User"]);
var user = await client.CreateUser(createUserRequest);
Assert.That(user, Is.Not.Null);

var message = Api.Smtp.WaitForMessage(x => x.To == email) ??
throw new InvalidOperationException("The message was not found.");
var token = message.GetToken();
var password = faker.Random.Hash();
var activateUserRequest = new ActivateUserRequest(token, password);
await client.ActivateUser(activateUserRequest);
}

[OneTimeTearDown]
public void TearDown()
{
client.Dispose();
}

[Test]
public async Task Tests()
{
await client.ForgotPassword(new ForgotPasswordRequest(login));
var message = Api.Smtp.WaitForMessage(x => x.To == email) ??
throw new InvalidOperationException("The message was not found.");
var token = message.GetToken();

var password = faker.Random.Hash();
await client.ResetPassword(new ResetPasswordRequest(token, password));

await client.Logout();
await client.Login(login, password);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.

namespace Pyro.Contracts.Requests.Identity;

public record ForgotPasswordRequest(string Login);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.

namespace Pyro.Contracts.Requests.Identity;

public record ResetPasswordRequest(string Token, string Password);
124 changes: 124 additions & 0 deletions Pyro.Api/Pyro.Domain.Identity.UnitTests/Models/UserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,130 @@ public void ChangePassword()
Assert.That(user.Password, Is.Not.EqualTo(passwordHash));
Assert.That(user.Salt, Is.Not.EqualTo(salt));
Assert.That(user.PasswordExpiresAt, Is.EqualTo(currentDateTime.AddDays(90)));
Assert.That(user.AuthenticationTokens, Is.Empty);
Assert.That(user.AccessTokens, Is.Empty);
Assert.That(user.OneTimePasswords, Is.Empty);
});
}

[Test]
public void ResetPasswordWithTokenIsNull()
{
var user = new User
{
Login = "test",
DisplayName = "test",
Email = "test@localhost.local",
};

var timeProvider = Substitute.For<TimeProvider>();
var passwordService = Substitute.For<IPasswordService>();

Assert.Throws<ArgumentNullException>(() =>
user.ResetPassword(timeProvider, passwordService, null!, string.Empty));
}

[Test]
public void ResetPasswordWithExpiredToken()
{
var currentTime = DateTimeOffset.UtcNow;

var user = new User
{
Login = "test",
DisplayName = "test",
Email = "test@localhost.local",
};
var oneTimePassword = new OneTimePassword
{
Token = "token",
ExpiresAt = currentTime.AddDays(-1),
Purpose = OneTimePasswordPurpose.PasswordReset,
User = user,
};
user.AddOneTimePassword(oneTimePassword);

var timeProvider = Substitute.For<TimeProvider>();
timeProvider
.GetUtcNow()
.Returns(currentTime);

var passwordService = Substitute.For<IPasswordService>();

Assert.Throws<DomainException>(() =>
user.ResetPassword(timeProvider, passwordService, oneTimePassword, string.Empty));
}

[Test]
public void ResetPasswordWithLockedUser()
{
var currentTime = DateTimeOffset.UtcNow;

var user = new User
{
Login = "test",
DisplayName = "test",
Email = "test@localhost.local",
};
var oneTimePassword = new OneTimePassword
{
Token = "token",
ExpiresAt = currentTime.AddDays(1),
Purpose = OneTimePasswordPurpose.PasswordReset,
User = user,
};
user.AddOneTimePassword(oneTimePassword);
user.Lock();

var timeProvider = Substitute.For<TimeProvider>();
timeProvider
.GetUtcNow()
.Returns(currentTime);

var passwordService = Substitute.For<IPasswordService>();

Assert.Throws<DomainException>(() =>
user.ResetPassword(timeProvider, passwordService, oneTimePassword, string.Empty));
}

[Test]
public void ResetPassword()
{
var currentTime = DateTimeOffset.UtcNow;
const string password = "12345678";

var user = new User
{
Login = "test",
DisplayName = "test",
Email = "test@localhost.local",
};
var oneTimePassword = new OneTimePassword
{
Token = "token",
ExpiresAt = currentTime.AddDays(1),
Purpose = OneTimePasswordPurpose.PasswordReset,
User = user,
};
user.AddOneTimePassword(oneTimePassword);

var timeProvider = Substitute.For<TimeProvider>();
timeProvider
.GetUtcNow()
.Returns(currentTime);

var passwordService = Substitute.For<IPasswordService>();
passwordService
.GeneratePasswordHash(password)
.Returns((new byte[64], new byte[16]));

user.ResetPassword(timeProvider, passwordService, oneTimePassword, password);

Assert.Multiple(() =>
{
Assert.That(user.Password, Is.Not.Null);
Assert.That(user.Salt, Is.Not.Null);
Assert.That(user.OneTimePasswords, Is.Empty);
});
}
}
80 changes: 80 additions & 0 deletions Pyro.Api/Pyro.Domain.Identity/Commands/ForgotPassword.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.

using System.Text.Encodings.Web;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Options;
using Pyro.Domain.Shared;
using Pyro.Domain.Shared.Email;
using Pyro.Domain.Shared.Exceptions;

namespace Pyro.Domain.Identity.Commands;

public record ForgotPassword(string Login) : IRequest;

public class ForgotPasswordValidator : AbstractValidator<ForgotPassword>
{
public ForgotPasswordValidator()
{
RuleFor(x => x.Login)
.NotEmpty()
.MaximumLength(32)
.Matches(@"^[a-zA-Z0-9_\-]*$");
}
}

public class ForgotPasswordHandler : IRequestHandler<ForgotPassword>
{
private readonly IUserRepository repository;
private readonly IPasswordService passwordService;
private readonly IEmailService emailService;
private readonly EmailServiceOptions emailServiceOptions;
private readonly ServiceOptions serviceOptions;
private readonly UrlEncoder urlEncoder;

public ForgotPasswordHandler(
IUserRepository repository,
IPasswordService passwordService,
IEmailService emailService,
IOptions<EmailServiceOptions> emailServiceOptions,
IOptions<ServiceOptions> serviceOptions,
UrlEncoder urlEncoder)
{
this.repository = repository;
this.passwordService = passwordService;
this.emailService = emailService;
this.emailServiceOptions = emailServiceOptions.Value;
this.serviceOptions = serviceOptions.Value;
this.urlEncoder = urlEncoder;
}

public async Task Handle(ForgotPassword request, CancellationToken cancellationToken = default)
{
var user = await repository.GetUserByLogin(request.Login, cancellationToken) ??
throw new NotFoundException($"User (Login: {request.Login}) not found");

var oneTimePassword = passwordService.GeneratePasswordResetTokenFor(user);

var inviteLink = new UriBuilder(serviceOptions.PublicUrl!)
{
Path = "/reset-password",
Query = $"token={urlEncoder.Encode(oneTimePassword.Token)}",
}
.Uri
.ToString();
var body = $"""
Hello {user.DisplayName}!

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.
Comment thread
sys27 marked this conversation as resolved.

Thank you, Pyro.
""";
var message = new EmailMessage(
new EmailAddress("No Reply", $"no-reply@{emailServiceOptions.Domain}"),
new EmailAddress(user.DisplayName, user.Email),
"Reset your password",
body);
await emailService.SendEmail(message, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task Handle(NotifyExpiringPasswords request, CancellationToken canc
await foreach (var user in expiringUsers)
{
var body = $"""
Hello {user.DisplayName},
Hello {user.DisplayName}!

Your password is about to expire. The expiration date is {user.PasswordExpiresAt:yyyy-MM-dd}.
Please change your password to avoid any issues.
Expand Down
58 changes: 58 additions & 0 deletions Pyro.Api/Pyro.Domain.Identity/Commands/ResetPassword.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) Dmytro Kyshchenko. All rights reserved.
// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information.

using FluentValidation;
using MediatR;
using Microsoft.Extensions.Logging;

namespace Pyro.Domain.Identity.Commands;

public record ResetPassword(string Token, string Password) : IRequest;

public class ResetPasswordValidator : AbstractValidator<ResetPassword>
{
public ResetPasswordValidator()
{
RuleFor(x => x.Token)
.NotEmpty();

RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8);
}
}

public class ResetPasswordHandler : IRequestHandler<ResetPassword>
{
private readonly ILogger<ResetPasswordHandler> logger;
private readonly IUserRepository repository;
private readonly TimeProvider timeProvider;
private readonly IPasswordService passwordService;

public ResetPasswordHandler(
ILogger<ResetPasswordHandler> logger,
IUserRepository repository,
TimeProvider timeProvider,
IPasswordService passwordService)
{
this.logger = logger;
this.repository = repository;
this.timeProvider = timeProvider;
this.passwordService = passwordService;
}

public async Task Handle(ResetPassword request, CancellationToken cancellationToken = default)
{
var user = await repository.GetUserByToken(request.Token, cancellationToken);
var otp = user?.GetOneTimePassword(request.Token);
if (user is null || otp is null)
{
logger.LogError("The token '{Token}' is invalid", request.Token);
return;
}

user.ResetPassword(timeProvider, passwordService, otp, request.Password);

logger.LogInformation("User '{Login}' password reset", user.Login);
}
}
2 changes: 2 additions & 0 deletions Pyro.Api/Pyro.Domain.Identity/IPasswordService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface IPasswordService
string GeneratePassword();

OneTimePassword GenerateOneTimePasswordFor(User user);

OneTimePassword GeneratePasswordResetTokenFor(User user);
}
6 changes: 5 additions & 1 deletion Pyro.Api/Pyro.Domain.Identity/Models/AccessToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ public required IReadOnlyList<byte> Salt
init => salt = [..value];
}

public required DateTimeOffset ExpiresAt { get; init; }
// TODO: remove setter
public required DateTimeOffset ExpiresAt { get; set; }

public Guid UserId { get; init; }

public void Invalidate(TimeProvider timeProvider)
=> ExpiresAt = timeProvider.GetUtcNow();
}
Loading