Deploy Promiscuity Auth API / deploy (push) Failing after 20s
Deploy Promiscuity Character API / deploy (push) Failing after 19s
Deploy Promiscuity Crafting API / deploy (push) Failing after 20s
Deploy Promiscuity Inventory API / deploy (push) Failing after 19s
Deploy Promiscuity Locations API / deploy (push) Failing after 19s
Deploy Promiscuity Mail API / deploy (push) Failing after 20s
Deploy Promiscuity World API / deploy (push) Failing after 20s
k8s smoke test / test (push) Failing after 20s
246 lines
11 KiB
C#
246 lines
11 KiB
C#
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<SteamAuthController> _logger;
|
|
|
|
public SteamAuthController(UserService users, SteamAuthTransactionService transactions,
|
|
SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg,
|
|
ILogger<SteamAuthController> logger)
|
|
{
|
|
_users = users;
|
|
_transactions = transactions;
|
|
_openId = openId;
|
|
_tokens = tokens;
|
|
_cfg = cfg;
|
|
_logger = logger;
|
|
}
|
|
|
|
[HttpPost("start")]
|
|
[EnableRateLimiting("steam-start")]
|
|
public async Task<IActionResult> 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<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";
|
|
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<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 = 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<IActionResult> 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<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() =>
|
|
(_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 = $$"""
|
|
<!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? 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('/', '_');
|
|
}
|