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; namespace QMax.Api.Controllers; [ApiController] [Route("api/auth")] public sealed class AuthController( QMaxDbContext db, ITokenService tokenService, IOptions options) : ControllerBase { private readonly QMaxOptions _options = options.Value; [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); } }