DEV Community

Cover image for Implementing LDAP Authentication in .NET Web API Apps
Tran Manh Hung
Tran Manh Hung

Posted on

Implementing LDAP Authentication in .NET Web API Apps

You may have heard of something called LDAP, or you have been tasked to integrate it into your .NET app. Well, you're in luck, as this article focuses on explaining this technology with practical code examples. What you'll see is how I implemented LDAP authentication in a .NET Web API.

What is LDAP?

Lightweight Directory Access Protocol (LDAP) is a crucial component for managing user accounts, groups, and other directory-related data in your .NET applications.

What does this mean? LDAP is a protocol that allows you to integrate directory structure into your app. Large enterprise companies typically have complex organizational structures. By utilizing LDAP in your application, you can simplify your infrastructure and maintain authentication and authorization data (such as user lists with roles) in one centralized place.

There are many other use cases for LDAP, but this article will focus primarily on authentication. Authorization will be covered in a future article.

Different types of LDAP

You should know there are more "types" of LDAP. For example OpenLDAP or Active Directory (or Azure). This article covers mainly active directory. However it is really simple use example with any other LDAP disctribution. You just need replace cn keywords and the code should work

for example this (Active directory)

"OU=Users,DC=example,DC=com"
Enter fullscreen mode Exit fullscreen mode

may look like

"cn=Users, o=example, c=com" 
Enter fullscreen mode Exit fullscreen mode

Basic Authentication Flow with LDAP in .NET

The following example implements JWT authentication with LDAP as the identity provider:

Login Flow

  1. User enters credentials
  2. Input is validated (username and password) by calling LDAP
  3. Refresh token is generated
  4. JWT token is generated
  5. Both tokens are returned to the client
  6. Client can now call any authorized endpoint with the JWT included in the authorization header

This approach allows you to authenticate users against LDAP once and then use stateless JWT tokens for subsequent requests.

Required NuGet Packages

To implement the solution in this article, you'll need the following packages:

Microsoft.AspNetCore.Authentication.JwtBearer
System.DirectoryServices.Protocols
System.IdentityModel.Tokens.Jwt
Enter fullscreen mode Exit fullscreen mode

Implementation in Code

Let's walk through the implementation of LDAP authentication in a .NET 8 Web API application, from user login to JWT token generation.

1. Setting Up the Login Endpoint

First, we need a login endpoint that accepts user credentials:

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly ILdapService _ldapService;
    private readonly ITokenService _tokenService;

    public AuthController(ILdapService ldapService, ITokenService tokenService)
    {
        _ldapService = ldapService;
        _tokenService = tokenService;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest request)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        // Authentication logic will be implemented here
        // ...
    }
}

public class LoginRequest
{
    [Required]
    public string Username { get; set; } = string.Empty;

