diff --git a/Client/Pages/Index.razor.css b/Client/Pages/Index.razor.css index d2e07a9..a466d3c 100644 --- a/Client/Pages/Index.razor.css +++ b/Client/Pages/Index.razor.css @@ -1,7 +1,7 @@ /* Top navigation buttons */ .top-nav-buttons { position: fixed; - top: 0; + top: 60px; right: 24px; display: flex; gap: 10px; diff --git a/Client/Program.cs b/Client/Program.cs index 0ac5da8..469e719 100644 --- a/Client/Program.cs +++ b/Client/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using ShiftScheduler.Client; @@ -7,5 +8,7 @@ builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddAuthorizationCore(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/Client/ServerAuthenticationStateProvider.cs b/Client/ServerAuthenticationStateProvider.cs new file mode 100644 index 0000000..7f923cb --- /dev/null +++ b/Client/ServerAuthenticationStateProvider.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Components.Authorization; +using System.Net.Http.Json; +using System.Security.Claims; + +namespace ShiftScheduler.Client +{ + public class ServerAuthenticationStateProvider : AuthenticationStateProvider + { + private readonly HttpClient _httpClient; + + public ServerAuthenticationStateProvider(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public override async Task GetAuthenticationStateAsync() + { + try + { + var userInfo = await _httpClient.GetFromJsonAsync("api/auth/user"); + + if (userInfo?.IsAuthenticated == true && !string.IsNullOrEmpty(userInfo.Email)) + { + var claims = new List + { + new Claim(ClaimTypes.Name, userInfo.Email), + new Claim(ClaimTypes.Email, userInfo.Email) + }; + + var identity = new ClaimsIdentity(claims, "Server authentication"); + var user = new ClaimsPrincipal(identity); + + return new AuthenticationState(user); + } + } + catch (HttpRequestException) + { + // User is not authenticated + } + + return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); + } + + public void NotifyAuthenticationStateChanged() + { + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + } + + public class UserInfo + { + public string? Email { get; set; } + public bool IsAuthenticated { get; set; } + } +} \ No newline at end of file diff --git a/Client/Shared/AuthHeader.razor b/Client/Shared/AuthHeader.razor new file mode 100644 index 0000000..33f2167 --- /dev/null +++ b/Client/Shared/AuthHeader.razor @@ -0,0 +1,32 @@ +@inject HttpClient Http +@inject IJSRuntime JSRuntime + +
+ + +
+ +@code { + [Parameter] public string Email { get; set; } = string.Empty; + [Parameter] public EventCallback OnLogout { get; set; } + + private async Task Logout() + { + try + { + await Http.PostAsync("api/auth/logout", null); + } + catch (Exception) + { + // Ignore errors, just redirect + } + finally + { + await JSRuntime.InvokeVoidAsync("window.location.reload"); + } + } +} \ No newline at end of file diff --git a/Client/Shared/AuthHeader.razor.css b/Client/Shared/AuthHeader.razor.css new file mode 100644 index 0000000..0235e9d --- /dev/null +++ b/Client/Shared/AuthHeader.razor.css @@ -0,0 +1,19 @@ +.auth-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; + margin-bottom: 20px; +} + +.user-info { + font-weight: 500; + color: #495057; +} + +.logout-btn { + padding: 5px 15px; + font-size: 14px; +} \ No newline at end of file diff --git a/Client/Shared/LoginDisplay.razor b/Client/Shared/LoginDisplay.razor new file mode 100644 index 0000000..a296755 --- /dev/null +++ b/Client/Shared/LoginDisplay.razor @@ -0,0 +1,29 @@ +@inject IJSRuntime JSRuntime + + + +@code { + [Parameter] public string? ErrorMessage { get; set; } + + private async Task Login() + { + await JSRuntime.InvokeVoidAsync("eval", "window.location.href = '/api/auth/login'"); + } +} \ No newline at end of file diff --git a/Client/Shared/LoginDisplay.razor.css b/Client/Shared/LoginDisplay.razor.css new file mode 100644 index 0000000..c0f8d83 --- /dev/null +++ b/Client/Shared/LoginDisplay.razor.css @@ -0,0 +1,63 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 80vh; + padding: 20px; +} + +.login-card { + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + text-align: center; + max-width: 400px; + width: 100%; +} + +.login-card h2 { + color: #333; + margin-bottom: 15px; +} + +.login-card p { + color: #666; + margin-bottom: 25px; +} + +.login-btn { + background-color: #4285f4; + color: white; + border: none; + padding: 12px 24px; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; +} + +.login-btn:hover { + background-color: #3367d6; +} + +.google-icon { + font-size: 18px; +} + +.alert { + padding: 10px; + margin-bottom: 20px; + border-radius: 4px; +} + +.alert-danger { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} \ No newline at end of file diff --git a/Client/Shared/MainLayout.razor b/Client/Shared/MainLayout.razor index f170e38..2e4fbe5 100644 --- a/Client/Shared/MainLayout.razor +++ b/Client/Shared/MainLayout.razor @@ -1,9 +1,33 @@ @inherits LayoutComponentBase +@inject IJSRuntime JSRuntime
-
- @Body -
+ + + + +
+ @Body +
+
+ + + +
+
+ +@code { + private string GetEmailFromContext(AuthenticationState authState) + { + return authState.User?.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "Unknown"; + } + + private string GetErrorMessage() + { + // Get error message from URL query parameters + return ""; // We'll handle this through JS if needed + } +} diff --git a/Client/ShiftScheduler.Client.csproj b/Client/ShiftScheduler.Client.csproj index 840e903..5b25a0c 100644 --- a/Client/ShiftScheduler.Client.csproj +++ b/Client/ShiftScheduler.Client.csproj @@ -8,6 +8,7 @@ + diff --git a/Client/_Imports.razor b/Client/_Imports.razor index a03b48d..ef54adc 100644 --- a/Client/_Imports.razor +++ b/Client/_Imports.razor @@ -5,6 +5,7 @@ @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.JSInterop @using ShiftScheduler.Client @using ShiftScheduler.Client.Shared diff --git a/Server/Controllers/AuthController.cs b/Server/Controllers/AuthController.cs new file mode 100644 index 0000000..59e9703 --- /dev/null +++ b/Server/Controllers/AuthController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace ShiftScheduler.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly List _authorizedEmails; + + public AuthController(List authorizedEmails) + { + _authorizedEmails = authorizedEmails; + } + + [HttpGet("login")] + public IActionResult Login() + { + return Challenge(new AuthenticationProperties + { + RedirectUri = "/" + }, GoogleDefaults.AuthenticationScheme); + } + + [HttpPost("logout")] + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return Ok(); + } + + [HttpGet("user")] + public IActionResult GetUser() + { + if (User.Identity?.IsAuthenticated == true) + { + var emailClaim = User.FindFirst(ClaimTypes.Email) ?? + User.FindFirst("email"); + + // Verify the user is in the authorized emails list + if (emailClaim?.Value != null && _authorizedEmails.Contains(emailClaim.Value)) + { + return Ok(new { Email = emailClaim.Value, IsAuthenticated = true }); + } + } + + return Ok(new { Email = (string?)null, IsAuthenticated = false }); + } + } +} \ No newline at end of file diff --git a/Server/Controllers/ConfigurationController.cs b/Server/Controllers/ConfigurationController.cs index 93a3e7a..26f154a 100644 --- a/Server/Controllers/ConfigurationController.cs +++ b/Server/Controllers/ConfigurationController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ShiftScheduler.Services; using ShiftScheduler.Shared; @@ -7,6 +8,7 @@ namespace ShiftScheduler.Server.Controllers { [ApiController] [Route("api/[controller]")] + [Authorize(Policy = "AllowedEmails")] public class ConfigurationController : ControllerBase { private readonly IConfigurationService _configurationService; diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs index 9de6a11..7d750f5 100644 --- a/Server/Controllers/ShiftController.cs +++ b/Server/Controllers/ShiftController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ShiftScheduler.Services; using ShiftScheduler.Shared; @@ -6,6 +7,7 @@ namespace ShiftScheduler.Server.Controllers { [ApiController] [Route("api/[controller]")] + [Authorize(Policy = "AllowedEmails")] public class ShiftController : ControllerBase { private readonly IcsExportService _icsService; diff --git a/Server/Program.cs b/Server/Program.cs index 821312d..7563ece 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -1,3 +1,7 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.Google; +using Microsoft.AspNetCore.Authorization; +using System.Security.Claims; using ShiftScheduler.Services; using ShiftScheduler.Shared; @@ -6,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args); // Load configurations from appsettings.json var shifts = builder.Configuration.GetSection("Shifts").Get>() ?? new(); var transportConfig = builder.Configuration.GetSection("Transport").Get() ?? new(); +var authorizedEmails = builder.Configuration.GetSection("Authentication:AuthorizedEmails").Get>() ?? new(); // Create application configuration var appConfiguration = new ApplicationConfiguration @@ -15,6 +20,7 @@ var appConfiguration = new ApplicationConfiguration }; // Register services +builder.Services.AddSingleton(authorizedEmails); builder.Services.AddSingleton(new ConfigurationService(appConfiguration)); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); @@ -23,6 +29,60 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Configure authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; +}) +.AddCookie(options => +{ + options.LoginPath = "/api/auth/login"; + options.LogoutPath = "/api/auth/logout"; + options.AccessDeniedPath = "/"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.SlidingExpiration = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; +}) +.AddGoogle(googleOptions => +{ + googleOptions.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? ""; + googleOptions.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? ""; + googleOptions.CallbackPath = "/signin-google"; + googleOptions.SaveTokens = true; + googleOptions.Events.OnTicketReceived = async context => + { + var emailClaim = context.Principal?.FindFirst(ClaimTypes.Email) ?? + context.Principal?.FindFirst("email"); + + if (emailClaim?.Value == null || !authorizedEmails.Contains(emailClaim.Value)) + { + context.Fail("Email not authorized"); + context.Response.Redirect("/?error=unauthorized"); + return; + } + + await Task.CompletedTask; + }; +}); + +// Configure authorization policy for allowed emails +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AllowedEmails", policy => + policy.RequireAssertion(context => + { + var emailClaim = context.User.FindFirst(ClaimTypes.Email) ?? + context.User.FindFirst("email"); + if (emailClaim?.Value != null) + { + return authorizedEmails.Contains(emailClaim.Value); + } + return false; + })); +}); + builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); @@ -47,6 +107,9 @@ app.UseStaticFiles(); app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapRazorPages(); app.MapControllers(); diff --git a/Server/ShiftScheduler.Server.csproj b/Server/ShiftScheduler.Server.csproj index d35c1ba..9dd91d8 100644 --- a/Server/ShiftScheduler.Server.csproj +++ b/Server/ShiftScheduler.Server.csproj @@ -7,6 +7,7 @@ + diff --git a/Server/appsettings.json b/Server/appsettings.json index 7263db2..5c8be59 100644 --- a/Server/appsettings.json +++ b/Server/appsettings.json @@ -7,6 +7,17 @@ }, "AllowedHosts": "*", + "Authentication": { + "Google": { + "ClientId": "YOUR_GOOGLE_CLIENT_ID", + "ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET" + }, + "AuthorizedEmails": [ + "example1@gmail.com", + "example2@gmail.com" + ] + }, + "Transport": { "StartStation": "Zurich", "EndStation": "Basel", diff --git a/authentication-setup.md b/authentication-setup.md new file mode 100644 index 0000000..621d8e2 --- /dev/null +++ b/authentication-setup.md @@ -0,0 +1,71 @@ +# Authentication Setup Guide + +## Overview +The ShiftScheduler application now includes Google OAuth authentication with configurable authorized email addresses. Only users with emails listed in the configuration can access the application. + +## Setup Instructions + +### 1. Create Google OAuth Application +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the Google+ API +4. Go to "Credentials" and create OAuth 2.0 Client IDs +5. Set the authorized redirect URI to: `http://localhost:5000/signin-google` (for development) +6. For production, use your domain: `https://yourdomain.com/signin-google` + +### 2. Configure Application +Edit `Server/appsettings.json` and update the authentication section: + +```json +{ + "Authentication": { + "Google": { + "ClientId": "your-google-client-id.apps.googleusercontent.com", + "ClientSecret": "your-google-client-secret" + }, + "AuthorizedEmails": [ + "user1@gmail.com", + "user2@example.com" + ] + } +} +``` + +### 3. For Production +For production deployment, consider using environment variables or Azure Key Vault: +- `Authentication__Google__ClientId` +- `Authentication__Google__ClientSecret` +- `Authentication__AuthorizedEmails__0`, `Authentication__AuthorizedEmails__1`, etc. + +## How It Works + +### Authentication Flow +1. Unauthenticated users see a login screen +2. Clicking "Sign in with Google" redirects to Google OAuth +3. After successful Google authentication, the application checks if the user's email is in the authorized list +4. Authorized users are redirected to the main application +5. Unauthorized users are redirected back with an error message + +### API Security +- All API endpoints require authentication (`[Authorize]` attribute) +- Only users with emails in the `AuthorizedEmails` list can access the API +- Unauthenticated requests return a 302 redirect to login + +### User Interface +- **Login Screen**: Clean, centered login form with Google sign-in button +- **Authenticated Header**: Shows user email and sign-out button +- **Main Application**: Normal shift scheduler functionality for authenticated users + +## Testing +To test the authentication: +1. Configure Google OAuth credentials as described above +2. Add your email to the `AuthorizedEmails` list +3. Start the application: `dotnet run` from the Server directory +4. Navigate to `http://localhost:5000` +5. Click "Sign in with Google" and complete the OAuth flow + +## Security Features +- **Email-based Authorization**: Only specified emails can access the application +- **Secure API Endpoints**: All shift management APIs require authentication +- **Session Management**: Proper login/logout functionality +- **OAuth Integration**: Uses Google's secure OAuth 2.0 flow \ No newline at end of file