diff --git a/microservices/AuthApi.Tests/AuthApi.Tests.csproj b/microservices/AuthApi.Tests/AuthApi.Tests.csproj new file mode 100644 index 0000000..9427ac7 --- /dev/null +++ b/microservices/AuthApi.Tests/AuthApi.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/microservices/AuthApi.Tests/SteamOpenIdServiceTests.cs b/microservices/AuthApi.Tests/SteamOpenIdServiceTests.cs new file mode 100644 index 0000000..817cdd7 --- /dev/null +++ b/microservices/AuthApi.Tests/SteamOpenIdServiceTests.cs @@ -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)); + } +} diff --git a/microservices/AuthApi/AuthApi.csproj b/microservices/AuthApi/AuthApi.csproj index 66c00d8..84647a9 100644 --- a/microservices/AuthApi/AuthApi.csproj +++ b/microservices/AuthApi/AuthApi.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/microservices/AuthApi/Controllers/AuthController.cs b/microservices/AuthApi/Controllers/AuthController.cs index cdb513b..03fc34c 100644 --- a/microservices/AuthApi/Controllers/AuthController.cs +++ b/microservices/AuthApi/Controllers/AuthController.cs @@ -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 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 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 CurrentUserAsync() { var id = User.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/microservices/AuthApi/Controllers/SteamAuthController.cs b/microservices/AuthApi/Controllers/SteamAuthController.cs new file mode 100644 index 0000000..b1bc51b --- /dev/null +++ b/microservices/AuthApi/Controllers/SteamAuthController.cs @@ -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 _logger; + + public SteamAuthController(UserService users, SteamAuthTransactionService transactions, + SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg, + ILogger logger) + { + _users = users; + _transactions = transactions; + _openId = openId; + _tokens = tokens; + _cfg = cfg; + _logger = logger; + } + + [HttpPost("start")] + [EnableRateLimiting("steam-start")] + public async Task 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 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 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 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 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 = $$""" + + RanaZe Steam sign-in +

{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}

{{System.Net.WebUtility.HtmlEncode(message)}}

+ + """; + 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('/', '_'); +} diff --git a/microservices/AuthApi/Models/Dto.cs b/microservices/AuthApi/Models/Dto.cs index 12f0ccf..089ba0e 100644 --- a/microservices/AuthApi/Models/Dto.cs +++ b/microservices/AuthApi/Models/Dto.cs @@ -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; } = ""; +} diff --git a/microservices/AuthApi/Models/SteamAuthTransaction.cs b/microservices/AuthApi/Models/SteamAuthTransaction.cs new file mode 100644 index 0000000..0b2609a --- /dev/null +++ b/microservices/AuthApi/Models/SteamAuthTransaction.cs @@ -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; } +} diff --git a/microservices/AuthApi/Models/User.cs b/microservices/AuthApi/Models/User.cs index 57ca812..44cf2cd 100644 --- a/microservices/AuthApi/Models/User.cs +++ b/microservices/AuthApi/Models/User.cs @@ -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; } } diff --git a/microservices/AuthApi/Program.cs b/microservices/AuthApi/Program.cs index 50b69a9..df801da 100644 --- a/microservices/AuthApi/Program.cs +++ b/microservices/AuthApi/Program.cs @@ -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(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient(); +builder.Services.AddRateLimiter(options => +{ + static RateLimitPartition 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(); diff --git a/microservices/AuthApi/Services/JwtTokenService.cs b/microservices/AuthApi/Services/JwtTokenService.cs new file mode 100644 index 0000000..82f7cbe --- /dev/null +++ b/microservices/AuthApi/Services/JwtTokenService.cs @@ -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); + } +} diff --git a/microservices/AuthApi/Services/SteamAuthTransactionService.cs b/microservices/AuthApi/Services/SteamAuthTransactionService.cs new file mode 100644 index 0000000..ad39ea7 --- /dev/null +++ b/microservices/AuthApi/Services/SteamAuthTransactionService.cs @@ -0,0 +1,49 @@ +using AuthApi.Models; +using MongoDB.Driver; + +namespace AuthApi.Services; + +public class SteamAuthTransactionService +{ + private readonly IMongoCollection _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("SteamAuthTransactions"); + var ttl = Builders.IndexKeys.Ascending(x => x.ExpiresUtc); + _transactions.Indexes.CreateOne(new CreateIndexModel(ttl, + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); + } + + public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction); + + public async Task GetAsync(string id) => + await _transactions.Find(x => x.Id == id).FirstOrDefaultAsync(); + + public async Task MarkVerifiedAsync(string id, string steamId, string status, string? userId) + { + var filter = Builders.Filter.Eq(x => x.Id, id) & + Builders.Filter.Eq(x => x.Status, "pending") & + Builders.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow); + var update = Builders.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 MarkCompleteAsync(string id, string steamId, string userId) + { + var filter = Builders.Filter.Eq(x => x.Id, id) & + Builders.Filter.Eq(x => x.Status, "requires_account") & + Builders.Filter.Eq(x => x.SteamId, steamId) & + Builders.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow); + var update = Builders.Update + .Set(x => x.Status, "complete") + .Set(x => x.UserId, userId); + return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } +} diff --git a/microservices/AuthApi/Services/SteamOpenIdService.cs b/microservices/AuthApi/Services/SteamOpenIdService.cs new file mode 100644 index 0000000..8ed0512 --- /dev/null +++ b/microservices/AuthApi/Services/SteamOpenIdService.cs @@ -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/(?[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 + { + ["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 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; + } +} diff --git a/microservices/AuthApi/Services/UserService.cs b/microservices/AuthApi/Services/UserService.cs index 59c0e0e..bebdb9f 100644 --- a/microservices/AuthApi/Services/UserService.cs +++ b/microservices/AuthApi/Services/UserService.cs @@ -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.IndexKeys.Ascending(u => u.Username); _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { Unique = true })); + var steamKeys = Builders.IndexKeys.Ascending(u => u.SteamId); + _col.Indexes.CreateOne(new CreateIndexModel(steamKeys, new CreateIndexOptions + { + Unique = true, + PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string")) + })); } public async Task GetByUsernameAsync(string username) => @@ -28,11 +36,36 @@ public class UserService public async Task GetByEmailAsync(string email) => await _col.Find(u => u.Email == email).FirstOrDefaultAsync(); + public async Task> 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.Filter.Regex(u => u.Email, emailPattern)).ToListAsync(); + } + + public async Task 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 TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry) + { + var filter = Builders.Filter.Eq(u => u.Id, userId) & + (Builders.Filter.Eq(u => u.SteamId, null) | Builders.Filter.Eq(u => u.SteamId, steamId)); + var update = Builders.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> GetAllAsync() => diff --git a/microservices/AuthApi/appsettings.json b/microservices/AuthApi/appsettings.json index 40a1fb6..b3fb57f 100644 --- a/microservices/AuthApi/appsettings.json +++ b/microservices/AuthApi/appsettings.json @@ -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": "*" } diff --git a/microservices/AuthApi/k8s/deployment.yaml b/microservices/AuthApi/k8s/deployment.yaml index 31623db..c4dabc0 100644 --- a/microservices/AuthApi/k8s/deployment.yaml +++ b/microservices/AuthApi/k8s/deployment.yaml @@ -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 diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln index 3f9745e..b3ca6ed 100644 --- a/microservices/micro-services.sln +++ b/microservices/micro-services.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi.Tests", "AuthApi.Tests\AuthApi.Tests.csproj", "{E5FE4B7B-6535-4D11-883A-63859E1B48CF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution 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|x86.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE