feat(backend): Implement Refresh Token mechanism (Day 5 Phase 1)

Implemented secure refresh token rotation with the following features:
- RefreshToken domain entity with IsExpired(), IsRevoked(), IsActive(), Revoke() methods
- IRefreshTokenService with token generation, rotation, and revocation
- RefreshTokenService with SHA-256 hashing and token family tracking
- RefreshTokenRepository for database operations
- Database migration for refresh_tokens table with proper indexes
- Updated LoginCommandHandler and RegisterTenantCommandHandler to return refresh tokens
- Added POST /api/auth/refresh endpoint (token rotation)
- Added POST /api/auth/logout endpoint (revoke single token)
- Added POST /api/auth/logout-all endpoint (revoke all user tokens)
- Updated JWT access token expiration to 15 minutes (from 60)
- Refresh token expiration set to 7 days
- Security features: token reuse detection, IP address tracking, user-agent logging

Changes:
- Domain: RefreshToken.cs, IRefreshTokenRepository.cs
- Application: IRefreshTokenService.cs, updated LoginResponseDto and RegisterTenantResult
- Infrastructure: RefreshTokenService.cs, RefreshTokenRepository.cs, RefreshTokenConfiguration.cs
- API: AuthController.cs (3 new endpoints), RefreshTokenRequest.cs, LogoutRequest.cs
- Configuration: appsettings.Development.json (updated JWT settings)
- DI: DependencyInjection.cs (registered new services)
- Migration: AddRefreshTokens migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-03 14:44:36 +01:00
parent 1f66b25f30
commit 9e2edb2965
32 changed files with 4669 additions and 28 deletions

View File

@@ -0,0 +1,283 @@
// <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("20251103133337_AddRefreshTokens")]
partial class AddRefreshTokens
{
/// <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);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRefreshTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "identity");
migrationBuilder.CreateTable(
name: "refresh_tokens",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
token_hash = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
revoked_reason = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
ip_address = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
user_agent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
replaced_by_token = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
device_info = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_refresh_tokens", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_expires_at",
schema: "identity",
table: "refresh_tokens",
column: "expires_at");
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_tenant_id",
schema: "identity",
table: "refresh_tokens",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_token_hash",
schema: "identity",
table: "refresh_tokens",
column: "token_hash",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_user_id",
schema: "identity",
table: "refresh_tokens",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "refresh_tokens",
schema: "identity");
}
}
}

View File

@@ -95,6 +95,81 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Migrations
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")