202 lines
8.9 KiB
Markdown
202 lines
8.9 KiB
Markdown
# 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.
|