Add init link.

This commit is contained in:
Chris Chen
2026-06-24 10:53:13 -07:00
parent e88ea7917f
commit e53cea7a82
20 changed files with 971 additions and 11 deletions
+44 -1
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
using ROLAC.API.Services;
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService;
private readonly IInvitationService _invitations;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env;
public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
IAuthService authService, IInvitationService invitations,
UserManager<AppUser> userManager, IWebHostEnvironment env)
{
_authService = authService;
_invitations = invitations;
_userManager = userManager;
_env = env;
}
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
return NoContent();
}
// -------------------------------------------------------------------------
// GET /api/auth/invitation/validate?token=...
// -------------------------------------------------------------------------
/// <summary>
/// Checks whether an invitation token can still be used. Anonymous so the public
/// "set your password" page can decide what to show before the member types anything.
/// </summary>
[HttpGet("invitation/validate")]
[AllowAnonymous]
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
=> Ok(await _invitations.ValidateAsync(token));
// -------------------------------------------------------------------------
// POST /api/auth/accept-invitation
// -------------------------------------------------------------------------
/// <summary>
/// Consumes an invitation: sets the account password and, on success, logs the member in
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
/// </summary>
[HttpPost("accept-invitation")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
{
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
if (user is null)
return BadRequest(new { message = error });
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var device = Request.Headers.UserAgent.FirstOrDefault();
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
SetRefreshCookie(raw);
return Ok(response);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for generating and e-mailing first-login invitation links.
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
/// refresh-token cookie and stay anonymous.
/// </summary>
[ApiController]
[Route("api/invitations")]
[Authorize]
public class InvitationsController : ControllerBase
{
private readonly IInvitationService _invitations;
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
{
try { return Ok(await _invitations.CreateAsync(request)); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
[HttpPost("send")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
{
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Invitations;
/// <summary>
/// Admin request to generate a first-login invitation link for a member. If the member has no
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
/// </summary>
public class CreateInvitationRequest
{
[Required]
public int MemberId { get; set; }
/// <summary>Optional override for the login email when the member has none on file.</summary>
public string? Email { get; set; }
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
public List<string>? Roles { get; set; }
}
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
public class CreateInvitationResult
{
public string Token { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
}
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
public class SendInvitationRequest
{
[Required]
public int MemberId { get; set; }
[Required]
public string Link { get; set; } = null!;
}
/// <summary>Public result describing whether an invitation token can still be used.</summary>
public class ValidateInvitationResult
{
public bool Valid { get; set; }
public bool Expired { get; set; }
public string? MemberName { get; set; }
public string? Email { get; set; }
}
/// <summary>Public request to consume an invitation and set the account password.</summary>
public class AcceptInvitationRequest
{
[Required]
public string Token { get; set; } = null!;
[Required]
[StringLength(128, MinimumLength = 8)]
public string NewPassword { get; set; } = null!;
}
+19 -1
View File
@@ -10,7 +10,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
public DbSet<Member> Members => Set<Member>();
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
@@ -56,6 +57,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Ignore(e => e.IsActive);
});
// ── UserInvitation (single-use, expiring first-login links) ─────────
builder.Entity<UserInvitation>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsUsed);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
builder.Entity<AppUser>(entity =>
{
+4 -1
View File
@@ -48,6 +48,8 @@ public static class AuditActions
public const string PasswordChanged = "PasswordChanged";
public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged";
public const string InvitationCreated = "InvitationCreated";
public const string InvitationAccepted = "InvitationAccepted";
public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved";
@@ -56,7 +58,8 @@ public static class AuditActions
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
PasswordChanged, UserDeactivated, PermissionChanged,
InvitationCreated, InvitationAccepted, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized,
];
}
+35
View File
@@ -0,0 +1,35 @@
namespace ROLAC.API.Entities;
/// <summary>
/// A single-use, expiring invitation that lets a member set their own password and log in for
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
/// </summary>
public class UserInvitation
{
public int Id { get; set; }
public string UserId { get; set; } = null!;
public AppUser User { get; set; } = null!;
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
public string TokenHash { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Id of the admin who generated the link.</summary>
public string CreatedBy { get; set; } = null!;
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
public DateTime? UsedAt { get; set; }
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
public DateTime? RevokedAt { get; set; }
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsUsed => UsedAt.HasValue;
public bool IsRevoked => RevokedAt.HasValue;
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
}
@@ -1803,6 +1803,51 @@ namespace ROLAC.API.Migrations
b.ToTable("SiteSettings");
});
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("UserInvitations");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
@@ -2024,6 +2069,17 @@ namespace ROLAC.API.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
+1
View File
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IMemberService, MemberService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddScoped<IInvitationService, InvitationService>();
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
builder.Services.AddScoped<IGivingService, GivingService>();
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
+16 -6
View File
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
throw new UnauthorizedAccessException("Account is inactive.");
}
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return await IssueSessionAsync(user, ipAddress, deviceInfo);
}
// -------------------------------------------------------------------------
// Issue session (shared by login and passwordless flows like invitations)
// -------------------------------------------------------------------------
public async Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
AppUser user, string? ipAddress = null, string? deviceInfo = null)
{
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles);
var rawRefresh = _tokenService.GenerateRefreshToken();
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
}
+10
View File
@@ -25,6 +25,16 @@ public interface IAuthService
string rawRefreshToken,
string? ipAddress = null);
/// <summary>
/// Issues a fresh access token + refresh token for an already-verified user (no password
/// check). Stores the refresh token and returns the raw value for the caller to put in the
/// HttpOnly cookie. Used by passwordless flows such as accepting an invitation link.
/// </summary>
Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
AppUser user,
string? ipAddress = null,
string? deviceInfo = null);
/// <summary>
/// Revokes the refresh token identified by its raw value.
/// Silently succeeds if the token is not found.
@@ -0,0 +1,28 @@
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public interface IInvitationService
{
/// <summary>
/// Generates a single-use, 7-day invitation link for a member. Auto-creates the member's
/// login account (no password) when none exists, and revokes any prior unused invitation for
/// that account. Returns the raw token (shown once) and its expiry.
/// Throws <see cref="InvalidOperationException"/> when the member is missing or has no email.
/// </summary>
Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request);
/// <summary>Checks whether a raw token is still usable, without mutating it.</summary>
Task<ValidateInvitationResult> ValidateAsync(string rawToken);
/// <summary>
/// Consumes an invitation: validates the token, sets the account password (enforcing the
/// Identity policy), and marks the invitation used. Returns the account on success, or an
/// error message describing why it failed (invalid/expired/used token or a policy violation).
/// </summary>
Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword);
/// <summary>E-mails an already-generated invitation link to the member via IEmailService.</summary>
Task SendEmailAsync(int memberId, string link);
}
+237
View File
@@ -0,0 +1,237 @@
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('=');
}
}