feature: MIMIC-IV Replay Scenario Generator
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# Guide 17: Password Hashing with BCrypt
|
||||
|
||||
## What is Password Hashing?
|
||||
|
||||
When a user creates an account with the password `"DemoNurse1!"`, the application must store something that lets it verify the password later — but it should **never store the password itself**. If the database is compromised, plaintext passwords would be immediately usable by the attacker.
|
||||
|
||||
**Hashing** converts a password into a fixed-length string of random-looking characters using a one-way mathematical function. "One-way" means you can compute the hash from the password, but you can't compute the password from the hash:
|
||||
|
||||
```
|
||||
"DemoNurse1!" → hash() → "$2a$12$xK7W3M...long hash string..."
|
||||
```
|
||||
|
||||
When the user logs in, you hash the submitted password and compare it to the stored hash. If they match, the password is correct — without ever storing the actual password.
|
||||
|
||||
## Why BCrypt Specifically?
|
||||
|
||||
Not all hash functions are created equal. General-purpose hash functions like SHA-256 are designed to be **fast** — billions of hashes per second on modern hardware. That's a problem for passwords: an attacker who steals the hashed passwords can try billions of guesses per second.
|
||||
|
||||
**BCrypt** is specifically designed for password hashing with two key properties:
|
||||
|
||||
1. **It's intentionally slow**: BCrypt has a configurable "cost factor" (also called "work factor") that controls how many iterations the algorithm performs. Cost factor 12 (the default) means 2^12 = 4,096 iterations, making each hash take ~250ms. Fast enough that a single login is imperceptible, but an attacker trying 1 million passwords would need ~70 hours.
|
||||
|
||||
2. **It includes a built-in salt**: A **salt** is a random value mixed into the hash. Without a salt, two users with the same password would have the same hash — an attacker could build a precomputed table (a "rainbow table") of common passwords and their hashes, then look up matches instantly. BCrypt generates a random salt for each password and embeds it in the output, so identical passwords produce different hashes.
|
||||
|
||||
A BCrypt hash looks like this:
|
||||
|
||||
```
|
||||
$2a$12$xK7W3MqQ5Z6Y8B9A0C1D2EfGhIjKlMnOpQrStUvWxYz0123456789Ab
|
||||
│ │ │ │
|
||||
│ │ │ └── The hash itself
|
||||
│ │ └── The salt (22 chars)
|
||||
│ └── Cost factor (12 = 2^12 iterations)
|
||||
└── Algorithm version
|
||||
```
|
||||
|
||||
The salt and cost factor are stored right in the hash string, so you don't need a separate column for them.
|
||||
|
||||
---
|
||||
|
||||
## How BCrypt is Used in This Project
|
||||
|
||||
### NuGet Package
|
||||
|
||||
```xml
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
```
|
||||
|
||||
`BCrypt.Net-Next` is a .NET implementation of the BCrypt algorithm. It provides two key methods: `HashPassword()` and `Verify()`.
|
||||
|
||||
### Hashing on Account Creation
|
||||
|
||||
When a new user is created, the plaintext password is hashed before storage:
|
||||
|
||||
```csharp
|
||||
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
|
||||
{
|
||||
// Validate the password meets minimum requirements
|
||||
ValidatePassword(req.Password);
|
||||
|
||||
var user = new ClinicalUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = req.Username.Trim(),
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password), // hash here
|
||||
DisplayName = req.DisplayName.Trim(),
|
||||
Role = role,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_db.ClinicalUsers.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
return Map(user);
|
||||
}
|
||||
```
|
||||
|
||||
`HashPassword(req.Password)` generates a random salt, applies BCrypt with the default cost factor (12), and returns the full hash string. Each call with the same password produces a different hash (because the salt is random).
|
||||
|
||||
### Password Validation Rules
|
||||
|
||||
```csharp
|
||||
private static void ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
|
||||
if (password.Length < 8)
|
||||
throw new ValidationException(
|
||||
"Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
|
||||
}
|
||||
```
|
||||
|
||||
The minimum length of 8 characters is a baseline. In production, you'd typically also require uppercase, lowercase, digits, and special characters — but the BCrypt hash itself doesn't care about password complexity.
|
||||
|
||||
### Verification on Login
|
||||
|
||||
When a user logs in, the submitted password is verified against the stored hash:
|
||||
|
||||
```csharp
|
||||
public async Task<LoginResponse> LoginAsync(LoginRequest req)
|
||||
{
|
||||
var user = await _db.ClinicalUsers
|
||||
.FirstOrDefaultAsync(u => u.Username == req.Username && u.IsActive);
|
||||
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
throw new ValidationException(
|
||||
"Invalid username or password.", "INVALID_CREDENTIALS");
|
||||
|
||||
// ... generate JWT token
|
||||
}
|
||||
```
|
||||
|
||||
**How does `Verify` work?**
|
||||
1. Extract the salt and cost factor from the stored hash string
|
||||
2. Hash the submitted password using the same salt and cost factor
|
||||
3. Compare the result to the stored hash
|
||||
4. If they match, the password is correct
|
||||
|
||||
**Security note**: The error message says "Invalid username or password" — it does not distinguish between "user not found" and "wrong password." This prevents an attacker from enumerating valid usernames by observing different error messages.
|
||||
|
||||
### Seed Data (Development Only)
|
||||
|
||||
The user seeder creates demo accounts with hashed passwords:
|
||||
|
||||
```csharp
|
||||
new ClinicalUser
|
||||
{
|
||||
Username = "nurse.demo",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoNurse1!"),
|
||||
DisplayName = "Demo Nurse",
|
||||
Role = ClinicalRole.Nurse,
|
||||
},
|
||||
new ClinicalUser
|
||||
{
|
||||
Username = "physician.demo",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoPhysician1!"),
|
||||
DisplayName = "Dr. Demo Physician",
|
||||
Role = ClinicalRole.Physician,
|
||||
},
|
||||
new ClinicalUser
|
||||
{
|
||||
Username = "admin.demo",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword("DemoAdmin1!"),
|
||||
DisplayName = "Demo Admin",
|
||||
Role = ClinicalRole.Admin,
|
||||
},
|
||||
```
|
||||
|
||||
Even in development, passwords are never stored in plaintext in the database. The passwords themselves (`"DemoNurse1!"` etc.) appear in the seeder code, but they're only used during the initial seeding and don't persist as plaintext anywhere.
|
||||
|
||||
### Database Schema
|
||||
|
||||
```csharp
|
||||
builder.Property(u => u.PasswordHash).HasColumnName("password_hash")
|
||||
.HasMaxLength(500).IsRequired();
|
||||
```
|
||||
|
||||
`HasMaxLength(500)` accommodates the BCrypt hash string (typically ~60 characters) with room for future algorithm changes that might produce longer hashes.
|
||||
|
||||
---
|
||||
|
||||
## Cost Factor Considerations
|
||||
|
||||
The default cost factor of 12 is a good balance for 2024-era hardware:
|
||||
|
||||
| Cost Factor | Iterations | Approximate Time | Use Case |
|
||||
|-------------|-----------|------------------|----------|
|
||||
| 10 | 1,024 | ~65ms | Minimum for production |
|
||||
| 11 | 2,048 | ~130ms | Reasonable for high-traffic APIs |
|
||||
| **12** | **4,096** | **~250ms** | **Default — good balance** |
|
||||
| 13 | 8,192 | ~500ms | More security, but login feels slower |
|
||||
| 14 | 16,384 | ~1s | High-security environments |
|
||||
|
||||
The cost factor should be increased over time as hardware gets faster. What takes 250ms today might take 25ms in 10 years. The industry recommendation: choose the highest cost factor that keeps login time under ~500ms for your hardware.
|
||||
|
||||
You can customize the cost factor:
|
||||
|
||||
```csharp
|
||||
BCrypt.Net.BCrypt.HashPassword(password, workFactor: 13);
|
||||
```
|
||||
|
||||
Existing hashes with a lower cost factor continue to verify correctly — BCrypt reads the cost factor from the hash string.
|
||||
|
||||
---
|
||||
|
||||
## What BCrypt Does NOT Protect Against
|
||||
|
||||
- **Weak passwords**: BCrypt slows down brute-force attacks, but "password123" will still be cracked quickly. Enforce password complexity rules at the application level.
|
||||
- **Phishing**: If a user gives their password to an attacker directly, hashing doesn't help.
|
||||
- **Memory dumps**: While the application is running, the plaintext password exists briefly in memory (during the Verify call). In extremely sensitive environments, you'd use secure memory handling.
|
||||
- **Credential stuffing**: If a user reuses their password from another breached site, BCrypt can't help. Multi-factor authentication (MFA) addresses this.
|
||||
|
||||
---
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
- **Never store plaintext passwords** — always hash them before writing to the database. There is no valid reason to store or log a user's actual password.
|
||||
- **BCrypt is purposefully slow** — the cost factor makes brute-force attacks impractical while keeping legitimate logins fast
|
||||
- **Each hash includes its own salt** — even identical passwords produce different hashes, defeating rainbow table attacks
|
||||
- **The cost factor is embedded in the hash** — you can increase the cost factor for new passwords without invalidating existing ones
|
||||
- **Give generic error messages** — "Invalid username or password" prevents username enumeration. Never reveal whether the username or the password was wrong.
|
||||
- **BCrypt handles the hard parts** — salt generation, iteration count, and comparison are all managed by the library. You call `HashPassword()` and `Verify()` — nothing else.
|
||||
Reference in New Issue
Block a user