Skip to content

User Management

Flaggy includes built-in user management with secure authentication for the dashboard. Users are stored in the database with BCrypt-hashed passwords, ensuring secure access to your feature flag management interface.

Overview

User management features include:

  • Secure Authentication: BCrypt password hashing with work factor 12
  • Database-Backed Storage: Users stored alongside feature flags
  • User CRUD Operations: Full create, read, update, delete support
  • Session Management: Cookie-based authentication
  • Multi-Provider Support: Works with all database providers
  • Programmatic API: Manage users through code or dashboard UI

User Model

Each user has the following properties:

public class User
{
    public string username { get; set; }       // Unique identifier, primary key
    public string password_hash { get; set; }   // BCrypt hashed password
    public string? email { get; set; }         // Optional email address
    public DateTime created_at { get; set; }    // User creation timestamp
}

Database Schema

The users table is automatically created during migration:

MySQL

CREATE TABLE users (
    username VARCHAR(255) PRIMARY KEY,
    password_hash VARCHAR(255) NOT NULL,
    email VARCHAR(255) NULL,
    created_at DATETIME NULL
);

PostgreSQL

CREATE TABLE users (
    username VARCHAR(255) PRIMARY KEY,
    password_hash VARCHAR(255) NOT NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP NULL
);

MS SQL Server

CREATE TABLE users (
    username NVARCHAR(255) PRIMARY KEY,
    password_hash NVARCHAR(255) NOT NULL,
    email NVARCHAR(255) NULL,
    created_at DATETIME NULL
);

The user table is created automatically when using database providers with auto-migration enabled.

Configuration

Enabling Authentication

Authentication is enabled by default:

using Flaggy.Extensions;
using Flaggy.UI.Extensions;

var builder = WebApplication.CreateBuilder(args);

// Configure Flaggy with MySQL (creates user table automatically)
builder.Services.AddFlaggy(options =>
{
    options.UseMySQL(
        connectionString: "Server=localhost;Database=myapp;User=root;Password=pass;",
        autoMigrate: true // Creates both feature_flags and users tables
    );
});

var app = builder.Build();

// Dashboard with authentication (default)
app.UseFlaggyUI(options =>
{
    options.EnableAuthentication = true;  // Default: true
});

app.Run();

Disabling Authentication

For development or internal tools only:

app.UseFlaggyUI(options =>
{
    options.EnableAuthentication = false;  // Disable authentication
});

Warning: Never disable authentication in production environments!

Default Credentials

If no users exist in the database, Flaggy falls back to default credentials:

  • username: admin
  • Password: admin

Important: The default credentials are only used as a fallback. Once you create users through the dashboard, the default credentials are disabled.

Security Best Practice

  1. On first login with default credentials
  2. Create a new admin user with a strong password
  3. The default admin fallback is automatically disabled once real users exist

Dashboard User Management

Accessing User Management

