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
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:
@@ -14,4 +14,8 @@
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AuthApi.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,13 +2,11 @@ using AuthApi.Models;
|
||||
using AuthApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Bson;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Mail;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
@@ -22,10 +20,12 @@ public class AuthController : ControllerBase
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly BlacklistService _blacklist;
|
||||
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")]
|
||||
@@ -62,11 +62,12 @@ public class AuthController : ControllerBase
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var user = await _users.GetByUsernameAsync(req.Username);
|
||||
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
var candidates = await _users.GetByLoginCandidatesAsync(req.Username);
|
||||
var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(req.Password, candidate.PasswordHash));
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var (accessToken, jti, expUtc) = GenerateJwtToken(user);
|
||||
var (accessToken, jti, expUtc) = _tokens.Generate(user);
|
||||
user.RefreshToken = Guid.NewGuid().ToString("N");
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await _users.UpdateAsync(user);
|
||||
@@ -81,7 +82,7 @@ public class AuthController : ControllerBase
|
||||
if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow)
|
||||
return Unauthorized("Invalid or expired refresh token");
|
||||
|
||||
var (accessToken, _, expUtc) = GenerateJwtToken(user);
|
||||
var (accessToken, _, expUtc) = _tokens.Generate(user);
|
||||
return Ok(new { accessToken, exp = expUtc });
|
||||
}
|
||||
|
||||
@@ -186,27 +187,6 @@ public class AuthController : ControllerBase
|
||||
[Authorize(Roles = "SUPER")]
|
||||
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()
|
||||
{
|
||||
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('/', '_');
|
||||
}
|
||||
@@ -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 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 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; }
|
||||
}
|
||||
@@ -15,4 +15,5 @@ public class User
|
||||
public DateTime? CreatedUtc { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? RefreshTokenExpiry { get; set; }
|
||||
public string? SteamId { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
@@ -11,7 +12,26 @@ builder.Services.AddControllers();
|
||||
// DI
|
||||
builder.Services.AddSingleton<UserService>();
|
||||
builder.Services.AddSingleton<BlacklistService>();
|
||||
builder.Services.AddSingleton<SteamAuthTransactionService>();
|
||||
builder.Services.AddSingleton<JwtTokenService>();
|
||||
builder.Services.AddSingleton<SteamOpenIdService>();
|
||||
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
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -81,6 +101,8 @@ app.UseSwaggerUI(o =>
|
||||
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1");
|
||||
o.RoutePrefix = "swagger";
|
||||
});
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
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 MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
@@ -17,6 +19,12 @@ public class UserService
|
||||
|
||||
var keys = Builders<User>.IndexKeys.Ascending(u => u.Username);
|
||||
_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) =>
|
||||
@@ -28,11 +36,36 @@ public class UserService
|
||||
public async Task<User?> GetByEmailAsync(string email) =>
|
||||
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 UpdateAsync(User 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<List<User>> GetAllAsync() =>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } },
|
||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||
"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" } },
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ spec:
|
||||
secretKeyRef:
|
||||
name: medmind-internal-api
|
||||
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:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
|
||||
Reference in New Issue
Block a user