diff --git a/src/Controllers/HomeController.cs b/src/Controllers/HomeController.cs index 156b55d..b4217e0 100644 --- a/src/Controllers/HomeController.cs +++ b/src/Controllers/HomeController.cs @@ -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 Login(string username, string password) + { + var authenticationResult = await _ldap.AuthenticateUser(username, password); + if (authenticationResult.Success) + { + List 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(); + } } \ No newline at end of file diff --git a/src/Controllers/UsersController.cs b/src/Controllers/UsersController.cs index 4d72204..48811d2 100644 --- a/src/Controllers/UsersController.cs +++ b/src/Controllers/UsersController.cs @@ -17,7 +17,7 @@ public class UsersController : Controller } [HttpGet("Index")] - public async Task>> Index(UsersIndexRequestModel requestModel) + public async Task> Index(UsersIndexRequestModel requestModel) { string? uid = requestModel.Uid; List attributes = ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"]; @@ -96,12 +96,12 @@ public class UsersController : Controller return false; } string uid = requestModel.uid; - Dictionary? 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; } diff --git a/src/Exceptions/HellFrozeOverException.cs b/src/Exceptions/HellFrozeOverException.cs new file mode 100644 index 0000000..d55ad2f --- /dev/null +++ b/src/Exceptions/HellFrozeOverException.cs @@ -0,0 +1,3 @@ +namespace Berufsschule_HAM.Exceptions; + +public class HellFrozeOverException : Exception {} \ No newline at end of file diff --git a/src/Models/LoginViewModel.cs b/src/Models/LoginViewModel.cs new file mode 100644 index 0000000..240dbd3 --- /dev/null +++ b/src/Models/LoginViewModel.cs @@ -0,0 +1,6 @@ +namespace Berufsschule_HAM.Models; + +class LoginViewModel +{ + public string? ErrorText { get; set; } +} \ No newline at end of file diff --git a/src/Models/UserModels.cs b/src/Models/UserModels.cs new file mode 100644 index 0000000..c16b520 --- /dev/null +++ b/src/Models/UserModels.cs @@ -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 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 +} \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 70a56d5..006b691 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -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(Options => builder.Services.AddSingleton(); +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()) diff --git a/src/Services/LdapService.cs b/src/Services/LdapService.cs index a0b190d..48c1420 100644 --- a/src/Services/LdapService.cs +++ b/src/Services/LdapService.cs @@ -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>> ListUsersAsync(string[] attributes) + public async Task> ListUsersAsync(string[] attributes) { - return await ListObjectBy(UsersBaseDn, "", attributes); + List returnValue = []; + (await ListObjectBy(UsersBaseDn, "", attributes)) + .ToList() + .ForEach(x => + returnValue.Add( + new UserModel(x) + { + Uid = x["uid"] + } + ) + ); + return returnValue; } - public async Task> GetUserByUidAsync(string uid) + public async Task 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> GetUserByUidAsync(string uid, string[] attributes) + public async Task 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 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(); } \ No newline at end of file diff --git a/src/Views/Home/AccessDenied.cshtml b/src/Views/Home/AccessDenied.cshtml new file mode 100644 index 0000000..a419ba3 --- /dev/null +++ b/src/Views/Home/AccessDenied.cshtml @@ -0,0 +1,9 @@ +@{ + ViewData["Title"] = "Access denied"; +} + +
+

Access denied

+

You currently do not have permission to access the page you tried to access.

+ Please click here to return to the login page +
diff --git a/src/Views/Home/Login.cshtml b/src/Views/Home/Login.cshtml new file mode 100644 index 0000000..424dc08 --- /dev/null +++ b/src/Views/Home/Login.cshtml @@ -0,0 +1,28 @@ +@{ + ViewData["Title"] = "Login"; +} + +
+

Login

+ @{ + if (Model.ErrorText is not null) + { + + } + } +
+
+ + +
+ +
+ + +
+ + +
+
diff --git a/src/Views/Shared/_Layout.cshtml b/src/Views/Shared/_Layout.cshtml index 0d50272..47d9b15 100644 --- a/src/Views/Shared/_Layout.cshtml +++ b/src/Views/Shared/_Layout.cshtml @@ -9,9 +9,9 @@ - +
-