Files
QMAX/server/QMax.Api/Controllers/AuthController.cs
T
2026-07-03 22:39:48 +03:00

131 lines
4.7 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;
namespace QMax.Api.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController(
QMaxDbContext db,
ITokenService tokenService,
IOptions<QMaxOptions> options) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[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);
}
}