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:
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/', '_');
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user