feat(auth): add configurable email verification enforcement
Auth:RequireEmailVerification (default off) gates whether local register requires confirming email before login. OAuth new-user paths are untouched -- Google/Microsoft already assert a verified email. Adds verify-email and resend-verification-email endpoints, mirroring the existing reset-password enumeration-avoidance and rate-limiting patterns, plus a login-embedded resend affordance and a verify-email landing page on the frontend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ public sealed class AuthController : ControllerBase
|
||||
var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim());
|
||||
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
|
||||
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
|
||||
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -54,6 +55,7 @@ public sealed class AuthController : ControllerBase
|
||||
microsoftEnabled,
|
||||
localEnabled = true,
|
||||
allowRegistration,
|
||||
requireEmailVerification,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +115,14 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
await _users.ResetAccessFailedCountAsync(user);
|
||||
|
||||
// Same enumeration-avoidance discipline as the password-check branch above: this only
|
||||
// runs once the password is already confirmed correct, so it can never be used to probe
|
||||
// whether an email is registered.
|
||||
if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
|
||||
}
|
||||
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
|
||||
}
|
||||
|
||||
@@ -133,13 +143,28 @@ public sealed class AuthController : ControllerBase
|
||||
var existing = await _users.FindByEmailAsync(email);
|
||||
if (existing is not null) return BadRequest("User already exists.");
|
||||
|
||||
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true };
|
||||
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
|
||||
var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification };
|
||||
var res = await _users.CreateAsync(user, password);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
if (requireEmailVerification)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendVerificationEmailAsync(user, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ponytail: don't fail registration over a flaky email send -- the account is
|
||||
// created either way, the user can request a fresh link via resend-verification-email.
|
||||
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
|
||||
}
|
||||
}
|
||||
|
||||
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
|
||||
}
|
||||
|
||||
@@ -662,6 +687,82 @@ public sealed class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record VerifyEmailRequest(string UserId, string Token);
|
||||
|
||||
[HttpPost("verify-email")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> VerifyEmail([FromBody] VerifyEmailRequest request)
|
||||
{
|
||||
var userId = (request.UserId ?? string.Empty).Trim();
|
||||
var token = request.Token ?? string.Empty;
|
||||
|
||||
if (userId.Length == 0) return BadRequest("UserId is required.");
|
||||
if (token.Length == 0) return BadRequest("Token is required.");
|
||||
|
||||
var user = await _users.FindByIdAsync(userId);
|
||||
if (user is null) return BadRequest("Invalid or expired link.");
|
||||
|
||||
var res = await _users.ConfirmEmailAsync(user, token);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
return BadRequest("Invalid or expired link.");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record ResendVerificationEmailRequest(string Email);
|
||||
|
||||
[HttpPost("resend-verification-email")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("auth-email")]
|
||||
public async Task<IActionResult> ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var email = (request.Email ?? string.Empty).Trim();
|
||||
if (email.Length == 0) return NoContent();
|
||||
|
||||
// Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually
|
||||
// send when there's a matching local account that still needs verifying.
|
||||
var user = await _users.FindByEmailAsync(email);
|
||||
if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user))
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendVerificationEmailAsync(user, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
|
||||
return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later.");
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
|
||||
|
||||
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = $"{Request.Scheme}://{Request.Host}";
|
||||
}
|
||||
|
||||
var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}";
|
||||
|
||||
await _email.SendAsync(
|
||||
user.Email!,
|
||||
"Verify your email",
|
||||
$"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.",
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
private IActionResult EmailDeliveryUnavailable(string detail)
|
||||
{
|
||||
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail);
|
||||
|
||||
Reference in New Issue
Block a user