Merge pull request #48 from LD-Reborn/17-create-a-login-page-that-uses-the-ldap-users-for-authentication

Added UserModel, Added Login and Logout and authorization, Made dark …
This commit is contained in:
LD50
2025-09-29 21:52:03 +02:00
committed by GitHub
11 changed files with 246 additions and 20 deletions

View File

@@ -1,9 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
using Berufsschule_HAM.Models;
using Novell.Directory.Ldap;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using System.Security.Claims;
using Berufsschule_HAM.Services;
using ElmahCore;
using Berufsschule_HAM.Exceptions;
using Microsoft.AspNetCore.Authorization;
[ApiExplorerSettings(IgnoreApi = true)]
[Route("[controller]")]
@@ -16,11 +19,72 @@ public class HomeController : Controller
_ldap = ldap ?? throw new ArgumentNullException(nameof(ldap));
}
// GET: /Assets
[Authorize]
[HttpGet("Index")]
[HttpGet("/")]
public IActionResult Index()
{
return View();
}
[HttpPost("Login")]
public async Task<ActionResult> Login(string username, string password)
{
var authenticationResult = await _ldap.AuthenticateUser(username, password);
if (authenticationResult.Success)
{
List<Claim> claims =
[
new(ClaimTypes.Name, username)
];
var claimsIdentity = new ClaimsIdentity(
claims,
CookieAuthenticationDefaults.AuthenticationScheme);
var authProperties = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(300)
};
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
return RedirectToAction("Index", "Home");
}
switch (authenticationResult.AuthenticationState)
{
case UserNotAuthenticatedReason.InvalidCredentials:
return View(new LoginViewModel() { ErrorText = "Invalid login credentials" }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
case UserNotAuthenticatedReason.UserLockedOut:
return View(new LoginViewModel() { ErrorText = "Your account has been locked. Wait a few minutes or ask an administrator to unlock you" }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
case UserNotAuthenticatedReason.UserNotAuthorized:
return View(new LoginViewModel() { ErrorText = "You are not authorized for login. Ask an administrator to authorize you." }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
default:
await HttpContext.RaiseError(new HellFrozeOverException());
return View(new LoginViewModel() { ErrorText = "Hell froze over. Make a screenshot and send it to an administrator." }); // TODO add localization (e.g. T["Invalid login credentials"]; see: https://learn.microsoft.com/de-de/dotnet/core/extensions/localization)
}
}
[HttpGet("Login")]
public ActionResult Login()
{
return View(new LoginViewModel());
}
[HttpGet("Logout")]
public ActionResult Logout()
{
HttpContext.SignOutAsync();
return RedirectToAction("Index", "Home");
}
[HttpGet("AccessDenied")]
public ActionResult AccessDenied()
{
return View();
}
}

View File

@@ -17,7 +17,7 @@ public class UsersController : Controller
}
[HttpGet("Index")]
public async Task<IEnumerable<Dictionary<string, string>>> Index(UsersIndexRequestModel requestModel)
public async Task<IEnumerable<UserModel>> Index(UsersIndexRequestModel requestModel)
{
string? uid = requestModel.Uid;
List<string> attributes = ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"];
@@ -96,12 +96,12 @@ public class UsersController : Controller
return false;
}
string uid = requestModel.uid;
Dictionary<string, string>? user = null;
UserModel? user = null;
if (requestModel.Cn is not null)
{
await _ldap.UpdateUser(uid, "cn", requestModel.Cn);
user ??= await _ldap.GetUserByUidAsync(uid);
string newUid = user["sn"].ToLower() + requestModel.Cn.ToLower();
string newUid = user.Sn?.ToLower() + requestModel.Cn.ToLower();
await _ldap.UpdateUser(uid, "uid", newUid);
uid = newUid;
}
@@ -109,7 +109,7 @@ public class UsersController : Controller
{
await _ldap.UpdateUser(uid, "sn", requestModel.Sn);
user ??= await _ldap.GetUserByUidAsync(uid);
string newUid = requestModel.Sn.ToLower() + user["cn"].ToLower();
string newUid = requestModel.Sn.ToLower() + user.Cn?.ToLower();
await _ldap.UpdateUser(uid, "uid", newUid);
uid = newUid;
}

View File

@@ -0,0 +1,3 @@
namespace Berufsschule_HAM.Exceptions;
public class HellFrozeOverException : Exception {}

View File

@@ -0,0 +1,6 @@
namespace Berufsschule_HAM.Models;
class LoginViewModel
{
public string? ErrorText { get; set; }
}

36
src/Models/UserModels.cs Normal file
View File

@@ -0,0 +1,36 @@
namespace Berufsschule_HAM.Models;
public class UserModel
{
public required string Uid { get; set; }
public string? Cn { get; set; }
public string? Sn { get; set; }
public string? Description { get; set; }
public string? JpegPhoto { get; set; }
public string? Title { get; set; }
public string? UserPassword { get; set; }
public UserModel(Dictionary<string, string> ldapData)
{
Uid = ldapData.GetValueOrDefault("uid") ?? throw new ArgumentException("UID is required");
Cn = ldapData.GetValueOrDefault("cn");
Sn = ldapData.GetValueOrDefault("sn");
Description = ldapData.GetValueOrDefault("description");
JpegPhoto = ldapData.GetValueOrDefault("jpegPhoto");
Title = ldapData.GetValueOrDefault("title");
UserPassword = ldapData.GetValueOrDefault("userPassword");
}
}
public class UserAuthenticationResult
{
public required bool Success;
public UserNotAuthenticatedReason AuthenticationState { get; set; } = UserNotAuthenticatedReason.None;
}
public enum UserNotAuthenticatedReason
{
None,
InvalidCredentials,
UserNotAuthorized,
UserLockedOut
}

