Add Steam OpenID authentication
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

This commit is contained in:
2026-07-20 00:30:06 -05:00
parent 18c3d6fc5d
commit cde0e28e44
16 changed files with 564 additions and 29 deletions
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AuthApi\AuthApi.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,39 @@
using AuthApi.Services;
using Xunit;
namespace AuthApi.Tests;
public class SteamOpenIdServiceTests
{
[Fact]
public void BuildAuthenticationUrl_UsesSteamEndpointAndBindsReturnUrl()
{
const string callback = "https://pauth.ranaze.com/api/Auth/steam/callback?transactionId=abc-123";
var result = SteamOpenIdService.BuildAuthenticationUrl("https://pauth.ranaze.com", callback);
Assert.StartsWith(SteamOpenIdService.Endpoint + "?", result);
Assert.Contains("openid.mode=checkid_setup", result);
Assert.Contains(Uri.EscapeDataString(callback), result);
Assert.Contains(Uri.EscapeDataString("https://pauth.ranaze.com"), result);
}
[Theory]
[InlineData("https://steamcommunity.com/openid/id/76561198000000000", "76561198000000000")]
[InlineData("http://steamcommunity.com/openid/id/76561198000000000", "76561198000000000")]
[InlineData("https://example.com/openid/id/76561198000000000", null)]
[InlineData("https://steamcommunity.com/openid/id/not-a-steamid", null)]
public void ExtractSteamId_OnlyAcceptsSteamClaimedIds(string claimedId, string? expected)
{
Assert.Equal(expected, SteamOpenIdService.ExtractSteamId(claimedId));
}
[Theory]
[InlineData("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n", true)]
[InlineData("is_valid:false\n", false)]
[InlineData("is_valid:true-ish\n", false)]
public void IsValidAuthenticationResponse_RequiresExactValidLine(string response, bool expected)
{
Assert.Equal(expected, SteamOpenIdService.IsValidAuthenticationResponse(response));
}
}
+4
View File
@@ -14,4 +14,8 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AuthApi.Tests" />
</ItemGroup>
</Project> </Project>
@@ -2,13 +2,11 @@ using AuthApi.Models;
using AuthApi.Services; using AuthApi.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using MongoDB.Driver; using MongoDB.Driver;
using MongoDB.Bson; using MongoDB.Bson;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Net.Mail; using System.Net.Mail;
using System.Security.Claims; using System.Security.Claims;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -22,10 +20,12 @@ public class AuthController : ControllerBase
private readonly IConfiguration _cfg; private readonly IConfiguration _cfg;
private readonly BlacklistService _blacklist; private readonly BlacklistService _blacklist;
private readonly IHttpClientFactory _httpClients; private readonly IHttpClientFactory _httpClients;
private readonly JwtTokenService _tokens;
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist, IHttpClientFactory httpClients) public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist,
IHttpClientFactory httpClients, JwtTokenService tokens)
{ {
_users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens;
} }
[HttpPost("register")] [HttpPost("register")]
@@ -62,11 +62,12 @@ public class AuthController : ControllerBase
[HttpPost("login")] [HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest req) public async Task<IActionResult> Login([FromBody] LoginRequest req)
{ {
var user = await _users.GetByUsernameAsync(req.Username); var candidates = await _users.GetByLoginCandidatesAsync(req.Username);
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(req.Password, candidate.PasswordHash));
if (user == null)
return Unauthorized(); return Unauthorized();
var (accessToken, jti, expUtc) = GenerateJwtToken(user); var (accessToken, jti, expUtc) = _tokens.Generate(user);
user.RefreshToken = Guid.NewGuid().ToString("N"); user.RefreshToken = Guid.NewGuid().ToString("N");
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
await _users.UpdateAsync(user); await _users.UpdateAsync(user);
@@ -81,7 +82,7 @@ public class AuthController : ControllerBase
if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow) if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow)
return Unauthorized("Invalid or expired refresh token"); return Unauthorized("Invalid or expired refresh token");
var (accessToken, _, expUtc) = GenerateJwtToken(user); var (accessToken, _, expUtc) = _tokens.Generate(user);
return Ok(new { accessToken, exp = expUtc }); return Ok(new { accessToken, exp = expUtc });
} }
@@ -186,27 +187,6 @@ public class AuthController : ControllerBase
[Authorize(Roles = "SUPER")] [Authorize(Roles = "SUPER")]
public async Task<IActionResult> GetAllUsers() => Ok(await _users.GetAllAsync()); public async Task<IActionResult> GetAllUsers() => Ok(await _users.GetAllAsync());
private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user)
{
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!);
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
var audience = _cfg["Jwt:Audience"] ?? issuer;
var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
var jti = Guid.NewGuid().ToString("N");
var claims = new[]
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, jti)
};
var exp = DateTime.UtcNow.AddMinutes(15);
var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
}
private async Task<User?> CurrentUserAsync() private async Task<User?> CurrentUserAsync()
{ {
var id = User.FindFirstValue(ClaimTypes.NameIdentifier); var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -0,0 +1,245 @@
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('/', '_');
}
+8
View File
@@ -7,3 +7,11 @@ public class RefreshRequest { public string Username { get; set; } = ""; public
public class UpdateProfileRequest { public string? DisplayName { get; set; } public string? Email { get; set; } } public class UpdateProfileRequest { public string? DisplayName { get; set; } public string? Email { get; set; } }
public class ChangePasswordRequest { public string CurrentPassword { get; set; } = ""; public string NewPassword { get; set; } = ""; } public class ChangePasswordRequest { public string CurrentPassword { get; set; } = ""; public string NewPassword { get; set; } = ""; }
public class DeleteAccountRequest { public string Password { get; set; } = ""; public string Confirmation { get; set; } = ""; } public class DeleteAccountRequest { public string Password { get; set; } = ""; public string Confirmation { get; set; } = ""; }
public class CompleteSteamRequest
{
public string TransactionId { get; set; } = "";
public string Action { get; set; } = "";
public string Username { get; set; } = "";
public string? Email { get; set; }
public string Password { get; set; } = "";
}
@@ -0,0 +1,14 @@
using MongoDB.Bson.Serialization.Attributes;
namespace AuthApi.Models;
public class SteamAuthTransaction
{
[BsonId]
public string Id { get; set; } = "";
public string Status { get; set; } = "pending";
public string? SteamId { get; set; }
public string? UserId { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
}
+1
View File
@@ -15,4 +15,5 @@ public class User
public DateTime? CreatedUtc { get; set; } public DateTime? CreatedUtc { get; set; }
public string? RefreshToken { get; set; } public string? RefreshToken { get; set; }
public DateTime? RefreshTokenExpiry { get; set; } public DateTime? RefreshTokenExpiry { get; set; }
public string? SteamId { get; set; }
} }
+22
View File
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
@@ -11,7 +12,26 @@ builder.Services.AddControllers();
// DI // DI
builder.Services.AddSingleton<UserService>(); builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<BlacklistService>(); builder.Services.AddSingleton<BlacklistService>();
builder.Services.AddSingleton<SteamAuthTransactionService>();
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options =>
{
static RateLimitPartition<string> Policy(HttpContext context, int permits) =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = permits,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0
});
options.AddPolicy("steam-start", context => Policy(context, 10));
options.AddPolicy("steam-poll", context => Policy(context, 120));
options.AddPolicy("steam-complete", context => Policy(context, 20));
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Swagger + JWT auth in Swagger // Swagger + JWT auth in Swagger
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
@@ -81,6 +101,8 @@ app.UseSwaggerUI(o =>
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1"); o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1");
o.RoutePrefix = "swagger"; o.RoutePrefix = "swagger";
}); });
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
@@ -0,0 +1,33 @@
using AuthApi.Models;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace AuthApi.Services;
public class JwtTokenService
{
private readonly IConfiguration _cfg;
public JwtTokenService(IConfiguration cfg) => _cfg = cfg;
public (string Token, string Jti, DateTime ExpiresUtc) Generate(User user)
{
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key missing"));
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
var audience = _cfg["Jwt:Audience"] ?? issuer;
var credentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
var jti = Guid.NewGuid().ToString("N");
var claims = new[]
{
new Claim(ClaimTypes.Name, user.Username),
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Role, user.Role),
new Claim(JwtRegisteredClaimNames.Jti, jti)
};
var expiresUtc = DateTime.UtcNow.AddMinutes(15);
var token = new JwtSecurityToken(issuer, audience, claims, expires: expiresUtc, signingCredentials: credentials);
return (new JwtSecurityTokenHandler().WriteToken(token), jti, expiresUtc);
}
}
@@ -0,0 +1,49 @@
using AuthApi.Models;
using MongoDB.Driver;
namespace AuthApi.Services;
public class SteamAuthTransactionService
{
private readonly IMongoCollection<SteamAuthTransaction> _transactions;
public SteamAuthTransactionService(IConfiguration cfg)
{
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var database = new MongoClient(connectionString).GetDatabase(databaseName);
_transactions = database.GetCollection<SteamAuthTransaction>("SteamAuthTransactions");
var ttl = Builders<SteamAuthTransaction>.IndexKeys.Ascending(x => x.ExpiresUtc);
_transactions.Indexes.CreateOne(new CreateIndexModel<SteamAuthTransaction>(ttl,
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
}
public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
public async Task<SteamAuthTransaction?> GetAsync(string id) =>
await _transactions.Find(x => x.Id == id).FirstOrDefaultAsync();
public async Task<bool> MarkVerifiedAsync(string id, string steamId, string status, string? userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "pending") &
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.Update
.Set(x => x.SteamId, steamId)
.Set(x => x.Status, status)
.Set(x => x.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<bool> MarkCompleteAsync(string id, string steamId, string userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "requires_account") &
Builders<SteamAuthTransaction>.Filter.Eq(x => x.SteamId, steamId) &
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.Update
.Set(x => x.Status, "complete")
.Set(x => x.UserId, userId);
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
}
@@ -0,0 +1,66 @@
using System.Text.RegularExpressions;
namespace AuthApi.Services;
public class SteamOpenIdService
{
public const string Endpoint = "https://steamcommunity.com/openid/login";
private static readonly Regex ClaimedIdPattern = new(
"^https?://steamcommunity\\.com/openid/id/(?<id>[0-9]{17})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IHttpClientFactory _httpClients;
public SteamOpenIdService(IHttpClientFactory httpClients) => _httpClients = httpClients;
public static string BuildAuthenticationUrl(string realm, string returnUrl)
{
var parameters = new Dictionary<string, string>
{
["openid.ns"] = "http://specs.openid.net/auth/2.0",
["openid.mode"] = "checkid_setup",
["openid.return_to"] = returnUrl,
["openid.realm"] = realm,
["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select",
["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select"
};
return Endpoint + "?" + string.Join("&", parameters.Select(pair =>
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
}
public async Task<string?> VerifyCallbackAsync(IQueryCollection query, string expectedReturnUrl, CancellationToken cancellationToken)
{
if (!string.Equals(query["openid.mode"], "id_res", StringComparison.Ordinal) ||
!string.Equals(query["openid.op_endpoint"].ToString().TrimEnd('/'), Endpoint.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) ||
!string.Equals(query["openid.return_to"], expectedReturnUrl, StringComparison.Ordinal))
return null;
var claimedId = query["openid.claimed_id"].ToString();
if (!string.Equals(claimedId, query["openid.identity"], StringComparison.Ordinal))
return null;
var match = ClaimedIdPattern.Match(claimedId);
if (!match.Success)
return null;
var fields = query
.Where(pair => pair.Key.StartsWith("openid.", StringComparison.Ordinal))
.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
fields["openid.mode"] = "check_authentication";
var client = _httpClients.CreateClient(nameof(SteamOpenIdService));
client.Timeout = TimeSpan.FromSeconds(15);
using var response = await client.PostAsync(Endpoint, new FormUrlEncodedContent(fields), cancellationToken);
if (!response.IsSuccessStatusCode)
return null;
var validation = await response.Content.ReadAsStringAsync(cancellationToken);
return IsValidAuthenticationResponse(validation) ? match.Groups["id"].Value : null;
}
internal static bool IsValidAuthenticationResponse(string response) =>
response.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(line => string.Equals(line, "is_valid:true", StringComparison.Ordinal));
internal static string? ExtractSteamId(string claimedId)
{
var match = ClaimedIdPattern.Match(claimedId);
return match.Success ? match.Groups["id"].Value : null;
}
}
@@ -1,5 +1,7 @@
using AuthApi.Models; using AuthApi.Models;
using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using System.Text.RegularExpressions;
namespace AuthApi.Services; namespace AuthApi.Services;
@@ -17,6 +19,12 @@ public class UserService
var keys = Builders<User>.IndexKeys.Ascending(u => u.Username); var keys = Builders<User>.IndexKeys.Ascending(u => u.Username);
_col.Indexes.CreateOne(new CreateIndexModel<User>(keys, new CreateIndexOptions { Unique = true })); _col.Indexes.CreateOne(new CreateIndexModel<User>(keys, new CreateIndexOptions { Unique = true }));
var steamKeys = Builders<User>.IndexKeys.Ascending(u => u.SteamId);
_col.Indexes.CreateOne(new CreateIndexModel<User>(steamKeys, new CreateIndexOptions<User>
{
Unique = true,
PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string"))
}));
} }
public async Task<User?> GetByUsernameAsync(string username) => public async Task<User?> GetByUsernameAsync(string username) =>
@@ -28,11 +36,36 @@ public class UserService
public async Task<User?> GetByEmailAsync(string email) => public async Task<User?> GetByEmailAsync(string email) =>
await _col.Find(u => u.Email == email).FirstOrDefaultAsync(); await _col.Find(u => u.Email == email).FirstOrDefaultAsync();
public async Task<List<User>> GetByLoginCandidatesAsync(string login)
{
var normalized = login.Trim();
var usernameMatch = await GetByUsernameAsync(normalized);
if (usernameMatch is not null)
return [usernameMatch];
var emailPattern = new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i");
return await _col.Find(Builders<User>.Filter.Regex(u => u.Email, emailPattern)).ToListAsync();
}
public async Task<User?> GetBySteamIdAsync(string steamId) =>
await _col.Find(u => u.SteamId == steamId).FirstOrDefaultAsync();
public Task CreateAsync(User user) => _col.InsertOneAsync(user); public Task CreateAsync(User user) => _col.InsertOneAsync(user);
public Task UpdateAsync(User user) => public Task UpdateAsync(User user) =>
_col.ReplaceOneAsync(u => u.Id == user.Id, user); _col.ReplaceOneAsync(u => u.Id == user.Id, user);
public async Task<bool> TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry)
{
var filter = Builders<User>.Filter.Eq(u => u.Id, userId) &
(Builders<User>.Filter.Eq(u => u.SteamId, null) | Builders<User>.Filter.Eq(u => u.SteamId, steamId));
var update = Builders<User>.Update
.Set(u => u.SteamId, steamId)
.Set(u => u.RefreshToken, refreshToken)
.Set(u => u.RefreshTokenExpiry, refreshExpiry);
var result = await _col.UpdateOneAsync(filter, update);
return result.MatchedCount == 1;
}
public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id); public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id);
public Task<List<User>> GetAllAsync() => public Task<List<User>> GetAllAsync() =>
+1
View File
@@ -2,6 +2,7 @@
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } }, "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" }, "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Steam": { "Realm": "https://pauth.ranaze.com", "ReturnUrl": "https://pauth.ranaze.com/api/Auth/steam/callback", "TransactionTtlMinutes": 5 },
"Logging": { "LogLevel": { "Default": "Information" } }, "Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*" "AllowedHosts": "*"
} }
@@ -28,6 +28,12 @@ spec:
secretKeyRef: secretKeyRef:
name: medmind-internal-api name: medmind-internal-api
key: purge-secret key: purge-secret
- name: Steam__Realm
value: https://pauth.ranaze.com
- name: Steam__ReturnUrl
value: https://pauth.ranaze.com/api/Auth/steam/callback
- name: Steam__TransactionTtlMinutes
value: "5"
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
+14
View File
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi.Tests", "AuthApi.Tests\AuthApi.Tests.csproj", "{E5FE4B7B-6535-4D11-883A-63859E1B48CF}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -111,6 +113,18 @@ Global
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x64.Build.0 = Release|Any CPU {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x64.Build.0 = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.ActiveCfg = Release|Any CPU {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.ActiveCfg = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.Build.0 = Release|Any CPU {C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.Build.0 = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x64.ActiveCfg = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x64.Build.0 = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x86.ActiveCfg = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x86.Build.0 = Debug|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|Any CPU.Build.0 = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x64.ActiveCfg = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x64.Build.0 = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x86.ActiveCfg = Release|Any CPU
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE