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
+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)