From 53df2dcafaf632769d53903990fb924f9565af8b Mon Sep 17 00:00:00 2001 From: Maciej Zagozda Date: Tue, 3 Feb 2026 16:00:24 +0100 Subject: [PATCH 1/3] Initial version --- App_Data/users.json | 3 +- Controllers/AccountController.cs | 54 +++++++++++++-------------- Controllers/EntriesController.cs | 38 ++----------------- Models/LoginViewModel.cs | 3 +- Models/User.cs | 1 - Program.cs | 64 +++++++++++++++++++++++++++++--- Services/JsonDataStore.cs | 43 +++++++++++---------- Services/UserIdentifier.cs | 35 +++++++++++++++++ Views/Account/Login.cshtml | 17 +++------ Views/Shared/_Layout.cshtml | 4 +- appsettings.json | 9 +++++ wwwroot/css/site.css | 5 +++ 12 files changed, 167 insertions(+), 109 deletions(-) create mode 100644 Services/UserIdentifier.cs create mode 100644 appsettings.json diff --git a/App_Data/users.json b/App_Data/users.json index 870edf9..d3c4af2 100644 --- a/App_Data/users.json +++ b/App_Data/users.json @@ -1,6 +1,5 @@ [ { - "username": "admin", - "password": "admin123" + "username": "admin@contoso.com" } ] diff --git a/Controllers/AccountController.cs b/Controllers/AccountController.cs index a693839..837d75c 100644 --- a/Controllers/AccountController.cs +++ b/Controllers/AccountController.cs @@ -1,58 +1,54 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Passwords.Models; -using Passwords.Services; namespace Passwords.Controllers; public class AccountController : Controller { - private readonly JsonDataStore _store; - - public AccountController(JsonDataStore store) - { - _store = store; - } - [HttpGet] - public IActionResult Login() + [AllowAnonymous] + public IActionResult Login(string? error = null) { - if (IsLoggedIn) + if (User.Identity?.IsAuthenticated == true) { return RedirectToAction("Index", "Entries"); } - return View(new LoginViewModel()); + return View(new LoginViewModel { Error = error }); } [HttpPost] [ValidateAntiForgeryToken] - public IActionResult Login(LoginViewModel model) + [AllowAnonymous] + public IActionResult MicrosoftLogin(string? returnUrl = null) { - if (string.IsNullOrWhiteSpace(model.Username) || string.IsNullOrWhiteSpace(model.Password)) - { - ViewData["Error"] = "Username and password are required."; - return View(model); - } + var redirectUrl = Url.IsLocalUrl(returnUrl) + ? returnUrl + : Url.Action("Index", "Entries") ?? "/"; - if (!_store.ValidateUser(model.Username, model.Password)) + var properties = new AuthenticationProperties { - ViewData["Error"] = "Invalid username or password."; - return View(model); - } - - HttpContext.Session.SetString("username", model.Username); - _store.LogLogin(model.Username); + RedirectUri = redirectUrl + }; - return RedirectToAction("Index", "Entries"); + return Challenge(properties, OpenIdConnectDefaults.AuthenticationScheme); } [HttpPost] [ValidateAntiForgeryToken] public IActionResult Logout() { - HttpContext.Session.Clear(); - return RedirectToAction("Login"); - } + var properties = new AuthenticationProperties + { + RedirectUri = Url.Action("Login", "Account") + }; - private bool IsLoggedIn => !string.IsNullOrEmpty(HttpContext.Session.GetString("username")); + return SignOut(properties, + CookieAuthenticationDefaults.AuthenticationScheme, + OpenIdConnectDefaults.AuthenticationScheme); + } } diff --git a/Controllers/EntriesController.cs b/Controllers/EntriesController.cs index 52f41df..909ebb3 100644 --- a/Controllers/EntriesController.cs +++ b/Controllers/EntriesController.cs @@ -1,9 +1,11 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Passwords.Models; using Passwords.Services; namespace Passwords.Controllers; +[Authorize] public class EntriesController : Controller { private readonly JsonDataStore _store; @@ -16,11 +18,6 @@ public EntriesController(JsonDataStore store) [HttpGet] public IActionResult Index() { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - var entries = _store.GetEntries() .OrderBy(e => e.Title, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -31,11 +28,6 @@ public IActionResult Index() [HttpGet] public IActionResult Details(int id) { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - var entry = _store.GetEntry(id); if (entry == null) { @@ -49,11 +41,6 @@ public IActionResult Details(int id) [HttpGet] public IActionResult Create() { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - return View(new EntryCreateViewModel()); } @@ -61,11 +48,6 @@ public IActionResult Create() [ValidateAntiForgeryToken] public IActionResult Create(EntryCreateViewModel model) { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - if (string.IsNullOrWhiteSpace(model.Title)) { ModelState.AddModelError(nameof(model.Title), "Title is required."); @@ -79,11 +61,6 @@ public IActionResult Create(EntryCreateViewModel model) [HttpGet] public IActionResult Edit(int id) { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - var entry = _store.GetEntry(id); if (entry == null) { @@ -104,11 +81,6 @@ public IActionResult Edit(int id) [ValidateAntiForgeryToken] public IActionResult Edit(EntryEditViewModel model) { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - if (string.IsNullOrWhiteSpace(model.Title)) { ModelState.AddModelError(nameof(model.Title), "Title is required."); @@ -124,9 +96,5 @@ public IActionResult Edit(EntryEditViewModel model) return RedirectToAction("Details", new { id = model.Id }); } - private string CurrentUser => HttpContext.Session.GetString("username") ?? ""; - - private bool IsLoggedIn => !string.IsNullOrEmpty(CurrentUser); - - private IActionResult RedirectToLogin() => RedirectToAction("Login", "Account"); + private string CurrentUser => UserIdentifier.GetUserIdentifier(User) ?? "Unknown"; } diff --git a/Models/LoginViewModel.cs b/Models/LoginViewModel.cs index 06f7043..09042df 100644 --- a/Models/LoginViewModel.cs +++ b/Models/LoginViewModel.cs @@ -2,6 +2,5 @@ namespace Passwords.Models; public class LoginViewModel { - public string Username { get; set; } = ""; - public string Password { get; set; } = ""; + public string? Error { get; set; } } diff --git a/Models/User.cs b/Models/User.cs index ea8fd7a..7e6b7c3 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -3,5 +3,4 @@ namespace Passwords.Models; public class User { public string Username { get; set; } = ""; - public string Password { get; set; } = ""; } diff --git a/Program.cs b/Program.cs index 92c6930..0d24bd8 100644 --- a/Program.cs +++ b/Program.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Tokens; using Passwords.Services; var builder = WebApplication.CreateBuilder(args); @@ -6,13 +9,63 @@ builder.Services.AddControllersWithViews(); builder.Services.AddSingleton(); builder.Services.AddHttpContextAccessor(); -builder.Services.AddSession(options => +builder.Services.AddAuthentication(options => { - options.IdleTimeout = TimeSpan.FromMinutes(30); - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; +}) +.AddCookie(options => +{ + options.LoginPath = "/Account/Login"; + options.LogoutPath = "/Account/Logout"; +}) +.AddOpenIdConnect(options => +{ + var authSection = builder.Configuration.GetSection("Authentication:Microsoft"); + options.Authority = "https://login.microsoftonline.com/common/v2.0"; + options.ClientId = authSection["ClientId"] ?? string.Empty; + options.ClientSecret = authSection["ClientSecret"] ?? string.Empty; + options.CallbackPath = authSection["CallbackPath"] ?? "/signin-oidc"; + options.ResponseType = "code"; + options.SaveTokens = false; + options.Scope.Clear(); + options.Scope.Add("openid"); + options.Scope.Add("profile"); + options.Scope.Add("email"); + options.TokenValidationParameters = new TokenValidationParameters + { + NameClaimType = "preferred_username" + }; + options.Events = new OpenIdConnectEvents + { + OnTokenValidated = context => + { + var store = context.HttpContext.RequestServices.GetRequiredService(); + var identifier = UserIdentifier.GetUserIdentifier(context.Principal)?.Trim(); + if (string.IsNullOrWhiteSpace(identifier) || !store.IsAllowedUser(identifier)) + { + context.Fail("User is not allowed."); + return Task.CompletedTask; + } + + store.LogLogin(identifier); + return Task.CompletedTask; + }, + OnRemoteFailure = context => + { + context.HandleResponse(); + var message = context.Failure?.Message?.Contains("not allowed", StringComparison.OrdinalIgnoreCase) == true + ? "Your Microsoft account is not authorized for this app." + : "Microsoft sign-in failed."; + var encoded = Uri.EscapeDataString(message); + context.Response.Redirect($"/Account/Login?error={encoded}"); + return Task.CompletedTask; + } + }; }); +builder.Services.AddAuthorization(); + var app = builder.Build(); app.Use(async (context, next) => @@ -29,7 +82,8 @@ app.UseStaticFiles(); app.UseRouting(); -app.UseSession(); +app.UseAuthentication(); +app.UseAuthorization(); app.MapControllerRoute( name: "default", diff --git a/Services/JsonDataStore.cs b/Services/JsonDataStore.cs index 020b483..7887710 100644 --- a/Services/JsonDataStore.cs +++ b/Services/JsonDataStore.cs @@ -5,6 +5,13 @@ namespace Passwords.Services; public class JsonDataStore { + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + private readonly object _lock = new(); private readonly string _usersPath; private readonly string _entriesPath; @@ -22,12 +29,18 @@ public JsonDataStore(IHostEnvironment env) EnsureSeedData(); } - public bool ValidateUser(string username, string password) + public bool IsAllowedUser(string identifier) { + if (string.IsNullOrWhiteSpace(identifier)) + { + return false; + } + + var normalized = identifier.Trim(); lock (_lock) { var users = Load>(_usersPath) ?? new List(); - return users.Any(u => u.Username == username && u.Password == password); + return users.Any(u => string.Equals(u.Username, normalized, StringComparison.OrdinalIgnoreCase)); } } @@ -141,23 +154,12 @@ private void LogAccess(AccessLogEntry logEntry) private void EnsureSeedData() { - var users = Load>(_usersPath) ?? new List(); - var usersUpdated = false; - - void EnsureUser(string username, string password) + if (!File.Exists(_usersPath)) { - if (!users.Any(u => u.Username == username)) + var users = new List { - users.Add(new User { Username = username, Password = password }); - usersUpdated = true; - } - } - - EnsureUser("admin", "admin123"); - EnsureUser("test", "test"); - - if (!File.Exists(_usersPath) || usersUpdated) - { + new User { Username = "admin@contoso.com" } + }; Save(_usersPath, users); } @@ -194,15 +196,12 @@ void EnsureUser(string username, string password) return default; } - return JsonSerializer.Deserialize(json); + return JsonSerializer.Deserialize(json, SerializerOptions); } private static void Save(string path, T data) { - var json = JsonSerializer.Serialize(data, new JsonSerializerOptions - { - WriteIndented = true - }); + var json = JsonSerializer.Serialize(data, SerializerOptions); File.WriteAllText(path, json); } } diff --git a/Services/UserIdentifier.cs b/Services/UserIdentifier.cs new file mode 100644 index 0000000..029d68e --- /dev/null +++ b/Services/UserIdentifier.cs @@ -0,0 +1,35 @@ +using System.Security.Claims; + +namespace Passwords.Services; + +public static class UserIdentifier +{ + private static readonly string[] ClaimTypesToCheck = + { + "preferred_username", + "email", + ClaimTypes.Email, + ClaimTypes.Upn, + "upn", + "unique_name" + }; + + public static string? GetUserIdentifier(ClaimsPrincipal? principal) + { + if (principal == null) + { + return null; + } + + foreach (var claimType in ClaimTypesToCheck) + { + var value = principal.FindFirst(claimType)?.Value; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return principal.Identity?.Name; + } +} diff --git a/Views/Account/Login.cshtml b/Views/Account/Login.cshtml index b38a8cb..0ef4ba7 100644 --- a/Views/Account/Login.cshtml +++ b/Views/Account/Login.cshtml @@ -6,21 +6,14 @@

Login

- @if (ViewData["Error"] is string error && !string.IsNullOrWhiteSpace(error)) + @if (!string.IsNullOrWhiteSpace(Model?.Error)) { -
@error
+
@Model.Error
} -
+ @Html.AntiForgeryToken() -
- - -
-
- - -
- +
+

Access is limited to accounts listed in App_Data/users.json.

diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 77e5666..550146f 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -1,3 +1,4 @@ +@using Passwords.Services @inject IHttpContextAccessor HttpContextAccessor @@ -12,7 +13,8 @@
Passwords