Add multi-user MAX authentication and tenant isolation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user