Navigate to {RoutePrefix}/users (e.g., https://localhost:5001/flaggy/users)

The user management interface provides:

  • List all users
  • Create new users
  • Update existing users
  • Delete users
  • Search and filter users

Creating a User

  1. Click "Create User" button
  2. Enter username (required, unique)
  3. Enter password (required, min 8 characters recommended)
  4. Enter email (optional)
  5. Click "Save"

The password is automatically hashed using BCrypt before storage.

Updating a User

  1. Click "Edit" on the user row
  2. Update password (leave blank to keep current)
  3. Update email
  4. Click "Save"

Deleting a User

  1. Click "Delete" on the user row
  2. Confirm deletion
  3. User is permanently removed

Warning: Cannot delete the last remaining user.

Programmatic User Management

IUserProvider Interface

All user operations go through the IUserProvider interface:

public interface IUserProvider
{
    Task<User?> GetUserAsync(string username, CancellationToken cancellationToken = default);
    Task<IEnumerable<User>> GetAllUsersAsync(CancellationToken cancellationToken = default);
    Task<bool> CreateUserAsync(User user, CancellationToken cancellationToken = default);
    Task<bool> UpdateUserAsync(User user, CancellationToken cancellationToken = default);
    Task<bool> DeleteUserAsync(string username, CancellationToken cancellationToken = default);
    Task<bool> UserExistsAsync(string username, CancellationToken cancellationToken = default);
}

Creating Users Programmatically

using Flaggy.Abstractions;
using Flaggy.Models;
using Flaggy.Services;

public class UserManagementService
{
    private readonly IUserProvider _userProvider;

    public UserManagementService(IUserProvider userProvider)
    {
        _userProvider = userProvider;
    }

    public async Task CreateAdminUser()
    {
        var user = new User
        {
            username = "admin",
            password_hash = password_hasher.HashPassword("SecurePassword123!"),
            email = "admin@example.com",
            created_at = DateTime.UtcNow
        };

        var created = await _userProvider.CreateUserAsync(user);
        if (created)
        {
            Console.WriteLine("Admin user created successfully");
        }
    }
}

Checking User Credentials

using Flaggy.Services;

public async Task<bool> ValidateLogin(string username, string password)
{
    var user = await _userProvider.GetUserAsync(username);
    if (user == null)
    {
        return false;
    }

    return password_hasher.VerifyPassword(password, user.password_hash);
}

Listing All Users

public async Task ListAllUsers()
{
    var users = await _userProvider.GetAllUsersAsync();
    foreach (var user in users)
    {
        Console.WriteLine($"username: {user.username}, email: {user.email}, Created: {user.created_at}");
    }
}

Updating User Password

public async Task UpdateUserPassword(string username, string newPassword)
{
    var user = await _userProvider.GetUserAsync(username);
    if (user == null)
    {
        throw new Exception("User not found");
    }

    user.password_hash = password_hasher.HashPassword(newPassword);
    var updated = await _userProvider.UpdateUserAsync(user);

    if (updated)
    {
        Console.WriteLine("Password updated successfully");
    }
}

Deleting a User

public async Task DeleteUser(string username)
{
    var deleted = await _userProvider.DeleteUserAsync(username);
    if (deleted)
    {
        Console.WriteLine($"User {username} deleted successfully");
    }
}

Seeding Initial Users

At Application Startup

using Flaggy.Abstractions;
using Flaggy.Models;
using Flaggy.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFlaggy(options =>
{
    options.UseMySQL(
        connectionString: builder.Configuration.GetConnectionString("FlaggyDb"
    );
})
);

var app = builder.Build();

// Seed initial admin user
using (var scope = app.Services.CreateScope())
{
    var userProvider = scope.ServiceProvider.GetRequiredService<IUserProvider>();

    // Check if admin exists
    var adminExists = await userProvider.UserExistsAsync("admin");
    if (!adminExists)
    {
        var admin = new User
        {
            username = "admin",
            password_hash = password_hasher.HashPassword("YourSecurePassword123!"),
            email = "admin@yourcompany.com",
            created_at = DateTime.UtcNow
        };

        await userProvider.CreateUserAsync(admin);
    }
}

app.UseFlaggyUI();
app.Run();

Multiple Users

var users = new[]
{
    new User
    {
        username = "admin",
        password_hash = password_hasher.HashPassword("AdminPass123!"),
        email = "admin@company.com",
        created_at = DateTime.UtcNow
    },
    new User
    {
        username = "developer",
        password_hash = password_hasher.HashPassword("DevPass123!"),
        email = "dev@company.com",
        created_at = DateTime.UtcNow
    },
    new User
    {
        username = "operator",
        password_hash = password_hasher.HashPassword("OpPass123!"),
        email = "ops@company.com",
        created_at = DateTime.UtcNow
    }
};

foreach (var user in users)
{
    var exists = await userProvider.UserExistsAsync(user.username);
    if (!exists)
    {
        await userProvider.CreateUserAsync(user);
    }
}

Password Security

BCrypt Hashing

Flaggy uses BCrypt for password hashing with a work factor of 12:

public static class password_hasher
{
    private const int WorkFactor = 12;

    public static string HashPassword(string password)
    {
        return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
    }

    public static bool VerifyPassword(string password, string hash)
    {
        try
        {
            return BCrypt.Net.BCrypt.Verify(password, hash);
        }
        catch
        {
            return false;
        }
    }
}

Key Features: - Work factor 12 (recommended for production) - Automatic salt generation - Resistant to rainbow table attacks - Future-proof (cost increases over time)

Password Best Practices

  1. Minimum Length: Enforce at least 12 characters
  2. Complexity: Require uppercase, lowercase, numbers, and symbols
  3. No Dictionary Words: Avoid common words
  4. Unique Passwords: Different from other accounts
  5. Regular Updates: Change passwords periodically

Example password validation:

public static bool ValidatePassword(string password)
{
    if (password.Length < 12)
        return false;

    bool hasUpper = password.Any(char.IsUpper);
    bool hasLower = password.Any(char.IsLower);
    bool hasDigit = password.Any(char.IsDigit);
    bool hasSpecial = password.Any(c => !char.IsLetterOrDigit(c));

    return hasUpper && hasLower && hasDigit && hasSpecial;
}

Authentication Flow

Login Process

  1. User submits username and password
  2. System retrieves user from database
  3. Password is verified against BCrypt hash
  4. On success, authentication cookie is created
  5. Cookie contains Base64-encoded token with username and timestamp
  6. User is redirected to dashboard

Token Format

Base64("FlaggyAuth:username:timestamp")

Example:

RmxhZ2d5QXV0aDphZG1pbjoxNzM3MjExMjAw

Session Management

private string GenerateAuthToken(string username)
{
    var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    var token = $"FlaggyAuth:{username}:{timestamp}";
    return Convert.ToBase64String(Encoding.UTF8.GetBytes(token));
}
  • Name: FlaggyAuth
  • HttpOnly: true (prevents JavaScript access)
  • Secure: true (HTTPS only in production)
  • SameSite: Strict (CSRF protection)
  • Expiration: Session (browser close)

Logout Process

  1. User clicks logout
  2. Authentication cookie is cleared
  3. User is redirected to login page

User Providers by Platform

MySQL User Provider

using Flaggy.Extensions;

builder.Services.AddFlaggy(options =>
{
    options.UseMySQL(
        connectionString: "Server=localhost;Database=myapp;User=root;Password=pass;",
        userTableName: "users"  // Optional, default is "users"
    );
});

PostgreSQL User Provider

using Flaggy.Extensions;

builder.Services.AddFlaggy(options =>
{
    options.UsePostgreSQL(
        connectionString: "Host=localhost;Database=myapp;username=postgres;Password=pass",
        userTableName: "users"  // Optional, default is "users"
    );
});

MS SQL Server User Provider

using Flaggy.Extensions;

builder.Services.AddFlaggy(options =>
{
    options.UseMsSql(
        connectionString: "Server=localhost;Database=myapp;User Id=sa;Password=pass;TrustServerCertificate=True",
        userTableName: "users"  // Optional, default is "users"
    );
});

InMemory User Provider

The InMemory provider includes a user provider that stores users in memory:

using Flaggy.Extensions;
using Flaggy.Providers;

builder.Services.AddFlaggy(new InMemoryFeatureFlagProvider());

// Users are stored in memory (not persisted)
// Suitable for development/testing only

Advanced Scenarios

Custom User Provider

Implement IUserProvider for custom storage:

using Flaggy.Abstractions;
using Flaggy.Models;

public class CustomUserProvider : IUserProvider
{
    private readonly IYourCustomDatabase _database;

    public CustomUserProvider(IYourCustomDatabase database)
    {
        _database = database;
    }

    public async Task<User?> GetUserAsync(string username, CancellationToken cancellationToken = default)
    {
        return await _database.Users.FindAsync(username);
    }

    public async Task<IEnumerable<User>> GetAllUsersAsync(CancellationToken cancellationToken = default)
    {
        return await _database.Users.ToListAsync();
    }

    public async Task<bool> CreateUserAsync(User user, CancellationToken cancellationToken = default)
    {
        await _database.Users.AddAsync(user);
        return await _database.SaveChangesAsync() > 0;
    }

    public async Task<bool> UpdateUserAsync(User user, CancellationToken cancellationToken = default)
    {
        _database.Users.Update(user);
        return await _database.SaveChangesAsync() > 0;
    }

    public async Task<bool> DeleteUserAsync(string username, CancellationToken cancellationToken = default)
    {
        var user = await GetUserAsync(username);
        if (user != null)
        {
            _database.Users.Remove(user);
            return await _database.SaveChangesAsync() > 0;
        }
        return false;
    }

    public async Task<bool> UserExistsAsync(string username, CancellationToken cancellationToken = default)
    {
        return await _database.Users.AnyAsync(u => u.username == username);
    }
}

Integration with External Authentication

Use custom authorization filters to integrate with existing auth:

app.UseFlaggyUI(options =>
{
    options.EnableAuthentication = true;
    options.AuthorizationFilter = context =>
    {
        // Check your existing authentication system
        var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId))
            return false;

        // Check if user has permission
        var hasPermission = context.User.IsInRole("FlagAdmin") ||
                          context.User.HasClaim("Permission", "ManageFlags");

        return hasPermission;
    };
});

