This guide demonstrates how to replace the default in-memory user store in IdentityServer4 with a custom service that queries a database. While IdentityServer4 provides Entity Framework (EF) based data sources, this approach shows how to implement a database-driven user store by extending its core interfaces. The focus will be on the user authentication logic, while client and API configurations remain static.
Prerequisites: Start with a new IdentityServer4 project using the in-memory template.
dotnet new is4inmem
This command creates a project with in-memory stores and test users, providing a solid foundation for our modifications.
Step 1: Identify the Default User Store
The default user authentication logic is encapsulated within the TestUserStore class. Our goal is to replace this with our own implementation that interacts with a database.
Step 2: Create a Custom User Service
First, define an interface for our user service. This interface will mirror the methods rqeuired by IdentityServer4's user store contract.
public interface IUserRepository
{
Task<MyUser> FindBySubjectIdAsync(string subjectId);
Task<MyUser> FindByUsernameAsync(string username);
Task<bool> ValidateCredentialsAsync(string username, string password);
}
Next, implement this interface. You can begin by copying the logic from the original TestUserStore and then modify it to perform database queries instead of in-memory lookups. The key methods to implement are FindBySubjectIdAsync, FindByUsernameAsync, and ValidateCredentialsAsync.
public class DatabaseUserRepository : IUserRepository
{
private readonly MyDbContext _dbContext;
public DatabaseUserRepository(MyDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<MyUser> FindBySubjectIdAsync(string subjectId)
{
// Implement database query to find a user by their unique Subject ID
return await _dbContext.Users.FirstOrDefaultAsync(u => u.SubjectId == subjectId);
}
public async Task<MyUser> FindByUsernameAsync(string username)
{
// Implement database query to find a user by their username
return await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == username);
}
public async Task<bool> ValidateCredentialsAsync(string username, string password)
{
// Implement logic to validate username and password against the database
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user == null) return false;
return user.Password == password; // Note: Use proper password hashing in a real application
}
}
Step 3: Register the Service in Dependency Injection
Register the new user repository with the dependency injection container in the Startup.cs file within the ConfigureServices method.
public void ConfigureServices(IServiceCollection services)
{
// ... other service registrations
services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddTestUsers(Config.TestUsers); // Keep this for testing if needed
// Register our custom database user repository
services.AddTransient<IUserRepository, DatabaseUserRepository>();
// Register custom services for profile and password validation
services.AddTransient<IProfileService, CustomProfileService>();
services.AddTransient<IResourceOwnerPasswordValidator, CustomPasswordValidator>();
}
Step 4: Implement Profile and Password Validation Services
IdentityServer4 uses specific services to handle user profile data and password-based authentication. We must provide our own implementations.
// Custom Profile Service
public class CustomProfileService : IProfileService
{
private readonly IUserRepository _userRepository;
public CustomProfileService(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Retrieve user claims from the database and set them in the context
var user = await _userRepository.FindBySubjectIdAsync(context.Subject.GetSubjectId());
if (user != null)
{
var claims = new List<Claim>
{
new Claim("name", user.Username),
// Add other claims as needed
};
context.IssuedClaims = claims;
}
}
public async Task IsActiveAsync(IsActiveContext context)
{
// Determine if the user is active
var user = await _userRepository.FindBySubjectIdAsync(context.Subject.GetSubjectId());
context.IsActive = user != null;
}
}
// Custom Password Validator
public class CustomPasswordValidator : IResourceOwnerPasswordValidator
{
private readonly IUserRepository _userRepository;
public CustomPasswordValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public async Task<TokenValidationResult> ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var user = await _userRepository.FindByUsernameAsync(context.UserName);
if (user != null && await _userRepository.ValidateCredentialsAsync(context.UserName, context.Password))
{
context.Result = new TokenValidationResult
{
IsError = false,
UserInfo = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
new Claim("sub", user.SubjectId),
new Claim("name", user.Username)
}))
};
}
else
{
context.Result = new TokenValidationResult
{
IsError = true,
Error = "invalid_grant"
};
}
return context.Result;
}
}
Step 5: Remove the In-Memory User Store
Finally, ensure the original in-memory user store is not registered, as it would conflict with our new database service. The line that registers the TestUserStore should be removed or commented out.
// This line should be removed or commented out
// services.AddSingleton<IUserStore<TestUser>>(new TestUserStore(Config.TestUsers));
With these changes, IdentityServer4 will now use your database to auhtenticate users, validate credentials, and retrieve user profile information.