diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index 962a9f8..00e491b 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.RateLimiting; using System; using System.IdentityModel.Tokens.Jwt; using System.Linq; @@ -20,20 +21,32 @@ public class AuthController : ControllerBase private readonly UserService _userService; private readonly IConfiguration _config; private readonly BlacklistService _blacklistService; + private readonly PasswordResetService _passwordResetService; + private readonly EmailService _emailService; + private readonly SteamAuthService _steamAuthService; - public AuthController(UserService userService, IConfiguration config, BlacklistService blacklistService) + public AuthController(UserService userService, IConfiguration config, BlacklistService blacklistService, + PasswordResetService passwordResetService = null, EmailService emailService = null, + SteamAuthService steamAuthService = null) { _userService = userService; _config = config; _blacklistService = blacklistService; + _passwordResetService = passwordResetService; + _emailService = emailService; + _steamAuthService = steamAuthService; } [HttpPost("register")] public async Task Register([FromBody] RegisterRequest credentials) { - if (await _userService.GetByUsernameAsync(credentials.Username) != null) + if (await _userService.GetByLoginAsync(credentials.Username) != null || + (!string.IsNullOrWhiteSpace(credentials.Email) && await _userService.GetByLoginAsync(credentials.Email) != null)) return BadRequest("User already exists"); + if (!IsValidUsername(credentials.Username) || !IsValidEmail(credentials.Email) || !IsValidPassword(credentials.Password)) + return BadRequest("Username, email, or password is invalid"); + var hash = BCrypt.Net.BCrypt.HashPassword(credentials.Password); var user = new User @@ -51,7 +64,7 @@ public class AuthController : ControllerBase [HttpPost("login")] public async Task Login([FromBody] LoginRequest credentials) { - var user = await _userService.GetByUsernameAsync(credentials.Username); + var user = await _userService.GetByLoginAsync(credentials.Username); if (user == null || !BCrypt.Net.BCrypt.Verify(credentials.Password, user.PasswordHash)) return Unauthorized(); @@ -66,6 +79,105 @@ public class AuthController : ControllerBase return Ok(new { accessToken, refreshToken, user.Username, user.Role }); } + [HttpPost("password-reset/request")] + [EnableRateLimiting("password-reset")] + public async Task RequestPasswordReset([FromBody] PasswordResetRequest request) + { + if (_passwordResetService == null || _emailService == null) + return StatusCode(503, "Password recovery is not configured"); + var user = await _userService.GetByLoginAsync(request.Login ?? string.Empty); + if (user != null && !string.IsNullOrWhiteSpace(user.Email)) + { + var code = await _passwordResetService.CreateAsync(user.Id); + await _emailService.SendPasswordResetCodeAsync(user.Email, code); + } + return Accepted(new { message = "If the account exists, a verification code has been sent." }); + } + + [HttpPost("password-reset/confirm")] + [EnableRateLimiting("password-reset")] + public async Task ConfirmPasswordReset([FromBody] PasswordResetConfirmRequest request) + { + if (_passwordResetService == null) return StatusCode(503, "Password recovery is not configured"); + if (!IsValidPassword(request.NewPassword)) return BadRequest("Password must be at least 8 characters"); + var user = await _userService.GetByLoginAsync(request.Login ?? string.Empty); + if (user == null || !await _passwordResetService.ConsumeAsync(user.Id, request.Code ?? string.Empty)) + return BadRequest("The verification code is invalid or expired"); + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.NewPassword); + user.RefreshToken = null; + user.RefreshTokenExpiry = DateTime.MinValue; + await _userService.UpdateAsync(user); + return Ok(new { message = "Password updated" }); + } + + [HttpPost("steam/start")] + public async Task StartSteamLogin() + { + if (_steamAuthService == null) return StatusCode(503, "Steam authentication is not configured"); + var started = await _steamAuthService.StartAsync(); + return Ok(new { transactionId = started.Transaction.Id, authUrl = started.AuthUrl, expiresUtc = started.Transaction.ExpiresUtc }); + } + + [HttpGet("steam/callback")] + public async Task SteamCallback([FromQuery] string transactionId) + { + if (_steamAuthService == null || !await _steamAuthService.VerifyCallbackAsync(transactionId, Request.Query)) + return StatusCode(400, "Steam sign-in could not be verified. You may close this window."); + return Content("Steam sign-in verified. Return to MedMind to finish signing in.", "text/plain", Encoding.UTF8); + } + + [HttpGet("steam/status/{transactionId}")] + public async Task SteamStatus(string transactionId) + { + if (_steamAuthService == null) return StatusCode(503, "Steam authentication is not configured"); + var transaction = await _steamAuthService.GetAsync(transactionId); + if (transaction == null) return NotFound(new { status = "expired" }); + if (transaction.Status == "authenticated" && !string.IsNullOrWhiteSpace(transaction.UserId)) + { + var user = await _userService.GetByIdAsync(transaction.UserId); + if (user == null) return BadRequest("Linked account no longer exists"); + var tokens = await IssueTokensAsync(user); + await _steamAuthService.CompleteAsync(transactionId, user, tokens.AccessToken, tokens.RefreshToken); + transaction = await _steamAuthService.GetAsync(transactionId); + } + return Ok(new + { + transaction.Status, transaction.Username, transaction.Role, + transaction.AccessToken, transaction.RefreshToken + }); + } + + [HttpPost("steam/complete")] + public async Task CompleteSteamLogin([FromBody] SteamCompleteRequest request) + { + if (_steamAuthService == null) return StatusCode(503, "Steam authentication is not configured"); + User user; + if (string.Equals(request.Action, "link", StringComparison.OrdinalIgnoreCase)) + { + user = await _userService.GetByLoginAsync(request.Username ?? string.Empty); + if (user == null || !BCrypt.Net.BCrypt.Verify(request.Password ?? string.Empty, user.PasswordHash)) + return Unauthorized(); + } + else if (string.Equals(request.Action, "create", StringComparison.OrdinalIgnoreCase)) + { + if (!IsValidUsername(request.Username) || !IsValidEmail(request.Email) || !IsValidPassword(request.Password)) + return BadRequest("Username, email, or password is invalid"); + if (await _userService.GetByLoginAsync(request.Username) != null || await _userService.GetByLoginAsync(request.Email) != null) + return Conflict("Username or email is already in use"); + user = new User + { + Username = request.Username.Trim(), Email = request.Email.Trim(), + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), Role = "USER" + }; + await _userService.CreateAsync(user); + } + else return BadRequest("Action must be create or link"); + + try { await _steamAuthService.LinkAsync(request.TransactionId, user.Id); } + catch (MongoDB.Driver.MongoWriteException) { return Conflict("This Steam account is already linked"); } + return Ok(new { status = "linked" }); + } + [HttpPost("refresh")] public async Task Refresh([FromBody] RefreshRequest req) { @@ -140,9 +252,25 @@ public class AuthController : ControllerBase return new JwtSecurityTokenHandler().WriteToken(token); } + private async Task<(string AccessToken, string RefreshToken)> IssueTokensAsync(User user) + { + var accessToken = GenerateJwtToken(user); + var refreshToken = Guid.NewGuid().ToString(); + user.RefreshToken = refreshToken; + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await _userService.UpdateAsync(user); + return (accessToken, refreshToken); + } + + private static bool IsValidUsername(string value) => + !string.IsNullOrWhiteSpace(value) && value.Length is >= 3 and <= 24 && value.All(c => char.IsLetterOrDigit(c) || c == '_'); + private static bool IsValidEmail(string value) => + !string.IsNullOrWhiteSpace(value) && value.Contains('@') && value.Length <= 254; + private static bool IsValidPassword(string value) => !string.IsNullOrEmpty(value) && value.Length >= 8; + public class RefreshRequest { public string Username { get; set; } public string RefreshToken { get; set; } } -} \ No newline at end of file +} diff --git a/GameAuthApi.Tests/AuthControllerTests.cs b/GameAuthApi.Tests/AuthControllerTests.cs index c9edd45..a7afcce 100644 --- a/GameAuthApi.Tests/AuthControllerTests.cs +++ b/GameAuthApi.Tests/AuthControllerTests.cs @@ -48,6 +48,49 @@ public class AuthControllerTests Assert.IsType(result); } + [Fact] + public async Task Login_EmailIdentifier_ReturnsTokens() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "abcdefghijklmnopqrstuvwxyz123456", + ["Jwt:Issuer"] = "issuer", + ["MongoDB:ConnectionString"] = "mongodb://localhost", + ["MongoDB:DatabaseName"] = "test" + }).Build(); + var user = new User + { + Id = "507f1f77bcf86cd799439011", Username = "student", Email = "student@example.com", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("password123"), Role = "USER" + }; + var users = new Mock(config); + users.Setup(x => x.GetByLoginAsync("student@example.com")).ReturnsAsync(user); + users.Setup(x => x.UpdateAsync(user)).Returns(Task.CompletedTask); + var controller = new AuthController(users.Object, config, new Mock(config).Object); + + var result = await controller.Login(new LoginRequest { Username = "student@example.com", Password = "password123" }); + + Assert.IsType(result); + Assert.False(string.IsNullOrWhiteSpace(user.RefreshToken)); + } + + [Fact] + public async Task Register_DuplicateEmail_ReturnsBadRequest() + { + var config = GetConfig(); + var users = new Mock(config); + users.Setup(x => x.GetByLoginAsync("new_user")).ReturnsAsync((User?)null); + users.Setup(x => x.GetByLoginAsync("used@example.com")).ReturnsAsync(new User()); + var controller = new AuthController(users.Object, config, new Mock(config).Object); + + var result = await controller.Register(new RegisterRequest + { + Username = "new_user", Email = "used@example.com", Password = "password123" + }); + + Assert.IsType(result); + } + [Fact] public async Task Refresh_InvalidToken_ReturnsUnauthorized() { diff --git a/Models/DTOs/PasswordResetRequests.cs b/Models/DTOs/PasswordResetRequests.cs new file mode 100644 index 0000000..f5da851 --- /dev/null +++ b/Models/DTOs/PasswordResetRequests.cs @@ -0,0 +1,13 @@ +namespace GameAuthApi.Models; + +public class PasswordResetRequest +{ + public string Login { get; set; } +} + +public class PasswordResetConfirmRequest +{ + public string Login { get; set; } + public string Code { get; set; } + public string NewPassword { get; set; } +} diff --git a/Models/DTOs/SteamCompleteRequest.cs b/Models/DTOs/SteamCompleteRequest.cs new file mode 100644 index 0000000..13cf674 --- /dev/null +++ b/Models/DTOs/SteamCompleteRequest.cs @@ -0,0 +1,10 @@ +namespace GameAuthApi.Models; + +public class SteamCompleteRequest +{ + 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/Models/ExternalIdentityLink.cs b/Models/ExternalIdentityLink.cs new file mode 100644 index 0000000..613dc32 --- /dev/null +++ b/Models/ExternalIdentityLink.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System; + +namespace GameAuthApi.Models; + +public class ExternalIdentityLink +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + public string Provider { get; set; } + public string Subject { get; set; } + public string UserId { get; set; } + public DateTime CreatedUtc { get; set; } +} diff --git a/Models/PasswordResetChallenge.cs b/Models/PasswordResetChallenge.cs new file mode 100644 index 0000000..8e3519f --- /dev/null +++ b/Models/PasswordResetChallenge.cs @@ -0,0 +1,18 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using System; + +namespace GameAuthApi.Models; + +public class PasswordResetChallenge +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + public string UserId { get; set; } + public string CodeHash { get; set; } + public int FailedAttempts { get; set; } + public DateTime ExpiresUtc { get; set; } + public DateTime CreatedUtc { get; set; } + public bool Used { get; set; } +} diff --git a/Models/SteamAuthTransaction.cs b/Models/SteamAuthTransaction.cs new file mode 100644 index 0000000..5ade6b1 --- /dev/null +++ b/Models/SteamAuthTransaction.cs @@ -0,0 +1,17 @@ +using System; + +namespace GameAuthApi.Models; + +public class SteamAuthTransaction +{ + public string Id { get; set; } + public string Status { get; set; } = "pending"; + public string SteamId { get; set; } + public string UserId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string Username { get; set; } + public string Role { get; set; } + public DateTime ExpiresUtc { get; set; } + public DateTime CreatedUtc { get; set; } +} diff --git a/Models/User.cs b/Models/User.cs index 2fec0d0..a3905b8 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -16,4 +16,6 @@ public class User public string RefreshToken { get; set; } public DateTime RefreshTokenExpiry { get; set; } public string Email { get; set; } -} \ No newline at end of file + public string NormalizedUsername { get; set; } + public string NormalizedEmail { get; set; } +} diff --git a/Program.cs b/Program.cs index 79d12b9..0c0e8f2 100644 --- a/Program.cs +++ b/Program.cs @@ -9,6 +9,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using System.Text; using Microsoft.AspNetCore.Http; +using System.Threading.RateLimiting; +using System; var builder = WebApplication.CreateBuilder(args); builder.WebHost.ConfigureKestrel(serverOptions => {}); @@ -55,6 +57,22 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddRateLimiter(options => +{ + options.AddPolicy("password-reset", context => RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(15), + QueueLimit = 0 + })); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); var jwtKey = builder.Configuration["Jwt:Key"]; var keyBytes = Encoding.UTF8.GetBytes(jwtKey); @@ -85,7 +103,9 @@ builder.Services.AddCors(options => }); var app = builder.Build(); +app.UseRouting(); app.UseCors("AllowAll"); +app.UseRateLimiter(); // Enable OPTIONS requests to reach controllers app.Use(async (context, next) => @@ -109,4 +129,12 @@ app.UseAuthorization(); app.MapControllers(); +using (var scope = app.Services.CreateScope()) +{ + var users = scope.ServiceProvider.GetRequiredService(); + await users.BackfillNormalizedFieldsAsync(); + await users.EnsureIndexesAsync(); + await scope.ServiceProvider.GetRequiredService().EnsureIndexesAsync(); + await scope.ServiceProvider.GetRequiredService().EnsureIndexesAsync(); +} app.Run(); diff --git a/README.md b/README.md index d66cae9..3bd5a73 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # gameauthapi +## MedMind authentication configuration + +Configure production values through environment variables (double underscores map to nested .NET settings): + +- `Auth__PublicBaseUrl=https://pauth.ranaze.com` +- `Email__SmtpHost`, `Email__SmtpPort`, `Email__UseTls` +- `Email__Username`, `Email__Password`, `Email__Sender` + +The public base URL must be reachable by Steam and route `/api/Auth/steam/callback` to this service. + ## Spawnables Spawnables describe items that can appear in the world. Along with the diff --git a/Services/EmailService.cs b/Services/EmailService.cs new file mode 100644 index 0000000..a0e1a80 --- /dev/null +++ b/Services/EmailService.cs @@ -0,0 +1,37 @@ +using System.Net; +using System.Net.Mail; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; + +namespace GameAuthApi.Services; + +public class EmailService +{ + private readonly IConfiguration _config; + + public EmailService(IConfiguration config) => _config = config; + + public virtual async Task SendPasswordResetCodeAsync(string recipient, string code) + { + var host = _config["Email:SmtpHost"]; + var sender = _config["Email:Sender"]; + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(sender)) + throw new InvalidOperationException("SMTP email delivery is not configured."); + + using var message = new MailMessage(sender, recipient) + { + Subject = "Your MedMind password reset code", + Body = $"Your MedMind verification code is {code}. It expires in 15 minutes. If you did not request this, you can ignore this email.", + IsBodyHtml = false + }; + using var client = new SmtpClient(host, int.TryParse(_config["Email:SmtpPort"], out var port) ? port : 587) + { + EnableSsl = !bool.TryParse(_config["Email:UseTls"], out var tls) || tls + }; + var username = _config["Email:Username"]; + if (!string.IsNullOrWhiteSpace(username)) + client.Credentials = new NetworkCredential(username, _config["Email:Password"]); + await client.SendMailAsync(message); + } +} diff --git a/Services/PasswordResetService.cs b/Services/PasswordResetService.cs new file mode 100644 index 0000000..58ba55e --- /dev/null +++ b/Services/PasswordResetService.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using GameAuthApi.Models; +using MongoDB.Driver; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; + +namespace GameAuthApi.Services; + +public class PasswordResetService +{ + private readonly IMongoCollection _challenges; + + public PasswordResetService(IConfiguration config) + { + var client = new MongoClient(config["MongoDB:ConnectionString"]); + var database = client.GetDatabase(config["MongoDB:DatabaseName"]); + _challenges = database.GetCollection("PasswordResetChallenges"); + } + + public virtual async Task CreateAsync(string userId) + { + await _challenges.UpdateManyAsync(x => x.UserId == userId && !x.Used, + Builders.Update.Set(x => x.Used, true)); + var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + await _challenges.InsertOneAsync(new PasswordResetChallenge + { + UserId = userId, + CodeHash = BCrypt.Net.BCrypt.HashPassword(code), + CreatedUtc = DateTime.UtcNow, + ExpiresUtc = DateTime.UtcNow.AddMinutes(15) + }); + return code; + } + + public virtual async Task ConsumeAsync(string userId, string code) + { + var challenge = await _challenges.Find(x => x.UserId == userId && !x.Used && x.ExpiresUtc > DateTime.UtcNow) + .SortByDescending(x => x.CreatedUtc).FirstOrDefaultAsync(); + if (challenge == null || challenge.FailedAttempts >= 5) + return false; + if (!BCrypt.Net.BCrypt.Verify(code, challenge.CodeHash)) + { + await _challenges.UpdateOneAsync(x => x.Id == challenge.Id, + Builders.Update.Inc(x => x.FailedAttempts, 1)); + return false; + } + await _challenges.UpdateOneAsync(x => x.Id == challenge.Id, + Builders.Update.Set(x => x.Used, true)); + return true; + } + + public async Task EnsureIndexesAsync() => await _challenges.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "password_reset_ttl" })); +} diff --git a/Services/SteamAuthService.cs b/Services/SteamAuthService.cs new file mode 100644 index 0000000..bccc26b --- /dev/null +++ b/Services/SteamAuthService.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using GameAuthApi.Models; +using MongoDB.Driver; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace GameAuthApi.Services; + +public class SteamAuthService +{ + private const string Provider = "steam"; + private readonly IMongoCollection _transactions; + private readonly IMongoCollection _links; + private readonly IConfiguration _config; + private readonly HttpClient _http; + + public SteamAuthService(IConfiguration config, IHttpClientFactory factory) + { + _config = config; + _http = factory.CreateClient(); + var client = new MongoClient(config["MongoDB:ConnectionString"]); + var database = client.GetDatabase(config["MongoDB:DatabaseName"]); + _transactions = database.GetCollection("SteamAuthTransactions"); + _links = database.GetCollection("ExternalIdentityLinks"); + } + + public virtual async Task<(SteamAuthTransaction Transaction, string AuthUrl)> StartAsync() + { + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var transaction = new SteamAuthTransaction { Id = id, CreatedUtc = DateTime.UtcNow, ExpiresUtc = DateTime.UtcNow.AddMinutes(10) }; + await _transactions.InsertOneAsync(transaction); + var baseUrl = (_config["Auth:PublicBaseUrl"] ?? "https://pauth.ranaze.com").TrimEnd('/'); + var returnTo = $"{baseUrl}/api/Auth/steam/callback?transactionId={Uri.EscapeDataString(id)}"; + var realm = baseUrl + "/"; + var query = new Dictionary + { + ["openid.ns"] = "http://specs.openid.net/auth/2.0", + ["openid.mode"] = "checkid_setup", + ["openid.return_to"] = returnTo, + ["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" + }; + var authUrl = "https://steamcommunity.com/openid/login?" + string.Join("&", query.Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value)}")); + return (transaction, authUrl); + } + + public virtual async Task GetAsync(string id) => + await _transactions.Find(x => x.Id == id && x.ExpiresUtc > DateTime.UtcNow).FirstOrDefaultAsync(); + + public virtual async Task VerifyCallbackAsync(string transactionId, IQueryCollection query) + { + var transaction = await GetAsync(transactionId); + if (transaction == null) return false; + var payload = query.Where(x => x.Key.StartsWith("openid.")).ToDictionary(x => x.Key, x => x.Value.ToString()); + payload["openid.mode"] = "check_authentication"; + using var response = await _http.PostAsync("https://steamcommunity.com/openid/login", new FormUrlEncodedContent(payload)); + var result = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode || !result.Contains("is_valid:true", StringComparison.Ordinal)) return false; + var claimed = query["openid.claimed_id"].ToString(); + const string prefix = "https://steamcommunity.com/openid/id/"; + if (!claimed.StartsWith(prefix, StringComparison.Ordinal) || !claimed[prefix.Length..].All(char.IsDigit)) return false; + var steamId = claimed[prefix.Length..]; + var link = await _links.Find(x => x.Provider == Provider && x.Subject == steamId).FirstOrDefaultAsync(); + await _transactions.UpdateOneAsync(x => x.Id == transactionId, + Builders.Update + .Set(x => x.SteamId, steamId) + .Set(x => x.UserId, link?.UserId) + .Set(x => x.Status, link == null ? "requires_account" : "authenticated")); + return true; + } + + public virtual async Task LinkAsync(string transactionId, string userId) + { + var transaction = await GetAsync(transactionId) ?? throw new InvalidOperationException("Steam transaction expired."); + if (transaction.Status != "requires_account" || string.IsNullOrWhiteSpace(transaction.SteamId)) + throw new InvalidOperationException("Steam transaction is not ready."); + await _links.InsertOneAsync(new ExternalIdentityLink + { + Provider = Provider, Subject = transaction.SteamId, UserId = userId, CreatedUtc = DateTime.UtcNow + }); + await _transactions.UpdateOneAsync(x => x.Id == transactionId, + Builders.Update.Set(x => x.UserId, userId).Set(x => x.Status, "authenticated")); + } + + public virtual async Task CompleteAsync(string transactionId, User user, string accessToken, string refreshToken) + { + await _transactions.UpdateOneAsync(x => x.Id == transactionId, + Builders.Update + .Set(x => x.Status, "complete") + .Set(x => x.AccessToken, accessToken) + .Set(x => x.RefreshToken, refreshToken) + .Set(x => x.Username, user.Username) + .Set(x => x.Role, user.Role)); + } + + public async Task EnsureIndexesAsync() + { + await _transactions.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "steam_transaction_ttl" })); + await _links.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Provider).Ascending(x => x.Subject), + new CreateIndexOptions { Unique = true, Name = "external_identity_unique" })); + } +} diff --git a/Services/UserService.cs b/Services/UserService.cs index 919389f..87516d8 100644 --- a/Services/UserService.cs +++ b/Services/UserService.cs @@ -3,6 +3,8 @@ using System.Threading.Tasks; using GameAuthApi.Models; using Microsoft.Extensions.Configuration; using MongoDB.Driver; +using System; +using System.Linq; namespace GameAuthApi.Services; @@ -20,6 +22,19 @@ public class UserService public virtual async Task GetByUsernameAsync(string username) => await _users.Find(u => u.Username == username).FirstOrDefaultAsync(); + public virtual async Task GetByLoginAsync(string login) + { + var normalized = Normalize(login); + return await _users.Find(u => + u.NormalizedUsername == normalized || + u.NormalizedEmail == normalized || + u.Username == login || + u.Email == login).FirstOrDefaultAsync(); + } + + public virtual async Task GetByIdAsync(string id) => + await _users.Find(u => u.Id == id).FirstOrDefaultAsync(); + public virtual async Task> GetAllAsync() => await _users.Find(_ => true).ToListAsync(); @@ -29,9 +44,52 @@ public class UserService return result.DeletedCount > 0; } - public virtual async Task CreateAsync(User user) => + public virtual async Task CreateAsync(User user) + { + ApplyNormalizedFields(user); await _users.InsertOneAsync(user); + } - public virtual async Task UpdateAsync(User user) => + public virtual async Task UpdateAsync(User user) + { + ApplyNormalizedFields(user); await _users.ReplaceOneAsync(u => u.Id == user.Id, user); -} \ No newline at end of file + } + + public virtual async Task EnsureIndexesAsync() + { + var username = new CreateIndexModel( + Builders.IndexKeys.Ascending(u => u.NormalizedUsername), + new CreateIndexOptions { Unique = true, Sparse = true, Name = "normalized_username_unique" }); + var email = new CreateIndexModel( + Builders.IndexKeys.Ascending(u => u.NormalizedEmail), + new CreateIndexOptions { Unique = true, Sparse = true, Name = "normalized_email_unique" }); + await _users.Indexes.CreateManyAsync(new[] { username, email }); + } + + public virtual async Task BackfillNormalizedFieldsAsync() + { + var users = await _users.Find(_ => true).ToListAsync(); + var duplicateUsername = users.GroupBy(u => Normalize(u.Username)).FirstOrDefault(g => g.Key != string.Empty && g.Count() > 1); + var duplicateEmail = users.Where(u => !string.IsNullOrWhiteSpace(u.Email)).GroupBy(u => Normalize(u.Email)).FirstOrDefault(g => g.Count() > 1); + if (duplicateUsername != null || duplicateEmail != null) + throw new InvalidOperationException("Duplicate normalized usernames or emails must be resolved before starting the authentication service."); + foreach (var user in users) + { + var normalizedUsername = Normalize(user.Username); + var normalizedEmail = string.IsNullOrWhiteSpace(user.Email) ? null : Normalize(user.Email); + if (user.NormalizedUsername == normalizedUsername && user.NormalizedEmail == normalizedEmail) continue; + user.NormalizedUsername = normalizedUsername; + user.NormalizedEmail = normalizedEmail; + await _users.ReplaceOneAsync(u => u.Id == user.Id, user); + } + } + + public static string Normalize(string value) => value?.Trim().ToUpperInvariant() ?? string.Empty; + + private static void ApplyNormalizedFields(User user) + { + user.NormalizedUsername = Normalize(user.Username); + user.NormalizedEmail = string.IsNullOrWhiteSpace(user.Email) ? null : Normalize(user.Email); + } +}