    [Required]
    public string Password { get; set; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

2. Implementing LDAP Authentication Service

Next, we'll create an LDAP service to verify credentials against the directory:

public interface ILdapService
{
    Task<LdapUser?> AuthenticateAsync(string username, string password);
}

public class LdapService : ILdapService
{
    private readonly LdapSettings _settings;
    private readonly ILogger<LdapService> _logger;

    public LdapService(IOptions<LdapSettings> settings, ILogger<LdapService> logger)
    {
        _settings = settings.Value;
        _logger = logger;
    }

    public async Task<LdapUser?> AuthenticateAsync(string username, string password)
    {
        // Using System.DirectoryServices.Protocols for modern LDAP access in .NET
        using var connection = new LdapConnection(new LdapDirectoryIdentifier(_settings.ServerAddress, _settings.ServerPort));

        try
        {
            // Set connection options
            connection.SessionOptions.ProtocolVersion = 3;
            connection.SessionOptions.SecureSocketLayer = _settings.UseSsl;

            // Determine the user's distinguished name (DN)
            string userDn = GetUserDN(username);

            // Attempt to bind (authenticate) with the provided credentials
            await connection.AuthenticateAsync(
                new NetworkCredential(userDn, password));

            _logger.LogInformation("User {Username} authenticated successfully via LDAP", username);

            // If we get here, authentication was successful
            // Retrieve user information, including roles
            return await GetUserDetailsAsync(connection, userDn);
        }
        catch (LdapException ex)
        {
            _logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
            return null;
        }
    }

    private string GetUserDN(string username)
    {
        // Format the user's distinguished name based on your LDAP structure
        // Examples:
        // - Active Directory: "CN=username,OU=Users,DC=example,DC=com"
        // - OpenLDAP: "uid=username,ou=people,dc=example,dc=com"

        return $"CN={username},{_settings.BaseDN}";
    }

    // Implemented in the next section
    private Task<LdapUser?> GetUserDetailsAsync(LdapConnection connection, string userDn)
    {
        // ...
    }
}

public class LdapSettings
{
    public string ServerAddress { get; set; } = string.Empty;
    public int ServerPort { get; set; } = 389; // Default LDAP port
    public bool UseSsl { get; set; } = false;
    public string BaseDN { get; set; } = string.Empty;
    public string SearchBase { get; set; } = string.Empty;
    public string SearchFilter { get; set; } = string.Empty;
}
Enter fullscreen mode Exit fullscreen mode

Configure LDAP settings in your appsettings.json:

{
  "LdapSettings": {
    "ServerAddress": "ldap.example.com",
    "ServerPort": 389,
    "UseSsl": false,
    "BaseDN": "OU=Users,DC=example,DC=com",
    "SearchBase": "OU=Users,DC=example,DC=com",
    "SearchFilter": "(&(objectClass=user)(sAMAccountName={0}))"
  }
}
Enter fullscreen mode Exit fullscreen mode

Register the LDAP service in Program.cs:

builder.Services.Configure<LdapSettings>(
    builder.Configuration.GetSection("LdapSettings"));
builder.Services.AddScoped<ILdapService, LdapService>();
Enter fullscreen mode Exit fullscreen mode

3. Retrieving User Information and Roles

After authenticating, we need to fetch additional user information, particularly roles:

public class LdapUser
{
    public string Username { get; set; } = string.Empty;
    public string DisplayName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public List<string> Roles { get; set; } = new();
    public Dictionary<string, string> Attributes { get; set; } = new();
}

// Implementation of GetUserDetailsAsync method in the LdapService class:
private async Task<LdapUser?> GetUserDetailsAsync(LdapConnection connection, string userDn)
{
    // Create search request for user information
    var searchRequest = new SearchRequest(
        _settings.SearchBase,
        string.Format(_settings.SearchFilter, Path.GetFileName(userDn)),
        System.DirectoryServices.Protocols.SearchScope.Subtree,
        // Specify which attributes to return
        new string[] { "cn", "mail", "displayName", "memberOf", "givenName", "sn" }
    );

    try
    {
        // Execute the search request
        var response = (SearchResponse)await connection.SendRequestAsync(searchRequest);

        if (response.Entries.Count == 0)
        {
            _logger.LogWarning("No user found with DN: {UserDN}", userDn);
            return null;
        }

        var searchEntry = response.Entries[0];

        // Create and populate user object
        var user = new LdapUser
        {
            Username = Path.GetFileName(userDn),
            DisplayName = GetAttributeValue(searchEntry, "displayName") ?? string.Empty,
            Email = GetAttributeValue(searchEntry, "mail") ?? string.Empty
        };

        // Extract roles from group memberships
        if (searchEntry.Attributes.Contains("memberOf"))
        {
            var memberOfAttribute = searchEntry.Attributes["memberOf"];
            for (int i = 0; i < memberOfAttribute.Count; i++)
            {
                string groupDn = memberOfAttribute[i]!.ToString()!;
                // Extract the CN part from the DN
                string groupName = ExtractCNFromDN(groupDn);
                user.Roles.Add(groupName);
            }
        }

        // Store additional attributes for potential use in claims
        foreach (string attributeName in searchEntry.Attributes.AttributeNames)
        {
            var attribute = searchEntry.Attributes[attributeName];
            if (attribute.Count > 0 && attribute[0] != null)
            {
                user.Attributes[attributeName] = attribute[0].ToString()!;
            }
        }

        return user;
    }
    catch (LdapException ex)
    {
        _logger.LogError(ex, "Error retrieving user details for {UserDN}", userDn);
        return null;
    }
}

private string? GetAttributeValue(SearchResultEntry entry, string attributeName)
{
    if (entry.Attributes.Contains(attributeName) && entry.Attributes[attributeName].Count > 0)
    {
        return entry.Attributes[attributeName][0]?.ToString();
    }
    return null;
}

private string ExtractCNFromDN(string dn)
{
    // Extract CN value from a distinguished name
    var match = Regex.Match(dn, @"^CN=([^,]+)");
    if (match.Success)
    {
        return match.Groups[1].Value;
    }
    return dn; // Return the original DN if extraction fails
}
Enter fullscreen mode Exit fullscreen mode

This method extracts user details, including roles from group memberships, which we'll use in our JWT token.

4. JWT Token Generation Service

Now let's create a service for generating and validating JWT tokens:

public interface ITokenService
{
    string GenerateJwtToken(LdapUser user);
    ClaimsPrincipal? ValidateToken(string token);
}

public class TokenService : ITokenService
{
    private readonly JwtSettings _jwtSettings;

    public TokenService(IOptions<JwtSettings> jwtSettings)
    {
        _jwtSettings = jwtSettings.Value;
    }

    public string GenerateJwtToken(LdapUser user)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_jwtSettings.Secret);

        // Create claims based on user information
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.Name, user.Username),
            new Claim(ClaimTypes.Email, user.Email)
        };

        // Add roles as claims
        foreach (var role in user.Roles)
        {
            claims.Add(new Claim(ClaimTypes.Role, role));
        }

        // Add relevant additional claims from attributes
        foreach (var attr in user.Attributes)
        {
            // Avoid duplicating existing claims
            if (!claims.Any(c => c.Type == attr.Key))
            {
                claims.Add(new Claim(attr.Key, attr.Value));
            }
        }

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationMinutes),
            SigningCredentials = new SigningCredentials(
                new SymmetricSecurityKey(key),
                SecurityAlgorithms.HmacSha256Signature)
        };

        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes(_jwtSettings.Secret);

        try
        {
            var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false,
                // Set clockskew to zero for more accurate validation
                ClockSkew = TimeSpan.Zero
            }, out SecurityToken validatedToken);

            return principal;
        }
        catch
        {
            return null;
        }
    }
}

