454 lines
19 KiB
C#
454 lines
19 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Operations;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Auth;
|
|
|
|
public sealed class AuthSessionStore(
|
|
TikuDbContext dbContext,
|
|
ITokenService tokenService,
|
|
IOptions<JwtOptions> options) : IAuthSessionStore
|
|
{
|
|
private readonly JwtOptions options = options.Value;
|
|
|
|
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
|
|
{
|
|
var realmCode = realm == AuthRealm.Tenant ? "t" : "p";
|
|
var tenant = tenantId?.ToString("N") ?? "-";
|
|
return $"v2.{realmCode}.{tenant}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}";
|
|
}
|
|
|
|
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
|
|
{
|
|
locator = default;
|
|
var parts = refreshToken?.Split('.', 5, StringSplitOptions.None) ?? [];
|
|
if (parts.Length != 5 || parts[0] != "v2" || parts[4].Length < 64 ||
|
|
!Guid.TryParseExact(parts[3], "N", out var sessionId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (parts[1] == "p" && parts[2] == "-")
|
|
{
|
|
locator = new RefreshTokenLocator(AuthRealm.Platform, null, sessionId);
|
|
return true;
|
|
}
|
|
|
|
if (parts[1] == "t" && Guid.TryParseExact(parts[2], "N", out var tenantId))
|
|
{
|
|
locator = new RefreshTokenLocator(AuthRealm.Tenant, tenantId, sessionId);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public string HashRefreshToken(string refreshToken) =>
|
|
Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(refreshToken))).ToLowerInvariant();
|
|
|
|
public async Task<AuthTokenPair> IssueAsync(
|
|
AuthSessionIssueRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ValidateRealm(request.Realm, request.TenantId);
|
|
var session = CreateSession(request, Guid.NewGuid());
|
|
var refreshToken = GenerateRefreshToken(session.Realm, session.TenantId, session.Id);
|
|
session.TokenHash = HashRefreshToken(refreshToken);
|
|
dbContext.AuthSessions.Add(session);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return CreatePair(request, session, refreshToken);
|
|
}
|
|
|
|
public async Task<AuthTokenPair> RotateAsync(
|
|
string refreshToken,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!TryParseRefreshToken(refreshToken, out var locator))
|
|
{
|
|
throw new SessionRevokedException();
|
|
}
|
|
|
|
var tokenHash = HashRefreshToken(refreshToken);
|
|
var now = DateTimeOffset.UtcNow;
|
|
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
|
var current = await dbContext.AuthSessions.SingleOrDefaultAsync(
|
|
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
|
|
item.TenantId == locator.TenantId && item.TokenHash == tokenHash,
|
|
cancellationToken);
|
|
if (current is null)
|
|
{
|
|
throw new SessionRevokedException();
|
|
}
|
|
|
|
if (current.RevokedAt.HasValue || current.ReplacedBySessionId.HasValue || current.ExpiresAt <= now)
|
|
{
|
|
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
throw new SessionRevokedException();
|
|
}
|
|
|
|
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == current.UserId, cancellationToken);
|
|
if (user is null || user.Status != UserStatus.Active ||
|
|
!string.Equals(user.SecurityStamp, current.SecurityStamp, StringComparison.Ordinal))
|
|
{
|
|
await RevokeFamilyCoreAsync(current.TokenFamilyId, "identity_state_changed", now, cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
throw new SessionRevokedException();
|
|
}
|
|
|
|
try
|
|
{
|
|
await AssertRealmAccessAsync(
|
|
current.Realm, current.TenantId, current.UserId, cancellationToken);
|
|
}
|
|
catch (TenantAccessDeniedException)
|
|
{
|
|
await RevokeFamilyCoreAsync(current.TokenFamilyId, "realm_access_revoked", now, cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
throw new SessionRevokedException();
|
|
}
|
|
var nextId = Guid.NewGuid();
|
|
var updated = await dbContext.AuthSessions
|
|
.Where(item => item.Id == current.Id && item.RevokedAt == null && item.ReplacedBySessionId == null)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.RevokedAt, now)
|
|
.SetProperty(item => item.RevokedReason, "rotated")
|
|
.SetProperty(item => item.ReplacedBySessionId, nextId), cancellationToken);
|
|
if (updated != 1)
|
|
{
|
|
await RevokeFamilyCoreAsync(current.TokenFamilyId, "refresh_token_reuse", now, cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
throw new SessionRevokedException();
|
|
}
|
|
|
|
var request = new AuthSessionIssueRequest(
|
|
user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty,
|
|
current.Realm, current.TenantId, "refresh",
|
|
ipAddress, userAgent, current.TokenFamilyId, current.Id);
|
|
var next = CreateSession(request, nextId);
|
|
var nextToken = GenerateRefreshToken(next.Realm, next.TenantId, next.Id);
|
|
next.TokenHash = HashRefreshToken(nextToken);
|
|
dbContext.AuthSessions.Add(next);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return CreatePair(request, next, nextToken);
|
|
}
|
|
|
|
public async Task<AuthSessionValidationResult?> ValidateAccessSessionAsync(
|
|
Guid sessionId,
|
|
Guid userId,
|
|
AuthRealm realm,
|
|
Guid? tenantId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var state = await (
|
|
from session in dbContext.AuthSessions.AsNoTracking()
|
|
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
|
|
where session.Id == sessionId &&
|
|
session.UserId == userId &&
|
|
session.Realm == realm &&
|
|
session.TenantId == tenantId &&
|
|
session.RevokedAt == null &&
|
|
session.ExpiresAt > now
|
|
select new
|
|
{
|
|
UserStatus = user.Status,
|
|
UserSecurityStamp = user.SecurityStamp,
|
|
SessionSecurityStamp = session.SecurityStamp,
|
|
TenantAllowed = realm != AuthRealm.Tenant ||
|
|
(tenantId != null &&
|
|
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
|
|
dbContext.TenantMemberships.Any(item =>
|
|
item.TenantId == tenantId &&
|
|
item.UserId == userId &&
|
|
item.Status == MembershipStatus.Active)),
|
|
PlatformAllowed = realm != AuthRealm.Platform ||
|
|
(from userRole in dbContext.PlatformBackendUserRoles
|
|
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
|
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
|
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
|
where userRole.UserId == userId &&
|
|
role.Status == BackendRoleStatus.Active &&
|
|
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
|
select permission.Id).Any()
|
|
})
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (state is null ||
|
|
state.UserStatus != UserStatus.Active ||
|
|
!string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
|
|
!state.TenantAllowed ||
|
|
!state.PlatformAllowed)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new AuthSessionValidationResult(userId, realm, tenantId);
|
|
}
|
|
|
|
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
|
|
Guid sessionId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var session = await dbContext.AuthSessions.AsNoTracking()
|
|
.Where(item => item.Id == sessionId && item.UserId == userId)
|
|
.Select(item => new { item.Realm, item.TenantId })
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (session is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return await ValidateAccessSessionAsync(
|
|
sessionId,
|
|
userId,
|
|
session.Realm,
|
|
session.TenantId,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task RevokeFamilyAsync(string refreshToken, string reason, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!TryParseRefreshToken(refreshToken, out var locator))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var hash = HashRefreshToken(refreshToken);
|
|
var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.Id == locator.SessionId && item.TokenHash == hash, cancellationToken);
|
|
if (session is not null)
|
|
{
|
|
await RevokeFamilyCoreAsync(session.TokenFamilyId, reason, DateTimeOffset.UtcNow, cancellationToken);
|
|
}
|
|
}
|
|
|
|
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow)
|
|
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
|
|
if (count > 0)
|
|
{
|
|
dbContext.AuditLogs.Add(new AuditLog
|
|
{
|
|
ActorUserId = userId,
|
|
Action = "auth.sessions.revoked_all",
|
|
TargetType = "user",
|
|
TargetId = userId.ToString(),
|
|
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
public async Task RevokeRealmAsync(
|
|
Guid userId,
|
|
AuthRealm realm,
|
|
Guid? tenantId,
|
|
string reason,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ValidateRealm(realm, tenantId);
|
|
var now = DateTimeOffset.UtcNow;
|
|
var count = await dbContext.AuthSessions
|
|
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.RevokedAt, now)
|
|
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
|
|
if (count > 0)
|
|
{
|
|
dbContext.AuditLogs.Add(new AuditLog
|
|
{
|
|
TenantId = tenantId,
|
|
ActorUserId = userId,
|
|
Action = "auth.sessions.realm_revoked",
|
|
TargetType = "user",
|
|
TargetId = userId.ToString(),
|
|
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|
|
|
|
public async Task<IReadOnlyCollection<AuthSessionSummary>> ListActiveAsync(
|
|
Guid userId,
|
|
Guid currentSessionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var current = await dbContext.AuthSessions.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
|
|
?? throw new SessionRevokedException();
|
|
var now = DateTimeOffset.UtcNow;
|
|
var sessions = await dbContext.AuthSessions.AsNoTracking()
|
|
.Where(item => item.UserId == userId && item.Realm == current.Realm && item.TenantId == current.TenantId)
|
|
.OrderBy(item => item.CreatedAt)
|
|
.ToArrayAsync(cancellationToken);
|
|
|
|
return sessions
|
|
.GroupBy(item => item.TokenFamilyId)
|
|
.Select(group => new { All = group.ToArray(), Active = group.LastOrDefault(item => item.RevokedAt == null && item.ExpiresAt > now) })
|
|
.Where(value => value.Active is not null)
|
|
.Select(value => new AuthSessionSummary(
|
|
value.Active!.TokenFamilyId,
|
|
value.Active.Realm,
|
|
value.Active.TenantId,
|
|
value.Active.Provider,
|
|
value.All.Min(item => item.CreatedAt),
|
|
value.Active.CreatedAt,
|
|
value.Active.ExpiresAt,
|
|
MaskIpAddress(value.Active.IpAddress),
|
|
value.Active.UserAgent,
|
|
value.Active.Id == currentSessionId))
|
|
.OrderByDescending(item => item.IsCurrent)
|
|
.ThenByDescending(item => item.LastRotatedAt)
|
|
.ToArray();
|
|
}
|
|
|
|
public async Task RevokeOwnedFamilyAsync(
|
|
Guid userId,
|
|
Guid currentSessionId,
|
|
Guid sessionFamilyId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var current = await dbContext.AuthSessions.AsNoTracking()
|
|
.SingleOrDefaultAsync(item => item.Id == currentSessionId && item.UserId == userId, cancellationToken)
|
|
?? throw new SessionRevokedException();
|
|
if (current.TokenFamilyId == sessionFamilyId)
|
|
{
|
|
throw new CurrentAuthSessionCannotBeRevokedException();
|
|
}
|
|
|
|
var owned = await dbContext.AuthSessions.AsNoTracking().AnyAsync(
|
|
item => item.UserId == userId && item.TokenFamilyId == sessionFamilyId &&
|
|
item.Realm == current.Realm && item.TenantId == current.TenantId,
|
|
cancellationToken);
|
|
if (!owned)
|
|
{
|
|
throw new AuthSessionNotFoundException();
|
|
}
|
|
|
|
await RevokeFamilyCoreAsync(sessionFamilyId, "user_revoked_device", DateTimeOffset.UtcNow, cancellationToken);
|
|
}
|
|
|
|
private AuthSession CreateSession(AuthSessionIssueRequest request, Guid sessionId) => new()
|
|
{
|
|
Id = sessionId,
|
|
Realm = request.Realm,
|
|
TenantId = request.TenantId,
|
|
UserId = request.UserId,
|
|
TokenFamilyId = request.TokenFamilyId ?? sessionId,
|
|
ParentSessionId = request.ParentSessionId,
|
|
SecurityStamp = request.SecurityStamp,
|
|
Provider = request.Provider,
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
|
|
IpAddress = request.IpAddress,
|
|
UserAgent = request.UserAgent
|
|
};
|
|
|
|
private AuthTokenPair CreatePair(AuthSessionIssueRequest request, AuthSession session, string refreshToken)
|
|
{
|
|
var access = tokenService.CreateAccessToken(
|
|
request.UserId, session.Id, request.Phone, request.Email,
|
|
request.Realm, request.TenantId);
|
|
return new AuthTokenPair(access.Token, refreshToken, access.ExpiresAt, session.ExpiresAt);
|
|
}
|
|
|
|
private async Task AssertRealmAccessAsync(
|
|
AuthRealm realm,
|
|
Guid? tenantId,
|
|
Guid userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (realm == AuthRealm.Tenant && tenantId.HasValue)
|
|
{
|
|
var active = await dbContext.Tenants.AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken) &&
|
|
await dbContext.TenantMemberships.AnyAsync(item => item.TenantId == tenantId && item.UserId == userId && item.Status == MembershipStatus.Active, cancellationToken);
|
|
if (active)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
else if (realm == AuthRealm.Platform)
|
|
{
|
|
var active = await (
|
|
from userRole in dbContext.PlatformBackendUserRoles
|
|
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
|
|
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
|
|
join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
|
|
where userRole.UserId == userId && role.Status == BackendRoleStatus.Active &&
|
|
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
|
|
select permission.Id).AnyAsync(cancellationToken);
|
|
if (active) return;
|
|
}
|
|
|
|
throw new TenantAccessDeniedException();
|
|
}
|
|
|
|
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
var owner = await dbContext.AuthSessions.AsNoTracking()
|
|
.Where(item => item.TokenFamilyId == familyId)
|
|
.Select(item => new { item.UserId, item.TenantId })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
var count = await dbContext.AuthSessions.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
|
|
.ExecuteUpdateAsync(setters => setters
|
|
.SetProperty(item => item.RevokedAt, now)
|
|
.SetProperty(item => item.RevokedReason, reason), cancellationToken);
|
|
if (count > 0 && owner is not null)
|
|
{
|
|
dbContext.AuditLogs.Add(new AuditLog
|
|
{
|
|
TenantId = owner.TenantId,
|
|
ActorUserId = owner.UserId,
|
|
Action = "auth.session_family.revoked",
|
|
TargetType = "auth_session_family",
|
|
TargetId = familyId.ToString(),
|
|
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static void ValidateRealm(AuthRealm realm, Guid? tenantId)
|
|
{
|
|
if ((realm == AuthRealm.Tenant) != tenantId.HasValue)
|
|
{
|
|
throw new ArgumentException("Tenant sessions require a tenant and platform sessions must not have one.");
|
|
}
|
|
}
|
|
|
|
private static string? MaskIpAddress(string? value)
|
|
{
|
|
if (!System.Net.IPAddress.TryParse(value, out var address))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var bytes = address.GetAddressBytes();
|
|
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
{
|
|
bytes[3] = 0;
|
|
return $"{new System.Net.IPAddress(bytes)}/24";
|
|
}
|
|
|
|
Array.Clear(bytes, 8, bytes.Length - 8);
|
|
return $"{new System.Net.IPAddress(bytes)}/64";
|
|
}
|
|
}
|