Files
adas-core/adas-core.LdapLogin/LdapLoginService.cs
T

433 lines
20 KiB
C#

using adas_core.Authentication.Interfaces;
using adas_core.Domain.Enums;
using adas_core.Domain.Exceptions;
using adas_core.Domain.Models.MongoModels;
using adas_core.LdapLogin.Configuration;
using FluentValidation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using System.DirectoryServices.Protocols;
using System.Net;
using Authorization = adas_core.Domain.Models.MongoModels.Authorization;
namespace adas_core.LdapLogin;
/// <summary>
/// Implementation of ILoginService that authenticates users against an LDAP server.
/// It retrieves user information and authorities based on the LDAP entry and maps them to the application's user model.
/// The service also handles the creation of new users in the application if they do not already exist, based on the LDAP information.
/// It uses configuration settings for connecting to the LDAP server and for mapping LDAP attributes to user properties and authorities.
/// </summary>
/// <!-- aidoc:v1 sig=4e5637f -->
public class LdapLoginService : ILoginService
{
/// <summary>
/// The IAuthorityService is used to manage user authorities in the application.
/// </summary>
private readonly IAuthorityService _authorityService;
/// <summary>
/// The LdapConfig contains the necessary configuration for connecting to the LDAP server, such as server address, port, search base, and attribute mappings.
/// </summary>
private readonly LdapConfig _ldapConfig;
/// <summary>
/// The ILogger is used for logging information, warnings, and errors related to LDAP login operations.
/// </summary>
private readonly ILogger<LdapLoginService> _logger;
/// <summary>
/// The IUserService is used to manage user information in the application, such as retrieving existing users or creating new users based on LDAP information.
/// </summary>
private readonly Lazy<IUserService> _userService;
/// <summary>
/// Constructor for LdapLoginService. It initializes the service with the necessary dependencies and validates the LDAP configuration.
/// </summary>
/// <param name="ldapConfig">The LDAP configuration options.</param>
/// <param name="validator">The validator for the LDAP configuration.</param>
/// <param name="userService">The user service for managing user information.</param>
/// <param name="authorityService">The authority service for managing user authorities.</param>
/// <param name="logger">The logger for logging LDAP login operations.</param>
/// <!-- aidoc:v1 sig=f1b8e4e body=6c4718f -->
public LdapLoginService(
IOptions<LdapConfig> ldapConfig,
IValidator<LdapConfig> validator,
Lazy<IUserService> userService,
IAuthorityService authorityService,
ILogger<LdapLoginService> logger)
{
_userService = userService;
_authorityService = authorityService;
_logger = logger;
_ldapConfig = ldapConfig.Value;
validator.Validate(_ldapConfig, options => options.ThrowOnFailures());
}
/// <summary>
/// Indicates that this login service allows password authentication, as it connects to an LDAP server which typically requires a username and password for authentication.
/// </summary>
public bool AllowPassword => true;
/// <summary>
/// Indicates that this login service does not allow token-based authentication, as it is designed to authenticate users against an LDAP server using their credentials rather than tokens.
/// </summary>
public UserEnum.LoginMethod Method => UserEnum.LoginMethod.Ldap;
/// <summary>
/// Authenticates a user against the configured LDAP server using <paramref name="username"/> and <paramref name="password"/>, and resolves the matching <see cref="User"/> entry. When an LDAP service account is configured it is used for the bind; otherwise the username is optionally prefixed with the configured domain and bound with the supplied password. Searches the directory for the user entry and creates or retrieves the local <see cref="User"/> record.
/// </summary>
/// <param name="username">The username used to bind to LDAP and to locate the user entry in the directory.</param>
/// <param name="password">The password used for the LDAP bind when no service account is configured.</param>
/// <returns>The <see cref="User"/> resolved from the LDAP directory entry.</returns>
/// <exception cref="LoginServicesException">Thrown when the LDAP server is not configured, when the directory entry is not found, or when the resolved user is null.</exception>
/// <exception cref="UserNotFoundException">Thrown when the LDAP bind fails for the supplied credentials.</exception>
/// <!-- aidoc:v1 sig=bdfb451 body=e3fc1ac -->
public async Task<User> Login(string username, string password)
{
if (_ldapConfig.Server == null)
throw new LoginServicesException("LDAP Config not found");
var identifier = new LdapDirectoryIdentifier(_ldapConfig.Server, _ldapConfig.Port ?? 389);
var connection = new LdapConnection(identifier);
try
{
if (_ldapConfig.LdapUser != null)
{
_logger.LogInformation("[LDAP] Using configured LDAP user {LdapUser}", _ldapConfig.LdapUser);
connection.Credential = new NetworkCredential(_ldapConfig.LdapUser, _ldapConfig.LdapPassword);
}
else
{
var ldapUser = (!string.IsNullOrEmpty(_ldapConfig.UserDomainName)
? _ldapConfig.UserDomainName + @"\"
: "") + username;
connection.Credential = new NetworkCredential(ldapUser, password);
}
connection.AuthType = AuthType.Basic;
connection.Bind();
}
catch (LdapException e)
{
_logger.LogError("[LDAP] Error binding user {username}", username);
throw new UserNotFoundException(username, e);
}
SearchResultEntry? entry = null;
try
{
var request = new SearchRequest(
_ldapConfig.SearchBase,
$"({_ldapConfig.UserNameProperty}={username})",
SearchScope.Subtree
);
var response = (SearchResponse)connection.SendRequest(request);
foreach (SearchResultEntry current in response.Entries)
{
entry = current;
break;
}
}
catch (Exception e)
{
_logger.LogError("[LDAP] Search error for user {username}: {error}", username, e.Message);
throw;
}
if (entry == null)
throw new LoginServicesException("LDAP User not found");
var userEntryLdap = GetUser(entry);
var user = await GetOrCreateUser(userEntryLdap, entry);
_logger.LogInformation("[LDAP] entry found and user {user}", user);
connection.Dispose();
return user ?? throw new LoginServicesException("LDAP User not found");
}
/// <summary>
/// Authenticates a user based on the current <see cref="HttpContext"/>.
/// This method is not yet implemented and always throws a <see cref="LoginServicesException"/>.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> of the incoming HTTP request used to extract authentication information.</param>
/// <returns>A <see cref="Task{TResult}"/> that resolves to the authenticated user.</returns>
/// <exception cref="LoginServicesException">Always thrown because the method is not implemented.</exception>
/// <!-- aidoc:v1 sig=851dc90 -->
public Task<User> Login(HttpContext context)
=> throw new LoginServicesException("Not implemented");
/// <summary>
/// Authenticates a user with the provided <paramref name="username"/> and <paramref name="password"/> and returns the corresponding <see cref="User"/>.
/// This method is not implemented and always throws a <see cref="LoginServicesException"/> when invoked.
/// </summary>
/// <param name="username">The username of the user to authenticate.</param>
/// <param name="password">The password of the user to authenticate.</param>
/// <returns>A <see cref="Task{User}"/> that yields the authenticated <see cref="User"/>.</returns>
/// <exception cref="LoginServicesException">Thrown because authentication is not implemented.</exception>
/// <!-- aidoc:v1 sig=a0b1f46 -->
public Task<User> Authenticate(string username, string password)
=> throw new LoginServicesException("Not implemented");
/// <summary>
/// This method is not implemented in the LdapLoginService, as the user retrieval process is handled through the Login(string username, string password) method and the GetOrCreateUser(User userEntryLdap, LdapEntry entry) method.
/// </summary>
/// <param name="id">The ID of the user to retrieve.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="LoginServicesException">Thrown when the method is not implemented.</exception>
/// <!-- aidoc:v1 sig=877a869 -->
public Task<User?> GetById(ObjectId id)
=> throw new LoginServicesException("Not implemented");
/// <summary>
/// This method is not implemented in the LdapLoginService, as the user retrieval process is handled through the Login(string username, string password) method and the GetOrCreateUser(User userEntryLdap, LdapEntry entry) method.
/// </summary>
/// <param name="email">The email of the user to retrieve.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="LoginServicesException">Thrown when the method is not implemented.</exception>
/// <!-- aidoc:v1 sig=ba92b09 -->
public Task<User?> GetByEmail(string email)
=> throw new LoginServicesException("Not implemented");
/// <summary>
/// This method is not implemented in the LdapLoginService, as the user retrieval process is handled through the Login(string username, string password) method and the GetOrCreateUser(User userEntryLdap, LdapEntry entry) method.
/// </summary>
/// <param name="username">The username of the user to retrieve.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="LoginServicesException">Thrown when the method is not implemented.</exception>
/// <!-- aidoc:v1 sig=5184c30 -->
public Task<User?> GetByUsername(string username)
=> throw new LoginServicesException("Not implemented");
/// <summary>
/// This method is not implemented in the LdapLoginService, as the user retrieval process is handled through the Login(string username, string password) method and the GetOrCreateUser(User userEntryLdap, LdapEntry entry) method.
/// </summary>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="LoginServicesException">Thrown when the method is not implemented.</exception>
/// <!-- aidoc:v1 sig=aa066d6 -->
public Task<List<User>> GetAllUsers()
=> throw new LoginServicesException("Not implemented");
/// <summary>
/// This method retrieves an existing user from the application based on the information obtained from the LDAP entry, or creates a new user if one does not already exist.
/// It also checks and updates the user's authorities based on the LDAP entry and the application's configuration.
/// </summary>
/// <param name="userEntryLdap">The user information obtained from the LDAP entry.</param>
/// <param name="entry">The LDAP entry containing the user's information.</param>
/// <returns>The existing or newly created user with updated authorities.</returns>
/// <!-- aidoc:v1 sig=87de1c2 body=a43bebd -->
private async Task<User?> GetOrCreateUser(User userEntryLdap, LdapEntry entry)
{
var userToReturn = (await _userService.Value.GetUserByUserName(userEntryLdap.UserName) ??
await _userService.Value.GetUserByName(userEntryLdap.Name)) ??
await _userService.Value.CreateUser(userEntryLdap);
if (userToReturn == null) return userToReturn;
userToReturn.Authorization = [];
var authorities = await CheckAuthorities(userToReturn, entry);
userToReturn.Authorization.AddRange(authorities);
return userToReturn;
}
/// <summary>
/// Synchronizes the authorities of a <paramref name="user"/> with the authorities defined in the supplied <paramref name="entry"/>, updating existing records or inserting new ones as appropriate.
/// Authorities appearing in the whitelist are preserved and excluded from the LDAP map, while remaining LDAP authorities are compared against the current persisted list; entries with <see cref="Authorization.CanUpdate"/> set to <c>true</c> have their <see cref="Authorization.Rol"/> refreshed via <see cref="AuthorityService.updateOne"/>, otherwise they are inserted with <see cref="AuthorityService.InsertOne"/>.
/// On any failure, the error is logged and an empty list is returned.
/// </summary>
/// <param name="user">The user whose authorities are being reconciled.</param>
/// <param name="entry">The <see cref="LdapEntry"/> providing the source authorities map and whitelist.</param>
/// <returns>The updated <see cref="Authorization"/> list retrieved from the authority service.</returns>
/// <!-- aidoc:v1 sig=d2bafd5 body=4af15eb -->
private async Task<List<Authorization>> CheckAuthorities(User user, LdapEntry entry)
{
try
{
var authorizationMap = GetAuthoritiesMap(entry, user);
var authorizationWhiteList = GetAuthoritiesWhiteList(entry, user);
var whiteListDisplayIds = new HashSet<string?>(authorizationWhiteList.Select(a => a.DisplayId));
var uniqueMapAuthorizations = authorizationMap
.Where(a => !whiteListDisplayIds.Contains(a.DisplayId));
var combinedList = authorizationWhiteList.Concat(uniqueMapAuthorizations).ToList();
var userAuthorities = await _authorityService.GetUserAuthorities(user.Id);
foreach (var auth in combinedList)
{
var authFound = userAuthorities.Find(c => c.DisplayId == auth.DisplayId);
if (authFound is { CanUpdate: true })
{
authFound.Rol = auth.Rol;
await _authorityService.updateOne(authFound);
}
else
{
await _authorityService.InsertOne(auth);
}
}
return await _authorityService.GetUserAuthorities(user.Id);
}
catch (Exception e)
{
_logger.LogError("[LDAP] CheckAuthorities error for user {user}: {error}",
user.UserName, e.Message);
return [];
}
}
/// <summary>
/// This method retrieves a list of authorities for a user based on a whitelist defined in the application's configuration.
/// </summary>
/// <param name="entry">The LDAP entry containing the user's information.</param>
/// <param name="user">The user whose authorities are being retrieved.</param>
/// <returns>A list of authorities for the user based on the whitelist.</returns>
/// <!-- aidoc:v1 sig=a8263f0 body=9e6cc38 -->
private List<Authorization> GetAuthoritiesWhiteList(LdapEntry entry, User user)
{
try
{
var result = new List<Authorization>();
var whiteList = _ldapConfig.WhiteList.FindAll(u =>
(u.Name != null && entry.DistinguishedName.Contains(u.Name, StringComparison.CurrentCultureIgnoreCase)) ||
(u.Username != null &&
entry.Attributes[_ldapConfig.UserNameProperty]?[0]?.ToString()
?.Equals(u.Username, StringComparison.CurrentCultureIgnoreCase) == true)
);
foreach (var authorityMap in whiteList)
{
if (!Enum.TryParse<PermissionEnum.RolesType>(authorityMap.Rol, out _))
continue;
result.Add(new Authorization
{
UserId = user.Id,
DisplayId = authorityMap.DisplayId,
Rol = authorityMap.Rol
});
}
return result;
}
catch (Exception e)
{
_logger.LogError("[LDAP] GetAuthoritiesWhiteList error: {error}", e.Message);
return [];
}
}
/// <summary>
/// Maps the supplied <see cref="LdapEntry"/> to a <see cref="User"/> instance by reading attributes whose names are configured in the LDAP settings.
/// The <see cref="User.UserName"/> is taken from the configured user name property, falling back to an empty string when the attribute is absent.
/// When the first name and/or last name properties are configured, their attribute values are used to build the <see cref="User.Name"/>; otherwise the name is left unset, and the last name is appended to the first name when both are present.
/// </summary>
/// <param name="ldapEntry">The <see cref="LdapEntry"/> whose attributes are read to populate the resulting <see cref="User"/>.</param>
/// <returns>A <see cref="User"/> populated from the attributes exposed by <paramref name="ldapEntry"/> according to the current LDAP configuration.</returns>
/// <!-- aidoc:v1 sig=947181a body=33ffec8 -->
private User GetUser(LdapEntry ldapEntry)
{
var user = new User
{
UserName = entry.Attributes[_ldapConfig.UserNameProperty]?[0]?.ToString() ?? ""
};
if (!string.IsNullOrWhiteSpace(_ldapConfig.FirstNameProperty))
{
var first = entry.Attributes[_ldapConfig.FirstNameProperty]?[0]?.ToString();
if (first != null)
user.Name = first;
}
if (!string.IsNullOrWhiteSpace(_ldapConfig.LastNameProperty))
{
var last = entry.Attributes[_ldapConfig.LastNameProperty]?[0]?.ToString();
if (last != null)
user.Name = string.IsNullOrEmpty(user.Name)
? last
: $"{user.Name} {last}";
}
return user;
}
/// <summary>
/// This method retrieves a list of authorities for a user based on the LDAP entry and the application's configuration for mapping LDAP groups to authorities.
/// </summary>
/// <param name="ldapEntry">The LDAP entry containing the user's information.</param>
/// <param name="user">The user whose authorities are being retrieved.</param>
/// <returns>A list of authorities for the user based on the LDAP entry and the application's configuration.</returns>
/// <!-- aidoc:v1 sig=1e1f0f2 body=18015c0 -->
private List<Authorization> GetAuthoritiesMap(LdapEntry ldapEntry, User user)
{
try
{
var authorities = new List<Authorization>();
if (!_ldapConfig.AuthoritiesMap.Any())
return authorities;
var groupAttr = entry.Attributes[_ldapConfig.GroupsProperty];
if (groupAttr == null)
return authorities;
var groups = groupAttr.GetValues(typeof(string))
.Cast<string>()
.ToList();
foreach (var authorityMap in _ldapConfig.AuthoritiesMap)
{
if (groups.Exists(g =>
string.Equals(g, authorityMap.Group, StringComparison.CurrentCultureIgnoreCase)))
{
if (!Enum.TryParse<PermissionEnum.RolesType>(authorityMap.Rol, out _))
continue;
authorities.Add(new Authorization
{
UserId = user.Id,
DisplayId = authorityMap.DisplayId,
Rol = authorityMap.Rol
});
}
}
return authorities;
}
catch (Exception e)
{
_logger.LogError("[LDAP] GetAuthoritiesMap error: {error}", e.Message);
return [];
}
}
}