Files
2026-06-24 10:53:13 -07:00

238 lines
10 KiB
C#

using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Services;
public class InvitationService : IInvitationService
{
/// <summary>Lifetime of a freshly issued invitation link.</summary>
private const int InvitationLifetimeDays = 7;
private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db;
private readonly ITokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IAuditLogger _audit;
private readonly CurrentUserAccessor _currentUser;
public InvitationService(
UserManager<AppUser> userManager,
AppDbContext db,
ITokenService tokenService,
IEmailService emailService,
IAuditLogger audit,
CurrentUserAccessor currentUser)
{
_userManager = userManager;
_db = db;
_tokenService = tokenService;
_emailService = emailService;
_audit = audit;
_currentUser = currentUser;
}
// ── Create ───────────────────────────────────────────────────────────────
public async Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request)
{
var member = await _db.Members.FindAsync(request.MemberId)
?? throw new InvalidOperationException($"Member {request.MemberId} does not exist.");
var email = (request.Email ?? member.Email)?.Trim();
if (string.IsNullOrWhiteSpace(email))
throw new InvalidOperationException(
"This member has no email address. Add an email before creating an invitation.");
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId);
if (user is null)
user = await CreateAccountAsync(member, email, request.Roles);
var now = DateTime.UtcNow;
// Re-issue: revoke any prior unused invitation so only one link is ever live.
var existing = await _db.UserInvitations
.Where(invitation => invitation.UserId == user.Id
&& invitation.UsedAt == null
&& invitation.RevokedAt == null)
.ToListAsync();
foreach (var invitation in existing)
invitation.RevokedAt = now;
var rawToken = GenerateRawToken();
var expiresAt = now.AddDays(InvitationLifetimeDays);
_db.UserInvitations.Add(new UserInvitation
{
UserId = user.Id,
TokenHash = _tokenService.HashToken(rawToken),
ExpiresAt = expiresAt,
CreatedAt = now,
CreatedBy = _currentUser.UserIdOrSystem,
});
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Invitation link created for {user.Email}");
return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt };
}
/// <summary>Creates a passwordless login account linked to the member; mirrors UserManagementService.</summary>
private async Task<AppUser> CreateAccountAsync(Member member, string email, List<string>? roles)
{
if (await _userManager.FindByEmailAsync(email) is not null)
throw new InvalidOperationException($"Email '{email}' is already in use by another account.");
var user = new AppUser
{
UserName = email,
Email = email,
EmailConfirmed = true,
MemberId = member.Id,
LanguagePreference = member.LanguagePreference,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
// No-password overload: the member sets their own password via the invitation link.
var result = await _userManager.CreateAsync(user);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(error => error.Description)));
var rolesToAssign = roles is { Count: > 0 } ? roles : new List<string> { "member" };
await _userManager.AddToRolesAsync(user, rolesToAssign);
return user;
}
// ── Validate ───────────────────────────────────────────────────────────────
public async Task<ValidateInvitationResult> ValidateAsync(string rawToken)
{
var invitation = await FindByRawTokenAsync(rawToken);
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
return new ValidateInvitationResult { Valid = false, Expired = false };
if (invitation.IsExpired)
return new ValidateInvitationResult { Valid = false, Expired = true };
var user = await _userManager.FindByIdAsync(invitation.UserId);
return new ValidateInvitationResult
{
Valid = true,
Expired = false,
Email = user?.Email,
MemberName = await ResolveMemberNameAsync(user),
};
}
// ── Accept ───────────────────────────────────────────────────────────────
public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword)
{
var invitation = await FindByRawTokenAsync(rawToken);
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
return (null, "This invitation link is invalid or has already been used.");
if (invitation.IsExpired)
return (null, "This invitation link has expired. Please ask for a new one.");
var user = await _userManager.FindByIdAsync(invitation.UserId);
if (user is null)
return (null, "The account for this invitation no longer exists.");
// Set the password — works whether or not one already exists, and enforces the policy.
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword);
if (!result.Succeeded)
return (null, string.Join(" ", result.Errors.Select(error => error.Description)));
invitation.UsedAt = DateTime.UtcNow;
user.EmailConfirmed = true;
user.IsActive = true;
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Invitation accepted — password set for {user.Email}",
userId: user.Id, userEmail: user.Email);
return (user, null);
}
// ── Send email ───────────────────────────────────────────────────────────
public async Task SendEmailAsync(int memberId, string link)
{
var member = await _db.Members.FindAsync(memberId)
?? throw new InvalidOperationException($"Member {memberId} does not exist.");
var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en);
var safeLink = WebUtility.HtmlEncode(link);
var subject = "Your River Of Life Christian Church account invitation";
var htmlBody =
$"<p>Hi {name},</p>" +
"<p>You've been invited to set up your account for the River Of Life Christian Church portal.</p>" +
$"<p>Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.</p>" +
$"<p><a href=\"{safeLink}\">Set your password and sign in</a></p>" +
"<p>If the button doesn't work, copy and paste this address into your browser:</p>" +
$"<p>{safeLink}</p>";
var result = await _emailService.SendAsync(new EmailMessage(
MemberIds: new[] { memberId },
Addresses: Array.Empty<string>(),
Subject: subject,
HtmlBody: htmlBody));
if (result.SentCount == 0)
throw new InvalidOperationException(
result.Failures.Count > 0
? $"Failed to send email: {result.Failures[0].Error}"
: "No email address on file for this member.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private Task<UserInvitation?> FindByRawTokenAsync(string rawToken)
{
if (string.IsNullOrWhiteSpace(rawToken))
return Task.FromResult<UserInvitation?>(null);
var hash = _tokenService.HashToken(rawToken);
return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash);
}
private async Task<string?> ResolveMemberNameAsync(AppUser? user)
{
if (user?.MemberId is not int memberId)
return null;
return await _db.Members
.Where(member => member.Id == memberId)
.Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en)
.FirstOrDefaultAsync();
}
/// <summary>32 cryptographically-random bytes as a URL-safe base64 string.</summary>
private static string GenerateRawToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}