238 lines
10 KiB
C#
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('=');
|
|
}
|
|
}
|