Files
QMAX/server/QMax.Api/Controllers/AuthController.cs
T

273 lines
10 KiB
C#

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;
using QMax.Api.Infrastructure.Max;
namespace QMax.Api.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController(
QMaxDbContext db,
ITokenService tokenService,
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)
{
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<ActionResult<AuthResponse>> 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<IActionResult> 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<ActionResult<IReadOnlyList<SessionDto>>> 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);
}
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);
}