using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Ikar.Server.Data; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure.Auth; using Ikar.Shared; namespace Ikar.Server.Controllers; [ApiController] [Route("api/[controller]")] public sealed class AuthController( IkarDbContext dbContext, TokenService tokenService, PresenceTracker presenceTracker, PhoneAuthChallengeStore phoneAuthChallengeStore, EmailAuthChallengeStore emailAuthChallengeStore, IEmailCodeSender emailCodeSender) : ControllerBase { private readonly PasswordHasher _passwordHasher = new(); [HttpPost("register")] [AllowAnonymous] public async Task> Register(RegisterRequest request, CancellationToken cancellationToken) { var username = request.Username?.Trim() ?? string.Empty; var displayName = request.DisplayName?.Trim() ?? string.Empty; var password = request.Password ?? string.Empty; var phoneNumber = request.PhoneNumber?.Trim() ?? string.Empty; var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(phoneNumber); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(displayName) || password.Length < 6 || normalizedPhoneNumber is null) { return ValidationProblem("Username, display name, phone number and a password of at least 6 characters are required."); } var normalizedUsername = username.ToUpperInvariant(); if (await dbContext.Users.AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken)) { return Conflict("Username is already taken."); } if (await dbContext.Users.AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken)) { return Conflict("Phone number is already used."); } var user = new User { Username = username, NormalizedUsername = normalizedUsername, DisplayName = displayName, PhoneNumber = phoneNumber, NormalizedPhoneNumber = normalizedPhoneNumber }; user.PasswordHash = _passwordHasher.HashPassword(user, password); dbContext.Users.Add(user); try { await dbContext.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { var usernameTaken = await dbContext.Users .AsNoTracking() .AnyAsync(x => x.NormalizedUsername == normalizedUsername, cancellationToken); if (usernameTaken) { return Conflict("Username is already taken."); } var phoneTaken = await dbContext.Users .AsNoTracking() .AnyAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); if (phoneTaken) { return Conflict("Phone number is already used."); } throw; } var session = await tokenService.CreateSessionAsync( user, ClientVersionRequestReader.Read(Request), cancellationToken); return Ok(session with { User = user.ToDto(presenceTracker) }); } [HttpPost("login")] [AllowAnonymous] public async Task> Login(LoginRequest request, CancellationToken cancellationToken) { var username = request.Username?.Trim() ?? string.Empty; var password = request.Password ?? string.Empty; var normalizedUsername = username.ToUpperInvariant(); var user = await dbContext.Users.SingleOrDefaultAsync( x => x.NormalizedUsername == normalizedUsername, cancellationToken); if (user is null) { return Unauthorized("Invalid username or password."); } if (user.IsBot) { return Unauthorized("Bot accounts cannot sign in through the user client."); } if (user.IsBlocked) { return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } var verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); if (verification == PasswordVerificationResult.Failed) { return Unauthorized("Invalid username or password."); } var session = await tokenService.CreateSessionAsync( user, ClientVersionRequestReader.Read(Request), cancellationToken); return Ok(session with { User = user.ToDto(presenceTracker) }); } [HttpPost("request-code")] [AllowAnonymous] public async Task> RequestPhoneCode( RequestPhoneCodeRequest request, CancellationToken cancellationToken) { var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); if (normalizedPhoneNumber is null) { return ValidationProblem("Valid phone number is required."); } var user = await dbContext.Users .AsNoTracking() .SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); if (user is null) { return NotFound("Phone code login is available only for existing accounts. Register by email."); } if (user?.IsBlocked == true) { return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true); return Ok(new PhoneCodeChallengeDto( challenge.Id, challenge.ExpiresAt, challenge.Code, challenge.IsRegistered)); } [HttpPost("verify-code")] [AllowAnonymous] public async Task> VerifyPhoneCode( VerifyPhoneCodeRequest request, CancellationToken cancellationToken) { var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); if (normalizedPhoneNumber is null) { return ValidationProblem("Valid phone number is required."); } var challenge = phoneAuthChallengeStore.Consume( request.ChallengeId?.Trim() ?? string.Empty, normalizedPhoneNumber, request.Code?.Trim() ?? string.Empty); if (challenge is null) { return Unauthorized("Invalid or expired verification code."); } var user = await dbContext.Users.SingleOrDefaultAsync( x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); if (user is null) { return NotFound("Phone code login is available only for existing accounts. Register by email."); } if (user.IsBlocked) { return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } var session = await tokenService.CreateSessionAsync( user, ClientVersionRequestReader.Read(Request), cancellationToken); return Ok(session with { User = user.ToDto(presenceTracker) }); } [HttpPost("request-email-code")] [AllowAnonymous] public async Task> RequestEmailCode( RequestEmailCodeRequest request, CancellationToken cancellationToken) { var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email); if (normalizedEmail is null) { return ValidationProblem("Valid email is required."); } var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); if (normalizedPhoneNumber is null) { return ValidationProblem("Valid phone number is required."); } var email = request.Email.Trim(); var (user, conflictMessage) = await LoadEmailPhoneUserAsync( normalizedEmail, normalizedPhoneNumber, cancellationToken); if (conflictMessage is not null) { return Conflict(conflictMessage); } if (RequiresAllowedRegistrationDomain(user, normalizedEmail) && !EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail)) { return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage); } if (user?.IsBlocked == true) { return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } var challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null); try { await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken); } catch { emailAuthChallengeStore.Remove(challenge.Id); throw; } return Ok(new EmailCodeChallengeDto( challenge.Id, challenge.ExpiresAt, challenge.IsRegistered)); } [HttpPost("verify-email-code")] [AllowAnonymous] public async Task> VerifyEmailCode( VerifyEmailCodeRequest request, CancellationToken cancellationToken) { var normalizedEmail = EmailAddressNormalizer.Normalize(request.Email); if (normalizedEmail is null) { return ValidationProblem("Valid email is required."); } var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); if (normalizedPhoneNumber is null) { return ValidationProblem("Valid phone number is required."); } var challenge = emailAuthChallengeStore.Consume( request.ChallengeId?.Trim() ?? string.Empty, normalizedEmail, normalizedPhoneNumber, request.Code?.Trim() ?? string.Empty); if (challenge is null) { return Unauthorized("Invalid or expired verification code."); } var (user, conflictMessage) = await LoadEmailPhoneUserAsync( normalizedEmail, normalizedPhoneNumber, cancellationToken); if (conflictMessage is not null) { return Conflict(conflictMessage); } if (RequiresAllowedRegistrationDomain(user, normalizedEmail) && !EmailAddressNormalizer.IsAllowedRegistrationDomain(normalizedEmail)) { return BadRequest(EmailAddressNormalizer.UnsupportedRegistrationDomainMessage); } if (user is null) { var displayName = request.DisplayName?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(displayName)) { return ValidationProblem("Display name is required for a new account."); } user = await CreateEmailPhoneUserAsync( normalizedEmail, request.Email, normalizedPhoneNumber, request.PhoneNumber, displayName, cancellationToken); } if (user.IsBot) { return Unauthorized("Bot accounts cannot sign in through the user client."); } if (user.IsBlocked) { return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } await EnsureEmailPhoneLinkedAsync( user, normalizedEmail, request.Email, normalizedPhoneNumber, request.PhoneNumber, cancellationToken); var session = await tokenService.CreateSessionAsync( user, ClientVersionRequestReader.Read(Request), cancellationToken); return Ok(session with { User = user.ToDto(presenceTracker) }); } [HttpPost("refresh")] [AllowAnonymous] public async Task> Refresh(RefreshSessionRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.RefreshToken)) { return Unauthorized(); } var session = await tokenService.RefreshSessionAsync( request.RefreshToken, ClientVersionRequestReader.Read(Request), cancellationToken); if (session is null) { return Unauthorized(); } return Ok(session with { User = session.User with { IsOnline = presenceTracker.IsOnline(session.User.Id) } }); } [HttpPost("logout")] [Authorize] public async Task Logout(CancellationToken cancellationToken) { await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken); return NoContent(); } private async Task CreatePhoneOnlyUserAsync( string normalizedPhoneNumber, string rawPhoneNumber, string displayName, CancellationToken cancellationToken) { var username = await GenerateInternalUsernameAsync(cancellationToken); var user = new User { Username = username, NormalizedUsername = username.ToUpperInvariant(), DisplayName = displayName, PhoneNumber = rawPhoneNumber.Trim(), NormalizedPhoneNumber = normalizedPhoneNumber }; user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); dbContext.Users.Add(user); await dbContext.SaveChangesAsync(cancellationToken); return user; } private async Task CreateEmailPhoneUserAsync( string normalizedEmail, string rawEmail, string normalizedPhoneNumber, string rawPhoneNumber, string displayName, CancellationToken cancellationToken) { var username = await GenerateInternalUsernameAsync(cancellationToken); var user = new User { Username = username, NormalizedUsername = username.ToUpperInvariant(), DisplayName = displayName, Email = rawEmail.Trim(), NormalizedEmail = normalizedEmail, PhoneNumber = rawPhoneNumber.Trim(), NormalizedPhoneNumber = normalizedPhoneNumber }; user.PasswordHash = _passwordHasher.HashPassword(user, Guid.NewGuid().ToString("N")); dbContext.Users.Add(user); await dbContext.SaveChangesAsync(cancellationToken); return user; } private async Task<(User? User, string? ConflictMessage)> LoadEmailPhoneUserAsync( string normalizedEmail, string normalizedPhoneNumber, CancellationToken cancellationToken) { var users = await dbContext.Users .Where(x => x.NormalizedEmail == normalizedEmail || x.NormalizedPhoneNumber == normalizedPhoneNumber) .ToListAsync(cancellationToken); var emailUser = users.SingleOrDefault(x => x.NormalizedEmail == normalizedEmail); var phoneUser = users.SingleOrDefault(x => x.NormalizedPhoneNumber == normalizedPhoneNumber); if (emailUser is not null && phoneUser is not null && emailUser.Id != phoneUser.Id) { return (null, "Email and phone number belong to different accounts."); } var user = emailUser ?? phoneUser; if (user is null) { return (null, null); } if (!string.IsNullOrWhiteSpace(user.NormalizedEmail) && !string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal)) { return (null, "Phone number is already linked to another email."); } if (!string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber) && !string.Equals(user.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal)) { return (null, "Email is already linked to another phone number."); } return (user, null); } private static bool RequiresAllowedRegistrationDomain(User? user, string normalizedEmail) => user is null || !string.Equals(user.NormalizedEmail, normalizedEmail, StringComparison.Ordinal); private async Task EnsureEmailPhoneLinkedAsync( User user, string normalizedEmail, string rawEmail, string normalizedPhoneNumber, string rawPhoneNumber, CancellationToken cancellationToken) { var changed = false; if (string.IsNullOrWhiteSpace(user.NormalizedEmail)) { user.Email = rawEmail.Trim(); user.NormalizedEmail = normalizedEmail; changed = true; } if (string.IsNullOrWhiteSpace(user.NormalizedPhoneNumber)) { user.PhoneNumber = rawPhoneNumber.Trim(); user.NormalizedPhoneNumber = normalizedPhoneNumber; changed = true; } if (changed) { await dbContext.SaveChangesAsync(cancellationToken); } } private async Task GenerateInternalUsernameAsync(CancellationToken cancellationToken) { while (true) { var candidate = $"user_{Guid.NewGuid():N}".ToLowerInvariant(); var exists = await dbContext.Users .AsNoTracking() .AnyAsync(x => x.NormalizedUsername == candidate.ToUpperInvariant(), cancellationToken); if (!exists) { return candidate; } } } }