26ff8d9b4f
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
1087 lines
35 KiB
Markdown
1087 lines
35 KiB
Markdown
# Web API Design
|
|
|
|
## Overview
|
|
|
|
This document describes the architecture and implementation approach for the Web API layer, including ASP.NET Core 10 controllers, SignalR hub, LDAP authentication, and cookie-based session management.
|
|
|
|
## Architecture
|
|
|
|
### High-Level Component Diagram
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
│ Blazor WebAssembly Client │
|
|
│ - HTTP API calls (fetch) │
|
|
│ - SignalR HubConnection │
|
|
└────────────────────────────────────┬────────────────────────────────────────┘
|
|
│ HTTPS
|
|
┌───────────────────────────┼───────────────────────┐
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
│ AuthController │ │ SearchController│ │ StatusHub │
|
|
│ /api/auth/* │ │ /api/search/* │ │ /hubs/status │
|
|
│ [AllowAnonymous]│ │ [Authorize] │ │ Real-time │
|
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
|
│ │ │
|
|
▼ ▼ ▼
|
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
|
│ IAuthService │ │ ILotFinder │ │ IHubContext │
|
|
│ (LDAP/Fake) │ │ Repository │ │ <StatusHub> │
|
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
|
```
|
|
|
|
### Project Structure
|
|
|
|
```
|
|
NEW/src/JdeScoping.Api/
|
|
├── Controllers/
|
|
│ ├── ApiControllerBase.cs # Base controller with CurrentUser
|
|
│ ├── AuthController.cs # Authentication endpoints
|
|
│ ├── SearchController.cs # Search management endpoints
|
|
│ ├── LookupController.cs # Autocomplete lookup endpoints
|
|
│ └── FileController.cs # File upload/download endpoints
|
|
├── Hubs/
|
|
│ └── StatusHub.cs # SignalR hub for real-time updates
|
|
├── Services/
|
|
│ ├── IAuthService.cs # Authentication service interface
|
|
│ ├── LdapAuthService.cs # LDAP authentication implementation
|
|
│ └── FakeAuthService.cs # Development mode auth bypass
|
|
├── Models/
|
|
│ ├── LoginRequest.cs # Login request DTO
|
|
│ ├── AuthResult.cs # Authentication result record
|
|
│ └── FileUploadResult.cs # File upload response DTO
|
|
├── Security/
|
|
│ ├── UserIdentity.cs # Claims-based user identity
|
|
│ └── ClaimsPrincipalExtensions.cs # Extension methods for claims
|
|
├── Configuration/
|
|
│ ├── LdapOptions.cs # LDAP configuration options
|
|
│ └── AuthOptions.cs # Authentication configuration
|
|
├── ServiceCollectionExtensions.cs # DI registration
|
|
└── JdeScoping.Api.csproj
|
|
```
|
|
|
|
## Controller Architecture
|
|
|
|
### ApiControllerBase
|
|
|
|
Base controller providing access to current user context:
|
|
|
|
```csharp
|
|
[ApiController]
|
|
public abstract class ApiControllerBase : ControllerBase
|
|
{
|
|
/// <summary>
|
|
/// Gets the current authenticated user from claims.
|
|
/// Returns null if not authenticated.
|
|
/// </summary>
|
|
protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true
|
|
? User.ToUserInfo()
|
|
: null;
|
|
|
|
/// <summary>
|
|
/// Gets the current username from claims.
|
|
/// </summary>
|
|
protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name);
|
|
}
|
|
```
|
|
|
|
### AuthController
|
|
|
|
Authentication endpoints for Blazor WASM client:
|
|
|
|
```csharp
|
|
[Route("api/auth")]
|
|
[ApiController]
|
|
public class AuthController : ApiControllerBase
|
|
{
|
|
private readonly IAuthService _authService;
|
|
private readonly ILogger<AuthController> _logger;
|
|
|
|
[HttpPost("login")]
|
|
[AllowAnonymous]
|
|
public async Task<ActionResult<UserInfo>> Login(
|
|
[FromBody] LoginRequest request,
|
|
CancellationToken ct)
|
|
{
|
|
var result = await _authService.AuthenticateAsync(
|
|
request.Username, request.Password, ct);
|
|
|
|
if (!result.Success)
|
|
{
|
|
return Unauthorized(new { message = result.ErrorMessage });
|
|
}
|
|
|
|
// Sign out existing session
|
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
// Create claims identity
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, result.User!.Username),
|
|
new(ClaimTypes.GivenName, result.User.FirstName),
|
|
new(ClaimTypes.Surname, result.User.LastName),
|
|
new(ClaimTypes.Email, result.User.EmailAddress),
|
|
new("title", result.User.Title),
|
|
new("dn", result.User.DN)
|
|
};
|
|
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
// Sign in with non-persistent cookie
|
|
await HttpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
principal,
|
|
new AuthenticationProperties { IsPersistent = false });
|
|
|
|
return Ok(result.User);
|
|
}
|
|
|
|
[HttpPost("logout")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Logout()
|
|
{
|
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Ok();
|
|
}
|
|
|
|
[HttpGet("me")]
|
|
[Authorize]
|
|
public ActionResult<UserInfo> GetCurrentUser()
|
|
{
|
|
return Ok(CurrentUser);
|
|
}
|
|
}
|
|
```
|
|
|
|
### SearchController
|
|
|
|
Search management with proper authorization:
|
|
|
|
```csharp
|
|
[Route("api/search")]
|
|
[ApiController]
|
|
[Authorize]
|
|
public class SearchController : ApiControllerBase
|
|
{
|
|
private readonly ILotFinderRepository _repository;
|
|
private readonly IHubContext<StatusHub> _hubContext;
|
|
private readonly ILogger<SearchController> _logger;
|
|
|
|
[HttpGet]
|
|
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(
|
|
CancellationToken ct)
|
|
{
|
|
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
|
|
return Ok(searches.OrderByDescending(s => s.StartDT));
|
|
}
|
|
|
|
[HttpGet("queue")]
|
|
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetQueuedSearches(
|
|
CancellationToken ct)
|
|
{
|
|
var searches = await _repository.GetQueuedSearchesAsync(ct);
|
|
return Ok(searches);
|
|
}
|
|
|
|
[HttpGet("{id:int}")]
|
|
public async Task<ActionResult<SearchViewModel>> GetSearch(int id, CancellationToken ct)
|
|
{
|
|
var search = await _repository.GetSearchAsync(id, ct);
|
|
if (search is null) return NotFound();
|
|
return Ok(new SearchViewModel(search));
|
|
}
|
|
|
|
[HttpPost("{id:int}/copy")]
|
|
public async Task<ActionResult<int>> CopySearch(int id, CancellationToken ct)
|
|
{
|
|
var original = await _repository.GetSearchAsync(id, ct);
|
|
if (original is null) return NotFound();
|
|
|
|
var copy = original with
|
|
{
|
|
ID = 0,
|
|
Status = SearchStatus.New,
|
|
UserName = CurrentUserName!,
|
|
SubmitDT = null,
|
|
StartDT = null,
|
|
EndDT = null
|
|
};
|
|
|
|
var newId = await _repository.SubmitSearchAsync(copy, ct);
|
|
return CreatedAtAction(nameof(GetSearch), new { id = newId }, newId);
|
|
}
|
|
|
|
[HttpPost]
|
|
public async Task<ActionResult<int>> CreateSearch(
|
|
[FromBody] SearchViewModel viewModel,
|
|
CancellationToken ct)
|
|
{
|
|
var search = viewModel.ToEntity();
|
|
search.UserName = CurrentUserName!;
|
|
|
|
var searchId = await _repository.SubmitSearchAsync(search, ct);
|
|
|
|
// Publish to SignalR (best-effort, swallow exceptions)
|
|
try
|
|
{
|
|
var searchUpdate = new SearchUpdate
|
|
{
|
|
ID = searchId,
|
|
UserName = CurrentUserName!,
|
|
Name = search.Name,
|
|
Status = search.Status,
|
|
Timestamp = DateTime.UtcNow
|
|
};
|
|
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to publish search update to SignalR");
|
|
}
|
|
|
|
return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
|
|
}
|
|
|
|
[HttpGet("{id:int}/results")]
|
|
public async Task<IActionResult> GetResults(int id, CancellationToken ct)
|
|
{
|
|
var data = await _repository.GetSearchResultsAsync(id, ct);
|
|
if (data is null || data.Length == 0) return NotFound();
|
|
|
|
return File(
|
|
data,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"search_results.xlsx");
|
|
}
|
|
}
|
|
```
|
|
|
|
### LookupController
|
|
|
|
Public autocomplete endpoints (no authorization):
|
|
|
|
```csharp
|
|
[Route("api/lookup")]
|
|
[ApiController]
|
|
public class LookupController : ApiControllerBase
|
|
{
|
|
private readonly ILotFinderRepository _repository;
|
|
|
|
[HttpGet("items")]
|
|
public async Task<ActionResult<IEnumerable<ItemViewModel>>> FindItems(
|
|
[FromQuery] string q,
|
|
CancellationToken ct)
|
|
{
|
|
var items = await _repository.SearchItemsAsync(q, ct);
|
|
return Ok(items.OrderBy(i => i.ItemNumber).Select(i => i.ToViewModel()));
|
|
}
|
|
|
|
[HttpGet("profit-centers")]
|
|
public async Task<ActionResult<IEnumerable<ProfitCenterViewModel>>> FindProfitCenters(
|
|
[FromQuery] string q,
|
|
CancellationToken ct)
|
|
{
|
|
var centers = await _repository.SearchProfitCentersAsync(q, ct);
|
|
return Ok(centers.OrderBy(pc => pc.Code).Select(pc => pc.ToViewModel()));
|
|
}
|
|
|
|
[HttpGet("work-centers")]
|
|
public async Task<ActionResult<IEnumerable<WorkCenterViewModel>>> FindWorkCenters(
|
|
[FromQuery] string q,
|
|
CancellationToken ct)
|
|
{
|
|
var centers = await _repository.SearchWorkCentersAsync(q, ct);
|
|
return Ok(centers.OrderBy(wc => wc.Code).Select(wc => wc.ToViewModel()));
|
|
}
|
|
|
|
[HttpGet("operators")]
|
|
public async Task<ActionResult<IEnumerable<UserViewModel>>> FindOperators(
|
|
[FromQuery] string q,
|
|
CancellationToken ct)
|
|
{
|
|
var users = await _repository.SearchUsersAsync(q, ct);
|
|
return Ok(users.OrderBy(u => u.FullName).Select(u => u.ToViewModel()));
|
|
}
|
|
}
|
|
```
|
|
|
|
### FileController
|
|
|
|
Excel file upload/download with caching:
|
|
|
|
```csharp
|
|
[Route("api/file")]
|
|
[ApiController]
|
|
public class FileController : ApiControllerBase
|
|
{
|
|
private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
|
|
private readonly ILotFinderRepository _repository;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly ILogger<FileController> _logger;
|
|
|
|
[HttpPost("work-orders/upload")]
|
|
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
|
|
IFormFile file,
|
|
CancellationToken ct)
|
|
{
|
|
if (file is null)
|
|
return Ok(new FileUploadResult<WorkOrderViewModel> { WasSuccessful = false, ErrorMessage = "No file uploaded" });
|
|
|
|
try
|
|
{
|
|
using var stream = file.OpenReadStream();
|
|
using var workbook = new XLWorkbook(stream);
|
|
var worksheet = workbook.Worksheet(1);
|
|
|
|
var workOrderNumbers = new List<long>();
|
|
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
|
|
|
|
for (int row = 2; row <= lastRow; row++)
|
|
{
|
|
var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
|
|
if (long.TryParse(cellValue, out var woNumber))
|
|
{
|
|
workOrderNumbers.Add(woNumber);
|
|
}
|
|
}
|
|
|
|
var workOrders = await _repository.LookupWorkOrdersAsync(workOrderNumbers, ct);
|
|
var viewModels = workOrders
|
|
.Select(wo => wo.ToViewModel())
|
|
.DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
|
|
.OrderBy(wo => wo.WorkOrderNumber)
|
|
.ToArray();
|
|
|
|
return Ok(new FileUploadResult<WorkOrderViewModel>
|
|
{
|
|
WasSuccessful = true,
|
|
Data = viewModels
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to parse uploaded work order file");
|
|
return Ok(new FileUploadResult<WorkOrderViewModel>
|
|
{
|
|
WasSuccessful = false,
|
|
ErrorMessage = "Failed to parse uploaded file"
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpPost("work-orders/template")]
|
|
public ActionResult<string> GenerateWorkOrderTemplate([FromBody] List<long> workOrders)
|
|
{
|
|
var data = ExcelTemplateGenerator.Generate(workOrders ?? [], "Work Order Number");
|
|
var key = CacheData(data);
|
|
return Ok(key);
|
|
}
|
|
|
|
[HttpGet("work-orders/template/{key}")]
|
|
public IActionResult DownloadWorkOrderTemplate(string key)
|
|
{
|
|
return DownloadCachedFile(key, "work_order_template.xlsx");
|
|
}
|
|
|
|
// Similar methods for part-numbers, component-lots, part-operations...
|
|
|
|
private string CacheData(byte[] data)
|
|
{
|
|
var key = Guid.NewGuid().ToString("N").ToUpperInvariant();
|
|
_cache.Set(key, data, new MemoryCacheEntryOptions
|
|
{
|
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1)
|
|
});
|
|
return key;
|
|
}
|
|
|
|
private IActionResult DownloadCachedFile(string key, string fileName)
|
|
{
|
|
if (!_cache.TryGetValue(key, out byte[]? data) || data is null)
|
|
{
|
|
return NotFound("Cached file not found or expired");
|
|
}
|
|
|
|
_cache.Remove(key);
|
|
return File(data, ContentType, fileName);
|
|
}
|
|
}
|
|
```
|
|
|
|
## SignalR StatusHub
|
|
|
|
Real-time updates using ASP.NET Core SignalR:
|
|
|
|
```csharp
|
|
public class StatusHub : Hub
|
|
{
|
|
private static StatusUpdate _cachedStatus = new()
|
|
{
|
|
Message = "Unknown",
|
|
Timestamp = DateTime.UtcNow
|
|
};
|
|
|
|
private readonly ILogger<StatusHub> _logger;
|
|
|
|
public StatusHub(ILogger<StatusHub> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called by worker service to update status.
|
|
/// Caches the update and broadcasts to all clients.
|
|
/// </summary>
|
|
public async Task SetStatus(StatusUpdate statusUpdate)
|
|
{
|
|
_cachedStatus = statusUpdate;
|
|
await Clients.All.SendAsync("statusUpdate", statusUpdate);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called by clients to get initial cached status on connection.
|
|
/// </summary>
|
|
public StatusUpdate GetCachedStatus()
|
|
{
|
|
return _cachedStatus;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called by controllers/services to broadcast search updates.
|
|
/// </summary>
|
|
public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
|
|
{
|
|
await Clients.All.SendAsync("searchUpdate", searchUpdate);
|
|
}
|
|
|
|
public override Task OnConnectedAsync()
|
|
{
|
|
_logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId);
|
|
return base.OnConnectedAsync();
|
|
}
|
|
|
|
public override Task OnDisconnectedAsync(Exception? exception)
|
|
{
|
|
_logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId);
|
|
return base.OnDisconnectedAsync(exception);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Publishing from Controllers/Services
|
|
|
|
Use `IHubContext<StatusHub>` for publishing from outside the hub:
|
|
|
|
```csharp
|
|
// Inject IHubContext<StatusHub> into controller
|
|
private readonly IHubContext<StatusHub> _hubContext;
|
|
|
|
// Publish status update
|
|
await _hubContext.Clients.All.SendAsync("statusUpdate", statusUpdate);
|
|
|
|
// Publish search update
|
|
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate);
|
|
```
|
|
|
|
## LDAP Authentication
|
|
|
|
### IAuthService Interface
|
|
|
|
```csharp
|
|
public interface IAuthService
|
|
{
|
|
Task<AuthResult> AuthenticateAsync(
|
|
string username,
|
|
string password,
|
|
CancellationToken ct = default);
|
|
|
|
Task<UserInfo?> GetUserInfoAsync(
|
|
string username,
|
|
CancellationToken ct = default);
|
|
}
|
|
|
|
public record AuthResult(
|
|
bool Success,
|
|
UserInfo? User,
|
|
string? ErrorMessage);
|
|
```
|
|
|
|
### LdapAuthService Implementation
|
|
|
|
Using `System.DirectoryServices.Protocols` for cross-platform compatibility:
|
|
|
|
```csharp
|
|
public sealed class LdapAuthService : IAuthService
|
|
{
|
|
private const string LdapLookupFormat = "(sAMAccountName={0})";
|
|
|
|
private readonly LdapOptions _options;
|
|
private readonly ILogger<LdapAuthService> _logger;
|
|
|
|
public LdapAuthService(
|
|
IOptions<LdapOptions> options,
|
|
ILogger<LdapAuthService> logger)
|
|
{
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<AuthResult> AuthenticateAsync(
|
|
string username,
|
|
string password,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
|
{
|
|
return new AuthResult(false, null, "Username and password are required");
|
|
}
|
|
|
|
// Try each configured LDAP server
|
|
string? lastError = null;
|
|
foreach (var serverUrl in _options.ServerUrls)
|
|
{
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// Attempt authentication
|
|
if (!await TryBindAsync(serverUrl, username, password, ct))
|
|
{
|
|
return new AuthResult(false, null, "Incorrect username or password");
|
|
}
|
|
|
|
// Verify group membership
|
|
if (!string.IsNullOrEmpty(_options.GroupDn))
|
|
{
|
|
if (!await IsInGroupAsync(serverUrl, username, password, _options.GroupDn, ct))
|
|
{
|
|
return new AuthResult(false, null, "User is not a member of the required security group");
|
|
}
|
|
}
|
|
|
|
// Lookup user info
|
|
var userInfo = await LookupUserAsync(serverUrl, username, password, ct);
|
|
if (userInfo is null)
|
|
{
|
|
return new AuthResult(false, null, "Failed to retrieve user information");
|
|
}
|
|
|
|
return new AuthResult(true, userInfo, null);
|
|
}
|
|
catch (LdapException ex)
|
|
{
|
|
_logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl);
|
|
lastError = ex.Message;
|
|
continue; // Try next server
|
|
}
|
|
}
|
|
|
|
return new AuthResult(false, null, lastError ?? "Unable to connect to directory server");
|
|
}
|
|
|
|
private async Task<bool> TryBindAsync(
|
|
string serverUrl,
|
|
string username,
|
|
string password,
|
|
CancellationToken ct)
|
|
{
|
|
using var connection = CreateConnection(serverUrl);
|
|
var credential = new NetworkCredential(username, password);
|
|
connection.Credential = credential;
|
|
connection.AuthType = AuthType.Negotiate;
|
|
|
|
try
|
|
{
|
|
await Task.Run(() => connection.Bind(), ct);
|
|
return true;
|
|
}
|
|
catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task<bool> IsInGroupAsync(
|
|
string serverUrl,
|
|
string username,
|
|
string password,
|
|
string groupDn,
|
|
CancellationToken ct)
|
|
{
|
|
using var connection = CreateConnection(serverUrl);
|
|
connection.Credential = new NetworkCredential(username, password);
|
|
connection.AuthType = AuthType.Negotiate;
|
|
await Task.Run(() => connection.Bind(), ct);
|
|
|
|
var searchRequest = new SearchRequest(
|
|
_options.SearchBase,
|
|
string.Format(LdapLookupFormat, username),
|
|
SearchScope.Subtree,
|
|
"memberOf");
|
|
|
|
var response = (SearchResponse)await Task.Run(
|
|
() => connection.SendRequest(searchRequest), ct);
|
|
|
|
foreach (SearchResultEntry entry in response.Entries)
|
|
{
|
|
var memberOf = entry.Attributes["memberOf"];
|
|
if (memberOf != null)
|
|
{
|
|
foreach (var group in memberOf.GetValues(typeof(string)))
|
|
{
|
|
if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async Task<UserInfo?> LookupUserAsync(
|
|
string serverUrl,
|
|
string username,
|
|
string password,
|
|
CancellationToken ct)
|
|
{
|
|
using var connection = CreateConnection(serverUrl);
|
|
connection.Credential = new NetworkCredential(username, password);
|
|
connection.AuthType = AuthType.Negotiate;
|
|
await Task.Run(() => connection.Bind(), ct);
|
|
|
|
var searchRequest = new SearchRequest(
|
|
_options.SearchBase,
|
|
string.Format(LdapLookupFormat, username),
|
|
SearchScope.Subtree,
|
|
"distinguishedName", "givenName", "sn", "mail", "title");
|
|
|
|
var response = (SearchResponse)await Task.Run(
|
|
() => connection.SendRequest(searchRequest), ct);
|
|
|
|
if (response.Entries.Count == 0) return null;
|
|
|
|
var entry = response.Entries[0];
|
|
return new UserInfo
|
|
{
|
|
DN = GetAttribute(entry, "distinguishedName"),
|
|
Username = username.ToLowerInvariant(),
|
|
FirstName = GetAttribute(entry, "givenName"),
|
|
LastName = GetAttribute(entry, "sn"),
|
|
EmailAddress = GetAttribute(entry, "mail"),
|
|
Title = GetAttribute(entry, "title")
|
|
};
|
|
}
|
|
|
|
private LdapConnection CreateConnection(string serverUrl)
|
|
{
|
|
var connection = new LdapConnection(serverUrl);
|
|
connection.SessionOptions.ProtocolVersion = 3;
|
|
connection.SessionOptions.SecureSocketLayer = false;
|
|
connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds);
|
|
return connection;
|
|
}
|
|
|
|
private static string GetAttribute(SearchResultEntry entry, string name)
|
|
{
|
|
var attr = entry.Attributes[name];
|
|
return attr?.Count > 0 ? (string)attr[0] : string.Empty;
|
|
}
|
|
|
|
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
|
|
{
|
|
// Not implemented for LDAP - user info is only available during authentication
|
|
throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
|
|
}
|
|
}
|
|
```
|
|
|
|
### FakeAuthService for Development
|
|
|
|
```csharp
|
|
public sealed class FakeAuthService : IAuthService
|
|
{
|
|
public Task<AuthResult> AuthenticateAsync(
|
|
string username,
|
|
string password,
|
|
CancellationToken ct = default)
|
|
{
|
|
// Accept any credentials in development mode
|
|
var user = new UserInfo
|
|
{
|
|
DN = $"CN={username},OU=Users,DC=example,DC=com",
|
|
Username = username.ToLowerInvariant(),
|
|
FirstName = "Dev",
|
|
LastName = "User",
|
|
EmailAddress = $"{username}@example.com",
|
|
Title = "Developer"
|
|
};
|
|
|
|
return Task.FromResult(new AuthResult(true, user, null));
|
|
}
|
|
|
|
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
|
|
{
|
|
var user = new UserInfo
|
|
{
|
|
DN = $"CN={username},OU=Users,DC=example,DC=com",
|
|
Username = username.ToLowerInvariant(),
|
|
FirstName = "Dev",
|
|
LastName = "User",
|
|
EmailAddress = $"{username}@example.com",
|
|
Title = "Developer"
|
|
};
|
|
|
|
return Task.FromResult<UserInfo?>(user);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Configuration Options
|
|
|
|
### LdapOptions
|
|
|
|
```csharp
|
|
public class LdapOptions
|
|
{
|
|
public const string SectionName = "Ldap";
|
|
|
|
/// <summary>
|
|
/// LDAP server URLs (supports multiple for failover).
|
|
/// Example: ["ldap.corp.example.com", "ldap2.corp.example.com"]
|
|
/// </summary>
|
|
public string[] ServerUrls { get; set; } = [];
|
|
|
|
/// <summary>
|
|
/// Distinguished name of required group for access.
|
|
/// Example: "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com"
|
|
/// </summary>
|
|
public string GroupDn { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// LDAP search base for user lookups.
|
|
/// Example: "DC=corp,DC=example,DC=com"
|
|
/// </summary>
|
|
public string SearchBase { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Connection timeout in seconds.
|
|
/// </summary>
|
|
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
|
}
|
|
```
|
|
|
|
### AuthOptions
|
|
|
|
```csharp
|
|
public class AuthOptions
|
|
{
|
|
public const string SectionName = "Auth";
|
|
|
|
/// <summary>
|
|
/// Enable fake authentication for development.
|
|
/// When true, any credentials are accepted.
|
|
/// </summary>
|
|
public bool UseFakeAuth { get; set; } = false;
|
|
|
|
/// <summary>
|
|
/// Name of the authentication cookie.
|
|
/// </summary>
|
|
public string CookieName { get; set; } = "ScopingTool.Auth";
|
|
|
|
/// <summary>
|
|
/// Cookie expiration in minutes (default: 8 hours).
|
|
/// </summary>
|
|
public int CookieExpirationMinutes { get; set; } = 480;
|
|
|
|
/// <summary>
|
|
/// Optional list of usernames that bypass group check.
|
|
/// Use sparingly for admin/testing purposes.
|
|
/// </summary>
|
|
public string[] AdminBypassUsers { get; set; } = [];
|
|
}
|
|
```
|
|
|
|
### Configuration Example (appsettings.json)
|
|
|
|
```json
|
|
{
|
|
"Auth": {
|
|
"UseFakeAuth": false,
|
|
"CookieName": "ScopingTool.Auth",
|
|
"CookieExpirationMinutes": 480,
|
|
"AdminBypassUsers": []
|
|
},
|
|
"Ldap": {
|
|
"ServerUrls": ["ldap.corp.example.com"],
|
|
"GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com",
|
|
"SearchBase": "DC=corp,DC=example,DC=com",
|
|
"ConnectionTimeoutSeconds": 30
|
|
}
|
|
}
|
|
```
|
|
|
|
### Development Configuration (appsettings.Development.json)
|
|
|
|
```json
|
|
{
|
|
"Auth": {
|
|
"UseFakeAuth": true
|
|
}
|
|
}
|
|
```
|
|
|
|
## Cookie Authentication Configuration
|
|
|
|
### Program.cs Setup
|
|
|
|
```csharp
|
|
// Configure authentication
|
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
var authOptions = builder.Configuration
|
|
.GetSection(AuthOptions.SectionName)
|
|
.Get<AuthOptions>() ?? new AuthOptions();
|
|
|
|
options.Cookie.Name = authOptions.CookieName;
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
|
options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes);
|
|
options.SlidingExpiration = true;
|
|
|
|
// Return 401 instead of redirect for API requests (Blazor WASM)
|
|
options.Events.OnRedirectToLogin = context =>
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
options.Events.OnRedirectToAccessDenied = context =>
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
return Task.CompletedTask;
|
|
};
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
```
|
|
|
|
## Service Registration
|
|
|
|
### AddWebApi Extension Method
|
|
|
|
```csharp
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
public static IServiceCollection AddWebApi(
|
|
this IServiceCollection services,
|
|
IConfiguration configuration)
|
|
{
|
|
// Bind configuration options
|
|
services.Configure<LdapOptions>(configuration.GetSection(LdapOptions.SectionName));
|
|
services.Configure<AuthOptions>(configuration.GetSection(AuthOptions.SectionName));
|
|
|
|
// Register auth service based on configuration
|
|
var authOptions = configuration
|
|
.GetSection(AuthOptions.SectionName)
|
|
.Get<AuthOptions>() ?? new AuthOptions();
|
|
|
|
if (authOptions.UseFakeAuth)
|
|
{
|
|
services.AddSingleton<IAuthService, FakeAuthService>();
|
|
}
|
|
else
|
|
{
|
|
services.AddScoped<IAuthService, LdapAuthService>();
|
|
}
|
|
|
|
// Register memory cache for file downloads
|
|
services.AddMemoryCache();
|
|
|
|
// Configure SignalR
|
|
services.AddSignalR();
|
|
|
|
// Configure controllers
|
|
services.AddControllers()
|
|
.AddJsonOptions(options =>
|
|
{
|
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
|
});
|
|
|
|
// Configure Swagger/OpenAPI
|
|
services.AddEndpointsApiExplorer();
|
|
services.AddSwaggerGen(c =>
|
|
{
|
|
c.SwaggerDoc("v1", new OpenApiInfo
|
|
{
|
|
Title = "JDE Scoping Tool API",
|
|
Version = "v1"
|
|
});
|
|
});
|
|
|
|
return services;
|
|
}
|
|
|
|
public static WebApplication UseWebApi(this WebApplication app)
|
|
{
|
|
// Use Swagger in development
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapControllers();
|
|
app.MapHub<StatusHub>("/hubs/status");
|
|
|
|
return app;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Data Models
|
|
|
|
### UserInfo (renamed from LDAPEntry)
|
|
|
|
```csharp
|
|
public class UserInfo
|
|
{
|
|
public string DN { get; set; } = string.Empty;
|
|
public string Username { get; set; } = string.Empty;
|
|
public string FirstName { get; set; } = string.Empty;
|
|
public string LastName { get; set; } = string.Empty;
|
|
public string DisplayName => string.IsNullOrWhiteSpace(FirstName) && string.IsNullOrWhiteSpace(LastName)
|
|
? Username
|
|
: $"{FirstName} {LastName}".Trim();
|
|
public string Title { get; set; } = string.Empty;
|
|
public string EmailAddress { get; set; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
### StatusUpdate
|
|
|
|
```csharp
|
|
public class StatusUpdate
|
|
{
|
|
public string Message { get; set; } = string.Empty;
|
|
public DateTime Timestamp { get; set; }
|
|
}
|
|
```
|
|
|
|
### SearchUpdate
|
|
|
|
```csharp
|
|
public class SearchUpdate
|
|
{
|
|
public int ID { get; set; }
|
|
public string UserName { get; set; } = string.Empty;
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
|
public SearchStatus Status { get; set; }
|
|
|
|
public DateTime? SubmitDT { get; set; }
|
|
public DateTime? StartDT { get; set; }
|
|
public DateTime? EndDT { get; set; }
|
|
public DateTime Timestamp { get; set; }
|
|
}
|
|
```
|
|
|
|
### LoginRequest
|
|
|
|
```csharp
|
|
public class LoginRequest
|
|
{
|
|
[Required]
|
|
public string Username { get; set; } = string.Empty;
|
|
|
|
[Required]
|
|
public string Password { get; set; } = string.Empty;
|
|
}
|
|
```
|
|
|
|
### FileUploadResult<T>
|
|
|
|
```csharp
|
|
public class FileUploadResult<T>
|
|
{
|
|
public bool WasSuccessful { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
public T[]? Data { get; set; }
|
|
}
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
- **Controller Tests**: Test action methods with mocked dependencies
|
|
- **Auth Service Tests**: Test LDAP connection handling (integration tests)
|
|
- **File Controller Tests**: Test upload parsing and caching
|
|
- **SignalR Hub Tests**: Test hub method invocation
|
|
|
|
### Mock Patterns
|
|
|
|
```csharp
|
|
// Controller test with NSubstitute
|
|
[Fact]
|
|
public async Task GetSearches_ReturnsUserSearches_OrderedByStartDtDescending()
|
|
{
|
|
// Arrange
|
|
var repository = Substitute.For<ILotFinderRepository>();
|
|
var hubContext = Substitute.For<IHubContext<StatusHub>>();
|
|
var logger = Substitute.For<ILogger<SearchController>>();
|
|
|
|
var searches = new[]
|
|
{
|
|
new Search { ID = 1, StartDT = DateTime.Now.AddHours(-2) },
|
|
new Search { ID = 2, StartDT = DateTime.Now }
|
|
};
|
|
repository.GetUserSearchesAsync("testuser", Arg.Any<CancellationToken>())
|
|
.Returns(searches);
|
|
|
|
var controller = new SearchController(repository, hubContext, logger);
|
|
controller.ControllerContext = CreateControllerContext("testuser");
|
|
|
|
// Act
|
|
var result = await controller.GetSearches(CancellationToken.None);
|
|
|
|
// Assert
|
|
var okResult = result.Result.ShouldBeOfType<OkObjectResult>();
|
|
var viewModels = okResult.Value.ShouldBeOfType<SearchViewModel[]>();
|
|
viewModels[0].ID.ShouldBe(2); // Most recent first
|
|
viewModels[1].ID.ShouldBe(1);
|
|
}
|
|
```
|
|
|
|
## NuGet Dependencies
|
|
|
|
```xml
|
|
<ItemGroup>
|
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.*" />
|
|
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.*" />
|
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.*" />
|
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.*" />
|
|
<PackageReference Include="ClosedXML" Version="0.102.*" />
|
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.*" />
|
|
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.*" />
|
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.*" />
|
|
</ItemGroup>
|
|
|
|
<ItemGroup Condition="'$(Configuration)' == 'Test'">
|
|
<PackageReference Include="xunit" Version="2.9.*" />
|
|
<PackageReference Include="Shouldly" Version="4.2.*" />
|
|
<PackageReference Include="NSubstitute" Version="5.1.*" />
|
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.*" />
|
|
</ItemGroup>
|
|
```
|