297 lines
14 KiB
C#
297 lines
14 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
using QuestionsApi.Services;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace QuestionsApi.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/Auth/external")]
|
|
public sealed class ExternalAuthController(
|
|
IEnumerable<IExternalAuthProvider> providerServices,
|
|
ExternalIdentityStore identities,
|
|
ExternalAuthTransactionStore transactions,
|
|
AuthUserStore users,
|
|
JwtTokenService tokens,
|
|
LegalPolicy legal,
|
|
IConfiguration configuration,
|
|
ILogger<ExternalAuthController> logger) : ControllerBase
|
|
{
|
|
private readonly Dictionary<string, IExternalAuthProvider> _providers = providerServices
|
|
.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
|
|
|
|
[HttpGet("providers")]
|
|
[AllowAnonymous]
|
|
public IActionResult Providers() => Ok(new
|
|
{
|
|
providers = _providers.Values.Where(value => value.IsConfigured)
|
|
.OrderBy(value => value.DisplayName)
|
|
.Select(value => new { id = value.Id, name = value.DisplayName })
|
|
});
|
|
|
|
[HttpPost("{provider}/start")]
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("external-start")]
|
|
public async Task<IActionResult> Start(string provider, ExternalAuthStartRequest? request)
|
|
{
|
|
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
|
|
var purpose = (request?.Purpose ?? "login").Trim().ToLowerInvariant();
|
|
if (purpose is not ("login" or "link")) return BadRequest(new { message = "Purpose must be login or link." });
|
|
string? userId = null;
|
|
if (purpose == "link")
|
|
{
|
|
userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (await identities.GetForUserAsync(userId, service.Id) is not null)
|
|
return Conflict(new { message = $"This account is already connected to {service.DisplayName}." });
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var lifetime = Math.Clamp(configuration.GetValue("ExternalAuth:TransactionTtlMinutes", 5), 2, 15);
|
|
var transaction = new ExternalAuthTransaction
|
|
{
|
|
Id = Base64Url(RandomNumberGenerator.GetBytes(32)),
|
|
Provider = service.Id,
|
|
Purpose = purpose,
|
|
UserId = userId,
|
|
Nonce = GoogleExternalAuthProvider.NewNonce(),
|
|
CodeVerifier = GoogleExternalAuthProvider.NewVerifier(),
|
|
CreatedUtc = now,
|
|
ExpiresUtc = now.AddMinutes(lifetime)
|
|
};
|
|
await transactions.CreateAsync(transaction);
|
|
return Ok(new
|
|
{
|
|
transactionId = transaction.Id,
|
|
provider = service.Id,
|
|
authUrl = service.BuildAuthorizationUrl(transaction),
|
|
transaction.ExpiresUtc
|
|
});
|
|
}
|
|
|
|
[HttpGet("{provider}/callback")]
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("external-complete")]
|
|
public async Task<IActionResult> Callback(
|
|
string provider,
|
|
[FromQuery] string? transactionId,
|
|
[FromQuery] string? state,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!TryProvider(provider, out var service))
|
|
return CallbackPage(false, "External sign-in", "This provider is unavailable.");
|
|
var callbackTransactionId = string.IsNullOrWhiteSpace(transactionId) ? state : transactionId;
|
|
var transaction = await ActiveTransactionAsync(callbackTransactionId ?? "");
|
|
if (transaction is null || transaction.Status != "pending" || transaction.Provider != service.Id)
|
|
return CallbackPage(false, service.DisplayName, "This sign-in request has expired. Return to RanaZe and try again.");
|
|
var claims = await service.VerifyCallbackAsync(Request.Query, transaction, cancellationToken);
|
|
if (claims is null)
|
|
{
|
|
logger.LogWarning("{Provider} external identity verification failed", service.Id);
|
|
return CallbackPage(false, service.DisplayName, $"{service.DisplayName} could not verify this sign-in.");
|
|
}
|
|
|
|
if (transaction.Purpose == "link")
|
|
{
|
|
if (string.IsNullOrWhiteSpace(transaction.UserId) ||
|
|
!await identities.TryLinkAsync(transaction.UserId, service.Id, claims) ||
|
|
!await transactions.MarkVerifiedAsync(transaction.Id, claims, "linked", transaction.UserId))
|
|
return CallbackPage(false, service.DisplayName,
|
|
$"That {service.DisplayName} identity is already connected to another account.");
|
|
return CallbackPage(true, service.DisplayName, $"{service.DisplayName} is now connected to your RanaZe account.");
|
|
}
|
|
|
|
var identity = await identities.GetAsync(service.Id, claims.Subject);
|
|
var user = identity is null ? null : await users.GetByIdAsync(identity.UserId);
|
|
var status = user is null ? "requires_account" : "complete";
|
|
if (!await transactions.MarkVerifiedAsync(transaction.Id, claims, status, user?.Id))
|
|
return CallbackPage(false, service.DisplayName, "This sign-in request has already been used.");
|
|
if (user is not null)
|
|
{
|
|
await identities.TouchAsync(identity!.Id);
|
|
user.RefreshToken = JwtTokenService.NewRefreshToken();
|
|
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
|
await users.UpdateAsync(user);
|
|
}
|
|
return CallbackPage(true, service.DisplayName,
|
|
$"{service.DisplayName} verified your identity. You can return to RanaZe.");
|
|
}
|
|
|
|
[HttpGet("{provider}/status/{transactionId}")]
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("external-poll")]
|
|
public async Task<IActionResult> Status(string provider, string transactionId)
|
|
{
|
|
Response.Headers.CacheControl = "no-store";
|
|
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
|
|
var transaction = await ActiveTransactionAsync(transactionId);
|
|
if (transaction is null || transaction.Provider != service.Id)
|
|
return NotFound(new { message = $"{service.DisplayName} sign-in expired." });
|
|
if (transaction.Status == "requires_account")
|
|
return Ok(new
|
|
{
|
|
status = transaction.Status,
|
|
provider = service.Id,
|
|
providerName = service.DisplayName,
|
|
email = transaction.EmailVerified ? transaction.ProviderEmail : null,
|
|
emailVerified = transaction.EmailVerified
|
|
});
|
|
if (transaction.Status is "pending" or "linked" or "failed")
|
|
return Ok(new { status = transaction.Status, provider = service.Id, message = transaction.Error });
|
|
if (transaction.Status != "complete" || string.IsNullOrWhiteSpace(transaction.UserId))
|
|
return Conflict(new { message = $"{service.DisplayName} sign-in is incomplete." });
|
|
|
|
var user = await users.GetByIdAsync(transaction.UserId);
|
|
if (user is null || transaction.Subject is null ||
|
|
await identities.GetAsync(service.Id, transaction.Subject) is not { } identity ||
|
|
identity.UserId != user.Id)
|
|
return Conflict(new { message = $"{service.DisplayName} account link is no longer valid." });
|
|
if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow)
|
|
{
|
|
user.RefreshToken = JwtTokenService.NewRefreshToken();
|
|
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
|
await users.UpdateAsync(user);
|
|
}
|
|
var (accessToken, jti, expiresUtc) = tokens.Generate(user);
|
|
return Ok(new
|
|
{
|
|
status = "complete",
|
|
accessToken,
|
|
refreshToken = user.RefreshToken,
|
|
user.Username,
|
|
user.Role,
|
|
jti,
|
|
exp = expiresUtc,
|
|
legal = legal.Status(user)
|
|
});
|
|
}
|
|
|
|
[HttpPost("{provider}/complete")]
|
|
[AllowAnonymous]
|
|
[EnableRateLimiting("external-complete")]
|
|
public async Task<IActionResult> Complete(string provider, CompleteExternalAuthRequest request)
|
|
{
|
|
if (!TryProvider(provider, out var service)) return NotFound(new { message = "Provider is unavailable." });
|
|
var transaction = await ActiveTransactionAsync(request.TransactionId);
|
|
if (transaction?.Status == "complete") return Ok(new { status = "complete" });
|
|
if (transaction is null || transaction.Provider != service.Id || transaction.Status != "requires_account" ||
|
|
string.IsNullOrWhiteSpace(transaction.Subject))
|
|
return BadRequest(new { message = $"{service.DisplayName} sign-in is not ready or has expired." });
|
|
if (await identities.GetAsync(service.Id, transaction.Subject) is not null)
|
|
return Conflict(new { message = $"That {service.DisplayName} identity is already connected." });
|
|
|
|
var claims = new ExternalProviderClaims(
|
|
transaction.Subject, transaction.ProviderEmail, transaction.EmailVerified);
|
|
AuthUser user;
|
|
var createdUser = false;
|
|
if (request.Action == "create")
|
|
{
|
|
if (!request.AcceptLegal)
|
|
return BadRequest(new { message = "You must accept the current legal agreements." });
|
|
var username = request.Username.Trim();
|
|
var email = service.Id == ExternalAuthProviders.Google
|
|
? AuthValidation.NormalizeEmail(transaction.ProviderEmail ?? "")
|
|
: AuthValidation.NormalizeEmail(request.Email ?? "");
|
|
if (service.Id == ExternalAuthProviders.Google && !transaction.EmailVerified)
|
|
return BadRequest(new { message = "Google did not provide a verified email address." });
|
|
if (AuthValidation.ValidateNewAccount(username, email, request.Password) is { } validation)
|
|
return BadRequest(new { message = validation });
|
|
if (await users.GetByUsernameAsync(username) is not null)
|
|
return Conflict(new { message = "Username already exists." });
|
|
if (await users.EmailExistsAsync(email))
|
|
return Conflict(new
|
|
{
|
|
message = "That email already belongs to a RanaZe account. Link the existing account with its password."
|
|
});
|
|
user = new AuthUser
|
|
{
|
|
Username = username,
|
|
Email = email,
|
|
NormalizedEmail = email,
|
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
|
Role = "USER",
|
|
CreatedUtc = DateTime.UtcNow
|
|
};
|
|
legal.Accept(user, $"{service.Id}_registration");
|
|
try
|
|
{
|
|
await users.CreateAsync(user);
|
|
createdUser = true;
|
|
}
|
|
catch (MongoWriteException exception) when (
|
|
exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
return Conflict(new { message = "That username or email is already in use." });
|
|
}
|
|
}
|
|
else if (request.Action == "link")
|
|
{
|
|
var candidates = await users.GetByLoginCandidatesAsync(request.Username);
|
|
user = candidates.FirstOrDefault(candidate =>
|
|
BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash))!;
|
|
if (user is null) return Unauthorized(new { message = "The account credentials are incorrect." });
|
|
if (await identities.GetForUserAsync(user.Id, service.Id) is not null)
|
|
return Conflict(new { message = $"That RanaZe account already has a {service.DisplayName} identity." });
|
|
}
|
|
else
|
|
{
|
|
return BadRequest(new { message = "Action must be create or link." });
|
|
}
|
|
|
|
if (!await identities.TryLinkAsync(user.Id, service.Id, claims))
|
|
{
|
|
if (createdUser) await users.DeleteAsync(user.Id);
|
|
return Conflict(new { message = $"That {service.DisplayName} identity is already connected." });
|
|
}
|
|
if (!await transactions.MarkCompleteAsync(transaction.Id, transaction.Subject, user.Id))
|
|
{
|
|
if (createdUser)
|
|
{
|
|
await identities.Collection.DeleteManyAsync(value => value.UserId == user.Id);
|
|
await users.DeleteAsync(user.Id);
|
|
}
|
|
return Conflict(new { message = "This external sign-in was already completed." });
|
|
}
|
|
return Ok(new { status = "complete" });
|
|
}
|
|
|
|
private bool TryProvider(string provider, out IExternalAuthProvider service)
|
|
{
|
|
if (_providers.TryGetValue(provider.Trim(), out service!) && service.IsConfigured) return true;
|
|
service = null!;
|
|
return false;
|
|
}
|
|
|
|
private async Task<ExternalAuthTransaction?> ActiveTransactionAsync(string id)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(id) || id.Length > 128) return null;
|
|
var transaction = await transactions.GetAsync(id);
|
|
return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null;
|
|
}
|
|
|
|
private ContentResult CallbackPage(bool success, string provider, string message)
|
|
{
|
|
Response.Headers.CacheControl = "no-store";
|
|
Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'";
|
|
Response.Headers.XFrameOptions = "DENY";
|
|
Response.Headers["Referrer-Policy"] = "no-referrer";
|
|
var color = success ? "#22c55e" : "#ef4444";
|
|
var safeProvider = System.Net.WebUtility.HtmlEncode(provider);
|
|
var safeMessage = System.Net.WebUtility.HtmlEncode(message);
|
|
var html = $$"""
|
|
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
<title>RanaZe {{safeProvider}} sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
|
|
<body><main class="card"><h1>{{safeProvider}} sign-in {{(success ? "complete" : "failed")}}</h1><p>{{safeMessage}}</p></main>
|
|
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
|
|
""";
|
|
return Content(html, "text/html; charset=utf-8");
|
|
}
|
|
|
|
private static string Base64Url(byte[] bytes) =>
|
|
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
}
|