Files
step3/api/QuestionsApi/Controllers/SteamAuthController.cs
T

220 lines
10 KiB
C#

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<SteamAuthController> logger,
LegalPolicy legal) : ControllerBase
{
[HttpPost("start")]
[EnableRateLimiting("steam-start")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<SteamAuthTransaction?> 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 = $$"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>RanaZe Steam 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>{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}</h1><p>{{System.Net.WebUtility.HtmlEncode(message)}}</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('/', '_');
}