Audit Logging

Track user actions:

public class AuditedUserProvider : IUserProvider
{
    private readonly IUserProvider _innerProvider;
    private readonly ILogger<AuditedUserProvider> _logger;

    public AuditedUserProvider(IUserProvider innerProvider, ILogger<AuditedUserProvider> logger)
    {
        _innerProvider = innerProvider;
        _logger = logger;
    }

    public async Task<bool> CreateUserAsync(User user, CancellationToken cancellationToken = default)
    {
        var result = await _innerProvider.CreateUserAsync(user, cancellationToken);
        if (result)
        {
            _logger.LogInformation("User created: {username} at {Time}", user.username, DateTime.UtcNow);
        }
        return result;
    }

    public async Task<bool> DeleteUserAsync(string username, CancellationToken cancellationToken = default)
    {
        var result = await _innerProvider.DeleteUserAsync(username, cancellationToken);
        if (result)
        {
            _logger.LogWarning("User deleted: {username} at {Time}", username, DateTime.UtcNow);
        }
        return result;
    }

    // Implement other methods similarly...
}

Security Best Practices

1. Strong Password Policy

public static string ValidatePasswordStrength(string password)
{
    if (password.Length < 12)
        return "Password must be at least 12 characters long";

    if (!password.Any(char.IsUpper))
        return "Password must contain at least one uppercase letter";

    if (!password.Any(char.IsLower))
        return "Password must contain at least one lowercase letter";

    if (!password.Any(char.IsDigit))
        return "Password must contain at least one digit";

    if (!password.Any(c => !char.IsLetterOrDigit(c)))
        return "Password must contain at least one special character";

    return string.Empty; // Valid
}