public class JwtSettings
{
    public string Secret { get; set; } = string.Empty;
    public int ExpirationMinutes { get; set; } = 60;
    public int RefreshTokenExpirationDays { get; set; } = 7;
}
Enter fullscreen mode Exit fullscreen mode

Add JWT settings to your appsettings.json:

{
  "JwtSettings": {
    "Secret": "your-super-secret-key-with-at-least-32-characters",
    "ExpirationMinutes": 60,
    "RefreshTokenExpirationDays": 7
  }
}
Enter fullscreen mode Exit fullscreen mode

Register the token service in Program.cs:

builder.Services.Configure<JwtSettings>(
    builder.Configuration.GetSection("JwtSettings"));
builder.Services.AddScoped<ITokenService, TokenService>();
Enter fullscreen mode Exit fullscreen mode

5. Completing the Login Endpoint

Now we can complete the login endpoint implementation:

[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    // Authenticate user against LDAP
    var user = await _ldapService.AuthenticateAsync(request.Username, request.Password);

    if (user == null)
        return Unauthorized(new { message = "Invalid username or password" });

    // Generate JWT token
    var token = _tokenService.GenerateJwtToken(user);

    // Generate refresh token for longer sessions
    var refreshToken = GenerateRefreshToken();

    // In a production application, store the refresh token in a database
    // await _tokenRepository.SaveRefreshTokenAsync(user.Username, refreshToken);

    return Ok(new
    {
        token,
        refreshToken,
        user = new
        {
            username = user.Username,
            displayName = user.DisplayName,
            email = user.Email,
            roles = user.Roles
        }
    });
}

