diff --git a/.gitignore b/.gitignore index 284391e..5c2ae73 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,11 @@ bin obj *.user -App_Data/*.json + +# User secrets (lokalne dane uwierzytelniania) +appsettings.Development.json +appsettings.Production.json +appsettings.*.json + +# Produkcyjne dane użytkowników App_Data/*.json diff --git a/App_Data/users.json.example b/App_Data/users.json.example new file mode 100644 index 0000000..45d8062 --- /dev/null +++ b/App_Data/users.json.example @@ -0,0 +1,5 @@ +[ + { + "username": "twoj-email@outlook.com" + } +] \ No newline at end of file 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 e6d669a..dce1034 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 currentUser = CurrentUser; var entries = _store.GetEntries() .Where(e => IsVisibleToUser(e, currentUser)) @@ -33,11 +30,6 @@ public IActionResult Index() [HttpGet] public IActionResult Details(int id) { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - var entry = _store.GetEntry(id); if (entry == null) { @@ -51,11 +43,6 @@ public IActionResult Details(int id) [HttpGet] public IActionResult Create() { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - return View(new EntryCreateViewModel()); } @@ -63,11 +50,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."); @@ -85,11 +67,6 @@ public IActionResult Create(EntryCreateViewModel model) [HttpGet] public IActionResult Edit(int id) { - if (!IsLoggedIn) - { - return RedirectToLogin(); - } - var entry = _store.GetEntry(id); if (entry == null) { @@ -111,11 +88,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."); @@ -136,11 +108,7 @@ 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"; private static bool IsVisibleToUser(Entry entry, string currentUser) { 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/Passwords.csproj b/Passwords.csproj index 01a0f25..ba8e270 100644 --- a/Passwords.csproj +++ b/Passwords.csproj @@ -1,10 +1,16 @@ - + net8.0 enable enable + aspnet-SecretsManager-$(MSBuildProjectName) + + + + + PreserveNewest 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/README.md b/README.md new file mode 100644 index 0000000..f1680e7 --- /dev/null +++ b/README.md @@ -0,0 +1,275 @@ +# SecretsManager + +Password and sensitive information management application with Microsoft Account authentication. + +## Features + +- Secure authentication via Microsoft Account (Azure AD) +- Access control for authorized users only +- Entry management: add, edit, and view passwords +- Audit logging for access tracking +- JSON file storage (no database required) + +## Quick Start + +### Requirements + +- .NET 8 SDK +- Microsoft Account (Outlook, Hotmail, or Azure AD) +- Azure Account (for application registration) + +### Local Installation + +1. **Clone the repository:** + ```bash + git clone https://github.com/taskscape/SecretsManager.git + cd SecretsManager + ``` + +2. **Restore packages:** + ```bash + dotnet restore + ``` + +3. **Configure the application:** + + See detailed instructions: **[docs/Local-Setup-Guide.md](docs/Local-Setup-Guide.md)** + + Quick version: + ```bash + # Initialize User Secrets + dotnet user-secrets init + + # Copy user template + cp App_Data/users.json.example App_Data/users.json + + # Edit users.json and add your email + ``` + +4. **Register application in Azure:** + + See: **[docs/Azure-App-Registration-Guide.md](docs/Azure-App-Registration-Guide.md)** + +5. **Set authentication credentials:** + ```bash + dotnet user-secrets set "Authentication:Microsoft:ClientId" "YOUR-CLIENT-ID" + dotnet user-secrets set "Authentication:Microsoft:ClientSecret" "YOUR-SECRET" + ``` + +6. **Run the application:** + ```bash + dotnet run + ``` + +7. **Open your browser:** + ``` + https://localhost:5001 + ``` + +## Documentation + +| Document | Description | +|----------|-------------| +| **[docs/README.md](docs/README.md)** | Project and deployment summary | +| **[docs/Local-Setup-Guide.md](docs/Local-Setup-Guide.md)** | Local configuration for developers | +| **[docs/Azure-App-Registration-Guide.md](docs/Azure-App-Registration-Guide.md)** | Azure Portal application registration | +| **[docs/Deployment-Guide.md](docs/Deployment-Guide.md)** | Production server deployment (T01) | + +## Architecture + +- **Framework**: ASP.NET Core 8.0 (MVC) +- **Authentication**: Microsoft Identity Platform (OpenID Connect) +- **Storage**: JSON files (App_Data) +- **Frontend**: Razor Views + CSS + +### Project Structure + +``` +SecretsManager/ +??? Controllers/ # MVC Controllers +? ??? AccountController.cs +? ??? EntriesController.cs +??? Models/ # Data models +? ??? Entry.cs +? ??? User.cs +? ??? ... +??? Services/ # Business logic +? ??? JsonDataStore.cs +??? Views/ # Razor views +? ??? Account/ +? ??? Entries/ +? ??? Shared/ +??? App_Data/ # JSON data (not in repo!) +? ??? users.json +? ??? entries.json +? ??? access.json +??? docs/ # Documentation + ??? README.md + ??? Local-Setup-Guide.md + ??? Azure-App-Registration-Guide.md + ??? Deployment-Guide.md +``` + +## Security + +### Security Features: + +- Microsoft authentication (OAuth 2.0 / OpenID Connect) +- Controller-level authorization (`[Authorize]`) +- Allowed users list (`users.json`) +- HTTPS required +- Anti-forgery tokens (CSRF protection) +- Audit log for entry access + +### Files Not Committed to Git: + +The following files are in `.gitignore` and **should NOT** be in the repository: + +``` +appsettings.json +appsettings.Production.json +appsettings.*.json +App_Data/users.json +App_Data/entries.json +App_Data/access.json +``` + +### Secrets Storage: + +- **Local**: Use User Secrets (`dotnet user-secrets`) +- **Production**: Environment variables or `appsettings.Production.json` (not in repo) + +## User Management + +Add users by editing `App_Data/users.json`: + +```json +[ + { + "username": "john.doe@company.com" + }, + { + "username": "jane.smith@outlook.com" + } +] +``` + +**Note**: Use the exact email address associated with the Microsoft Account. + +Changes to this file are loaded dynamically - no application restart required. + +## Production Deployment + +To deploy the application to a production server (T01), follow the instructions in: + +**[docs/Deployment-Guide.md](docs/Deployment-Guide.md)** + +### Quick Steps: + +1. Update Redirect URI in Azure Portal +2. Publish application: `dotnet publish -c Release -o ./publish` +3. Transfer files to T01 server +4. Configure IIS or Nginx +5. Create `appsettings.Production.json` on server +6. Test authentication + +## Configuration + +### appsettings.json + +```json +{ + "Authentication": { + "Microsoft": { + "ClientId": "your-client-id-from-azure", + "ClientSecret": "your-client-secret-from-azure", + "CallbackPath": "/signin-oidc" + } + } +} +``` + +### Environment Variables (alternative) + +```bash +Authentication__Microsoft__ClientId=your-client-id +Authentication__Microsoft__ClientSecret=your-secret +``` + +## Audit Log + +The application automatically logs: +- User logins +- Entry access (Details view) +- New entry creation +- Entry modifications + +Logs are stored in `App_Data/access.json`. + +## Development + +### Development Requirements: + +- Visual Studio 2022 / Visual Studio Code / Rider +- .NET 8 SDK +- Git + +### Useful Commands: + +```bash +# Build +dotnet build + +# Run +dotnet run + +# Test +dotnet test + +# Publish +dotnet publish -c Release + +# User Secrets +dotnet user-secrets list +dotnet user-secrets set "Key" "Value" +dotnet user-secrets clear +``` + +## Troubleshooting + +### Local Issues: +See: **[docs/Local-Setup-Guide.md](docs/Local-Setup-Guide.md)** - "Troubleshooting" section + +### Production Issues: +See: **[docs/Deployment-Guide.md](docs/Deployment-Guide.md)** - "Troubleshooting" section + +### Common Problems: + +| Problem | Solution | +|---------|----------| +| "Microsoft sign-in failed" | Check ClientId/ClientSecret in configuration | +| "User is not allowed" | Add user email to `App_Data/users.json` | +| "AADSTS50011" | Check Redirect URI in Azure Portal | +| 502/503 Error | Check if application is running (IIS/systemd) | + +## License + +This project is private. No public license. + +## Contact + +**Repository**: https://github.com/taskscape/SecretsManager +**Branch**: `microsoft-account-authentication` + +## Project Status + +- Code: **Ready** +- Documentation: **Complete** +- Azure Registration: **To be done by developer** +- T01 Deployment: **To be done** + +--- + +**Version**: 1.0 +**Last Updated**: 2024 +**Status**: Ready for deployment diff --git a/Services/JsonDataStore.cs b/Services/JsonDataStore.cs index 73089a7..bcdd042 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)); } } @@ -153,23 +166,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); } @@ -206,15 +208,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