2. Account Lockout

Implement account lockout after failed attempts:

// Track failed login attempts (use distributed cache for load-balanced apps)
private readonly Dictionary<string, int> _failedAttempts = new();

public async Task<bool> AttemptLogin(string username, string password)
{
    // Check if account is locked
    if (_failedAttempts.TryGetValue(username, out var attempts) && attempts >= 5)
    {
        _logger.LogWarning("Account locked: {username}", username);
        return false;
    }

    var user = await _userProvider.GetUserAsync(username);
    if (user == null || !password_hasher.VerifyPassword(password, user.password_hash))
    {
        // Increment failed attempts
        _failedAttempts[username] = attempts + 1;
        return false;
    }

    // Reset failed attempts on successful login
    _failedAttempts.Remove(username);
    return true;
}

3. Password Expiration

Implement password expiration:

public class User
{
    public string username { get; set; }
    public string password_hash { get; set; }
    public string? email { get; set; }
    public DateTime created_at { get; set; }
    public DateTime PasswordChangedAt { get; set; }  // Add this field
}

public bool IsPasswordExpired(User user, int expirationDays = 90)
{
    return (DateTime.UtcNow - user.PasswordChangedAt).TotalDays > expirationDays;
}

4. Two-Factor Authentication (2FA)

Consider adding 2FA for additional security:

// This would require extending the User model and authentication flow
public class User
{
    public string username { get; set; }
    public string password_hash { get; set; }
    public string? email { get; set; }
    public DateTime created_at { get; set; }
    public bool TwoFactorEnabled { get; set; }
    public string? TwoFactorSecret { get; set; }
}

5. Secure Password Reset

Implement secure password reset with time-limited tokens:

public class PasswordResetToken
{
    public string username { get; set; }
    public string Token { get; set; }
    public DateTime ExpiresAt { get; set; }
}

public string GenerateResetToken(string username)
{
    var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
    // Store token with expiration (e.g., 1 hour)
    return token;
}

Troubleshooting

Cannot Login with Default Credentials

Problem: Default admin/admin credentials don't work.

Solution: Default credentials are disabled once users exist in the database. Create a new user or reset the database.

Password Not Updating

Problem: Password update doesn't take effect.

Solution: Ensure you're hashing the password:

user.password_hash = password_hasher.HashPassword(newPassword);
await _userProvider.UpdateUserAsync(user);

Users Table Not Created

Problem: Users table doesn't exist.

Solution: Enable auto-migration:

builder.Services.AddFlaggy(options =>
{
    options.UseMySQL(
        connectionString: connectionString,
        autoMigrate: true // Enables automatic table creation
    );
});

Problem: Users logged out after every request.

Solution: Ensure HTTPS in production and check cookie settings:

builder.Services.AddHttpsRedirection(options =>
{
    options.HttpsPort = 443;
});