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);
}