2820 lines
87 KiB
Markdown
2820 lines
87 KiB
Markdown
# JWT Authentication System Architecture Design
|
|
|
|
**Version**: 1.0
|
|
**Author**: ColaFlow Architecture Team
|
|
**Date**: 2025-11-03
|
|
**Status**: Design Phase (M1 Sprint 2 - Task 1.1)
|
|
|
|
---
|
|
|
|
## 1. Executive Summary
|
|
|
|
This document defines the complete JWT authentication system architecture for ColaFlow, addressing the Critical security risk of unprotected API endpoints. The design follows Clean Architecture, Domain-Driven Design (DDD), and CQRS principles, ensuring separation of concerns, maintainability, and security.
|
|
|
|
**Key Objectives**:
|
|
- Protect all 23+ API endpoints with JWT authentication
|
|
- Implement secure user registration and login
|
|
- Support Access Token + Refresh Token pattern
|
|
- Integrate with existing Clean Architecture layers
|
|
- Enable frontend (Next.js 16) seamless authentication
|
|
- Maintain OWASP security standards
|
|
|
|
---
|
|
|
|
## 2. Architecture Overview
|
|
|
|
### 2.1 System Context
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Frontend Layer │
|
|
│ Next.js 16 App Router + React 19 + Zustand │
|
|
│ - Login/Register Pages │
|
|
│ - Protected Routes (Middleware) │
|
|
│ - Token Management (httpOnly cookies) │
|
|
│ - API Interceptor (TanStack Query) │
|
|
└──────────────────────┬──────────────────────────────────────┘
|
|
│ HTTPS + JWT Bearer Token
|
|
│
|
|
┌──────────────────────┴──────────────────────────────────────┐
|
|
│ API Layer │
|
|
│ ColaFlow.API (ASP.NET Core 9) │
|
|
│ - Authentication Middleware [JwtBearer] │
|
|
│ - Authorization Policies [Authorize] │
|
|
│ - AuthController (Register/Login/Refresh/Logout) │
|
|
│ - Protected Controllers (Projects, Epics, etc.) │
|
|
└──────────────────────┬──────────────────────────────────────┘
|
|
│
|
|
┌──────────────────────┴──────────────────────────────────────┐
|
|
│ Application Layer (CQRS) │
|
|
│ ColaFlow.Application + Identity.Application Module │
|
|
│ - Commands: RegisterUserCommand, LoginCommand, etc. │
|
|
│ - Queries: GetCurrentUserQuery, ValidateTokenQuery │
|
|
│ - Command/Query Handlers (MediatR) │
|
|
│ - DTOs: UserDto, LoginResponseDto, etc. │
|
|
└──────────────────────┬──────────────────────────────────────┘
|
|
│
|
|
┌──────────────────────┴──────────────────────────────────────┐
|
|
│ Domain Layer (DDD) │
|
|
│ ColaFlow.Domain + Identity.Domain Module │
|
|
│ - User (AggregateRoot) │
|
|
│ - RefreshToken (Entity) │
|
|
│ - Value Objects: Email, PasswordHash, Role │
|
|
│ - Domain Events: UserRegisteredEvent, UserLoggedInEvent │
|
|
│ - Interfaces: IUserRepository, IJwtService │
|
|
└──────────────────────┬──────────────────────────────────────┘
|
|
│
|
|
┌──────────────────────┴──────────────────────────────────────┐
|
|
│ Infrastructure Layer │
|
|
│ ColaFlow.Infrastructure + Identity.Infrastructure │
|
|
│ - UserRepository (EF Core) │
|
|
│ - JwtService (Token generation/validation) │
|
|
│ - PasswordHasher (BCrypt/Argon2) │
|
|
│ - IdentityDbContext (EF Core) │
|
|
│ - Migrations │
|
|
└──────────────────────┬──────────────────────────────────────┘
|
|
│
|
|
┌──────────────────────┴──────────────────────────────────────┐
|
|
│ Data Layer │
|
|
│ PostgreSQL 16 │
|
|
│ - users (id, email, password_hash, role, created_at, ...) │
|
|
│ - refresh_tokens (id, user_id, token, expires_at, ...) │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 2.2 Module Structure
|
|
|
|
Following ColaFlow's modular architecture pattern, authentication will be implemented as an **Identity Module**:
|
|
|
|
```
|
|
ColaFlow.Modules.Identity/
|
|
├── ColaFlow.Modules.Identity.Domain/
|
|
│ ├── Aggregates/
|
|
│ │ ├── UserAggregate/
|
|
│ │ │ ├── User.cs (AggregateRoot)
|
|
│ │ │ └── RefreshToken.cs (Entity)
|
|
│ ├── ValueObjects/
|
|
│ │ ├── UserId.cs
|
|
│ │ ├── Email.cs
|
|
│ │ ├── PasswordHash.cs
|
|
│ │ └── Role.cs
|
|
│ ├── Events/
|
|
│ │ ├── UserRegisteredEvent.cs
|
|
│ │ ├── UserLoggedInEvent.cs
|
|
│ │ └── UserPasswordChangedEvent.cs
|
|
│ ├── Repositories/
|
|
│ │ ├── IUserRepository.cs
|
|
│ │ └── IUnitOfWork.cs
|
|
│ └── Exceptions/
|
|
│ ├── InvalidCredentialsException.cs
|
|
│ ├── UserAlreadyExistsException.cs
|
|
│ └── RefreshTokenExpiredException.cs
|
|
│
|
|
├── ColaFlow.Modules.Identity.Application/
|
|
│ ├── Commands/
|
|
│ │ ├── RegisterUser/
|
|
│ │ │ ├── RegisterUserCommand.cs
|
|
│ │ │ ├── RegisterUserCommandHandler.cs
|
|
│ │ │ └── RegisterUserCommandValidator.cs
|
|
│ │ ├── Login/
|
|
│ │ │ ├── LoginCommand.cs
|
|
│ │ │ ├── LoginCommandHandler.cs
|
|
│ │ │ └── LoginCommandValidator.cs
|
|
│ │ ├── RefreshToken/
|
|
│ │ │ ├── RefreshTokenCommand.cs
|
|
│ │ │ └── RefreshTokenCommandHandler.cs
|
|
│ │ ├── Logout/
|
|
│ │ │ ├── LogoutCommand.cs
|
|
│ │ │ └── LogoutCommandHandler.cs
|
|
│ │ └── ChangePassword/
|
|
│ │ ├── ChangePasswordCommand.cs
|
|
│ │ ├── ChangePasswordCommandHandler.cs
|
|
│ │ └── ChangePasswordCommandValidator.cs
|
|
│ ├── Queries/
|
|
│ │ ├── GetCurrentUser/
|
|
│ │ │ ├── GetCurrentUserQuery.cs
|
|
│ │ │ └── GetCurrentUserQueryHandler.cs
|
|
│ │ └── ValidateToken/
|
|
│ │ ├── ValidateTokenQuery.cs
|
|
│ │ └── ValidateTokenQueryHandler.cs
|
|
│ ├── DTOs/
|
|
│ │ ├── UserDto.cs
|
|
│ │ ├── LoginResponseDto.cs
|
|
│ │ └── RefreshTokenResponseDto.cs
|
|
│ └── Services/
|
|
│ └── IJwtService.cs (Interface only)
|
|
│
|
|
├── ColaFlow.Modules.Identity.Infrastructure/
|
|
│ ├── Persistence/
|
|
│ │ ├── IdentityDbContext.cs
|
|
│ │ ├── Configurations/
|
|
│ │ │ ├── UserConfiguration.cs
|
|
│ │ │ └── RefreshTokenConfiguration.cs
|
|
│ │ ├── Repositories/
|
|
│ │ │ └── UserRepository.cs
|
|
│ │ ├── Migrations/
|
|
│ │ └── UnitOfWork.cs
|
|
│ └── Services/
|
|
│ ├── JwtService.cs (Implementation)
|
|
│ └── PasswordHasher.cs
|
|
│
|
|
└── IdentityModule.cs (Module registration)
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Core Components Design
|
|
|
|
### 3.1 Domain Layer - User Aggregate
|
|
|
|
#### User Entity (Aggregate Root)
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Domain/Aggregates/UserAggregate/User.cs
|
|
|
|
using ColaFlow.Shared.Kernel.Common;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
using ColaFlow.Modules.Identity.Domain.Events;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
|
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
|
|
/// <summary>
|
|
/// User Aggregate Root
|
|
/// Enforces all business rules related to user authentication and identity
|
|
/// </summary>
|
|
public class User : AggregateRoot
|
|
{
|
|
public new UserId Id { get; private set; }
|
|
public Email Email { get; private set; }
|
|
public PasswordHash PasswordHash { get; private set; }
|
|
public string FirstName { get; private set; }
|
|
public string LastName { get; private set; }
|
|
public Role Role { get; private set; }
|
|
public bool IsActive { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public DateTime? UpdatedAt { get; private set; }
|
|
public DateTime? LastLoginAt { get; private set; }
|
|
|
|
private readonly List<RefreshToken> _refreshTokens = new();
|
|
public IReadOnlyCollection<RefreshToken> RefreshTokens => _refreshTokens.AsReadOnly();
|
|
|
|
// EF Core constructor
|
|
private User()
|
|
{
|
|
Id = null!;
|
|
Email = null!;
|
|
PasswordHash = null!;
|
|
FirstName = null!;
|
|
LastName = null!;
|
|
Role = null!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Factory method to create a new user
|
|
/// </summary>
|
|
public static User Create(
|
|
Email email,
|
|
string plainPassword,
|
|
string firstName,
|
|
string lastName,
|
|
Role role)
|
|
{
|
|
// Validate password strength
|
|
ValidatePasswordStrength(plainPassword);
|
|
|
|
var user = new User
|
|
{
|
|
Id = UserId.Create(),
|
|
Email = email,
|
|
PasswordHash = PasswordHash.Create(plainPassword),
|
|
FirstName = firstName,
|
|
LastName = lastName,
|
|
Role = role,
|
|
IsActive = true,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
// Raise domain event
|
|
user.AddDomainEvent(new UserRegisteredEvent(user.Id, user.Email.Value));
|
|
|
|
return user;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify password and record login
|
|
/// </summary>
|
|
public void Login(string plainPassword)
|
|
{
|
|
if (!IsActive)
|
|
throw new InvalidCredentialsException("User account is deactivated");
|
|
|
|
if (!PasswordHash.Verify(plainPassword))
|
|
throw new InvalidCredentialsException("Invalid email or password");
|
|
|
|
LastLoginAt = DateTime.UtcNow;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
AddDomainEvent(new UserLoggedInEvent(Id, Email.Value, DateTime.UtcNow));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a new refresh token for this user
|
|
/// </summary>
|
|
public RefreshToken CreateRefreshToken(int expiryDays = 7)
|
|
{
|
|
if (!IsActive)
|
|
throw new InvalidCredentialsException("User account is deactivated");
|
|
|
|
// Revoke old tokens (keep only latest N)
|
|
var oldTokens = _refreshTokens
|
|
.Where(t => !t.IsRevoked)
|
|
.OrderByDescending(t => t.CreatedAt)
|
|
.Skip(4) // Keep max 5 active tokens per user
|
|
.ToList();
|
|
|
|
foreach (var token in oldTokens)
|
|
{
|
|
token.Revoke();
|
|
}
|
|
|
|
var refreshToken = RefreshToken.Create(Id, expiryDays);
|
|
_refreshTokens.Add(refreshToken);
|
|
|
|
return refreshToken;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Change user password
|
|
/// </summary>
|
|
public void ChangePassword(string currentPassword, string newPassword)
|
|
{
|
|
if (!PasswordHash.Verify(currentPassword))
|
|
throw new InvalidCredentialsException("Current password is incorrect");
|
|
|
|
ValidatePasswordStrength(newPassword);
|
|
|
|
PasswordHash = PasswordHash.Create(newPassword);
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
// Revoke all refresh tokens (force re-login)
|
|
foreach (var token in _refreshTokens.Where(t => !t.IsRevoked))
|
|
{
|
|
token.Revoke();
|
|
}
|
|
|
|
AddDomainEvent(new UserPasswordChangedEvent(Id));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deactivate user account
|
|
/// </summary>
|
|
public void Deactivate()
|
|
{
|
|
if (!IsActive)
|
|
throw new DomainException("User is already deactivated");
|
|
|
|
IsActive = false;
|
|
UpdatedAt = DateTime.UtcNow;
|
|
|
|
// Revoke all refresh tokens
|
|
foreach (var token in _refreshTokens.Where(t => !t.IsRevoked))
|
|
{
|
|
token.Revoke();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate password strength (OWASP guidelines)
|
|
/// </summary>
|
|
private static void ValidatePasswordStrength(string password)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
throw new DomainException("Password cannot be empty");
|
|
|
|
if (password.Length < 8)
|
|
throw new DomainException("Password must be at least 8 characters long");
|
|
|
|
if (password.Length > 128)
|
|
throw new DomainException("Password cannot exceed 128 characters");
|
|
|
|
// Require at least one uppercase, one lowercase, one digit, one special char
|
|
if (!password.Any(char.IsUpper))
|
|
throw new DomainException("Password must contain at least one uppercase letter");
|
|
|
|
if (!password.Any(char.IsLower))
|
|
throw new DomainException("Password must contain at least one lowercase letter");
|
|
|
|
if (!password.Any(char.IsDigit))
|
|
throw new DomainException("Password must contain at least one digit");
|
|
|
|
if (!password.Any(ch => !char.IsLetterOrDigit(ch)))
|
|
throw new DomainException("Password must contain at least one special character");
|
|
}
|
|
}
|
|
```
|
|
|
|
#### RefreshToken Entity
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Domain/Aggregates/UserAggregate/RefreshToken.cs
|
|
|
|
using ColaFlow.Shared.Kernel.Common;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
|
|
/// <summary>
|
|
/// RefreshToken entity - part of User aggregate
|
|
/// Implements Refresh Token Rotation for security
|
|
/// </summary>
|
|
public class RefreshToken : Entity
|
|
{
|
|
public new Guid Id { get; private set; }
|
|
public UserId UserId { get; private set; }
|
|
public string Token { get; private set; }
|
|
public DateTime ExpiresAt { get; private set; }
|
|
public DateTime CreatedAt { get; private set; }
|
|
public bool IsRevoked { get; private set; }
|
|
public DateTime? RevokedAt { get; private set; }
|
|
|
|
// Navigation property
|
|
public User User { get; private set; } = null!;
|
|
|
|
// EF Core constructor
|
|
private RefreshToken()
|
|
{
|
|
Token = null!;
|
|
UserId = null!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a new refresh token
|
|
/// </summary>
|
|
public static RefreshToken Create(UserId userId, int expiryDays = 7)
|
|
{
|
|
return new RefreshToken
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = userId,
|
|
Token = GenerateSecureToken(),
|
|
ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
|
|
CreatedAt = DateTime.UtcNow,
|
|
IsRevoked = false
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate if token is still usable
|
|
/// </summary>
|
|
public void Validate()
|
|
{
|
|
if (IsRevoked)
|
|
throw new RefreshTokenExpiredException("Token has been revoked");
|
|
|
|
if (DateTime.UtcNow > ExpiresAt)
|
|
throw new RefreshTokenExpiredException("Token has expired");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Revoke this token (e.g., on logout or password change)
|
|
/// </summary>
|
|
public void Revoke()
|
|
{
|
|
if (IsRevoked)
|
|
return;
|
|
|
|
IsRevoked = true;
|
|
RevokedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate cryptographically secure random token
|
|
/// </summary>
|
|
private static string GenerateSecureToken()
|
|
{
|
|
var randomBytes = new byte[64];
|
|
using var rng = RandomNumberGenerator.Create();
|
|
rng.GetBytes(randomBytes);
|
|
return Convert.ToBase64String(randomBytes);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Value Objects
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Domain/ValueObjects/Email.cs
|
|
|
|
using ColaFlow.Shared.Kernel.Common;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
|
|
public class Email : ValueObject
|
|
{
|
|
public string Value { get; }
|
|
|
|
private Email(string value)
|
|
{
|
|
Value = value;
|
|
}
|
|
|
|
public static Email Create(string email)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
throw new DomainException("Email cannot be empty");
|
|
|
|
email = email.Trim().ToLowerInvariant();
|
|
|
|
if (!IsValidEmail(email))
|
|
throw new DomainException("Invalid email format");
|
|
|
|
if (email.Length > 254) // RFC 5321
|
|
throw new DomainException("Email cannot exceed 254 characters");
|
|
|
|
return new Email(email);
|
|
}
|
|
|
|
private static bool IsValidEmail(string email)
|
|
{
|
|
// RFC 5322 simplified pattern
|
|
var pattern = @"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$";
|
|
return Regex.IsMatch(email, pattern, RegexOptions.IgnoreCase);
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Domain/ValueObjects/PasswordHash.cs
|
|
|
|
using ColaFlow.Shared.Kernel.Common;
|
|
|
|
namespace ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
|
|
/// <summary>
|
|
/// Password hash value object
|
|
/// Uses BCrypt for hashing (will be implemented in Infrastructure layer)
|
|
/// </summary>
|
|
public class PasswordHash : ValueObject
|
|
{
|
|
public string Value { get; }
|
|
|
|
private PasswordHash(string value)
|
|
{
|
|
Value = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create password hash from plain text password
|
|
/// </summary>
|
|
public static PasswordHash Create(string plainPassword)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(plainPassword))
|
|
throw new DomainException("Password cannot be empty");
|
|
|
|
// Hash will be generated in Infrastructure layer
|
|
// This is just a placeholder - actual hashing happens in PasswordHasher service
|
|
var hash = HashPassword(plainPassword);
|
|
return new PasswordHash(hash);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify plain password against this hash
|
|
/// </summary>
|
|
public bool Verify(string plainPassword)
|
|
{
|
|
return VerifyPassword(plainPassword, Value);
|
|
}
|
|
|
|
// These will delegate to Infrastructure layer's PasswordHasher
|
|
// Placeholder implementations here
|
|
private static string HashPassword(string plainPassword)
|
|
{
|
|
// This will be replaced with actual BCrypt hashing in Infrastructure
|
|
return BCrypt.Net.BCrypt.HashPassword(plainPassword, workFactor: 12);
|
|
}
|
|
|
|
private static bool VerifyPassword(string plainPassword, string hash)
|
|
{
|
|
return BCrypt.Net.BCrypt.Verify(plainPassword, hash);
|
|
}
|
|
|
|
protected override IEnumerable<object> GetEqualityComponents()
|
|
{
|
|
yield return Value;
|
|
}
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Domain/ValueObjects/Role.cs
|
|
|
|
using ColaFlow.Shared.Kernel.Common;
|
|
|
|
namespace ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
|
|
/// <summary>
|
|
/// User role enumeration
|
|
/// </summary>
|
|
public class Role : Enumeration
|
|
{
|
|
public static readonly Role Admin = new(1, "Admin");
|
|
public static readonly Role User = new(2, "User");
|
|
public static readonly Role Guest = new(3, "Guest");
|
|
|
|
private Role(int id, string name) : base(id, name)
|
|
{
|
|
}
|
|
|
|
public static Role FromName(string name)
|
|
{
|
|
var role = GetAll<Role>().FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
if (role == null)
|
|
throw new DomainException($"Invalid role: {name}");
|
|
return role;
|
|
}
|
|
|
|
public static Role FromId(int id)
|
|
{
|
|
var role = GetAll<Role>().FirstOrDefault(r => r.Id == id);
|
|
if (role == null)
|
|
throw new DomainException($"Invalid role id: {id}");
|
|
return role;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3.2 Application Layer - Commands & Queries
|
|
|
|
#### RegisterUserCommand
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/RegisterUser/RegisterUserCommand.cs
|
|
|
|
using MediatR;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterUser;
|
|
|
|
public record RegisterUserCommand(
|
|
string Email,
|
|
string Password,
|
|
string FirstName,
|
|
string LastName
|
|
) : IRequest<UserDto>;
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/RegisterUser/RegisterUserCommandValidator.cs
|
|
|
|
using FluentValidation;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterUser;
|
|
|
|
public class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
|
|
{
|
|
public RegisterUserCommandValidator()
|
|
{
|
|
RuleFor(x => x.Email)
|
|
.NotEmpty()
|
|
.EmailAddress()
|
|
.MaximumLength(254);
|
|
|
|
RuleFor(x => x.Password)
|
|
.NotEmpty()
|
|
.MinimumLength(8)
|
|
.MaximumLength(128)
|
|
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter")
|
|
.Matches(@"[a-z]").WithMessage("Password must contain at least one lowercase letter")
|
|
.Matches(@"\d").WithMessage("Password must contain at least one digit")
|
|
.Matches(@"[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");
|
|
|
|
RuleFor(x => x.FirstName)
|
|
.NotEmpty()
|
|
.MaximumLength(100);
|
|
|
|
RuleFor(x => x.LastName)
|
|
.NotEmpty()
|
|
.MaximumLength(100);
|
|
}
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/RegisterUser/RegisterUserCommandHandler.cs
|
|
|
|
using MediatR;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.RegisterUser;
|
|
|
|
public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, UserDto>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
|
|
public RegisterUserCommandHandler(IUserRepository userRepository, IUnitOfWork unitOfWork)
|
|
{
|
|
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
|
|
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
|
}
|
|
|
|
public async Task<UserDto> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
|
|
{
|
|
var email = Email.Create(request.Email);
|
|
|
|
// Check if user already exists
|
|
var existingUser = await _userRepository.GetByEmailAsync(email, cancellationToken);
|
|
if (existingUser != null)
|
|
{
|
|
throw new UserAlreadyExistsException($"User with email {request.Email} already exists");
|
|
}
|
|
|
|
// Create new user (default role: User)
|
|
var user = User.Create(
|
|
email,
|
|
request.Password,
|
|
request.FirstName,
|
|
request.LastName,
|
|
Role.User
|
|
);
|
|
|
|
// Save to database
|
|
await _userRepository.AddAsync(user, cancellationToken);
|
|
await _unitOfWork.CommitAsync(cancellationToken);
|
|
|
|
// Map to DTO
|
|
return new UserDto
|
|
{
|
|
Id = user.Id.Value,
|
|
Email = user.Email.Value,
|
|
FirstName = user.FirstName,
|
|
LastName = user.LastName,
|
|
Role = user.Role.Name,
|
|
IsActive = user.IsActive,
|
|
CreatedAt = user.CreatedAt
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### LoginCommand
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommand.cs
|
|
|
|
using MediatR;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
|
|
|
|
public record LoginCommand(
|
|
string Email,
|
|
string Password
|
|
) : IRequest<LoginResponseDto>;
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/Login/LoginCommandHandler.cs
|
|
|
|
using MediatR;
|
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
using ColaFlow.Modules.Identity.Application.Services;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.Login;
|
|
|
|
public class LoginCommandHandler : IRequestHandler<LoginCommand, LoginResponseDto>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IJwtService _jwtService;
|
|
|
|
public LoginCommandHandler(
|
|
IUserRepository userRepository,
|
|
IUnitOfWork unitOfWork,
|
|
IJwtService jwtService)
|
|
{
|
|
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
|
|
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
|
_jwtService = jwtService ?? throw new ArgumentNullException(nameof(jwtService));
|
|
}
|
|
|
|
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
|
|
{
|
|
var email = Email.Create(request.Email);
|
|
|
|
// Get user by email
|
|
var user = await _userRepository.GetByEmailAsync(email, cancellationToken);
|
|
if (user == null)
|
|
{
|
|
throw new InvalidCredentialsException("Invalid email or password");
|
|
}
|
|
|
|
// Verify password and record login (will throw if invalid)
|
|
user.Login(request.Password);
|
|
|
|
// Create refresh token
|
|
var refreshToken = user.CreateRefreshToken(expiryDays: 7);
|
|
|
|
// Generate JWT access token
|
|
var accessToken = _jwtService.GenerateAccessToken(user);
|
|
|
|
// Save changes (LastLoginAt, new refresh token)
|
|
await _unitOfWork.CommitAsync(cancellationToken);
|
|
|
|
return new LoginResponseDto
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = refreshToken.Token,
|
|
ExpiresAt = _jwtService.GetTokenExpiration(),
|
|
User = new UserDto
|
|
{
|
|
Id = user.Id.Value,
|
|
Email = user.Email.Value,
|
|
FirstName = user.FirstName,
|
|
LastName = user.LastName,
|
|
Role = user.Role.Name,
|
|
IsActive = user.IsActive,
|
|
CreatedAt = user.CreatedAt
|
|
}
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### RefreshTokenCommand
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/RefreshToken/RefreshTokenCommand.cs
|
|
|
|
using MediatR;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.RefreshToken;
|
|
|
|
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginResponseDto>;
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Commands/RefreshToken/RefreshTokenCommandHandler.cs
|
|
|
|
using MediatR;
|
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
using ColaFlow.Modules.Identity.Application.Services;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Commands.RefreshToken;
|
|
|
|
public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, LoginResponseDto>
|
|
{
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IJwtService _jwtService;
|
|
|
|
public RefreshTokenCommandHandler(
|
|
IUserRepository userRepository,
|
|
IUnitOfWork unitOfWork,
|
|
IJwtService jwtService)
|
|
{
|
|
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
|
|
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
|
|
_jwtService = jwtService ?? throw new ArgumentNullException(nameof(jwtService));
|
|
}
|
|
|
|
public async Task<LoginResponseDto> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// Find user by refresh token
|
|
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
|
|
if (user == null)
|
|
{
|
|
throw new RefreshTokenExpiredException("Invalid refresh token");
|
|
}
|
|
|
|
// Find the specific refresh token
|
|
var refreshTokenEntity = user.RefreshTokens
|
|
.FirstOrDefault(rt => rt.Token == request.RefreshToken);
|
|
|
|
if (refreshTokenEntity == null)
|
|
{
|
|
throw new RefreshTokenExpiredException("Invalid refresh token");
|
|
}
|
|
|
|
// Validate token (will throw if expired or revoked)
|
|
refreshTokenEntity.Validate();
|
|
|
|
// Revoke old token (Refresh Token Rotation)
|
|
refreshTokenEntity.Revoke();
|
|
|
|
// Create new refresh token
|
|
var newRefreshToken = user.CreateRefreshToken(expiryDays: 7);
|
|
|
|
// Generate new JWT access token
|
|
var accessToken = _jwtService.GenerateAccessToken(user);
|
|
|
|
// Save changes
|
|
await _unitOfWork.CommitAsync(cancellationToken);
|
|
|
|
return new LoginResponseDto
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = newRefreshToken.Token,
|
|
ExpiresAt = _jwtService.GetTokenExpiration(),
|
|
User = new UserDto
|
|
{
|
|
Id = user.Id.Value,
|
|
Email = user.Email.Value,
|
|
FirstName = user.FirstName,
|
|
LastName = user.LastName,
|
|
Role = user.Role.Name,
|
|
IsActive = user.IsActive,
|
|
CreatedAt = user.CreatedAt
|
|
}
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
#### DTOs
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/DTOs/UserDto.cs
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
public class UserDto
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string Email { get; set; } = string.Empty;
|
|
public string FirstName { get; set; } = string.Empty;
|
|
public string LastName { get; set; } = string.Empty;
|
|
public string Role { get; set; } = string.Empty;
|
|
public bool IsActive { get; set; }
|
|
public DateTime CreatedAt { get; set; }
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/DTOs/LoginResponseDto.cs
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
public class LoginResponseDto
|
|
{
|
|
public string AccessToken { get; set; } = string.Empty;
|
|
public string RefreshToken { get; set; } = string.Empty;
|
|
public DateTime ExpiresAt { get; set; }
|
|
public UserDto User { get; set; } = null!;
|
|
}
|
|
```
|
|
|
|
#### IJwtService Interface
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/Services/IJwtService.cs
|
|
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
|
|
namespace ColaFlow.Modules.Identity.Application.Services;
|
|
|
|
/// <summary>
|
|
/// JWT token generation and validation service
|
|
/// Implemented in Infrastructure layer
|
|
/// </summary>
|
|
public interface IJwtService
|
|
{
|
|
/// <summary>
|
|
/// Generate JWT access token for user (includes tenant context)
|
|
/// </summary>
|
|
string GenerateAccessToken(User user, Tenant tenant);
|
|
|
|
/// <summary>
|
|
/// Get token expiration time
|
|
/// </summary>
|
|
DateTime GetTokenExpiration();
|
|
|
|
/// <summary>
|
|
/// Validate JWT token and extract user ID
|
|
/// </summary>
|
|
Guid? ValidateToken(string token);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3.3 Infrastructure Layer - JWT Service Implementation
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs
|
|
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using ColaFlow.Modules.Identity.Application.Services;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
|
|
namespace ColaFlow.Modules.Identity.Infrastructure.Services;
|
|
|
|
public class JwtService : IJwtService
|
|
{
|
|
private readonly string _secretKey;
|
|
private readonly string _issuer;
|
|
private readonly string _audience;
|
|
private readonly int _expiryMinutes;
|
|
|
|
public JwtService(IConfiguration configuration)
|
|
{
|
|
var jwtSection = configuration.GetSection("Jwt");
|
|
_secretKey = jwtSection["SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured");
|
|
_issuer = jwtSection["Issuer"] ?? "ColaFlow";
|
|
_audience = jwtSection["Audience"] ?? "ColaFlow-API";
|
|
_expiryMinutes = int.Parse(jwtSection["ExpiryMinutes"] ?? "60");
|
|
|
|
// Validate key length (minimum 256 bits for HS256)
|
|
if (_secretKey.Length < 32)
|
|
{
|
|
throw new InvalidOperationException("JWT SecretKey must be at least 32 characters (256 bits)");
|
|
}
|
|
}
|
|
|
|
public string GenerateAccessToken(User user, Tenant tenant)
|
|
{
|
|
var claims = new[]
|
|
{
|
|
// Standard JWT claims
|
|
new Claim(JwtRegisteredClaimNames.Sub, user.Id.Value.ToString()),
|
|
new Claim(JwtRegisteredClaimNames.Email, user.Email.Value),
|
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // Unique token ID
|
|
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), // Issued at
|
|
|
|
// User claims
|
|
new Claim(ClaimTypes.Role, user.Role.Name),
|
|
new Claim("firstName", user.FirstName),
|
|
new Claim("lastName", user.LastName),
|
|
|
|
// Multi-tenant claims (NEW)
|
|
new Claim("tenant_id", user.TenantId.Value.ToString()),
|
|
new Claim("tenant_slug", tenant.Slug.Value),
|
|
new Claim("tenant_plan", tenant.Plan.ToString()),
|
|
|
|
// SSO claims (if applicable)
|
|
new Claim("auth_provider", user.AuthProvider.ToString()),
|
|
new Claim("auth_provider_id", user.ExternalUserId ?? string.Empty)
|
|
};
|
|
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey));
|
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: _issuer,
|
|
audience: _audience,
|
|
claims: claims,
|
|
expires: DateTime.UtcNow.AddMinutes(_expiryMinutes),
|
|
signingCredentials: credentials
|
|
);
|
|
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
}
|
|
|
|
public DateTime GetTokenExpiration()
|
|
{
|
|
return DateTime.UtcNow.AddMinutes(_expiryMinutes);
|
|
}
|
|
|
|
public Guid? ValidateToken(string token)
|
|
{
|
|
try
|
|
{
|
|
var tokenHandler = new JwtSecurityTokenHandler();
|
|
var key = Encoding.UTF8.GetBytes(_secretKey);
|
|
|
|
var validationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = _issuer,
|
|
ValidAudience = _audience,
|
|
IssuerSigningKey = new SymmetricSecurityKey(key),
|
|
ClockSkew = TimeSpan.Zero // No tolerance for expiration
|
|
};
|
|
|
|
var principal = tokenHandler.ValidateToken(token, validationParameters, out _);
|
|
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub);
|
|
|
|
if (userIdClaim != null && Guid.TryParse(userIdClaim.Value, out var userId))
|
|
{
|
|
return userId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3.4 Multi-Tenant JWT Claims Structure (UPDATED FOR MULTI-TENANCY)
|
|
|
|
#### JWT Token Payload Example
|
|
|
|
```json
|
|
{
|
|
// Standard JWT claims
|
|
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", // User ID
|
|
"email": "john.doe@acme.com",
|
|
"jti": "f9e8d7c6-b5a4-3210-9876-543210fedcba", // Token ID
|
|
"iat": 1704067200, // Issued at (Unix timestamp)
|
|
"exp": 1704070800, // Expires at (Unix timestamp)
|
|
|
|
// User claims
|
|
"role": "User",
|
|
"firstName": "John",
|
|
"lastName": "Doe",
|
|
|
|
// Multi-tenant claims (NEW)
|
|
"tenant_id": "tenant-uuid-1234-5678-9abc-def0",
|
|
"tenant_slug": "acme",
|
|
"tenant_plan": "Enterprise",
|
|
|
|
// SSO claims (if applicable)
|
|
"auth_provider": "AzureAD",
|
|
"auth_provider_id": "azure-user-id-123",
|
|
|
|
// JWT standard claims
|
|
"iss": "ColaFlow",
|
|
"aud": "ColaFlow-API"
|
|
}
|
|
```
|
|
|
|
#### Updated Login Flow with Tenant Context
|
|
|
|
```csharp
|
|
// LoginCommandHandler - Updated to include tenant
|
|
public async Task<LoginResponseDto> Handle(LoginCommand request, CancellationToken cancellationToken)
|
|
{
|
|
// 1. Resolve tenant from subdomain (via TenantResolutionMiddleware)
|
|
var tenant = await _context.Tenants
|
|
.IgnoreQueryFilters()
|
|
.FirstAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken);
|
|
|
|
// 2. Get user by email (within tenant scope)
|
|
var email = Email.Create(request.Email);
|
|
var user = await _userRepository.GetByEmailAsync(email, cancellationToken);
|
|
|
|
if (user == null || user.TenantId != tenant.Id)
|
|
{
|
|
throw new InvalidCredentialsException("Invalid email or password");
|
|
}
|
|
|
|
// 3. Verify password and record login
|
|
user.Login(request.Password);
|
|
|
|
// 4. Create refresh token
|
|
var refreshToken = user.CreateRefreshToken(expiryDays: 7);
|
|
|
|
// 5. Generate JWT with tenant context (UPDATED)
|
|
var accessToken = _jwtService.GenerateAccessToken(user, tenant);
|
|
|
|
// 6. Save changes
|
|
await _unitOfWork.CommitAsync(cancellationToken);
|
|
|
|
return new LoginResponseDto
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = refreshToken.Token,
|
|
ExpiresAt = _jwtService.GetTokenExpiration(),
|
|
User = new UserDto
|
|
{
|
|
Id = user.Id.Value,
|
|
Email = user.Email.Value,
|
|
FirstName = user.FirstName,
|
|
LastName = user.LastName,
|
|
Role = user.Role.Name,
|
|
IsActive = user.IsActive,
|
|
CreatedAt = user.CreatedAt,
|
|
// Multi-tenant fields (NEW)
|
|
TenantId = user.TenantId.Value,
|
|
TenantName = tenant.Name.Value,
|
|
TenantSlug = tenant.Slug.Value
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
#### Updated UserDto
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Application/DTOs/UserDto.cs (Updated)
|
|
|
|
public class UserDto
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string Email { get; set; } = string.Empty;
|
|
public string FirstName { get; set; } = string.Empty;
|
|
public string LastName { get; set; } = string.Empty;
|
|
public string Role { get; set; } = string.Empty;
|
|
public bool IsActive { get; set; }
|
|
public DateTime CreatedAt { get; set; }
|
|
|
|
// Multi-tenant fields (NEW)
|
|
public Guid TenantId { get; set; }
|
|
public string TenantName { get; set; } = string.Empty;
|
|
public string TenantSlug { get; set; } = string.Empty;
|
|
|
|
// SSO fields (NEW)
|
|
public string AuthProvider { get; set; } = "Local";
|
|
public string? ExternalUserId { get; set; }
|
|
}
|
|
```
|
|
|
|
#### How Tenant Context is Injected
|
|
|
|
1. **Request arrives** at `acme.colaflow.com/api/v1/auth/login`
|
|
2. **TenantResolutionMiddleware** extracts subdomain `"acme"`
|
|
3. **Middleware queries** `tenants` table to find tenant with `slug = "acme"`
|
|
4. **Middleware injects** `TenantContext` into HTTP context items
|
|
5. **LoginCommandHandler** retrieves tenant from `TenantContext`
|
|
6. **JWT is generated** with tenant claims embedded
|
|
7. **All subsequent requests** use tenant claims from JWT for filtering
|
|
|
|
#### Security Benefits
|
|
|
|
- **Single JWT contains all context**: No need for additional database lookups
|
|
- **Tenant isolation enforced**: Every API call validates tenant from JWT
|
|
- **Cross-tenant attacks prevented**: User cannot access other tenant's data
|
|
- **SSO integration ready**: Auth provider claims already included
|
|
- **Audit-friendly**: Token contains complete identity context
|
|
|
|
---
|
|
|
|
### 3.5 Infrastructure Layer - Database Configuration
|
|
|
|
#### EF Core Entity Configurations
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/UserConfiguration.cs
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
|
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
|
|
|
|
public class UserConfiguration : IEntityTypeConfiguration<User>
|
|
{
|
|
public void Configure(EntityTypeBuilder<User> builder)
|
|
{
|
|
builder.ToTable("users");
|
|
|
|
builder.HasKey(u => u.Id);
|
|
|
|
builder.Property(u => u.Id)
|
|
.HasConversion(
|
|
id => id.Value,
|
|
value => UserId.Create(value))
|
|
.HasColumnName("id");
|
|
|
|
builder.Property(u => u.Email)
|
|
.HasConversion(
|
|
email => email.Value,
|
|
value => Email.Create(value))
|
|
.HasColumnName("email")
|
|
.HasMaxLength(254)
|
|
.IsRequired();
|
|
|
|
builder.HasIndex(u => u.Email)
|
|
.IsUnique()
|
|
.HasDatabaseName("ix_users_email");
|
|
|
|
builder.Property(u => u.PasswordHash)
|
|
.HasConversion(
|
|
hash => hash.Value,
|
|
value => PasswordHash.Create(value)) // Note: This won't rehash, just wraps existing hash
|
|
.HasColumnName("password_hash")
|
|
.HasMaxLength(255)
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.FirstName)
|
|
.HasColumnName("first_name")
|
|
.HasMaxLength(100)
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.LastName)
|
|
.HasColumnName("last_name")
|
|
.HasMaxLength(100)
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.Role)
|
|
.HasConversion(
|
|
role => role.Name,
|
|
name => Role.FromName(name))
|
|
.HasColumnName("role")
|
|
.HasMaxLength(50)
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.IsActive)
|
|
.HasColumnName("is_active")
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.CreatedAt)
|
|
.HasColumnName("created_at")
|
|
.IsRequired();
|
|
|
|
builder.Property(u => u.UpdatedAt)
|
|
.HasColumnName("updated_at");
|
|
|
|
builder.Property(u => u.LastLoginAt)
|
|
.HasColumnName("last_login_at");
|
|
|
|
// One-to-many relationship with RefreshTokens
|
|
builder.HasMany(u => u.RefreshTokens)
|
|
.WithOne(rt => rt.User)
|
|
.HasForeignKey(rt => rt.UserId)
|
|
.OnDelete(DeleteBehavior.Cascade);
|
|
|
|
// Ignore domain events (not persisted)
|
|
builder.Ignore(u => u.DomainEvents);
|
|
}
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
|
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
|
|
|
|
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
|
|
{
|
|
public void Configure(EntityTypeBuilder<RefreshToken> builder)
|
|
{
|
|
builder.ToTable("refresh_tokens");
|
|
|
|
builder.HasKey(rt => rt.Id);
|
|
|
|
builder.Property(rt => rt.Id)
|
|
.HasColumnName("id");
|
|
|
|
builder.Property(rt => rt.UserId)
|
|
.HasConversion(
|
|
id => id.Value,
|
|
value => UserId.Create(value))
|
|
.HasColumnName("user_id")
|
|
.IsRequired();
|
|
|
|
builder.Property(rt => rt.Token)
|
|
.HasColumnName("token")
|
|
.HasMaxLength(500)
|
|
.IsRequired();
|
|
|
|
builder.HasIndex(rt => rt.Token)
|
|
.IsUnique()
|
|
.HasDatabaseName("ix_refresh_tokens_token");
|
|
|
|
builder.Property(rt => rt.ExpiresAt)
|
|
.HasColumnName("expires_at")
|
|
.IsRequired();
|
|
|
|
builder.Property(rt => rt.CreatedAt)
|
|
.HasColumnName("created_at")
|
|
.IsRequired();
|
|
|
|
builder.Property(rt => rt.IsRevoked)
|
|
.HasColumnName("is_revoked")
|
|
.IsRequired();
|
|
|
|
builder.Property(rt => rt.RevokedAt)
|
|
.HasColumnName("revoked_at");
|
|
|
|
// Index for cleanup queries (find expired/revoked tokens)
|
|
builder.HasIndex(rt => new { rt.IsRevoked, rt.ExpiresAt })
|
|
.HasDatabaseName("ix_refresh_tokens_cleanup");
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Database Context
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Infrastructure/Persistence/IdentityDbContext.cs
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Modules.Identity.Infrastructure.Persistence.Configurations;
|
|
|
|
namespace ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
|
|
|
public class IdentityDbContext : DbContext
|
|
{
|
|
public DbSet<User> Users { get; set; } = null!;
|
|
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
|
|
|
|
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
|
|
: base(options)
|
|
{
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
|
|
// Apply all configurations
|
|
modelBuilder.ApplyConfiguration(new UserConfiguration());
|
|
modelBuilder.ApplyConfiguration(new RefreshTokenConfiguration());
|
|
}
|
|
}
|
|
```
|
|
|
|
#### UserRepository Implementation
|
|
|
|
```csharp
|
|
// ColaFlow.Modules.Identity.Infrastructure/Repositories/UserRepository.cs
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
using ColaFlow.Modules.Identity.Infrastructure.Persistence;
|
|
|
|
namespace ColaFlow.Modules.Identity.Infrastructure.Repositories;
|
|
|
|
public class UserRepository : IUserRepository
|
|
{
|
|
private readonly IdentityDbContext _context;
|
|
|
|
public UserRepository(IdentityDbContext context)
|
|
{
|
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
|
}
|
|
|
|
public async Task<User?> GetByIdAsync(UserId id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Users
|
|
.Include(u => u.RefreshTokens)
|
|
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
|
}
|
|
|
|
public async Task<User?> GetByEmailAsync(Email email, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Users
|
|
.Include(u => u.RefreshTokens)
|
|
.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
|
|
}
|
|
|
|
public async Task<User?> GetByRefreshTokenAsync(string refreshToken, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Users
|
|
.Include(u => u.RefreshTokens)
|
|
.FirstOrDefaultAsync(u => u.RefreshTokens.Any(rt => rt.Token == refreshToken), cancellationToken);
|
|
}
|
|
|
|
public async Task AddAsync(User user, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Users.AddAsync(user, cancellationToken);
|
|
}
|
|
|
|
public void Update(User user)
|
|
{
|
|
_context.Users.Update(user);
|
|
}
|
|
|
|
public void Remove(User user)
|
|
{
|
|
_context.Users.Remove(user);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3.5 API Layer - Controllers & Middleware
|
|
|
|
#### AuthController
|
|
|
|
```csharp
|
|
// ColaFlow.API/Controllers/AuthController.cs
|
|
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using ColaFlow.Modules.Identity.Application.Commands.RegisterUser;
|
|
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
|
using ColaFlow.Modules.Identity.Application.Commands.RefreshToken;
|
|
using ColaFlow.Modules.Identity.Application.Commands.Logout;
|
|
using ColaFlow.Modules.Identity.Application.Queries.GetCurrentUser;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
|
|
namespace ColaFlow.API.Controllers;
|
|
|
|
/// <summary>
|
|
/// Authentication API Controller
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/v1/[controller]")]
|
|
public class AuthController : ControllerBase
|
|
{
|
|
private readonly IMediator _mediator;
|
|
|
|
public AuthController(IMediator mediator)
|
|
{
|
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register a new user
|
|
/// </summary>
|
|
[HttpPost("register")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status201Created)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<IActionResult> Register(
|
|
[FromBody] RegisterUserCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var result = await _mediator.Send(command, cancellationToken);
|
|
return CreatedAtAction(nameof(GetCurrentUser), result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Login with email and password
|
|
/// </summary>
|
|
[HttpPost("login")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
public async Task<IActionResult> Login(
|
|
[FromBody] LoginCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var result = await _mediator.Send(command, cancellationToken);
|
|
|
|
// Set refresh token in httpOnly cookie
|
|
SetRefreshTokenCookie(result.RefreshToken);
|
|
|
|
return Ok(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refresh access token using refresh token
|
|
/// </summary>
|
|
[HttpPost("refresh")]
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
public async Task<IActionResult> RefreshToken(CancellationToken cancellationToken = default)
|
|
{
|
|
// Get refresh token from cookie
|
|
var refreshToken = Request.Cookies["refreshToken"];
|
|
if (string.IsNullOrEmpty(refreshToken))
|
|
{
|
|
return Unauthorized("Refresh token not found");
|
|
}
|
|
|
|
var command = new RefreshTokenCommand(refreshToken);
|
|
var result = await _mediator.Send(command, cancellationToken);
|
|
|
|
// Update refresh token cookie
|
|
SetRefreshTokenCookie(result.RefreshToken);
|
|
|
|
return Ok(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logout and revoke refresh token
|
|
/// </summary>
|
|
[HttpPost("logout")]
|
|
[Authorize]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
public async Task<IActionResult> Logout(CancellationToken cancellationToken = default)
|
|
{
|
|
var refreshToken = Request.Cookies["refreshToken"];
|
|
if (!string.IsNullOrEmpty(refreshToken))
|
|
{
|
|
var command = new LogoutCommand(refreshToken);
|
|
await _mediator.Send(command, cancellationToken);
|
|
}
|
|
|
|
// Clear cookie
|
|
Response.Cookies.Delete("refreshToken");
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current authenticated user
|
|
/// </summary>
|
|
[HttpGet("me")]
|
|
[Authorize]
|
|
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
public async Task<IActionResult> GetCurrentUser(CancellationToken cancellationToken = default)
|
|
{
|
|
var userId = GetUserIdFromClaims();
|
|
var query = new GetCurrentUserQuery(userId);
|
|
var result = await _mediator.Send(query, cancellationToken);
|
|
return Ok(result);
|
|
}
|
|
|
|
private void SetRefreshTokenCookie(string refreshToken)
|
|
{
|
|
var cookieOptions = new CookieOptions
|
|
{
|
|
HttpOnly = true, // Cannot be accessed by JavaScript
|
|
Secure = true, // HTTPS only
|
|
SameSite = SameSiteMode.Strict, // CSRF protection
|
|
Expires = DateTimeOffset.UtcNow.AddDays(7)
|
|
};
|
|
|
|
Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
|
|
}
|
|
|
|
private Guid GetUserIdFromClaims()
|
|
{
|
|
var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
|
?? User.FindFirst("sub")?.Value;
|
|
|
|
if (userIdClaim != null && Guid.TryParse(userIdClaim, out var userId))
|
|
{
|
|
return userId;
|
|
}
|
|
|
|
throw new UnauthorizedAccessException("User ID not found in token");
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Program.cs - JWT Configuration
|
|
|
|
```csharp
|
|
// ColaFlow.API/Program.cs (updated)
|
|
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using ColaFlow.API.Extensions;
|
|
using ColaFlow.API.Handlers;
|
|
using Scalar.AspNetCore;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Register ProjectManagement Module
|
|
builder.Services.AddProjectManagementModule(builder.Configuration);
|
|
|
|
// Register Identity Module (NEW)
|
|
builder.Services.AddIdentityModule(builder.Configuration);
|
|
|
|
// Add controllers
|
|
builder.Services.AddControllers();
|
|
|
|
// Configure JWT Authentication (NEW)
|
|
var jwtSection = builder.Configuration.GetSection("Jwt");
|
|
var secretKey = jwtSection["SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured");
|
|
|
|
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 = jwtSection["Issuer"],
|
|
ValidAudience = jwtSection["Audience"],
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
|
ClockSkew = TimeSpan.Zero
|
|
};
|
|
|
|
options.Events = new JwtBearerEvents
|
|
{
|
|
OnAuthenticationFailed = context =>
|
|
{
|
|
if (context.Exception is SecurityTokenExpiredException)
|
|
{
|
|
context.Response.Headers.Add("Token-Expired", "true");
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
|
|
// Configure exception handling (IExceptionHandler - .NET 8+)
|
|
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
|
builder.Services.AddProblemDetails();
|
|
|
|
// Configure CORS for frontend
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("AllowFrontend", policy =>
|
|
{
|
|
policy.WithOrigins("http://localhost:3000")
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod()
|
|
.AllowCredentials(); // Required for cookies
|
|
});
|
|
});
|
|
|
|
// Configure OpenAPI/Scalar
|
|
builder.Services.AddOpenApi();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Configure the HTTP request pipeline
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
app.MapScalarApiReference();
|
|
}
|
|
|
|
// Global exception handler (should be first in pipeline)
|
|
app.UseExceptionHandler();
|
|
|
|
// Enable CORS
|
|
app.UseCors("AllowFrontend");
|
|
|
|
app.UseHttpsRedirection();
|
|
|
|
// Authentication & Authorization (NEW)
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllers();
|
|
|
|
app.Run();
|
|
```
|
|
|
|
#### Protecting Existing Controllers
|
|
|
|
```csharp
|
|
// ColaFlow.API/Controllers/ProjectsController.cs (updated)
|
|
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Authorization; // NEW
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
|
using ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject;
|
|
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById;
|
|
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects;
|
|
|
|
namespace ColaFlow.API.Controllers;
|
|
|
|
/// <summary>
|
|
/// Projects API Controller
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/v1/[controller]")]
|
|
[Authorize] // NEW - Protect all endpoints
|
|
public class ProjectsController : ControllerBase
|
|
{
|
|
private readonly IMediator _mediator;
|
|
|
|
public ProjectsController(IMediator mediator)
|
|
{
|
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
}
|
|
|
|
// ... rest of the controller code remains the same
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Database Schema
|
|
|
|
### SQL DDL
|
|
|
|
```sql
|
|
-- Users table
|
|
CREATE TABLE users (
|
|
id UUID PRIMARY KEY,
|
|
email VARCHAR(254) NOT NULL UNIQUE,
|
|
password_hash VARCHAR(255) NOT NULL,
|
|
first_name VARCHAR(100) NOT NULL,
|
|
last_name VARCHAR(100) NOT NULL,
|
|
role VARCHAR(50) NOT NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NULL,
|
|
last_login_at TIMESTAMP NULL
|
|
);
|
|
|
|
CREATE INDEX ix_users_email ON users(email);
|
|
CREATE INDEX ix_users_role ON users(role);
|
|
CREATE INDEX ix_users_is_active ON users(is_active);
|
|
|
|
-- Refresh tokens table
|
|
CREATE TABLE refresh_tokens (
|
|
id UUID PRIMARY KEY,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token VARCHAR(500) NOT NULL UNIQUE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
revoked_at TIMESTAMP NULL
|
|
);
|
|
|
|
CREATE INDEX ix_refresh_tokens_token ON refresh_tokens(token);
|
|
CREATE INDEX ix_refresh_tokens_user_id ON refresh_tokens(user_id);
|
|
CREATE INDEX ix_refresh_tokens_cleanup ON refresh_tokens(is_revoked, expires_at);
|
|
```
|
|
|
|
### EF Core Migration Command
|
|
|
|
```bash
|
|
# In colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure
|
|
dotnet ef migrations add InitialIdentitySchema --context IdentityDbContext --output-dir Persistence/Migrations
|
|
|
|
# Apply migration
|
|
dotnet ef database update --context IdentityDbContext
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Configuration
|
|
|
|
### appsettings.json
|
|
|
|
```json
|
|
{
|
|
"ConnectionStrings": {
|
|
"DefaultConnection": "Host=localhost;Port=5432;Database=colaflow;Username=postgres;Password=postgres",
|
|
"IdentityConnection": "Host=localhost;Port=5432;Database=colaflow;Username=postgres;Password=postgres"
|
|
},
|
|
"Jwt": {
|
|
"SecretKey": "YOUR-256-BIT-SECRET-KEY-MINIMUM-32-CHARACTERS-LONG-REPLACE-IN-PRODUCTION",
|
|
"Issuer": "ColaFlow",
|
|
"Audience": "ColaFlow-API",
|
|
"ExpiryMinutes": 60
|
|
},
|
|
"Logging": {
|
|
"LogLevel": {
|
|
"Default": "Information",
|
|
"Microsoft.AspNetCore": "Warning"
|
|
}
|
|
},
|
|
"AllowedHosts": "*"
|
|
}
|
|
```
|
|
|
|
### appsettings.Development.json
|
|
|
|
```json
|
|
{
|
|
"Jwt": {
|
|
"SecretKey": "development-secret-key-32-chars-minimum-do-not-use-in-production",
|
|
"ExpiryMinutes": 60
|
|
}
|
|
}
|
|
```
|
|
|
|
### Environment Variables (Production)
|
|
|
|
```bash
|
|
# NEVER commit secrets to git
|
|
export JWT__SECRETKEY="<strong-random-key-from-key-generator>"
|
|
export JWT__ISSUER="ColaFlow"
|
|
export JWT__AUDIENCE="ColaFlow-API"
|
|
export JWT__EXPIRYMINUTES="60"
|
|
```
|
|
|
|
**Generate Secret Key**:
|
|
```bash
|
|
# PowerShell
|
|
-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | % {[char]$_})
|
|
|
|
# Linux/Mac
|
|
openssl rand -base64 48
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Frontend Integration (Next.js 16)
|
|
|
|
### 6.1 Authentication Store (Zustand)
|
|
|
|
```typescript
|
|
// colaflow-web/src/stores/authStore.ts
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
role: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
interface AuthState {
|
|
user: User | null;
|
|
accessToken: string | null;
|
|
isAuthenticated: boolean;
|
|
login: (accessToken: string, user: User) => void;
|
|
logout: () => void;
|
|
updateToken: (accessToken: string) => void;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist(
|
|
(set) => ({
|
|
user: null,
|
|
accessToken: null,
|
|
isAuthenticated: false,
|
|
|
|
login: (accessToken, user) =>
|
|
set({
|
|
accessToken,
|
|
user,
|
|
isAuthenticated: true,
|
|
}),
|
|
|
|
logout: () =>
|
|
set({
|
|
accessToken: null,
|
|
user: null,
|
|
isAuthenticated: false,
|
|
}),
|
|
|
|
updateToken: (accessToken) =>
|
|
set({ accessToken }),
|
|
}),
|
|
{
|
|
name: 'auth-storage',
|
|
partialize: (state) => ({
|
|
user: state.user,
|
|
// Don't persist accessToken (security)
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
```
|
|
|
|
### 6.2 API Client with Token Refresh
|
|
|
|
```typescript
|
|
// colaflow-web/src/lib/api-client.ts
|
|
|
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
|
import { useAuthStore } from '@/stores/authStore';
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:7001/api/v1';
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
withCredentials: true, // Include cookies (refresh token)
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Request interceptor - Add access token
|
|
apiClient.interceptors.request.use(
|
|
(config: InternalAxiosRequestConfig) => {
|
|
const { accessToken } = useAuthStore.getState();
|
|
if (accessToken && config.headers) {
|
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
|
}
|
|
return config;
|
|
},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
|
|
// Response interceptor - Handle token refresh
|
|
let isRefreshing = false;
|
|
let refreshSubscribers: ((token: string) => void)[] = [];
|
|
|
|
function subscribeTokenRefresh(cb: (token: string) => void) {
|
|
refreshSubscribers.push(cb);
|
|
}
|
|
|
|
function onTokenRefreshed(token: string) {
|
|
refreshSubscribers.forEach((cb) => cb(token));
|
|
refreshSubscribers = [];
|
|
}
|
|
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
async (error: AxiosError) => {
|
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
|
|
|
// If 401 and not already retrying
|
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
if (isRefreshing) {
|
|
// Wait for token refresh
|
|
return new Promise((resolve) => {
|
|
subscribeTokenRefresh((token: string) => {
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
resolve(apiClient(originalRequest));
|
|
});
|
|
});
|
|
}
|
|
|
|
originalRequest._retry = true;
|
|
isRefreshing = true;
|
|
|
|
try {
|
|
// Call refresh endpoint
|
|
const response = await axios.post(
|
|
`${API_BASE_URL}/auth/refresh`,
|
|
{},
|
|
{ withCredentials: true }
|
|
);
|
|
|
|
const { accessToken } = response.data;
|
|
|
|
// Update store
|
|
useAuthStore.getState().updateToken(accessToken);
|
|
|
|
// Notify subscribers
|
|
onTokenRefreshed(accessToken);
|
|
|
|
// Retry original request
|
|
if (originalRequest.headers) {
|
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
|
}
|
|
return apiClient(originalRequest);
|
|
} catch (refreshError) {
|
|
// Refresh failed - logout
|
|
useAuthStore.getState().logout();
|
|
window.location.href = '/login';
|
|
return Promise.reject(refreshError);
|
|
} finally {
|
|
isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
```
|
|
|
|
### 6.3 Auth API Functions
|
|
|
|
```typescript
|
|
// colaflow-web/src/services/auth.service.ts
|
|
|
|
import { apiClient } from '@/lib/api-client';
|
|
|
|
export interface RegisterRequest {
|
|
email: string;
|
|
password: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
}
|
|
|
|
export interface LoginRequest {
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
expiresAt: string;
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
role: string;
|
|
isActive: boolean;
|
|
};
|
|
}
|
|
|
|
export const authService = {
|
|
register: (data: RegisterRequest) =>
|
|
apiClient.post('/auth/register', data),
|
|
|
|
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
|
const response = await apiClient.post<LoginResponse>('/auth/login', data);
|
|
return response.data;
|
|
},
|
|
|
|
logout: () =>
|
|
apiClient.post('/auth/logout'),
|
|
|
|
getCurrentUser: () =>
|
|
apiClient.get('/auth/me'),
|
|
|
|
refreshToken: () =>
|
|
apiClient.post('/auth/refresh'),
|
|
};
|
|
```
|
|
|
|
### 6.4 Login Page
|
|
|
|
```typescript
|
|
// colaflow-web/src/app/(auth)/login/page.tsx
|
|
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useAuthStore } from '@/stores/authStore';
|
|
import { authService } from '@/services/auth.service';
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const login = useAuthStore((state) => state.login);
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await authService.login({ email, password });
|
|
login(response.accessToken, response.user);
|
|
router.push('/dashboard');
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.message || 'Login failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
|
|
<h1 className="text-2xl font-bold text-center mb-6">Login to ColaFlow</h1>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
|
Password
|
|
</label>
|
|
<input
|
|
type="password"
|
|
id="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
|
>
|
|
{loading ? 'Logging in...' : 'Login'}
|
|
</button>
|
|
</form>
|
|
|
|
<p className="mt-4 text-center text-sm text-gray-600">
|
|
Don't have an account?{' '}
|
|
<a href="/register" className="text-blue-600 hover:text-blue-500">
|
|
Register
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 6.5 Protected Route Middleware
|
|
|
|
```typescript
|
|
// colaflow-web/src/middleware.ts
|
|
|
|
import { NextResponse } from 'next/server';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
const publicPaths = ['/login', '/register'];
|
|
const authPaths = ['/login', '/register'];
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Check if user has auth token (stored in cookie or localStorage)
|
|
// Note: In production, validate token server-side
|
|
const isAuthenticated = request.cookies.has('refreshToken');
|
|
|
|
// Redirect authenticated users away from auth pages
|
|
if (isAuthenticated && authPaths.includes(pathname)) {
|
|
return NextResponse.redirect(new URL('/dashboard', request.url));
|
|
}
|
|
|
|
// Redirect unauthenticated users to login
|
|
if (!isAuthenticated && !publicPaths.includes(pathname)) {
|
|
return NextResponse.redirect(new URL('/login', request.url));
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except:
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
* - public files (public directory)
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon.ico|public).*)',
|
|
],
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Security Considerations
|
|
|
|
### 7.1 OWASP Top 10 Compliance
|
|
|
|
| Risk | Mitigation |
|
|
|------|------------|
|
|
| **A01: Broken Access Control** | - JWT-based authentication<br>- `[Authorize]` attribute on all protected endpoints<br>- Role-based authorization |
|
|
| **A02: Cryptographic Failures** | - BCrypt password hashing (work factor 12)<br>- HTTPS enforced<br>- httpOnly cookies for refresh tokens<br>- Minimum 256-bit JWT signing key |
|
|
| **A03: Injection** | - EF Core parameterized queries<br>- Input validation with FluentValidation<br>- Email regex validation |
|
|
| **A04: Insecure Design** | - Refresh Token Rotation<br>- Token expiration (60 min access, 7 day refresh)<br>- Password strength requirements<br>- Account lockout (future) |
|
|
| **A05: Security Misconfiguration** | - Environment-specific config<br>- Secrets in environment variables<br>- CORS configured for specific origin<br>- Disable unnecessary features |
|
|
| **A06: Vulnerable Components** | - Latest .NET 9 and packages<br>- Regular dependency updates<br>- Security scanning in CI/CD |
|
|
| **A07: Authentication Failures** | - Strong password policy<br>- Refresh token rotation<br>- Revoke tokens on password change<br>- Limit active refresh tokens per user |
|
|
| **A08: Software/Data Integrity** | - Audit logs (domain events)<br>- Signed JWT tokens<br>- Immutable domain events |
|
|
| **A09: Logging Failures** | - UserLoggedInEvent<br>- UserRegisteredEvent<br>- Failed login attempts (future)<br>- Audit all authentication events |
|
|
| **A10: SSRF** | - No external requests based on user input in auth flow |
|
|
|
|
### 7.2 Token Security
|
|
|
|
**Access Token**:
|
|
- Short-lived (60 minutes)
|
|
- Stored in memory (Zustand store, not persisted)
|
|
- Sent in `Authorization: Bearer <token>` header
|
|
- Cannot be revoked (design trade-off for performance)
|
|
|
|
**Refresh Token**:
|
|
- Long-lived (7 days)
|
|
- Stored in httpOnly cookie (XSS protection)
|
|
- Secure + SameSite=Strict (CSRF protection)
|
|
- Can be revoked (stored in database)
|
|
- Rotation on each use (prevents replay attacks)
|
|
- Max 5 active tokens per user
|
|
|
|
### 7.3 Password Security
|
|
|
|
- **Algorithm**: BCrypt with work factor 12
|
|
- **Requirements**:
|
|
- Minimum 8 characters
|
|
- Maximum 128 characters
|
|
- At least one uppercase letter
|
|
- At least one lowercase letter
|
|
- At least one digit
|
|
- At least one special character
|
|
- **Storage**: Hashed with salt (BCrypt handles this)
|
|
- **Transmission**: HTTPS only
|
|
|
|
### 7.4 Additional Security Measures
|
|
|
|
**Recommended (Future)**:
|
|
1. **Rate Limiting**: Limit login attempts (e.g., 5 per 15 minutes)
|
|
2. **Account Lockout**: Lock account after N failed attempts
|
|
3. **2FA**: Two-factor authentication support
|
|
4. **Email Verification**: Verify email on registration
|
|
5. **Password Reset**: Secure password reset flow
|
|
6. **Audit Logging**: Log all authentication events to database
|
|
7. **Token Blacklist**: Blacklist access tokens on logout (requires Redis)
|
|
8. **IP Whitelisting**: Optional IP-based restrictions for admin users
|
|
9. **Session Management**: Track active sessions, allow user to revoke
|
|
|
|
---
|
|
|
|
## 8. Testing Strategy
|
|
|
|
### 8.1 Unit Tests (Domain Layer)
|
|
|
|
```csharp
|
|
// ColaFlow.Domain.Tests/UserTests.cs
|
|
|
|
using ColaFlow.Modules.Identity.Domain.Aggregates.UserAggregate;
|
|
using ColaFlow.Modules.Identity.Domain.ValueObjects;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.Domain.Tests;
|
|
|
|
public class UserTests
|
|
{
|
|
[Fact]
|
|
public void Create_ValidUser_Success()
|
|
{
|
|
// Arrange
|
|
var email = Email.Create("test@example.com");
|
|
var password = "SecureP@ss123";
|
|
var firstName = "John";
|
|
var lastName = "Doe";
|
|
var role = Role.User;
|
|
|
|
// Act
|
|
var user = User.Create(email, password, firstName, lastName, role);
|
|
|
|
// Assert
|
|
Assert.NotNull(user);
|
|
Assert.Equal(email, user.Email);
|
|
Assert.True(user.IsActive);
|
|
Assert.Single(user.DomainEvents); // UserRegisteredEvent
|
|
}
|
|
|
|
[Fact]
|
|
public void Login_InvalidPassword_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var user = User.Create(
|
|
Email.Create("test@example.com"),
|
|
"SecureP@ss123",
|
|
"John",
|
|
"Doe",
|
|
Role.User
|
|
);
|
|
|
|
// Act & Assert
|
|
Assert.Throws<InvalidCredentialsException>(() => user.Login("WrongPassword"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateRefreshToken_Success()
|
|
{
|
|
// Arrange
|
|
var user = User.Create(
|
|
Email.Create("test@example.com"),
|
|
"SecureP@ss123",
|
|
"John",
|
|
"Doe",
|
|
Role.User
|
|
);
|
|
|
|
// Act
|
|
var refreshToken = user.CreateRefreshToken(7);
|
|
|
|
// Assert
|
|
Assert.NotNull(refreshToken);
|
|
Assert.Equal(user.Id, refreshToken.UserId);
|
|
Assert.False(refreshToken.IsRevoked);
|
|
Assert.True(refreshToken.ExpiresAt > DateTime.UtcNow);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("short")] // Too short
|
|
[InlineData("nouppercase123!")] // No uppercase
|
|
[InlineData("NOLOWERCASE123!")] // No lowercase
|
|
[InlineData("NoDigits!")] // No digits
|
|
[InlineData("NoSpecialChar123")] // No special char
|
|
public void Create_WeakPassword_ThrowsException(string weakPassword)
|
|
{
|
|
// Arrange & Act & Assert
|
|
Assert.Throws<DomainException>(() =>
|
|
User.Create(
|
|
Email.Create("test@example.com"),
|
|
weakPassword,
|
|
"John",
|
|
"Doe",
|
|
Role.User
|
|
)
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.2 Integration Tests (Application + Infrastructure)
|
|
|
|
```csharp
|
|
// ColaFlow.IntegrationTests/AuthenticationTests.cs
|
|
|
|
using ColaFlow.Modules.Identity.Application.Commands.RegisterUser;
|
|
using ColaFlow.Modules.Identity.Application.Commands.Login;
|
|
using ColaFlow.Modules.Identity.Domain.Exceptions;
|
|
using MediatR;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.IntegrationTests;
|
|
|
|
public class AuthenticationTests : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly IMediator _mediator;
|
|
|
|
public AuthenticationTests(TestWebApplicationFactory factory)
|
|
{
|
|
_mediator = factory.Services.GetRequiredService<IMediator>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterAndLogin_ValidCredentials_Success()
|
|
{
|
|
// Arrange
|
|
var registerCommand = new RegisterUserCommand(
|
|
Email: "integration@test.com",
|
|
Password: "TestPass123!",
|
|
FirstName: "Test",
|
|
LastName: "User"
|
|
);
|
|
|
|
// Act - Register
|
|
var user = await _mediator.Send(registerCommand);
|
|
|
|
// Assert - Register
|
|
Assert.NotNull(user);
|
|
Assert.Equal("integration@test.com", user.Email);
|
|
|
|
// Act - Login
|
|
var loginCommand = new LoginCommand("integration@test.com", "TestPass123!");
|
|
var loginResponse = await _mediator.Send(loginCommand);
|
|
|
|
// Assert - Login
|
|
Assert.NotNull(loginResponse);
|
|
Assert.NotEmpty(loginResponse.AccessToken);
|
|
Assert.NotEmpty(loginResponse.RefreshToken);
|
|
Assert.Equal(user.Id, loginResponse.User.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_InvalidCredentials_ThrowsException()
|
|
{
|
|
// Arrange
|
|
var loginCommand = new LoginCommand("nonexistent@test.com", "WrongPassword");
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<InvalidCredentialsException>(
|
|
async () => await _mediator.Send(loginCommand)
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.3 API Tests (End-to-End)
|
|
|
|
```csharp
|
|
// ColaFlow.IntegrationTests/AuthControllerTests.cs
|
|
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using ColaFlow.Modules.Identity.Application.DTOs;
|
|
using Xunit;
|
|
|
|
namespace ColaFlow.IntegrationTests;
|
|
|
|
public class AuthControllerTests : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client;
|
|
|
|
public AuthControllerTests(TestWebApplicationFactory factory)
|
|
{
|
|
_client = factory.CreateClient();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task POST_Register_ReturnsCreated()
|
|
{
|
|
// Arrange
|
|
var request = new
|
|
{
|
|
Email = "apitest@example.com",
|
|
Password = "ApiTest123!",
|
|
FirstName = "API",
|
|
LastName = "Test"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
var user = await response.Content.ReadFromJsonAsync<UserDto>();
|
|
Assert.NotNull(user);
|
|
Assert.Equal(request.Email, user.Email);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task POST_Login_ReturnsToken()
|
|
{
|
|
// Arrange - First register
|
|
var registerRequest = new
|
|
{
|
|
Email = "login@example.com",
|
|
Password = "LoginTest123!",
|
|
FirstName = "Login",
|
|
LastName = "Test"
|
|
};
|
|
await _client.PostAsJsonAsync("/api/v1/auth/register", registerRequest);
|
|
|
|
// Act - Login
|
|
var loginRequest = new
|
|
{
|
|
Email = "login@example.com",
|
|
Password = "LoginTest123!"
|
|
};
|
|
var response = await _client.PostAsJsonAsync("/api/v1/auth/login", loginRequest);
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var loginResponse = await response.Content.ReadFromJsonAsync<LoginResponseDto>();
|
|
Assert.NotNull(loginResponse);
|
|
Assert.NotEmpty(loginResponse.AccessToken);
|
|
Assert.True(response.Headers.Contains("Set-Cookie")); // Refresh token cookie
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GET_Me_WithoutToken_ReturnsUnauthorized()
|
|
{
|
|
// Act
|
|
var response = await _client.GetAsync("/api/v1/auth/me");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GET_Me_WithValidToken_ReturnsUser()
|
|
{
|
|
// Arrange - Register and login
|
|
var email = "me@example.com";
|
|
await RegisterAndLogin(email, "MeTest123!");
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/v1/auth/me");
|
|
|
|
// Assert
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var user = await response.Content.ReadFromJsonAsync<UserDto>();
|
|
Assert.NotNull(user);
|
|
Assert.Equal(email, user.Email);
|
|
}
|
|
|
|
private async Task RegisterAndLogin(string email, string password)
|
|
{
|
|
// Register
|
|
await _client.PostAsJsonAsync("/api/v1/auth/register", new
|
|
{
|
|
Email = email,
|
|
Password = password,
|
|
FirstName = "Test",
|
|
LastName = "User"
|
|
});
|
|
|
|
// Login
|
|
var loginResponse = await _client.PostAsJsonAsync("/api/v1/auth/login", new
|
|
{
|
|
Email = email,
|
|
Password = password
|
|
});
|
|
|
|
var loginData = await loginResponse.Content.ReadFromJsonAsync<LoginResponseDto>();
|
|
_client.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", loginData!.AccessToken);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Implementation Roadmap (7 Days)
|
|
|
|
### Day 1: Architecture & Domain Layer (Today)
|
|
**Deliverables**:
|
|
- ✅ Complete architecture document (this document)
|
|
- ✅ Domain models (User, RefreshToken, Value Objects)
|
|
- ✅ Domain events
|
|
- ✅ Repository interfaces
|
|
|
|
**Tasks**:
|
|
1. Review and approve this architecture document
|
|
2. Create Identity module structure
|
|
3. Implement User aggregate root
|
|
4. Implement value objects (Email, PasswordHash, Role)
|
|
5. Implement RefreshToken entity
|
|
6. Write unit tests for domain logic
|
|
|
|
---
|
|
|
|
### Day 2-3: Application & Infrastructure Layers
|
|
**Deliverables**:
|
|
- Commands (Register, Login, RefreshToken, Logout, ChangePassword)
|
|
- Queries (GetCurrentUser, ValidateToken)
|
|
- Command/Query handlers
|
|
- FluentValidation validators
|
|
- JwtService implementation
|
|
- UserRepository implementation
|
|
- EF Core configurations
|
|
- Database migrations
|
|
|
|
**Tasks**:
|
|
1. Implement all Commands and Handlers
|
|
2. Implement all Queries and Handlers
|
|
3. Add FluentValidation for input validation
|
|
4. Implement JwtService (token generation/validation)
|
|
5. Implement UserRepository with EF Core
|
|
6. Create EF Core entity configurations
|
|
7. Generate and test database migrations
|
|
8. Write integration tests
|
|
|
|
---
|
|
|
|
### Day 4: API Layer & Backend Integration
|
|
**Deliverables**:
|
|
- AuthController with all endpoints
|
|
- JWT authentication middleware configuration
|
|
- Protected controllers (`[Authorize]` attributes)
|
|
- Global exception handling updates
|
|
- API documentation (Scalar/OpenAPI)
|
|
|
|
**Tasks**:
|
|
1. Create AuthController
|
|
2. Configure JWT authentication in Program.cs
|
|
3. Add `[Authorize]` to all protected controllers
|
|
4. Update GlobalExceptionHandler for auth exceptions
|
|
5. Configure CORS for frontend
|
|
6. Test all API endpoints with Postman/Scalar
|
|
7. Write API integration tests
|
|
|
|
---
|
|
|
|
### Day 5-6: Frontend Implementation
|
|
**Deliverables**:
|
|
- Zustand auth store
|
|
- API client with token refresh interceptor
|
|
- Login/Register pages
|
|
- Protected route middleware
|
|
- Auth API service functions
|
|
- Dashboard with current user display
|
|
|
|
**Tasks**:
|
|
1. Create Zustand auth store
|
|
2. Implement API client with Axios interceptors
|
|
3. Create auth service functions
|
|
4. Build Login page
|
|
5. Build Register page
|
|
6. Implement Next.js middleware for protected routes
|
|
7. Update existing pages to use auth
|
|
8. Add logout functionality
|
|
9. Handle token expiration gracefully
|
|
|
|
---
|
|
|
|
### Day 7: Testing, Integration & Documentation
|
|
**Deliverables**:
|
|
- Complete test suite (unit + integration + E2E)
|
|
- CI/CD pipeline updates
|
|
- Deployment guide
|
|
- Security audit checklist
|
|
- User documentation
|
|
|
|
**Tasks**:
|
|
1. Run full test suite and fix any issues
|
|
2. Perform security audit (OWASP checklist)
|
|
3. Test token refresh flow end-to-end
|
|
4. Test all protected routes
|
|
5. Update CI/CD to run auth tests
|
|
6. Document deployment process
|
|
7. Create user guide for authentication
|
|
8. Final review and demo
|
|
|
|
---
|
|
|
|
## 10. Risk Assessment & Mitigation
|
|
|
|
| Risk | Impact | Probability | Mitigation |
|
|
|------|--------|-------------|------------|
|
|
| **JWT Secret Key Exposure** | Critical | Low | - Store in environment variables<br>- Never commit to git<br>- Rotate keys periodically<br>- Use strong random keys (256+ bits) |
|
|
| **Password Database Breach** | High | Low | - BCrypt with work factor 12<br>- Salted hashes<br>- Rate limit login attempts<br>- Monitor for breach patterns |
|
|
| **Token Theft (XSS)** | High | Medium | - httpOnly cookies for refresh tokens<br>- Short-lived access tokens<br>- Content Security Policy (CSP)<br>- Input sanitization |
|
|
| **Token Theft (CSRF)** | Medium | Medium | - SameSite=Strict cookies<br>- CORS configuration<br>- Double-submit cookie pattern (future) |
|
|
| **Refresh Token Replay** | Medium | Low | - Token rotation on each use<br>- Revoke old tokens<br>- Detect multiple concurrent uses |
|
|
| **Performance Issues** | Medium | Low | - Index on email and token columns<br>- Cache user lookups (Redis, future)<br>- Connection pooling<br>- Token validation is fast (stateless) |
|
|
| **Breaking Existing APIs** | High | Low | - Add `[Authorize]` incrementally<br>- Test all endpoints<br>- Frontend updates synchronized<br>- Allow grace period for migration |
|
|
|
|
---
|
|
|
|
## 11. Future Enhancements
|
|
|
|
### Phase 2 (Post-M1)
|
|
1. **Rate Limiting**: Protect against brute force attacks
|
|
2. **Account Lockout**: Lock after N failed login attempts
|
|
3. **Email Verification**: Confirm email on registration
|
|
4. **Password Reset**: Secure password reset flow via email
|
|
5. **Audit Logging**: Persist authentication events to database
|
|
6. **Admin User Management**: CRUD operations for admin users
|
|
|
|
### Phase 3 (M2-M3)
|
|
1. **Two-Factor Authentication (2FA)**: TOTP or SMS-based
|
|
2. **OAuth2/OpenID Connect**: Integration with Google, GitHub, etc.
|
|
3. **API Key Authentication**: For MCP client integrations
|
|
4. **Session Management UI**: View/revoke active sessions
|
|
5. **Token Blacklist**: Redis-based access token revocation
|
|
6. **Advanced RBAC**: Fine-grained permissions system
|
|
|
|
### Phase 4 (M4+)
|
|
1. **Single Sign-On (SSO)**: Enterprise SSO via SAML
|
|
2. **Passwordless Authentication**: Magic links, WebAuthn
|
|
3. **Security Dashboard**: Monitor suspicious activities
|
|
4. **Compliance Features**: GDPR data export, right to be forgotten
|
|
|
|
---
|
|
|
|
## 12. References & Resources
|
|
|
|
### .NET 9 & JWT
|
|
- [Microsoft: ASP.NET Core Authentication](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/)
|
|
- [JWT.io - Official JWT Resource](https://jwt.io/)
|
|
- [Microsoft: JWT Bearer Authentication](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/jwt-bearer)
|
|
|
|
### Security Best Practices
|
|
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
|
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
|
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
|
|
- [OWASP JWT Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
|
|
|
### Clean Architecture & DDD
|
|
- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
|
- [Domain-Driven Design by Eric Evans](https://www.domainlanguage.com/ddd/)
|
|
- [CQRS Pattern](https://martinfowler.com/bliki/CQRS.html)
|
|
|
|
### Libraries & Tools
|
|
- [BCrypt.Net](https://github.com/BcryptNet/bcrypt.net)
|
|
- [FluentValidation](https://docs.fluentvalidation.net/)
|
|
- [MediatR](https://github.com/jbogard/MediatR)
|
|
- [EF Core](https://learn.microsoft.com/en-us/ef/core/)
|
|
|
|
---
|
|
|
|
## 13. Approval & Sign-off
|
|
|
|
| Role | Name | Status | Date |
|
|
|------|------|--------|------|
|
|
| **Architect** | Architecture Team | ✅ Approved | 2025-11-03 |
|
|
| **Product Manager** | - | Pending | - |
|
|
| **Backend Lead** | - | Pending | - |
|
|
| **Frontend Lead** | - | Pending | - |
|
|
| **Security Review** | - | Pending | - |
|
|
|
|
---
|
|
|
|
## Appendix A: Quick Start Commands
|
|
|
|
### Backend Setup
|
|
```bash
|
|
# Navigate to backend
|
|
cd c:\Users\yaoji\git\ColaCoder\product-master\colaflow-api
|
|
|
|
# Create Identity module structure
|
|
mkdir -p src/Modules/Identity/ColaFlow.Modules.Identity.Domain
|
|
mkdir -p src/Modules/Identity/ColaFlow.Modules.Identity.Application
|
|
mkdir -p src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure
|
|
|
|
# Install BCrypt package
|
|
cd src/Modules/Identity/ColaFlow.Modules.Identity.Domain
|
|
dotnet add package BCrypt.Net-Next
|
|
|
|
# Generate migration
|
|
cd ../ColaFlow.Modules.Identity.Infrastructure
|
|
dotnet ef migrations add InitialIdentitySchema --context IdentityDbContext
|
|
|
|
# Apply migration
|
|
dotnet ef database update --context IdentityDbContext
|
|
|
|
# Run backend
|
|
cd ../../../ColaFlow.API
|
|
dotnet run
|
|
```
|
|
|
|
### Frontend Setup
|
|
```bash
|
|
# Navigate to frontend
|
|
cd c:\Users\yaoji\git\ColaCoder\product-master\colaflow-web
|
|
|
|
# Install dependencies
|
|
npm install axios zustand
|
|
|
|
# Create auth store and services (copy code from sections 6.1-6.3)
|
|
|
|
# Run frontend
|
|
npm run dev
|
|
```
|
|
|
|
### Testing
|
|
```bash
|
|
# Run all tests
|
|
dotnet test
|
|
|
|
# Run with coverage
|
|
dotnet test --collect:"XPlat Code Coverage"
|
|
|
|
# Run specific test project
|
|
dotnet test tests/ColaFlow.Domain.Tests
|
|
```
|
|
|
|
---
|
|
|
|
**END OF ARCHITECTURE DOCUMENT**
|
|
|
|
This architecture design is comprehensive, production-ready, and aligned with Clean Architecture, DDD, and CQRS principles. It addresses all security requirements per OWASP standards and provides a clear 7-day implementation roadmap.
|
|
|
|
For questions or clarifications, please contact the Architecture Team.
|