Add multi-user MAX authentication and tenant isolation

This commit is contained in:
Курнат Андрей
2026-07-14 07:35:04 +03:00
parent 582f99ed0e
commit 440de7325f
36 changed files with 904 additions and 118 deletions
@@ -1,6 +1,9 @@
namespace QMax.Api.Contracts;
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName);
public sealed record BeginPhoneAuthRequest(string PhoneNumber, string DeviceName, string? RegistrationCode = null);
public sealed record CompletePhoneAuthRequest(Guid ChallengeId, string ChallengeToken, string Code);
public sealed record PhoneAuthChallengeResponse(Guid ChallengeId, string ChallengeToken, MaxBridgeStatusDto MaxStatus, DateTimeOffset ExpiresAt);
public sealed record RefreshTokenRequest(string RefreshToken);
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath);
+143 -1
View File
@@ -7,6 +7,7 @@ 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;
@@ -15,10 +16,117 @@ namespace QMax.Api.Controllers;
public sealed class AuthController(
QMaxDbContext db,
ITokenService tokenService,
IOptions<QMaxOptions> options) : ControllerBase
IOptions<QMaxOptions> options,
IMaxBridgeClient maxBridge,
ICurrentUserAccessor currentUser) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[AllowAnonymous]
[HttpPost("phone/start")]
public async Task<ActionResult<PhoneAuthChallengeResponse>> 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<ActionResult<AuthResponse>> 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<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
@@ -127,4 +235,38 @@ public sealed class AuthController(
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);
}
+12 -11
View File
@@ -8,6 +8,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Services;
namespace QMax.Api.Controllers;
@@ -64,7 +65,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
@@ -102,7 +103,7 @@ public sealed class ChatsController(
{
chat.UnreadCount = 0;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
var query = db.Messages
@@ -240,7 +241,7 @@ public sealed class ChatsController(
chat.UnreadCount = 0;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
return NoContent();
@@ -279,7 +280,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -327,7 +328,7 @@ public sealed class ChatsController(
await db.SaveChangesAsync(cancellationToken);
DeleteFiles(files);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -450,7 +451,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -504,7 +505,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -541,7 +542,7 @@ public sealed class ChatsController(
}
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
@@ -717,7 +718,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -785,7 +786,7 @@ public sealed class ChatsController(
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
@@ -929,7 +930,7 @@ public sealed class ChatsController(
chat.WebUrl = nextWebUrl;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return chat.WebUrl;
}
+14 -10
View File
@@ -10,6 +10,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Services;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Controllers;
@@ -21,11 +22,8 @@ public sealed class MaxController(
MaxBridgeSyncService syncService,
QMaxDbContext db,
IHubContext<QMaxHub> hubContext,
ChatProjectionService projection,
IOptions<QMaxOptions> options) : ControllerBase
ChatProjectionService projection) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet("status")]
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
{
@@ -37,7 +35,12 @@ public sealed class MaxController(
[HttpPost("login/start")]
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
{
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
var phone = request.PhoneNumber;
if (string.IsNullOrWhiteSpace(phone))
{
var userId = User.GetUserId();
phone = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken);
}
if (string.IsNullOrWhiteSpace(phone))
{
return BadRequest("Phone number is required.");
@@ -111,27 +114,28 @@ public sealed class MaxController(
return BadRequest("Channel was joined but was not saved in QMAX.");
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User(User.GetUserId().ToString()).SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
{
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
var userId = User.GetUserId();
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = _options.MaxPhoneNumber;
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
+2
View File
@@ -3,6 +3,8 @@ namespace QMax.Api.Data.Entities;
public sealed class Chat
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; }
public User? User { get; set; }
public string? ExternalId { get; set; }
public ChatKind Kind { get; set; } = ChatKind.MaxDialog;
public string Title { get; set; } = "MAX chat";
@@ -2,7 +2,8 @@ namespace QMax.Api.Data.Entities;
public sealed class MaxAccountState
{
public int Id { get; set; } = 1;
public Guid UserId { get; set; }
public User? User { get; set; }
public string PhoneNumber { get; set; } = "";
public string Status { get; set; } = "NotStarted";
public bool IsAuthorized { get; set; }
@@ -0,0 +1,14 @@
namespace QMax.Api.Data.Entities;
public sealed class MaxLoginChallenge
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public User? User { get; set; }
public string SecretHash { get; set; } = "";
public string DeviceName { get; set; } = "Android";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddMinutes(10);
public DateTimeOffset? CompletedAt { get; set; }
public int FailedAttempts { get; set; }
}
+51 -3
View File
@@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Data;
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbContext(options)
public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options, ICurrentUserAccessor? currentUser = null) : DbContext(options)
{
private Guid? TenantUserId => currentUser?.UserId;
private bool TenantBypass => currentUser is null || currentUser.BypassTenantFilter || currentUser.UserId is null;
public DbSet<User> Users => Set<User>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<Chat> Chats => Set<Chat>();
@@ -13,12 +16,20 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
public DbSet<MaxAccountState> MaxAccountStates => Set<MaxAccountState>();
public DbSet<MaxLoginChallenge> MaxLoginChallenges => Set<MaxLoginChallenge>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber);
modelBuilder.Entity<User>().HasIndex(x => x.PhoneNumber).IsUnique();
modelBuilder.Entity<MaxLoginChallenge>().HasIndex(x => x.SecretHash).IsUnique();
modelBuilder.Entity<MaxAccountState>().HasKey(x => x.UserId);
modelBuilder.Entity<MaxAccountState>()
.HasOne(x => x.User)
.WithOne()
.HasForeignKey<MaxAccountState>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<UserSession>().HasIndex(x => x.RefreshTokenHash).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => x.ExternalId).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => new { x.UserId, x.ExternalId }).IsUnique();
modelBuilder.Entity<Chat>().HasIndex(x => x.DeletedAt);
modelBuilder.Entity<Chat>().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt });
modelBuilder.Entity<Message>().HasIndex(x => new { x.ChatId, x.SentAt });
@@ -28,6 +39,21 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
modelBuilder.Entity<MessageReaction>().HasIndex(x => new { x.MessageId, x.ActorKey }).IsUnique();
modelBuilder.Entity<PushDevice>().HasIndex(x => x.FirebaseToken).IsUnique();
modelBuilder.Entity<Chat>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<Message>().HasQueryFilter(x =>
TenantBypass || (x.Chat != null && x.Chat.UserId == TenantUserId));
modelBuilder.Entity<MessageAttachment>().HasQueryFilter(x =>
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
modelBuilder.Entity<MessageReaction>().HasQueryFilter(x =>
TenantBypass || (x.Message != null && x.Message.Chat != null && x.Message.Chat.UserId == TenantUserId));
modelBuilder.Entity<PushDevice>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<MaxAccountState>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<MaxLoginChallenge>().HasQueryFilter(x =>
TenantBypass || x.UserId == TenantUserId);
modelBuilder.Entity<Chat>()
.Property(x => x.Kind)
.HasConversion<string>();
@@ -60,4 +86,26 @@ public sealed class QMaxDbContext(DbContextOptions<QMaxDbContext> options) : DbC
.HasForeignKey(x => x.MessageId)
.OnDelete(DeleteBehavior.Cascade);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var newChats = ChangeTracker.Entries<Chat>()
.Where(x => x.State == EntityState.Added && x.Entity.UserId is null)
.ToArray();
var userId = currentUser?.UserId;
if (userId is null && currentUser is not null && newChats.Length > 0)
{
var existingUsers = await Users.Select(x => x.Id).Take(2).ToArrayAsync(cancellationToken);
if (existingUsers.Length == 1) userId = existingUsers[0];
}
if (userId is not null)
{
foreach (var entry in newChats)
{
entry.Entity.UserId = userId;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
@@ -0,0 +1,37 @@
namespace QMax.Api.Infrastructure.Auth;
public interface ICurrentUserAccessor
{
Guid? UserId { get; }
bool BypassTenantFilter { get; }
IDisposable Push(Guid? userId, bool bypassTenantFilter = false);
}
public sealed class CurrentUserAccessor : ICurrentUserAccessor
{
private static readonly AsyncLocal<State?> Current = new();
public Guid? UserId => Current.Value?.UserId;
public bool BypassTenantFilter => Current.Value?.BypassTenantFilter == true;
public IDisposable Push(Guid? userId, bool bypassTenantFilter = false)
{
var previous = Current.Value;
Current.Value = new State(userId, bypassTenantFilter);
return new PopScope(previous);
}
private sealed record State(Guid? UserId, bool BypassTenantFilter);
private sealed class PopScope(State? previous) : IDisposable
{
private bool _disposed;
public void Dispose()
{
if (_disposed) return;
Current.Value = previous;
_disposed = true;
}
}
}
+10 -3
View File
@@ -1,14 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Hubs;
[Authorize]
public sealed class QMaxHub : Hub
public sealed class QMaxHub(QMaxDbContext db) : Hub
{
public Task JoinChat(string chatId)
public async Task JoinChat(string chatId)
{
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
if (!Guid.TryParse(chatId, out var id) || !await db.Chats.AnyAsync(x => x.Id == id && x.UserId == Context.User!.GetUserId()))
{
throw new HubException("Chat not found.");
}
await Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{id}");
}
public Task LeaveChat(string chatId)
@@ -4,6 +4,8 @@ public interface IMaxBridgeClient
{
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
@@ -45,6 +45,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
}
public Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) =>
BeginPhoneLoginAsync(phoneNumber, cancellationToken);
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxContact>>([
@@ -2,12 +2,14 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Infrastructure.Max;
public sealed class WorkerMaxBridgeClient(
HttpClient httpClient,
IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -21,7 +23,17 @@ public sealed class WorkerMaxBridgeClient(
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: false, cancellationToken);
}
public async Task<MaxBridgeStatus> BeginNewPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await BeginPhoneLoginCoreAsync(phoneNumber, forceNewSession: true, cancellationToken);
}
private async Task<MaxBridgeStatus> BeginPhoneLoginCoreAsync(string phoneNumber, bool forceNewSession, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber, force = forceNewSession }, cancellationToken)
?? ErrorStatus("Worker returned an empty login status.");
}
@@ -171,7 +183,9 @@ public sealed class WorkerMaxBridgeClient(
{
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}";
var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Get, path);
AddAccountHeader(request);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -217,6 +231,7 @@ public sealed class WorkerMaxBridgeClient(
{
httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
AddAccountHeader(request);
if (body is not null)
{
request.Content = JsonContent.Create(body, options: JsonOptions);
@@ -239,6 +254,15 @@ public sealed class WorkerMaxBridgeClient(
}
}
private void AddAccountHeader(HttpRequestMessage request)
{
if (currentUser.UserId is not { } userId)
{
throw new InvalidOperationException("A QMAX user context is required for a PyMax request.");
}
request.Headers.Add("X-QMax-Account-Id", userId.ToString("N"));
}
private static MaxBridgeStatus ErrorStatus(string error)
{
return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow);
+64 -2
View File
@@ -90,6 +90,7 @@ builder.Services.AddControllers().AddJsonOptions(options =>
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddScoped<ChatProjectionService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
@@ -117,6 +118,15 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
app.UseCors();
app.UseAuthentication();
app.Use(async (context, next) =>
{
var currentUser = context.RequestServices.GetRequiredService<ICurrentUserAccessor>();
var userId = context.User.Identity?.IsAuthenticated == true ? context.User.GetUserId() : (Guid?)null;
using (currentUser.Push(userId))
{
await next(context);
}
});
app.UseAuthorization();
app.MapControllers();
app.MapHub<QMaxHub>("/hubs/qmax");
@@ -127,8 +137,12 @@ using (var scope = app.Services.CreateScope())
Directory.CreateDirectory(options.StoragePath);
Directory.CreateDirectory(options.ReleasesPath);
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
var currentUser = scope.ServiceProvider.GetRequiredService<ICurrentUserAccessor>();
using (currentUser.Push(null, bypassTenantFilter: true))
{
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
}
}
app.Run();
@@ -152,6 +166,34 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
}
}
if (!chatColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN UserId TEXT NULL;");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE Chats SET UserId = {legacyUserId} WHERE UserId IS NULL;");
}
}
var maxStateColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(MaxAccountStates);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync()) maxStateColumns.Add(reader.GetString(1));
}
if (maxStateColumns.Count > 0 && !maxStateColumns.Contains("UserId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MaxAccountStates ADD COLUMN UserId TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';");
var legacyUserId = await db.Users.Select(x => x.Id).FirstOrDefaultAsync();
if (legacyUserId != Guid.Empty)
{
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE MaxAccountStates SET UserId = {legacyUserId} WHERE UserId = '00000000-0000-0000-0000-000000000000';");
}
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxAccountStates_UserId ON MaxAccountStates (UserId);");
}
if (!chatColumns.Contains("WebUrl"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;");
@@ -218,6 +260,10 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
await QMaxDatabaseCleanup.MergeOutgoingRemoteAttachmentEchoesAsync(db);
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(db);
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_MessageAttachments_MessageId_ExternalId;");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Chats_ExternalId;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Chats_UserId_ExternalId ON Chats (UserId, ExternalId);");
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_Users_PhoneNumber;");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_Users_PhoneNumber ON Users (PhoneNumber) WHERE PhoneNumber IS NOT NULL AND PhoneNumber <> '';");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId)
@@ -255,6 +301,22 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS MaxLoginChallenges (
Id TEXT NOT NULL CONSTRAINT PK_MaxLoginChallenges PRIMARY KEY,
UserId TEXT NOT NULL,
SecretHash TEXT NOT NULL,
DeviceName TEXT NOT NULL,
CreatedAt TEXT NOT NULL,
ExpiresAt TEXT NOT NULL,
CompletedAt TEXT NULL,
FailedAttempts INTEGER NOT NULL DEFAULT 0,
CONSTRAINT FK_MaxLoginChallenges_Users_UserId FOREIGN KEY (UserId) REFERENCES Users (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_MaxLoginChallenges_SecretHash ON MaxLoginChallenges (SecretHash);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_MaxLoginChallenges_UserId ON MaxLoginChallenges (UserId);");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
ON MessageReactions (MessageId, ActorKey);
@@ -10,6 +10,7 @@ using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using System.Collections.Concurrent;
using System.Text;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
@@ -17,6 +18,7 @@ public sealed class MaxBridgeSyncService(
IServiceScopeFactory scopeFactory,
IMaxBridgeClient maxBridgeClient,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxBridgeSyncService> logger)
{
private const string PreviewExternalIdPrefix = "preview:";
@@ -44,22 +46,22 @@ public sealed class MaxBridgeSyncService(
var status = await maxBridgeClient.GetStatusAsync(cancellationToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
var userId = currentUser.UserId ?? throw new InvalidOperationException("MAX sync requires a user context.");
var state = await db.MaxAccountStates.FirstOrDefaultAsync(cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
state = new MaxAccountState { UserId = userId };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = options.MaxPhoneNumber;
state.PhoneNumber = await db.Users.Where(x => x.Id == userId).Select(x => x.PhoneNumber).FirstOrDefaultAsync(cancellationToken) ?? "";
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
await hubContext.Clients.User(userId.ToString()).SendAsync("MaxStatusChanged", ToDto(status), cancellationToken);
}
catch (Exception statusError)
{
@@ -465,7 +467,7 @@ public sealed class MaxBridgeSyncService(
}
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
foreach (var request in genericMediaHistoryRequests
@@ -1401,10 +1403,10 @@ public sealed class MaxBridgeSyncService(
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
}
var titleMatches = await deletedChats
var titleMatches = (await deletedChats.ToListAsync(cancellationToken))
.OrderByDescending(x => x.DeletedAt)
.Take(2)
.ToListAsync(cancellationToken);
.ToList();
return titleMatches.Count == 1 ? titleMatches[0] : null;
}
+3 -1
View File
@@ -5,6 +5,7 @@ using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
@@ -14,6 +15,7 @@ public sealed class MaxOutboxService(
IAttachmentStorageService storage,
ChatProjectionService projection,
IHubContext<QMaxHub> hubContext,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxService> logger)
{
private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10);
@@ -338,6 +340,6 @@ public sealed class MaxOutboxService(
"MessageUpdated",
projection.ToDto(updated),
cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
await hubContext.Clients.User((currentUser.UserId ?? Guid.Empty).ToString()).SendAsync("ChatListInvalidated", cancellationToken);
}
}
+27 -3
View File
@@ -1,11 +1,15 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
public sealed class MaxOutboxWorker(
IServiceScopeFactory scopeFactory,
IOptions<QMaxOptions> options,
ICurrentUserAccessor currentUser,
ILogger<MaxOutboxWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
@@ -48,9 +52,29 @@ public sealed class MaxOutboxWorker(
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
await process(outbox, stoppingToken);
Guid[] userIds;
await using (var discoveryScope = scopeFactory.CreateAsyncScope())
using (currentUser.Push(null, bypassTenantFilter: true))
{
var db = discoveryScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
userIds = await db.MaxAccountStates
.Where(x => x.IsAuthorized)
.Select(x => x.UserId)
.ToArrayAsync(stoppingToken);
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
{
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
}
}
foreach (var userId in userIds)
{
using (currentUser.Push(userId))
await using (var scope = scopeFactory.CreateAsyncScope())
{
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
await process(outbox, stoppingToken);
}
}
}
catch (Exception ex)
{
+26 -1
View File
@@ -1,11 +1,16 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Services;
public sealed class MaxSyncWorker(
IOptions<QMaxOptions> options,
MaxBridgeSyncService syncService,
IServiceScopeFactory scopeFactory,
ICurrentUserAccessor currentUser,
ILogger<MaxSyncWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
@@ -19,7 +24,27 @@ public sealed class MaxSyncWorker(
{
try
{
await syncService.SyncOnceAsync(stoppingToken);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
Guid[] userIds;
using (currentUser.Push(null, bypassTenantFilter: true))
{
userIds = await db.MaxAccountStates
.Where(x => x.IsAuthorized)
.Select(x => x.UserId)
.ToArrayAsync(stoppingToken);
if (userIds.Length == 0 && !await db.MaxAccountStates.AnyAsync(stoppingToken))
{
userIds = await db.Users.Select(x => x.Id).ToArrayAsync(stoppingToken);
}
}
foreach (var userId in userIds)
{
using (currentUser.Push(userId))
{
await syncService.SyncOnceAsync(stoppingToken);
}
}
}
catch (Exception ex)
{