View File

@@ -1,6 +1,7 @@
using ElmahCore;
using ElmahCore.Mvc;
using Serilog;
using Microsoft.AspNetCore.Authentication.Cookies;
using Berufsschule_HAM.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -26,6 +27,14 @@ builder.Services.AddElmah<XmlFileErrorLog>(Options =>
builder.Services.AddSingleton<LdapService>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Home/Login";
options.LogoutPath = "/Home/Login/Logout";
options.AccessDeniedPath = "/Home/AccessDenied";
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())

View File

@@ -1,8 +1,12 @@
using Novell.Directory.Ldap;
using Microsoft.Extensions.Options;
using Berufsschule_HAM.Models;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Berufsschule_HAM.Services;
public class LdapService : IDisposable
public partial class LdapService : IDisposable
{
private readonly LdapOptions _opts;
private readonly LdapConnection _conn;
@@ -49,19 +53,30 @@ public class LdapService : IDisposable
return await ListObjectBy(UsersBaseDn, "", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"]);
}
public async Task<IEnumerable<Dictionary<string, string>>> ListUsersAsync(string[] attributes)
public async Task<IEnumerable<UserModel>> ListUsersAsync(string[] attributes)
{
return await ListObjectBy(UsersBaseDn, "", attributes);
List<UserModel> returnValue = [];
(await ListObjectBy(UsersBaseDn, "", attributes))
.ToList()
.ForEach(x =>
returnValue.Add(
new UserModel(x)
{
Uid = x["uid"]
}
)
);
return returnValue;
}
public async Task<Dictionary<string, string>> GetUserByUidAsync(string uid)
public async Task<UserModel> GetUserByUidAsync(string uid)
{
return (await ListObjectBy(UsersBaseDn, $"uid={uid}", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"])).First();
return new UserModel((await ListObjectBy(UsersBaseDn, $"uid={uid}", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"])).First()) {Uid = uid};
}
public async Task<Dictionary<string, string>> GetUserByUidAsync(string uid, string[] attributes)
public async Task<UserModel> GetUserByUidAsync(string uid, string[] attributes)
{
return (await ListObjectBy(UsersBaseDn, $"uid={uid}", attributes)).First();
return new UserModel((await ListObjectBy(UsersBaseDn, $"uid={uid}", attributes)).First()) {Uid = uid};
}
@@ -86,6 +101,46 @@ public class LdapService : IDisposable
CreateObject(LocationsBaseDn, attributeSet);
}
public async Task<UserAuthenticationResult> AuthenticateUser(string username, string password)
{
await ConnectAndBind();
try
{
UserModel user = await GetUserByUidAsync(username);
if (user.UserPassword is null)
{
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
}
if (CompareStringToSha256(password, user.UserPassword))
{
return new() { Success = true};
}
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
}
catch (LdapException)
{
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
}
}
public bool CompareStringToSha256(string sourcePassword, string targetPasswordHashed)
{
byte[] sourcePasswordBytes = SHA256.HashData(Encoding.UTF8.GetBytes(sourcePassword));
byte[] targetPasswordHashedBytes = Convert.FromBase64String(CurlyBracesRemover().Replace(targetPasswordHashed, ""));
if (sourcePasswordBytes.Length != targetPasswordHashedBytes.Length)
{
return false;
}
for (int i = 0; i < sourcePasswordBytes.Length; i++)
{
if (sourcePasswordBytes[i] != targetPasswordHashedBytes[i])
{
return false;
}
}
return true;
}
private string PrependRDN(string rdn, string dn)
{
return rdn + "," + dn;
@@ -166,4 +221,7 @@ public class LdapService : IDisposable
_conn.Disconnect();
}
}
[GeneratedRegex(@"\{.*?\}")]
private static partial Regex CurlyBracesRemover();
}

View File

@@ -0,0 +1,9 @@
@{
ViewData["Title"] = "Access denied";
}
<div class="text-center">
<h1 class="display-4">Access denied</h1>
<p>You currently do not have permission to access the page you tried to access.</p>
<a href="/Home/Login">Please click here to return to the login page</a>
</div>

View File

@@ -0,0 +1,28 @@
@{
ViewData["Title"] = "Login";
}
<div class="text-center">
<h1 class="display-4">Login</h1>
@{
if (Model.ErrorText is not null)
{
<div class="alert alert-danger text-center login-error">
<h2>@Model.ErrorText</h2>
</div>
}
}
<form method="post" action="/Home/Login" class="mt-4" style="max-width: 400px; margin: auto;">
<div class="form-group mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="form-group mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>

View File

@@ -9,9 +9,9 @@
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/Berufsschule_HAM.styles.css" asp-append-version="true" />
</head>
<body>
<body data-bs-theme="dark">
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar bg border-bottom box-shadow mb-3">
<div class="container-fluid">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">Berufsschule_HAM</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
@@ -21,10 +21,17 @@
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
<a class="nav-link text" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
@if (User.Identity.IsAuthenticated)
{
<a class="nav-link text" asp-area="" asp-controller="Home" asp-action="Logout">Log out</a>
}
else
{
<a class="nav-link text" asp-area="" asp-controller="Home" asp-action="Login">Login</a>
}
</li>
</ul>
</div>
@@ -39,7 +46,7 @@
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2025 - Berufsschule_HAM - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
&copy; 2025 - Berufsschule_HAM
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>

View File

@@ -29,3 +29,9 @@ body {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
.login-error {
display: inline-block;
margin: 1rem 0 1rem 0;
padding: 2rem 4rem 2rem 4rem;
}