private string GenerateRefreshToken()
{
    var randomNumber = new byte[32];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(randomNumber);
    return Convert.ToBase64String(randomNumber);
}
Enter fullscreen mode Exit fullscreen mode

6. Configuring JWT Authentication

Now we need to configure JWT authentication in our application:

// In Program.cs
// Add JWT authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.ASCII.GetBytes(builder.Configuration["JwtSettings:Secret"]!)),
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.Zero
        };
    });

// Add the authentication middleware in the correct order
var app = builder.Build();

// ... other middleware

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();
Enter fullscreen mode Exit fullscreen mode

7. Implementing Refresh Token Functionality

To avoid frequent LDAP calls, let's implement a refresh token endpoint:

[HttpPost("refresh-token")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
    if (string.IsNullOrEmpty(request.RefreshToken))
        return BadRequest("Refresh token is required");

    // In a production application, validate the refresh token from a database
    // var storedToken = await _tokenRepository.GetByTokenAsync(request.RefreshToken);
    var storedToken = ValidateStoredRefreshToken(request.RefreshToken);

    if (storedToken == null)
        return Unauthorized(new { message = "Invalid or expired refresh token" });

    // Get the user associated with this refresh token
    // In a production app, you would retrieve this from a database
    var user = await GetUserForRefreshToken(storedToken);
    if (user == null)
        return Unauthorized(new { message = "User not found" });

    // Generate new tokens
    var newToken = _tokenService.GenerateJwtToken(user);
    var newRefreshToken = GenerateRefreshToken();

    // Update the stored refresh token
    // await _tokenRepository.UpdateAsync(storedToken.Id, newRefreshToken);

    return Ok(new
    {
        token = newToken,
        refreshToken = newRefreshToken
    });
}

public class RefreshTokenRequest
{
    [Required]
    public string RefreshToken { get; set; } = string.Empty;
}

// Example methods for token validation
// In a real application, these would interact with a database
private RefreshTokenInfo? ValidateStoredRefreshToken(string token)
{
    // Implementation would validate the token against a database
    // and check if it's expired
    return null; // Placeholder
}

private async Task<LdapUser?> GetUserForRefreshToken(RefreshTokenInfo tokenInfo)
{
    // Implementation would retrieve the user from a database or
    // possibly from LDAP directly
    return null; // Placeholder
}

public class RefreshTokenInfo
{
    public string Id { get; set; } = string.Empty;
    public string Token { get; set; } = string.Empty;
    public string UserId { get; set; } = string.Empty;
    public DateTime ExpiryDate { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Note: In a production application, you would implement a proper repository for storing and retrieving refresh tokens and user information from a database.

Using Protected Resources

Now that we have authentication in place, we can create protected endpoints:

[ApiController]
[Route("api/[controller]")]
[Authorize] // This ensures the user is authenticated
public class ProtectedController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        // Access the authenticated user's identity
        var username = User.Identity?.Name;

        return Ok(new { message = $"Hello, {username}! This is a protected endpoint." });
    }

    [HttpGet("admin")]
    [Authorize(Roles = "Administrators")] // Role-based access control
    public IActionResult GetAdminData()
    {
        return Ok(new { message = "This is admin-only data" });
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach provides several benefits:

  1. Centralized Identity Management: Leverage your existing directory infrastructure
  2. Reduced Authentication Overhead: Authenticate once against LDAP, then use lightweight JWT tokens
  3. Stateless Authentication: No need to maintain server-side session state
  4. Role-Based Access Control: Easily implement authorization based on directory roles
  5. Security: Proper implementation of industry-standard security practices

Conclusion

By implementing LDAP, you can integrate your applications with enterprise directory services. The pattern of authenticating once against LDAP and then using JWT tokens for subsequent requests creates a secure, efficient authentication system that works well in modern web applications. I am aware of using JWT is currently discouraged tho. However, with the code example you can simply replace JWT part with cookie or ANY auth approach you want. :)

If you are an unfortunate soul who needs to integrate this into your app. I wish you good luck.

Top comments (0)