using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using QMax.Api.Configuration; using QMax.Api.Contracts; using QMax.Api.Data; using QMax.Api.Data.Entities; using QMax.Api.Infrastructure.Auth; using QMax.Api.Infrastructure.Max; namespace QMax.Api.Controllers; [ApiController] [Route("api/auth")] public sealed class AuthController( QMaxDbContext db, ITokenService tokenService, IOptions options, IMaxBridgeClient maxBridge, ICurrentUserAccessor currentUser) : ControllerBase { private readonly QMaxOptions _options = options.Value; [AllowAnonymous] [HttpPost("phone/start")] public async Task> BeginPhoneAuth( BeginPhoneAuthRequest request, CancellationToken cancellationToken) { if (!RegistrationCodeIsValid(request.RegistrationCode)) { return Unauthorized(); } var phone = NormalizePhone(request.PhoneNumber); if (phone is null) { return BadRequest("Phone number must contain 10 to 15 digits."); } var user = await db.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phone, cancellationToken); if (user is null) { user = new User { DisplayName = phone, PhoneNumber = phone }; db.Users.Add(user); try { await db.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { db.Entry(user).State = EntityState.Detached; user = await db.Users.FirstAsync(x => x.PhoneNumber == phone, cancellationToken); } } var retryAfter = DateTimeOffset.UtcNow.AddSeconds(-60); var recentChallengeTimes = await db.MaxLoginChallenges.IgnoreQueryFilters() .Where(x => x.UserId == user.Id && x.CompletedAt == null) .Select(x => x.CreatedAt) .ToArrayAsync(cancellationToken); if (recentChallengeTimes.Any(x => x >= retryAfter)) { return StatusCode(StatusCodes.Status429TooManyRequests, "Wait 60 seconds before requesting another MAX code."); } var challengeToken = tokenService.CreateRefreshToken(); var challenge = new MaxLoginChallenge { UserId = user.Id, DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(), SecretHash = tokenService.HashRefreshToken(challengeToken) }; using var tenant = currentUser.Push(user.Id); db.MaxLoginChallenges.Add(challenge); await db.SaveChangesAsync(cancellationToken); var status = await maxBridge.BeginNewPhoneLoginAsync(phone, cancellationToken); await SaveMaxStateAsync(user.Id, phone, status, cancellationToken); return new PhoneAuthChallengeResponse( challenge.Id, challengeToken, ToDto(status), challenge.ExpiresAt); } [AllowAnonymous] [HttpPost("phone/code")] public async Task> CompletePhoneAuth( CompletePhoneAuthRequest request, CancellationToken cancellationToken) { var hash = tokenService.HashRefreshToken(request.ChallengeToken ?? ""); var challenge = await db.MaxLoginChallenges .IgnoreQueryFilters() .Include(x => x.User) .FirstOrDefaultAsync(x => x.Id == request.ChallengeId && x.SecretHash == hash, cancellationToken); if (challenge?.User is null || challenge.CompletedAt is not null || challenge.ExpiresAt <= DateTimeOffset.UtcNow || challenge.FailedAttempts >= 5) { return Unauthorized(); } using var tenant = currentUser.Push(challenge.UserId); var status = await maxBridge.SubmitLoginCodeAsync(request.Code?.Trim() ?? "", cancellationToken); await SaveMaxStateAsync(challenge.UserId, challenge.User.PhoneNumber ?? "", status, cancellationToken); if (!status.IsAuthorized) { challenge.FailedAttempts++; await db.SaveChangesAsync(cancellationToken); return Conflict(ToDto(status)); } challenge.CompletedAt = DateTimeOffset.UtcNow; challenge.User.UpdatedAt = DateTimeOffset.UtcNow; var refreshToken = tokenService.CreateRefreshToken(); var session = new UserSession { User = challenge.User, DeviceName = challenge.DeviceName, RefreshTokenHash = tokenService.HashRefreshToken(refreshToken) }; db.UserSessions.Add(session); await db.SaveChangesAsync(cancellationToken); return CreateAuthResponse(challenge.User, session, refreshToken); } [AllowAnonymous] [HttpPost("device/login")] public async Task> Login(DeviceLoginRequest request, CancellationToken cancellationToken) { var expected = _options.PairingCode; if (string.IsNullOrWhiteSpace(expected)) { return Problem("QMax:PairingCode is not configured on the server.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (!FixedTimeEquals(expected, request.PairingCode)) { return Unauthorized(); } var user = await db.Users.FirstOrDefaultAsync(cancellationToken); if (user is null) { user = new User { DisplayName = "QMAX Owner", PhoneNumber = string.IsNullOrWhiteSpace(_options.MaxPhoneNumber) ? null : _options.MaxPhoneNumber }; db.Users.Add(user); } var refreshToken = tokenService.CreateRefreshToken(); var session = new UserSession { User = user, DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(), RefreshTokenHash = tokenService.HashRefreshToken(refreshToken) }; db.UserSessions.Add(session); await db.SaveChangesAsync(cancellationToken); return CreateAuthResponse(user, session, refreshToken); } [AllowAnonymous] [HttpPost("refresh")] public async Task> Refresh(RefreshTokenRequest request, CancellationToken cancellationToken) { var hash = tokenService.HashRefreshToken(request.RefreshToken); var session = await db.UserSessions .Include(x => x.User) .FirstOrDefaultAsync(x => x.RefreshTokenHash == hash, cancellationToken); if (session?.User is null || session.RevokedAt is not null || session.ExpiresAt <= DateTimeOffset.UtcNow) { return Unauthorized(); } session.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); return CreateAuthResponse(session.User, session, request.RefreshToken); } [Authorize] [HttpPost("logout")] public async Task Logout(CancellationToken cancellationToken) { var sessionId = User.GetSessionId(); if (sessionId is not null) { var session = await db.UserSessions.FindAsync([sessionId.Value], cancellationToken); if (session is not null) { session.RevokedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); } } return NoContent(); } [Authorize] [HttpGet("sessions")] public async Task>> Sessions(CancellationToken cancellationToken) { var userId = User.GetUserId(); var currentSessionId = User.GetSessionId(); var sessions = (await db.UserSessions .Where(x => x.UserId == userId && x.RevokedAt == null) .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.LastSeenAt) .Select(x => new SessionDto(x.Id, x.DeviceName, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.Id == currentSessionId)) .ToArray(); return sessions; } private AuthResponse CreateAuthResponse(User user, UserSession session, string refreshToken) { var expiresAt = DateTimeOffset.UtcNow.AddHours(8); var accessToken = tokenService.CreateAccessToken(user, session, expiresAt); var dto = new UserDto(user.Id, user.DisplayName, user.PhoneNumber, user.AvatarPath); return new AuthResponse(accessToken, refreshToken, expiresAt, dto); } private static bool FixedTimeEquals(string expected, string actual) { var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected); var actualBytes = System.Text.Encoding.UTF8.GetBytes(actual); return expectedBytes.Length == actualBytes.Length && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes); } private bool RegistrationCodeIsValid(string? supplied) { return string.IsNullOrWhiteSpace(_options.PairingCode) || FixedTimeEquals(_options.PairingCode, supplied ?? ""); } private static string? NormalizePhone(string? value) { var digits = new string((value ?? "").Where(char.IsDigit).ToArray()); if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..]; if (digits.Length == 10) digits = "7" + digits; return digits.Length is >= 10 and <= 15 ? "+" + digits : null; } private async Task SaveMaxStateAsync(Guid userId, string phone, MaxBridgeStatus status, CancellationToken cancellationToken) { var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken); if (state is null) { state = new MaxAccountState { UserId = userId }; db.MaxAccountStates.Add(state); } state.PhoneNumber = phone; state.Status = status.Status; state.IsAuthorized = status.IsAuthorized; state.LastUrl = status.Url; state.LastError = status.LastError; state.UpdatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken); } private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status) => new(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt); }