UDT-002: Logout + Refresh Token con rotación y chain revocation #3

Merged
dmolinari merged 36 commits from feature/UDT-002 into main 2026-04-14 17:37:47 +00:00
Showing only changes of commit 99bb3364c3 - Show all commits

View File

@@ -0,0 +1,72 @@
namespace SIGCM2.Domain.Entities;
public sealed class RefreshToken
{
public int Id { get; init; }
public int UsuarioId { get; init; }
public string TokenHash { get; init; } = null!;
public Guid FamilyId { get; init; }
public DateTime IssuedAt { get; init; }
public DateTime ExpiresAt { get; init; }
public DateTime? RevokedAt { get; private set; }
public int? ReplacedById { get; private set; }
public string CreatedByIp { get; init; } = null!;
public string? UserAgent { get; init; }
/// <summary>Factory for a brand-new session (login). Generates a new FamilyId.</summary>
public static RefreshToken IssueForNewFamily(
int usuarioId,
string tokenHash,
DateTime now,
TimeSpan ttl,
string createdByIp,
string? userAgent)
=> new()
{
UsuarioId = usuarioId,
TokenHash = tokenHash,
FamilyId = Guid.NewGuid(),
IssuedAt = now,
ExpiresAt = now + ttl,
CreatedByIp = createdByIp,
UserAgent = userAgent,
};
/// <summary>Factory for a rotation. Inherits FamilyId and ExpiresAt (absolute TTL).</summary>
public static RefreshToken IssueRotation(
RefreshToken previous,
string newTokenHash,
DateTime now,
string createdByIp,
string? userAgent)
=> new()
{
UsuarioId = previous.UsuarioId,
TokenHash = newTokenHash,
FamilyId = previous.FamilyId,
IssuedAt = now,
ExpiresAt = previous.ExpiresAt, // ABSOLUTE — inherited from original
CreatedByIp = createdByIp,
UserAgent = userAgent,
};
/// <summary>Returns true if ExpiresAt &lt;= now (expired).</summary>
public bool IsExpired(DateTime now) => now >= ExpiresAt;
/// <summary>Returns true if the token has been explicitly revoked.</summary>
public bool IsRevoked => RevokedAt.HasValue;
/// <summary>Returns true if the token is neither revoked nor expired.</summary>
public bool IsActive(DateTime now) => !IsRevoked && !IsExpired(now);
/// <summary>
/// Marks the token as revoked with the given timestamp and optional successor id.
/// Should only be called by the repository when reconstructing or persisting state.
/// This is NOT the way to revoke in business logic — use the repository SQL methods.
/// </summary>
public void MarkAsPersistedRevocation(DateTime revokedAt, int? replacedById)
{
RevokedAt = revokedAt;
ReplacedById = replacedById;
}
}