Skip to content
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
2 changes: 1 addition & 1 deletion src/AdminConsole/EventLog/Loggers/IEventLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public static void LogAdminInvalidInviteUsedEvent(
logger.LogEvent(
new(invite.ToEmail,
EventType.AdminInvalidInviteUsed,
"Expired invite used.",
"Invalid/expired invite used.",
Severity.Warning,
invite.ToEmail,
invite.TargetOrgId,
Expand Down
9 changes: 9 additions & 0 deletions src/AdminConsole/Helpers/InviteExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Passwordless.AdminConsole.Identity;

namespace Passwordless.AdminConsole.Helpers;

public static class InviteExtensions
{
public static bool IsExpired(this Invite invite, TimeProvider timeProvider) =>
invite.ExpireAt < timeProvider.GetUtcNow().UtcDateTime;
}
4 changes: 2 additions & 2 deletions src/AdminConsole/Pages/Organization/Join.cshtml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@page
@using Microsoft.AspNetCore.Authorization
@model Passwordless.AdminConsole.Pages.Organization.Join
@model Join
@attribute [AllowAnonymous]
@{
ViewBag.Title = "Join an organization";
Expand Down Expand Up @@ -30,7 +30,7 @@ else
<div>
<label asp-for="Form.Email" class="block text-sm font-medium leading-6 text-gray-900">Admin Email</label>
<div class="mt-2">
<input placeholder="pjfry@@example.org" type="text" asp-for="Form.Email" class="text-input">
<input placeholder="pjfry@@example.org" type="email" asp-for="Form.Email" class="text-input" readonly>
<span asp-validation-for="Form.Email"></span>
</div>
</div>
Expand Down
27 changes: 17 additions & 10 deletions src/AdminConsole/Pages/Organization/Join.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,12 @@ public Join(IInvitationService invitationService,
_timeProvider = timeProvider;
}

public Invite Invite { get; set; }
public Invite? Invite { get; set; }
public JoinForm Form { get; set; }

public async Task<IActionResult> OnGet(string code)
{
if (User.Identity.IsAuthenticated)
if (User.Identity?.IsAuthenticated == true)
{
return RedirectToPage("JoinBusy", new { code = code });
}
Expand All @@ -52,11 +52,12 @@ public async Task<IActionResult> OnGet(string code)
Invite = null;
}

if (Invite == null)
if (Invite is null)
{
ModelState.AddModelError("bad-invite", "Invite is invalid or expired");
return Page();
}

// todo: We could add a check if the email is busy here and if so show a message.

Form = new JoinForm { Code = code, Email = Invite.ToEmail };
Expand All @@ -77,24 +78,30 @@ public async Task<IActionResult> OnPost(JoinForm form)
return Page();
}

Invite invite = await _invitationService.GetInviteFromRawCodeAsync(form.Code);
var ok = await _invitationService.ConsumeInviteAsync(invite);
var invite = await _invitationService.GetInviteFromRawCodeAsync(form.Code);

if (!ok)
if (invite is null)
{
ModelState.AddModelError("bad-invite", "Invite is invalid or expired");
return Page();
}

if (!await _invitationService.ConsumeInviteAsync(invite))
{
_eventLogger.LogAdminInvalidInviteUsedEvent(invite, _timeProvider.GetUtcNow().UtcDateTime);
ModelState.AddModelError("bad-invite", "Invite is invalid or expired");
Comment thread
jrmccannon marked this conversation as resolved.
return Page();
}

ConsoleAdmin? existingUser = await _userManager.FindByEmailAsync(form.Email);
ConsoleAdmin? existingUser = await _userManager.FindByEmailAsync(invite.ToEmail);

if (existingUser == null)
{
// create account
var user = new ConsoleAdmin
{
UserName = form.Email,
Email = form.Email,
UserName = invite.ToEmail,
Email = invite.ToEmail,
OrganizationId = invite.TargetOrgId,
Name = form.Name
};
Expand All @@ -108,7 +115,7 @@ public async Task<IActionResult> OnPost(JoinForm form)
}
else
{
await _mailService.SendEmailIsAlreadyInUseAsync(existingUser.Email);
await _mailService.SendEmailIsAlreadyInUseAsync(existingUser.Email!);
}

return Redirect("/Organization/Verify");
Expand Down
3 changes: 2 additions & 1 deletion src/AdminConsole/Services/IInvitationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public interface IInvitationService
Task SendInviteAsync(string toEmail, int targetOrgId, string targetOrgName, string fromEmail, string fromName);
Task<List<Invite>> GetInvitesAsync(int orgId);
Task CancelInviteAsync(Invite inviteToCancel);
Task<Invite> GetInviteFromRawCodeAsync(string code);
Task<Invite?> GetInviteFromRawCodeAsync(string code);
Task<bool> RemoveExpiredInviteAsync(Invite invite);
Task<bool> ConsumeInviteAsync(Invite inv);
}
37 changes: 24 additions & 13 deletions src/AdminConsole/Services/InvitationService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Passwordless.AdminConsole.Db;
using Passwordless.AdminConsole.Helpers;
using Passwordless.AdminConsole.Identity;
using Passwordless.AdminConsole.Services.Mail;
using Passwordless.Common.Extensions;
Expand All @@ -12,12 +13,18 @@ public class InvitationService : IInvitationService
private readonly ConsoleDbContext _db;
private readonly IMailService _mailService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly TimeProvider _timeProvider;

public InvitationService(ConsoleDbContext db, IMailService mailService, IHttpContextAccessor httpContextAccessor)
public InvitationService(
ConsoleDbContext db,
IMailService mailService,
IHttpContextAccessor httpContextAccessor,
TimeProvider timeProvider)
{
_db = db;
_mailService = mailService;
_httpContextAccessor = httpContextAccessor;
_timeProvider = timeProvider;
}

public async Task SendInviteAsync(string toEmail, int targetOrgId, string targetOrgName, string fromEmail, string fromName)
Expand All @@ -38,7 +45,7 @@ public async Task SendInviteAsync(string toEmail, int targetOrgId, string target
var hashedCode = HashCode(code);

inv.HashedCode = hashedCode;
inv.CreatedAt = DateTime.UtcNow;
inv.CreatedAt = _timeProvider.GetUtcNow().UtcDateTime;
inv.ExpireAt = inv.CreatedAt.AddDays(7);

// store
Expand Down Expand Up @@ -72,29 +79,33 @@ public async Task CancelInviteAsync(Invite inviteToCancel)
await _db.Invites.Where(i => i.HashedCode == inviteToCancel.HashedCode).ExecuteDeleteAsync();
}

public async Task<Invite> GetInviteFromRawCodeAsync(string code)
public async Task<Invite?> GetInviteFromRawCodeAsync(string code)
{
var hashed = HashCode(code);
return await _db.Invites.Where(i => i.HashedCode == hashed).FirstOrDefaultAsync();
return await _db.Invites
.Where(i => i.HashedCode == hashed)
.FirstOrDefaultAsync();
}

public async Task<bool> ConsumeInviteAsync(Invite inv)
public async Task<bool> RemoveExpiredInviteAsync(Invite invite)
{
if (inv == null)
if (!invite.IsExpired(_timeProvider))
{
return false;
}

// check if expired
if (inv.ExpireAt < DateTime.UtcNow)
{
return false;
}
_db.Invites.Remove(invite);
await _db.SaveChangesAsync();
return true;
}

public async Task<bool> ConsumeInviteAsync(Invite inv)
{
var isValid = !inv.IsExpired(_timeProvider);

// delete it
_db.Invites.Remove(inv);
await _db.SaveChangesAsync();

return true;
return isValid;
}
}
3 changes: 2 additions & 1 deletion tests/AdminConsole.Tests/AdminConsole.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<PackageReference Include="bunit" Version="2.5.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3"/>
Expand All @@ -22,4 +23,4 @@
<ProjectReference Include="..\..\src\AdminConsole\AdminConsole.csproj" />
</ItemGroup>

</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ public class FakeMagicLinkSignInManager : MagicLinkSignInManager<ConsoleAdmin>
public const string SuccessToken = "successtoken";
public const string FailToken = "failtoken";

public FakeMagicLinkSignInManager()
public List<string> SentEmails { get; } = new();

public FakeMagicLinkSignInManager() : this(new FakeUserManager()) { }

public FakeMagicLinkSignInManager(UserManager<ConsoleAdmin> userManager)
: base(
new Mock<IPasswordlessClient>().Object,
new Mock<IMagicLinkBuilder>().Object,
new FakeUserManager(),
userManager,
new Mock<IHttpContextAccessor>().Object,
new Mock<IUserClaimsPrincipalFactory<ConsoleAdmin>>().Object,
new Mock<IOptions<IdentityOptions>>().Object,
Expand All @@ -41,4 +45,10 @@ public override async Task<SignInResult> PasswordlessSignInAsync(string token, b
return SignInResult.Failed;
}
}

public override Task SendEmailForSignInAsync(string email, string? returnUrl)
{
SentEmails.Add(email);
return Task.CompletedTask;
}
}
16 changes: 16 additions & 0 deletions tests/AdminConsole.Tests/DataFactory/FakeUserManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ namespace Passwordless.AdminConsole.Tests.DataFactory;

public class FakeUserManager : UserManager<ConsoleAdmin>
{
public List<ConsoleAdmin> CreatedUsers { get; } = new();
private readonly Dictionary<string, ConsoleAdmin> _byEmail = new(StringComparer.OrdinalIgnoreCase);

public FakeUserManager()
: base(new Mock<IUserStore<ConsoleAdmin>>().Object,
new Mock<IOptions<IdentityOptions>>().Object,
Expand All @@ -20,6 +23,19 @@ public FakeUserManager()
new Mock<ILogger<UserManager<ConsoleAdmin>>>().Object)
{ }

public override Task<ConsoleAdmin?> FindByEmailAsync(string email) =>
Task.FromResult(_byEmail.TryGetValue(email, out var u) ? u : null);

public override Task<IdentityResult> CreateAsync(ConsoleAdmin user)
{
CreatedUsers.Add(user);
if (!string.IsNullOrEmpty(user.Email))
{
_byEmail[user.Email] = user;
}
return Task.FromResult(IdentityResult.Success);
}

public override Task<IdentityResult> CreateAsync(ConsoleAdmin user, string password)
{
return Task.FromResult(IdentityResult.Success);
Expand Down
Loading
Loading