Initial QMAX production app
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user