feat(backend): Implement complete RBAC system (Day 5 Phase 2)
Implemented Role-Based Access Control (RBAC) with 5 tenant-level roles following Clean Architecture principles. Changes: - Created TenantRole enum (TenantOwner, TenantAdmin, TenantMember, TenantGuest, AIAgent) - Created UserTenantRole entity with repository pattern - Updated JWT service to include role claims (tenant_role, role) - Updated RegisterTenant to auto-assign TenantOwner role - Updated Login to query and include user role in JWT - Updated RefreshToken to preserve role claims - Added authorization policies in Program.cs (RequireTenantOwner, RequireTenantAdmin, etc.) - Updated /api/auth/me endpoint to return role information - Created EF Core migration for user_tenant_roles table - Applied database migration successfully Database: - New table: identity.user_tenant_roles - Columns: id, user_id, tenant_id, role, assigned_at, assigned_by_user_id - Indexes: user_id, tenant_id, role, unique(user_id, tenant_id) - Foreign keys: CASCADE on user and tenant deletion Testing: - Created test-rbac.ps1 PowerShell script - All RBAC tests passing - JWT tokens contain role claims - Role persists across login and token refresh Documentation: - DAY5-PHASE2-RBAC-IMPLEMENTATION-SUMMARY.md with complete implementation details 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,8 @@ public class AuthController : ControllerBase
|
||||
var email = User.FindFirst(ClaimTypes.Email)?.Value;
|
||||
var fullName = User.FindFirst("full_name")?.Value;
|
||||
var tenantSlug = User.FindFirst("tenant_slug")?.Value;
|
||||
var tenantRole = User.FindFirst("tenant_role")?.Value; // NEW: Role claim
|
||||
var role = User.FindFirst(ClaimTypes.Role)?.Value;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -58,6 +60,8 @@ public class AuthController : ControllerBase
|
||||
email,
|
||||
fullName,
|
||||
tenantSlug,
|
||||
tenantRole, // NEW: Role information
|
||||
role, // NEW: Standard role claim
|
||||
claims = User.Claims.Select(c => new { c.Type, c.Value })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,30 @@ builder.Services.AddAuthentication(options =>
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
// Configure Authorization Policies for RBAC
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
// Tenant Owner only
|
||||
options.AddPolicy("RequireTenantOwner", policy =>
|
||||
policy.RequireRole("TenantOwner"));
|
||||
|
||||
// Tenant Owner or Tenant Admin
|
||||
options.AddPolicy("RequireTenantAdmin", policy =>
|
||||
policy.RequireRole("TenantOwner", "TenantAdmin"));
|
||||
|
||||
// Tenant Owner, Tenant Admin, or Tenant Member (excludes Guest and AIAgent)
|
||||
options.AddPolicy("RequireTenantMember", policy =>
|
||||
policy.RequireRole("TenantOwner", "TenantAdmin", "TenantMember"));
|
||||
|
||||
// Human users only (excludes AIAgent)
|
||||
options.AddPolicy("RequireHumanUser", policy =>
|
||||
policy.RequireAssertion(context =>
|
||||
!context.User.IsInRole("AIAgent")));
|
||||
|
||||
// AI Agent only (for MCP integration testing)
|
||||
options.AddPolicy("RequireAIAgent", policy =>
|
||||
policy.RequireRole("AIAgent"));
|
||||
});
|
||||
|
||||
// Configure CORS for frontend
|
||||
builder.Services.AddCors(options =>
|
||||
|
||||
@@ -14,19 +14,22 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenService _refreshTokenService;
|
||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
||||
|
||||
public LoginCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IUserRepository userRepository,
|
||||
IJwtService jwtService,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenService refreshTokenService)
|
||||
IRefreshTokenService refreshTokenService,
|
||||
IUserTenantRoleRepository userTenantRoleRepository)
|
||||
{
|
||||
_tenantRepository = tenantRepository;
|
||||
_userRepository = userRepository;
|
||||
_jwtService = jwtService;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenService = refreshTokenService;
|
||||
_userTenantRoleRepository = userTenantRoleRepository;
|
||||
}
|
||||
|
||||
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||
@@ -53,21 +56,32 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
|
||||
throw new UnauthorizedAccessException("Invalid credentials");
|
||||
}
|
||||
|
||||
// 4. Generate JWT token
|
||||
var accessToken = _jwtService.GenerateToken(user, tenant);
|
||||
// 4. Get user's tenant role
|
||||
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||
user.Id,
|
||||
tenant.Id,
|
||||
cancellationToken);
|
||||
|
||||
// 5. Generate refresh token
|
||||
if (userTenantRole == null)
|
||||
{
|
||||
throw new InvalidOperationException($"User {user.Id} has no role assigned for tenant {tenant.Id}");
|
||||
}
|
||||
|
||||
// 5. Generate JWT token with role
|
||||
var accessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
||||
|
||||
// 6. Generate refresh token
|
||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
||||
user,
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
cancellationToken);
|
||||
|
||||
// 6. Update last login time
|
||||
// 7. Update last login time
|
||||
user.RecordLogin();
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
// 7. Return result
|
||||
// 8. Return result
|
||||
return new LoginResponseDto
|
||||
{
|
||||
User = new UserDto
|
||||
|
||||
@@ -13,19 +13,22 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenService _refreshTokenService;
|
||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
||||
|
||||
public RegisterTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IUserRepository userRepository,
|
||||
IJwtService jwtService,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenService refreshTokenService)
|
||||
IRefreshTokenService refreshTokenService,
|
||||
IUserTenantRoleRepository userTenantRoleRepository)
|
||||
{
|
||||
_tenantRepository = tenantRepository;
|
||||
_userRepository = userRepository;
|
||||
_jwtService = jwtService;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenService = refreshTokenService;
|
||||
_userTenantRoleRepository = userTenantRoleRepository;
|
||||
}
|
||||
|
||||
public async Task<RegisterTenantResult> Handle(
|
||||
@@ -59,10 +62,18 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
|
||||
|
||||
await _userRepository.AddAsync(adminUser, cancellationToken);
|
||||
|
||||
// 4. Generate JWT token
|
||||
var accessToken = _jwtService.GenerateToken(adminUser, tenant);
|
||||
// 4. Assign TenantOwner role to admin user
|
||||
var tenantOwnerRole = UserTenantRole.Create(
|
||||
UserId.Create(adminUser.Id),
|
||||
TenantId.Create(tenant.Id),
|
||||
TenantRole.TenantOwner);
|
||||
|
||||
// 5. Generate refresh token
|
||||
await _userTenantRoleRepository.AddAsync(tenantOwnerRole, cancellationToken);
|
||||
|
||||
// 5. Generate JWT token with role
|
||||
var accessToken = _jwtService.GenerateToken(adminUser, tenant, TenantRole.TenantOwner);
|
||||
|
||||
// 6. Generate refresh token
|
||||
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
|
||||
adminUser,
|
||||
ipAddress: null,
|
||||
|
||||
@@ -5,6 +5,6 @@ namespace ColaFlow.Modules.Identity.Application.Services;
|
||||
|
||||
public interface IJwtService
|
||||
{
|
||||
string GenerateToken(User user, Tenant tenant);
|
||||
string GenerateToken(User user, Tenant tenant, TenantRole tenantRole);
|
||||
Task<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
/// <summary>
|
||||
/// Defines tenant-level roles for users
|
||||
/// </summary>
|
||||
public enum TenantRole
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant owner - Full control over tenant, billing, and all resources
|
||||
/// </summary>
|
||||
TenantOwner = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Tenant administrator - Can manage users, projects, but not billing
|
||||
/// </summary>
|
||||
TenantAdmin = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Tenant member - Default role, can create and manage own projects
|
||||
/// </summary>
|
||||
TenantMember = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Tenant guest - Read-only access to assigned resources
|
||||
/// </summary>
|
||||
TenantGuest = 4,
|
||||
|
||||
/// <summary>
|
||||
/// AI Agent - Read access + Write with preview (requires human approval)
|
||||
/// Special role for MCP integration
|
||||
/// </summary>
|
||||
AIAgent = 5
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ColaFlow.Shared.Kernel.Common;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a user's role within a specific tenant
|
||||
/// </summary>
|
||||
public sealed class UserTenantRole : Entity
|
||||
{
|
||||
public UserId UserId { get; private set; } = null!;
|
||||
public TenantId TenantId { get; private set; } = null!;
|
||||
public TenantRole Role { get; private set; }
|
||||
|
||||
public DateTime AssignedAt { get; private set; }
|
||||
public Guid? AssignedByUserId { get; private set; }
|
||||
|
||||
// Navigation properties (optional, for EF Core)
|
||||
public User User { get; private set; } = null!;
|
||||
public Tenant Tenant { get; private set; } = null!;
|
||||
|
||||
// Private constructor for EF Core
|
||||
private UserTenantRole() : base()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory method to create a user-tenant-role assignment
|
||||
/// </summary>
|
||||
public static UserTenantRole Create(
|
||||
UserId userId,
|
||||
TenantId tenantId,
|
||||
TenantRole role,
|
||||
Guid? assignedByUserId = null)
|
||||
{
|
||||
return new UserTenantRole
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
TenantId = tenantId,
|
||||
Role = role,
|
||||
AssignedAt = DateTime.UtcNow,
|
||||
AssignedByUserId = assignedByUserId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the user's role (e.g., promote Member to Admin)
|
||||
/// </summary>
|
||||
public void UpdateRole(TenantRole newRole, Guid updatedByUserId)
|
||||
{
|
||||
if (Role == newRole)
|
||||
return;
|
||||
|
||||
Role = newRole;
|
||||
AssignedByUserId = updatedByUserId;
|
||||
// Note: AssignedAt is NOT updated to preserve original assignment timestamp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if user has permission (extensible for future fine-grained permissions)
|
||||
/// </summary>
|
||||
public bool HasPermission(string permission)
|
||||
{
|
||||
// Future implementation: Check permission against role-permission mapping
|
||||
// For now, this is a placeholder for fine-grained permission checks
|
||||
return Role switch
|
||||
{
|
||||
TenantRole.TenantOwner => true, // Owner has all permissions
|
||||
TenantRole.AIAgent when permission.StartsWith("read") => true,
|
||||
TenantRole.AIAgent when permission.StartsWith("write_preview") => true,
|
||||
_ => false // Implement specific permission checks as needed
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for managing user-tenant-role assignments
|
||||
/// </summary>
|
||||
public interface IUserTenantRoleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Get user's role for a specific tenant
|
||||
/// </summary>
|
||||
Task<UserTenantRole?> GetByUserAndTenantAsync(
|
||||
Guid userId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all roles for a specific user (across all tenants)
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<UserTenantRole>> GetByUserAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all user-role assignments for a specific tenant
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<UserTenantRole>> GetByTenantAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Add a new user-tenant-role assignment
|
||||
/// </summary>
|
||||
Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing user-tenant-role assignment
|
||||
/// </summary>
|
||||
Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user-tenant-role assignment (remove user from tenant)
|
||||
/// </summary>
|
||||
Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantRepository, TenantRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
|
||||
services.AddScoped<IUserTenantRoleRepository, UserTenantRoleRepository>();
|
||||
|
||||
// Application Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class UserTenantRoleConfiguration : IEntityTypeConfiguration<UserTenantRole>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserTenantRole> builder)
|
||||
{
|
||||
builder.ToTable("user_tenant_roles", "identity");
|
||||
|
||||
// Primary key
|
||||
builder.HasKey(utr => utr.Id);
|
||||
|
||||
// Properties
|
||||
builder.Property(utr => utr.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(utr => utr.Role)
|
||||
.HasColumnName("role")
|
||||
.HasConversion<string>() // Store as string (e.g., "TenantOwner")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(utr => utr.AssignedAt)
|
||||
.HasColumnName("assigned_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(utr => utr.AssignedByUserId)
|
||||
.HasColumnName("assigned_by_user_id");
|
||||
|
||||
// Value objects (UserId, TenantId)
|
||||
builder.Property(utr => utr.UserId)
|
||||
.HasColumnName("user_id")
|
||||
.HasConversion(
|
||||
id => id.Value,
|
||||
value => UserId.Create(value))
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(utr => utr.TenantId)
|
||||
.HasColumnName("tenant_id")
|
||||
.HasConversion(
|
||||
id => id.Value,
|
||||
value => TenantId.Create(value))
|
||||
.IsRequired();
|
||||
|
||||
// Foreign keys - use shadow properties with Guid values
|
||||
builder.HasOne(utr => utr.User)
|
||||
.WithMany() // User has many UserTenantRole
|
||||
.HasForeignKey("user_id") // Use shadow property (column name)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(utr => utr.Tenant)
|
||||
.WithMany() // Tenant has many UserTenantRole
|
||||
.HasForeignKey("tenant_id") // Use shadow property (column name)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(utr => utr.UserId)
|
||||
.HasDatabaseName("ix_user_tenant_roles_user_id");
|
||||
|
||||
builder.HasIndex(utr => utr.TenantId)
|
||||
.HasDatabaseName("ix_user_tenant_roles_tenant_id");
|
||||
|
||||
builder.HasIndex(utr => utr.Role)
|
||||
.HasDatabaseName("ix_user_tenant_roles_role");
|
||||
|
||||
// Unique constraint: One role per user per tenant
|
||||
builder.HasIndex(utr => new { utr.UserId, utr.TenantId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("uq_user_tenant_roles_user_tenant");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class IdentityDbContext : DbContext
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<UserTenantRole> UserTenantRoles => Set<UserTenantRole>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20251103135644_AddUserTenantRoles")]
|
||||
partial class AddUserTenantRoles
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("MaxProjects")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_projects");
|
||||
|
||||
b.Property<int>("MaxStorageGB")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_storage_gb");
|
||||
|
||||
b.Property<int>("MaxUsers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_users");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("plan");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("slug");
|
||||
|
||||
b.Property<string>("SsoConfig")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("sso_config");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTime?>("SuspendedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("suspended_at");
|
||||
|
||||
b.Property<string>("SuspensionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("suspension_reason");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Slug")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tenants_slug");
|
||||
|
||||
b.ToTable("tenants", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DeviceInfo")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("device_info");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
b.Property<string>("ReplacedByToken")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("replaced_by_token");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("RevokedReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("revoked_reason");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExpiresAt")
|
||||
.HasDatabaseName("ix_refresh_tokens_expires_at");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("ix_refresh_tokens_tenant_id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", "identity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AuthProvider")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("auth_provider");
|
||||
|
||||
b.Property<string>("AvatarUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("avatar_url");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("EmailVerificationToken")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("email_verification_token");
|
||||
|
||||
b.Property<DateTime?>("EmailVerifiedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("email_verified_at");
|
||||
|
||||
b.Property<string>("ExternalEmail")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_email");
|
||||
|
||||
b.Property<string>("ExternalUserId")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("external_user_id");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("full_name");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("job_title");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PasswordResetToken")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("password_reset_token");
|
||||
|
||||
b.Property<DateTime?>("PasswordResetTokenExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("password_reset_token_expires_at");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("phone_number");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_tenant_id_email");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("assigned_at");
|
||||
|
||||
b.Property<Guid?>("AssignedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("assigned_by_user_id");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("tenant_id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("user_id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Role")
|
||||
.HasDatabaseName("ix_user_tenant_roles_role");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("ix_user_tenant_roles_tenant_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_user_tenant_roles_user_id");
|
||||
|
||||
b.HasIndex("tenant_id");
|
||||
|
||||
b.HasIndex("user_id");
|
||||
|
||||
b.HasIndex("UserId", "TenantId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("uq_user_tenant_roles_user_tenant");
|
||||
|
||||
b.ToTable("user_tenant_roles", "identity", t =>
|
||||
{
|
||||
t.Property("tenant_id")
|
||||
.HasColumnName("tenant_id1");
|
||||
|
||||
t.Property("user_id")
|
||||
.HasColumnName("user_id1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
|
||||
{
|
||||
b.HasOne("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("tenant_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("user_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Tenant");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserTenantRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_tenant_roles",
|
||||
schema: "identity",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
role = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
assigned_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
assigned_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
user_id1 = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id1 = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_tenant_roles", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_tenant_roles_tenants_tenant_id1",
|
||||
column: x => x.tenant_id1,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_tenant_roles_users_user_id1",
|
||||
column: x => x.user_id1,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_user_tenant_roles_role",
|
||||
schema: "identity",
|
||||
table: "user_tenant_roles",
|
||||
column: "role");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_user_tenant_roles_tenant_id",
|
||||
schema: "identity",
|
||||
table: "user_tenant_roles",
|
||||
column: "tenant_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_user_tenant_roles_tenant_id1",
|
||||
schema: "identity",
|
||||
table: "user_tenant_roles",
|
||||
column: "tenant_id1");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_user_tenant_roles_user_id",
|
||||
schema: "identity",
|
||||
table: "user_tenant_roles",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_user_tenant_roles_user_id1",
|
||||
schema: "identity",
|
||||
table: "user_tenant_roles",
|
||||
column: "user_id1");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "uq_user_tenant_roles_user_tenant",
|
||||
schema: "identity",
|
||||
table: "user_tenant_roles",
|
||||
columns: new[] { "user_id", "tenant_id" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_tenant_roles",
|
||||
schema: "identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,89 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("AssignedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("assigned_at");
|
||||
|
||||
b.Property<Guid?>("AssignedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("assigned_by_user_id");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("tenant_id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("user_id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Role")
|
||||
.HasDatabaseName("ix_user_tenant_roles_role");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasDatabaseName("ix_user_tenant_roles_tenant_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_user_tenant_roles_user_id");
|
||||
|
||||
b.HasIndex("tenant_id");
|
||||
|
||||
b.HasIndex("user_id");
|
||||
|
||||
b.HasIndex("UserId", "TenantId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("uq_user_tenant_roles_user_tenant");
|
||||
|
||||
b.ToTable("user_tenant_roles", "identity", t =>
|
||||
{
|
||||
t.Property("tenant_id")
|
||||
.HasColumnName("tenant_id1");
|
||||
|
||||
t.Property("user_id")
|
||||
.HasColumnName("user_id1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ColaFlow.Modules.Identity.Domain.Aggregates.Users.UserTenantRole", b =>
|
||||
{
|
||||
b.HasOne("ColaFlow.Modules.Identity.Domain.Aggregates.Tenants.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("tenant_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ColaFlow.Modules.Identity.Domain.Aggregates.Users.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("user_id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Tenant");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
|
||||
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public class UserTenantRoleRepository : IUserTenantRoleRepository
|
||||
{
|
||||
private readonly IdentityDbContext _context;
|
||||
|
||||
public UserTenantRoleRepository(IdentityDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<UserTenantRole?> GetByUserAndTenantAsync(
|
||||
Guid userId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserTenantRoles
|
||||
.FirstOrDefaultAsync(
|
||||
utr => utr.UserId.Value == userId && utr.TenantId.Value == tenantId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserTenantRole>> GetByUserAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserTenantRoles
|
||||
.Where(utr => utr.UserId.Value == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserTenantRole>> GetByTenantAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.UserTenantRoles
|
||||
.Where(utr => utr.TenantId.Value == tenantId)
|
||||
.Include(utr => utr.User) // Include user details for tenant management
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.UserTenantRoles.AddAsync(role, cancellationToken);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.UserTenantRoles.Update(role);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.UserTenantRoles.Remove(role);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class JwtService : IJwtService
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public string GenerateToken(User user, Tenant tenant)
|
||||
public string GenerateToken(User user, Tenant tenant, TenantRole tenantRole)
|
||||
{
|
||||
var securityKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")));
|
||||
@@ -36,7 +36,10 @@ public class JwtService : IJwtService
|
||||
new("tenant_plan", tenant.Plan.ToString()),
|
||||
new("full_name", user.FullName.Value),
|
||||
new("auth_provider", user.AuthProvider.ToString()),
|
||||
new(ClaimTypes.Role, "User") // TODO: Implement real roles
|
||||
|
||||
// Role claims (both standard and custom)
|
||||
new("tenant_role", tenantRole.ToString()), // Custom claim for application logic
|
||||
new(ClaimTypes.Role, tenantRole.ToString()) // Standard ASP.NET Core role claim
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
|
||||
@@ -14,6 +14,7 @@ public class RefreshTokenService : IRefreshTokenService
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ITenantRepository _tenantRepository;
|
||||
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<RefreshTokenService> _logger;
|
||||
@@ -22,6 +23,7 @@ public class RefreshTokenService : IRefreshTokenService
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IUserRepository userRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IUserTenantRoleRepository userTenantRoleRepository,
|
||||
IJwtService jwtService,
|
||||
IConfiguration configuration,
|
||||
ILogger<RefreshTokenService> logger)
|
||||
@@ -29,6 +31,7 @@ public class RefreshTokenService : IRefreshTokenService
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_userRepository = userRepository;
|
||||
_tenantRepository = tenantRepository;
|
||||
_userTenantRoleRepository = userTenantRoleRepository;
|
||||
_jwtService = jwtService;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
@@ -127,8 +130,20 @@ public class RefreshTokenService : IRefreshTokenService
|
||||
throw new UnauthorizedAccessException("Tenant not found or inactive");
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
var newAccessToken = _jwtService.GenerateToken(user, tenant);
|
||||
// Get user's tenant role
|
||||
var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
|
||||
user.Id,
|
||||
tenant.Id,
|
||||
cancellationToken);
|
||||
|
||||
if (userTenantRole == null)
|
||||
{
|
||||
_logger.LogWarning("User {UserId} has no role assigned for tenant {TenantId}", user.Id, tenant.Id);
|
||||
throw new UnauthorizedAccessException("User role not found");
|
||||
}
|
||||
|
||||
// Generate new access token with role
|
||||
var newAccessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role);
|
||||
|
||||
// Generate new refresh token (token rotation)
|
||||
var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user