using AuthApi.Models; using AuthApi.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using MongoDB.Driver; using System.Net.Mail; using System.Security.Cryptography; using System.Text.RegularExpressions; namespace AuthApi.Controllers; [ApiController] [Route("api/Auth/steam")] public class SteamAuthController : ControllerBase { private readonly UserService _users; private readonly SteamAuthTransactionService _transactions; private readonly SteamOpenIdService _openId; private readonly JwtTokenService _tokens; private readonly IConfiguration _cfg; private readonly ILogger _logger; public SteamAuthController(UserService users, SteamAuthTransactionService transactions, SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg, ILogger logger) { _users = users; _transactions = transactions; _openId = openId; _tokens = tokens; _cfg = cfg; _logger = logger; } [HttpPost("start")] [EnableRateLimiting("steam-start")] public async Task Start() { var realm = ConfiguredRealm(); var transactionId = Base64Url(RandomNumberGenerator.GetBytes(32)); var lifetime = Math.Clamp(_cfg.GetValue("Steam:TransactionTtlMinutes", 5), 2, 15); var expiresUtc = DateTime.UtcNow.AddMinutes(lifetime); await _transactions.CreateAsync(new SteamAuthTransaction { Id = transactionId, CreatedUtc = DateTime.UtcNow, ExpiresUtc = expiresUtc }); var returnUrl = ReturnUrl(transactionId); return Ok(new { transactionId, authUrl = SteamOpenIdService.BuildAuthenticationUrl(realm, returnUrl), 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"; var changed = await _transactions.MarkVerifiedAsync(transactionId, steamId, status, user?.Id); if (!changed) return CallbackPage(false, "This Steam sign-in request has already been used."); if (user is not null) { user.RefreshToken = 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 = NewRefreshToken(); user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); await _users.UpdateAsync(user); } var (accessToken, jti, exp) = _tokens.Generate(user); return Ok(new { status = "complete", accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp }); } [HttpPost("complete")] [EnableRateLimiting("steam-complete")] public async Task Complete([FromBody] 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." }); User? user; var createdUser = false; if (request.Action == "create") { var validation = ValidateNewAccount(request); if (validation is not null) return BadRequest(new { message = validation }); if (await _users.GetByUsernameAsync(request.Username.Trim()) is not null) return Conflict(new { message = "Username already exists." }); user = new User { Username = request.Username.Trim(), Email = request.Email!.Trim().ToLowerInvariant(), PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), Role = "USER", CreatedUtc = DateTime.UtcNow, SteamId = transaction.SteamId, RefreshToken = NewRefreshToken(), RefreshTokenExpiry = DateTime.UtcNow.AddDays(7) }; try { await _users.CreateAsync(user); createdUser = true; } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict(new { message = "That username 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 { var linked = await _users.TryLinkSteamAsync(user.Id, transaction.SteamId, NewRefreshToken(), DateTime.UtcNow.AddDays(7)); if (!linked) return Conflict(new { message = "That account could not be connected to Steam." }); } catch (MongoWriteException ex) when (ex.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() => (_cfg["Steam:Realm"] ?? "https://pauth.ranaze.com").TrimEnd('/'); private string ReturnUrl(string transactionId) { var callback = _cfg["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? ValidateNewAccount(CompleteSteamRequest request) { var username = request.Username.Trim(); var email = request.Email?.Trim() ?? ""; if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$")) return "Username must be 3-24 characters using only letters, numbers, or underscores."; if (request.Password.Length < 8) return "Password must be at least 8 characters."; var domain = email.Split('@').LastOrDefault() ?? ""; if (!MailAddress.TryCreate(email, out _) || !domain.Contains('.')) return "A valid email address is required."; return null; } private static string NewRefreshToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }