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

@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>

View File

@@ -1,6 +1,11 @@
using ColaFlow.API.Models;
using ColaFlow.Modules.Identity.Application.Commands.Login;
using ColaFlow.Modules.Identity.Application.Services;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.Security.Claims;
namespace ColaFlow.API.Controllers;
@@ -9,10 +14,17 @@ namespace ColaFlow.API.Controllers;
public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
private readonly IRefreshTokenService _refreshTokenService;
private readonly ILogger<AuthController> _logger;
public AuthController(IMediator mediator)
public AuthController(
IMediator mediator,
IRefreshTokenService refreshTokenService,
ILogger<AuthController> logger)
{
_mediator = mediator;
_refreshTokenService = refreshTokenService;
_logger = logger;
}
/// <summary>
@@ -29,10 +41,106 @@ public class AuthController : ControllerBase
/// Get current user (requires authentication)
/// </summary>
[HttpGet("me")]
// [Authorize] // TODO: Add after JWT middleware is configured
public async Task<IActionResult> GetCurrentUser()
[Authorize]
public IActionResult GetCurrentUser()
{
// TODO: Implement after JWT middleware
return Ok(new { message = "Current user endpoint - to be implemented" });
// Extract user information from JWT Claims
var userId = User.FindFirst("user_id")?.Value;
var tenantId = User.FindFirst("tenant_id")?.Value;
var email = User.FindFirst(ClaimTypes.Email)?.Value;
var fullName = User.FindFirst("full_name")?.Value;
var tenantSlug = User.FindFirst("tenant_slug")?.Value;
return Ok(new
{
userId,
tenantId,
email,
fullName,
tenantSlug,
claims = User.Claims.Select(c => new { c.Type, c.Value })
});
}
/// <summary>
/// Refresh access token using refresh token
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
try
{
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers["User-Agent"].ToString();
var (accessToken, newRefreshToken) = await _refreshTokenService.RefreshTokenAsync(
request.RefreshToken,
ipAddress,
userAgent,
HttpContext.RequestAborted);
return Ok(new
{
accessToken,
refreshToken = newRefreshToken,
expiresIn = 900, // 15 minutes in seconds
tokenType = "Bearer"
});
}
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Refresh token failed");
return Unauthorized(new { message = "Invalid or expired refresh token" });
}
}
/// <summary>
/// Logout (revoke refresh token)
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
public async Task<IActionResult> Logout([FromBody] LogoutRequest request)
{
try
{
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
await _refreshTokenService.RevokeTokenAsync(
request.RefreshToken,
ipAddress,
HttpContext.RequestAborted);
return Ok(new { message = "Logged out successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Logout failed");
return BadRequest(new { message = "Logout failed" });
}
}
/// <summary>
/// Logout from all devices (revoke all user refresh tokens)
/// </summary>
[HttpPost("logout-all")]
[Authorize]
public async Task<IActionResult> LogoutAllDevices()
{
try
{
var userId = Guid.Parse(User.FindFirstValue("user_id")!);
await _refreshTokenService.RevokeAllUserTokensAsync(
userId,
HttpContext.RequestAborted);
return Ok(new { message = "Logged out from all devices successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Logout from all devices failed");
return BadRequest(new { message = "Logout failed" });
}
}
}

View File

@@ -0,0 +1,6 @@
namespace ColaFlow.API.Models;
public class LogoutRequest
{
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,6 @@
namespace ColaFlow.API.Models;
public class RefreshTokenRequest
{
public string RefreshToken { get; set; } = string.Empty;
}

View File

@@ -2,7 +2,10 @@ using ColaFlow.API.Extensions;
using ColaFlow.API.Handlers;
using ColaFlow.Modules.Identity.Application;
using ColaFlow.Modules.Identity.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
@@ -20,6 +23,29 @@ builder.Services.AddControllers();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// Configure Authentication
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")))
};
});
builder.Services.AddAuthorization();
// Configure CORS for frontend
builder.Services.AddCors(options =>
{
@@ -50,6 +76,11 @@ app.UseExceptionHandler();
app.UseCors("AllowFrontend");
app.UseHttpsRedirection();
// Authentication & Authorization
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -1,4 +1,11 @@
{
"Jwt": {
"SecretKey": "your-super-secret-key-min-32-characters-long-12345",
"Issuer": "ColaFlow.API",
"Audience": "ColaFlow.Web",
"ExpirationMinutes": "15",
"RefreshTokenExpirationDays": "7"
},
"ConnectionStrings": {
"PMDatabase": "Host=localhost;Port=5432;Database=colaflow_pm;Username=colaflow;Password=colaflow_dev_password",
"DefaultConnection": "Host=localhost;Port=5432;Database=colaflow_identity;Username=colaflow;Password=colaflow_dev_password"

View File

@@ -1,4 +1,5 @@
using ColaFlow.Modules.Identity.Application.Dtos;
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
@@ -10,14 +11,22 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IPasswordHasher and IJwtService
private readonly IJwtService _jwtService;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenService _refreshTokenService;
public LoginCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
}
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
@@ -38,20 +47,27 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
throw new UnauthorizedAccessException("Invalid credentials");
}
// 3. Verify password (simplified - TODO: use IPasswordHasher)
// if (!PasswordHasher.Verify(request.Password, user.PasswordHash))
// {
// throw new UnauthorizedAccessException("Invalid credentials");
// }
// 3. Verify password
if (user.PasswordHash == null || !_passwordHasher.VerifyPassword(request.Password, user.PasswordHash))
{
throw new UnauthorizedAccessException("Invalid credentials");
}
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 4. Generate JWT token
var accessToken = _jwtService.GenerateToken(user, tenant);
// 5. Update last login time
// 5. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
user,
ipAddress: null,
userAgent: null,
cancellationToken);
// 6. Update last login time
user.RecordLogin();
await _userRepository.UpdateAsync(user, cancellationToken);
// 6. Return result
// 7. Return result
return new LoginResponseDto
{
User = new UserDto
@@ -78,7 +94,8 @@ public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDt
CreatedAt = tenant.CreatedAt,
UpdatedAt = tenant.UpdatedAt ?? tenant.CreatedAt
},
AccessToken = accessToken
AccessToken = accessToken,
RefreshToken = refreshToken
};
}
}

View File

@@ -15,5 +15,6 @@ public record RegisterTenantCommand(
public record RegisterTenantResult(
TenantDto Tenant,
UserDto AdminUser,
string AccessToken
string AccessToken,
string RefreshToken
);

View File

@@ -1,3 +1,4 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
@@ -9,14 +10,22 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
{
private readonly ITenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
// Note: In production, inject IJwtService and IPasswordHasher
private readonly IJwtService _jwtService;
private readonly IPasswordHasher _passwordHasher;
private readonly IRefreshTokenService _refreshTokenService;
public RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IUserRepository userRepository)
IUserRepository userRepository,
IJwtService jwtService,
IPasswordHasher passwordHasher,
IRefreshTokenService refreshTokenService)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_jwtService = jwtService;
_passwordHasher = passwordHasher;
_refreshTokenService = refreshTokenService;
}
public async Task<RegisterTenantResult> Handle(
@@ -40,20 +49,27 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
await _tenantRepository.AddAsync(tenant, cancellationToken);
// 3. Create admin user
// Note: In production, hash password first using IPasswordHasher
// 3. Create admin user with hashed password
var hashedPassword = _passwordHasher.HashPassword(request.AdminPassword);
var adminUser = User.CreateLocal(
TenantId.Create(tenant.Id),
Email.Create(request.AdminEmail),
request.AdminPassword, // TODO: Hash password
hashedPassword,
FullName.Create(request.AdminFullName));
await _userRepository.AddAsync(adminUser, cancellationToken);
// 4. Generate JWT token (simplified - TODO: use IJwtService)
var accessToken = "dummy-token";
// 4. Generate JWT token
var accessToken = _jwtService.GenerateToken(adminUser, tenant);
// 5. Return result
// 5. Generate refresh token
var refreshToken = await _refreshTokenService.GenerateRefreshTokenAsync(
adminUser,
ipAddress: null,
userAgent: null,
cancellationToken);
// 6. Return result
return new RegisterTenantResult(
new Dtos.TenantDto
{
@@ -78,6 +94,7 @@ public class RegisterTenantCommandHandler : IRequestHandler<RegisterTenantComman
IsEmailVerified = adminUser.EmailVerifiedAt.HasValue,
CreatedAt = adminUser.CreatedAt
},
accessToken);
accessToken,
refreshToken);
}
}

View File

@@ -5,4 +5,7 @@ public class LoginResponseDto
public UserDto User { get; set; } = null!;
public TenantDto Tenant { get; set; } = null!;
public string AccessToken { get; set; } = string.Empty;
public string RefreshToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; } = 900; // 15 minutes in seconds
public string TokenType { get; set; } = "Bearer";
}

View File

@@ -0,0 +1,10 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IJwtService
{
string GenerateToken(User user, Tenant tenant);
Task<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,7 @@
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyPassword(string password, string hashedPassword);
}

View File

@@ -0,0 +1,39 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Application.Services;
public interface IRefreshTokenService
{
/// <summary>
/// Generate a new refresh token for the user
/// </summary>
Task<string> GenerateRefreshTokenAsync(
User user,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Refresh access token using refresh token (with token rotation)
/// </summary>
Task<(string accessToken, string refreshToken)> RefreshTokenAsync(
string refreshToken,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revoke a specific refresh token
/// </summary>
Task RevokeTokenAsync(
string refreshToken,
string? ipAddress = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revoke all refresh tokens for a user
/// </summary>
Task RevokeAllUserTokensAsync(
Guid userId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,88 @@
using ColaFlow.Shared.Kernel.Common;
namespace ColaFlow.Modules.Identity.Domain.Aggregates.Users;
/// <summary>
/// Refresh Token entity for secure token rotation
/// </summary>
public sealed class RefreshToken : Entity
{
public string TokenHash { get; private set; } = null!;
public UserId UserId { get; private set; } = null!;
public Guid TenantId { get; private set; }
// Token lifecycle
public DateTime ExpiresAt { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? RevokedAt { get; private set; }
public string? RevokedReason { get; private set; }
// Security tracking
public string? IpAddress { get; private set; }
public string? UserAgent { get; private set; }
// Token rotation (token family tracking)
public string? ReplacedByToken { get; private set; }
// Navigation properties
public string? DeviceInfo { get; private set; }
// Private constructor for EF Core
private RefreshToken() : base() { }
// Factory method
public static RefreshToken Create(
string tokenHash,
UserId userId,
Guid tenantId,
DateTime expiresAt,
string? ipAddress = null,
string? userAgent = null,
string? deviceInfo = null)
{
if (string.IsNullOrWhiteSpace(tokenHash))
throw new ArgumentException("Token hash cannot be empty", nameof(tokenHash));
if (expiresAt <= DateTime.UtcNow)
throw new ArgumentException("Expiration date must be in the future", nameof(expiresAt));
return new RefreshToken
{
Id = Guid.NewGuid(),
TokenHash = tokenHash,
UserId = userId,
TenantId = tenantId,
ExpiresAt = expiresAt,
CreatedAt = DateTime.UtcNow,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceInfo = deviceInfo
};
}
// Business methods
public bool IsExpired() => DateTime.UtcNow >= ExpiresAt;
public bool IsRevoked() => RevokedAt.HasValue;
public bool IsActive() => !IsExpired() && !IsRevoked();
public void Revoke(string reason)
{
if (IsRevoked())
throw new InvalidOperationException("Token is already revoked");
RevokedAt = DateTime.UtcNow;
RevokedReason = reason;
}
public void MarkAsReplaced(string newTokenHash)
{
if (string.IsNullOrWhiteSpace(newTokenHash))
throw new ArgumentException("New token hash cannot be empty", nameof(newTokenHash));
ReplacedByToken = newTokenHash;
RevokedAt = DateTime.UtcNow;
RevokedReason = "Token rotated";
}
}

View File

@@ -0,0 +1,13 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
namespace ColaFlow.Modules.Identity.Domain.Repositories;
public interface IRefreshTokenRepository
{
Task<RefreshToken?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RefreshToken>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task AddAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default);
Task UpdateAsync(RefreshToken refreshToken, CancellationToken cancellationToken = default);
Task RevokeAllUserTokensAsync(Guid userId, string reason, CancellationToken cancellationToken = default);
Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default);
}

View File

@@ -5,6 +5,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
@@ -12,7 +13,9 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
<PropertyGroup>

View File

@@ -1,3 +1,4 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories;
@@ -27,6 +28,12 @@ public static class DependencyInjection
// Repositories
services.AddScoped<ITenantRepository, TenantRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
// Application Services
services.AddScoped<IJwtService, JwtService>();
services.AddScoped<IPasswordHasher, PasswordHasher>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
return services;
}

View File

@@ -0,0 +1,83 @@
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
public void Configure(EntityTypeBuilder<RefreshToken> builder)
{
builder.ToTable("refresh_tokens", "identity");
builder.HasKey(rt => rt.Id);
builder.Property(rt => rt.TokenHash)
.HasColumnName("token_hash")
.HasMaxLength(500)
.IsRequired();
builder.Property(rt => rt.TenantId)
.HasColumnName("tenant_id")
.IsRequired();
builder.Property(rt => rt.ExpiresAt)
.HasColumnName("expires_at")
.IsRequired();
builder.Property(rt => rt.CreatedAt)
.HasColumnName("created_at")
.IsRequired();
builder.Property(rt => rt.RevokedAt)
.HasColumnName("revoked_at")
.IsRequired(false);
builder.Property(rt => rt.RevokedReason)
.HasColumnName("revoked_reason")
.HasMaxLength(500)
.IsRequired(false);
builder.Property(rt => rt.IpAddress)
.HasColumnName("ip_address")
.HasMaxLength(50)
.IsRequired(false);
builder.Property(rt => rt.UserAgent)
.HasColumnName("user_agent")
.HasMaxLength(500)
.IsRequired(false);
builder.Property(rt => rt.ReplacedByToken)
.HasColumnName("replaced_by_token")
.HasMaxLength(500)
.IsRequired(false);
builder.Property(rt => rt.DeviceInfo)
.HasColumnName("device_info")
.HasMaxLength(500)
.IsRequired(false);
// Value object conversion for UserId
builder.Property(rt => rt.UserId)
.HasColumnName("user_id")
.HasConversion(
id => id.Value,
value => UserId.Create(value))
.IsRequired();
// Indexes for performance
builder.HasIndex(rt => rt.TokenHash)
.HasDatabaseName("ix_refresh_tokens_token_hash")
.IsUnique();
builder.HasIndex(rt => rt.UserId)
.HasDatabaseName("ix_refresh_tokens_user_id");
builder.HasIndex(rt => rt.ExpiresAt)
.HasDatabaseName("ix_refresh_tokens_expires_at");
builder.HasIndex(rt => rt.TenantId)
.HasDatabaseName("ix_refresh_tokens_tenant_id");
}
}

View File

@@ -19,6 +19,7 @@ public class IdentityDbContext : DbContext
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

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")

View File

@@ -0,0 +1,76 @@
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 RefreshTokenRepository : IRefreshTokenRepository
{
private readonly IdentityDbContext _context;
public RefreshTokenRepository(IdentityDbContext context)
{
_context = context;
}
public async Task<RefreshToken?> GetByTokenHashAsync(
string tokenHash,
CancellationToken cancellationToken = default)
{
return await _context.RefreshTokens
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken);
}
public async Task<IReadOnlyList<RefreshToken>> GetByUserIdAsync(
Guid userId,
CancellationToken cancellationToken = default)
{
return await _context.RefreshTokens
.Where(rt => rt.UserId.Value == userId)
.OrderByDescending(rt => rt.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task AddAsync(
RefreshToken refreshToken,
CancellationToken cancellationToken = default)
{
await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(
RefreshToken refreshToken,
CancellationToken cancellationToken = default)
{
_context.RefreshTokens.Update(refreshToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task RevokeAllUserTokensAsync(
Guid userId,
string reason,
CancellationToken cancellationToken = default)
{
var tokens = await _context.RefreshTokens
.Where(rt => rt.UserId.Value == userId && rt.RevokedAt == null)
.ToListAsync(cancellationToken);
foreach (var token in tokens)
{
token.Revoke(reason);
}
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default)
{
var expiredTokens = await _context.RefreshTokens
.Where(rt => rt.ExpiresAt < DateTime.UtcNow)
.ToListAsync(cancellationToken);
_context.RefreshTokens.RemoveRange(expiredTokens);
await _context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,58 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class JwtService : IJwtService
{
private readonly IConfiguration _configuration;
public JwtService(IConfiguration configuration)
{
_configuration = configuration;
}
public string GenerateToken(User user, Tenant tenant)
{
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured")));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, user.Email.Value),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("user_id", user.Id.ToString()),
new("tenant_id", tenant.Id.ToString()),
new("tenant_slug", tenant.Slug.Value),
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
};
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpirationMinutes"] ?? "60")),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public Task<string> GenerateRefreshTokenAsync(User user, CancellationToken cancellationToken = default)
{
// TODO: Implement refresh token generation and storage
throw new NotImplementedException("Refresh token not yet implemented");
}
}

View File

@@ -0,0 +1,16 @@
using ColaFlow.Modules.Identity.Application.Services;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class PasswordHasher : IPasswordHasher
{
public string HashPassword(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
}
public bool VerifyPassword(string password, string hashedPassword)
{
return BCrypt.Net.BCrypt.Verify(password, hashedPassword);
}
}

View File

@@ -0,0 +1,201 @@
using ColaFlow.Modules.Identity.Application.Services;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Aggregates.Users;
using ColaFlow.Modules.Identity.Domain.Repositories;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text;
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
public class RefreshTokenService : IRefreshTokenService
{
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
private readonly IJwtService _jwtService;
private readonly IConfiguration _configuration;
private readonly ILogger<RefreshTokenService> _logger;
public RefreshTokenService(
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository,
ITenantRepository tenantRepository,
IJwtService jwtService,
IConfiguration configuration,
ILogger<RefreshTokenService> logger)
{
_refreshTokenRepository = refreshTokenRepository;
_userRepository = userRepository;
_tenantRepository = tenantRepository;
_jwtService = jwtService;
_configuration = configuration;
_logger = logger;
}
public async Task<string> GenerateRefreshTokenAsync(
User user,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default)
{
// Generate cryptographically secure random token (64 bytes)
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
var token = Convert.ToBase64String(randomBytes);
// Hash token before storage (SHA-256)
var tokenHash = ComputeSha256Hash(token);
// Get expiration from configuration (default 7 days)
var expirationDays = _configuration.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
var expiresAt = DateTime.UtcNow.AddDays(expirationDays);
// Create refresh token entity
var refreshToken = RefreshToken.Create(
tokenHash: tokenHash,
userId: UserId.Create(user.Id),
tenantId: user.TenantId.Value,
expiresAt: expiresAt,
ipAddress: ipAddress,
userAgent: userAgent,
deviceInfo: userAgent // Use user agent as device info for now
);
// Save to database
await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken);
_logger.LogInformation(
"Generated refresh token for user {UserId}, expires at {ExpiresAt}",
user.Id, expiresAt);
// Return plain text token (only time we return it)
return token;
}
public async Task<(string accessToken, string refreshToken)> RefreshTokenAsync(
string refreshToken,
string? ipAddress = null,
string? userAgent = null,
CancellationToken cancellationToken = default)
{
// Hash the provided token to look it up
var tokenHash = ComputeSha256Hash(refreshToken);
// Find existing token
var existingToken = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (existingToken == null)
{
_logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash[..10] + "...");
throw new UnauthorizedAccessException("Invalid refresh token");
}
// Check if token is active (not expired and not revoked)
if (!existingToken.IsActive())
{
_logger.LogWarning(
"Attempted to use invalid refresh token for user {UserId}. Expired: {IsExpired}, Revoked: {IsRevoked}",
existingToken.UserId.Value, existingToken.IsExpired(), existingToken.IsRevoked());
// SECURITY: Token reuse detection - revoke all user tokens
if (existingToken.IsRevoked())
{
_logger.LogWarning(
"SECURITY ALERT: Revoked token reused for user {UserId}. Revoking all tokens.",
existingToken.UserId.Value);
await RevokeAllUserTokensAsync(existingToken.UserId.Value, cancellationToken);
}
throw new UnauthorizedAccessException("Invalid or expired refresh token");
}
// Get user and tenant
var user = await _userRepository.GetByIdAsync(existingToken.UserId, cancellationToken);
if (user == null || user.Status != UserStatus.Active)
{
_logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value);
throw new UnauthorizedAccessException("User not found or inactive");
}
var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken);
if (tenant == null || tenant.Status != TenantStatus.Active)
{
_logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId);
throw new UnauthorizedAccessException("Tenant not found or inactive");
}
// Generate new access token
var newAccessToken = _jwtService.GenerateToken(user, tenant);
// Generate new refresh token (token rotation)
var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken);
// Mark old token as replaced
var newTokenHash = ComputeSha256Hash(newRefreshToken);
existingToken.MarkAsReplaced(newTokenHash);
await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken);
_logger.LogInformation(
"Rotated refresh token for user {UserId}",
user.Id);
return (newAccessToken, newRefreshToken);
}
public async Task RevokeTokenAsync(
string refreshToken,
string? ipAddress = null,
CancellationToken cancellationToken = default)
{
var tokenHash = ComputeSha256Hash(refreshToken);
var token = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (token == null)
{
_logger.LogWarning("Attempted to revoke non-existent token");
return; // Silent failure for security
}
if (token.IsRevoked())
{
_logger.LogWarning("Token already revoked: {TokenId}", token.Id);
return;
}
var reason = ipAddress != null
? $"User logout from {ipAddress}"
: "User logout";
token.Revoke(reason);
await _refreshTokenRepository.UpdateAsync(token, cancellationToken);
_logger.LogInformation(
"Revoked refresh token {TokenId} for user {UserId}",
token.Id, token.UserId.Value);
}
public async Task RevokeAllUserTokensAsync(
Guid userId,
CancellationToken cancellationToken = default)
{
await _refreshTokenRepository.RevokeAllUserTokensAsync(
userId,
"User requested logout from all devices",
cancellationToken);
_logger.LogInformation(
"Revoked all refresh tokens for user {UserId}",
userId);
}
private static string ComputeSha256Hash(string input)
{
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(input);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}