273 lines
10 KiB
C#
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);
|
|
}
|