using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using MongoDB.Driver; using QuestionsApi.Models; using QuestionsApi.Services; using System.Security.Cryptography; namespace QuestionsApi.Controllers; [ApiController] [Route("api/Auth/steam")] public sealed class SteamAuthController( AuthUserStore users, SteamAuthTransactionStore transactions, SteamOpenIdService openId, JwtTokenService tokens, IConfiguration configuration, ILogger logger, LegalPolicy legal) : ControllerBase { [HttpPost("start")] [EnableRateLimiting("steam-start")] public async Task Start() { var transactionId = Base64Url(RandomNumberGenerator.GetBytes(32)); var lifetime = Math.Clamp(configuration.GetValue("Steam:TransactionTtlMinutes", 5), 2, 15); var expiresUtc = DateTime.UtcNow.AddMinutes(lifetime); await transactions.CreateAsync(new SteamAuthTransaction { Id = transactionId, CreatedUtc = DateTime.UtcNow, ExpiresUtc = expiresUtc }); return Ok(new { transactionId, authUrl = SteamOpenIdService.BuildAuthenticationUrl(ConfiguredRealm(), ReturnUrl(transactionId)), expiresUtc }); } [HttpGet("callback")] [EnableRateLimiting("steam-complete")] public async Task Callback([FromQuery] string transactionId, CancellationToken cancellationToken) { var transaction = await ActiveTransactionAsync(transactionId); if (transaction is null || transaction.Status != "pending") return CallbackPage(false, "This Steam sign-in request has expired. Return to RanaZe and try again."); var steamId = await openId.VerifyCallbackAsync(Request.Query, ReturnUrl(transactionId), cancellationToken); if (steamId is null) { logger.LogWarning("Steam OpenID verification failed"); return CallbackPage(false, "Steam could not verify this sign-in. Return to RanaZe and try again."); } var user = await users.GetBySteamIdAsync(steamId); var status = user is null ? "requires_account" : "complete"; if (!await transactions.MarkVerifiedAsync(transactionId, steamId, status, user?.Id)) return CallbackPage(false, "This Steam sign-in request has already been used."); if (user is not null) { user.RefreshToken = JwtTokenService.NewRefreshToken(); user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); await users.UpdateAsync(user); } return CallbackPage(true, "Steam verified your identity. You can return to RanaZe."); } [HttpGet("status/{transactionId}")] [EnableRateLimiting("steam-poll")] public async Task Status(string transactionId) { Response.Headers.CacheControl = "no-store"; var transaction = await ActiveTransactionAsync(transactionId); if (transaction is null) return NotFound(new { message = "Steam sign-in expired." }); if (transaction.Status != "complete") return Ok(new { status = transaction.Status }); if (string.IsNullOrWhiteSpace(transaction.UserId)) return Conflict(new { message = "Steam sign-in is incomplete." }); var user = await users.GetByIdAsync(transaction.UserId); if (user is null || user.SteamId != transaction.SteamId) return Conflict(new { message = "Steam 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("complete")] [EnableRateLimiting("steam-complete")] public async Task Complete(CompleteSteamRequest request) { var transaction = await ActiveTransactionAsync(request.TransactionId); if (transaction?.Status == "complete") return Ok(new { status = "complete" }); if (transaction is null || transaction.Status != "requires_account" || string.IsNullOrWhiteSpace(transaction.SteamId)) return BadRequest(new { message = "Steam sign-in is not ready or has expired." }); AuthUser user; var createdUser = false; if (request.Action == "create") { if (!request.AcceptLegal) return BadRequest(new { message = "You must agree to the Terms of Service and acknowledge the Privacy Policy." }); var username = request.Username.Trim(); var email = AuthValidation.NormalizeEmail(request.Email ?? ""); 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 = "Email address is already in use." }); user = new AuthUser { Username = username, Email = email, NormalizedEmail = email, PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), Role = "USER", CreatedUtc = DateTime.UtcNow, SteamId = transaction.SteamId, RefreshToken = JwtTokenService.NewRefreshToken(), RefreshTokenExpiry = DateTime.UtcNow.AddDays(7) }; legal.Accept(user, "steam_registration"); try { await users.CreateAsync(user); createdUser = true; } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict(new { message = "That username, email, or Steam account is already connected." }); } } 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 (!string.IsNullOrWhiteSpace(user.SteamId) && user.SteamId != transaction.SteamId) return Conflict(new { message = "That RanaZe account is already connected to another Steam account." }); try { if (!await users.TryLinkSteamAsync(user.Id, transaction.SteamId, JwtTokenService.NewRefreshToken(), DateTime.UtcNow.AddDays(7))) return Conflict(new { message = "That account could not be connected to Steam." }); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict(new { message = "That Steam account is already connected." }); } } else { return BadRequest(new { message = "Action must be create or link." }); } if (!await transactions.MarkCompleteAsync(request.TransactionId, transaction.SteamId, user.Id)) { if (createdUser) await users.DeleteAsync(user.Id); return Conflict(new { message = "This Steam sign-in was already completed." }); } return Ok(new { status = "complete" }); } private async Task 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 string ConfiguredRealm() => (configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/'); private string ReturnUrl(string transactionId) { var callback = configuration["Steam:ReturnUrl"] ?? $"{ConfiguredRealm()}/api/Auth/steam/callback"; return $"{callback}?transactionId={Uri.EscapeDataString(transactionId)}"; } private ContentResult CallbackPage(bool success, 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 html = $$""" RanaZe Steam sign-in

{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}

{{System.Net.WebUtility.HtmlEncode(message)}}

"""; return Content(html, "text/html; charset=utf-8"); } private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }