131 lines
4.7 KiB
C#
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);
|
|
}
|
|
}
|