From 2443cc06a795b9650bd9333056123324f4238d52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 Aug 2025 04:15:48 +0000 Subject: [PATCH] Add Google authentication with authorized email configuration Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com> --- Client/Program.cs | 3 + Client/ServerAuthenticationStateProvider.cs | 55 +++++++++++++++++ Client/Shared/AuthHeader.razor | 32 ++++++++++ Client/Shared/AuthHeader.razor.css | 19 ++++++ Client/Shared/LoginDisplay.razor | 29 +++++++++ Client/Shared/LoginDisplay.razor.css | 63 +++++++++++++++++++ Client/Shared/MainLayout.razor | 30 ++++++++- Client/ShiftScheduler.Client.csproj | 1 + Client/_Imports.razor | 1 + Server/Controllers/AuthController.cs | 68 +++++++++++++++++++++ Server/Controllers/ShiftController.cs | 2 + Server/Program.cs | 38 ++++++++++++ Server/ShiftScheduler.Server.csproj | 1 + Server/appsettings.json | 11 ++++ 14 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 Client/ServerAuthenticationStateProvider.cs create mode 100644 Client/Shared/AuthHeader.razor create mode 100644 Client/Shared/AuthHeader.razor.css create mode 100644 Client/Shared/LoginDisplay.razor create mode 100644 Client/Shared/LoginDisplay.razor.css create mode 100644 Server/Controllers/AuthController.cs 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..60ec2ca --- /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("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..70ce71d --- /dev/null +++ b/Server/Controllers/AuthController.cs @@ -0,0 +1,68 @@ +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 = "/api/auth/callback" + }, GoogleDefaults.AuthenticationScheme); + } + + [HttpGet("callback")] + public async Task Callback() + { + var result = await HttpContext.AuthenticateAsync(); + if (!result.Succeeded) + { + return Redirect("/?error=auth_failed"); + } + + var emailClaim = result.Principal?.FindFirst(ClaimTypes.Email) ?? + result.Principal?.FindFirst("email"); + + if (emailClaim?.Value == null || !_authorizedEmails.Contains(emailClaim.Value)) + { + await HttpContext.SignOutAsync(); + return Redirect("/?error=unauthorized"); + } + + return Redirect("/"); + } + + [HttpPost("logout")] + [Authorize] + public async Task Logout() + { + await HttpContext.SignOutAsync(); + return Ok(); + } + + [HttpGet("user")] + [Authorize] + public IActionResult GetUser() + { + var emailClaim = User.FindFirst(ClaimTypes.Email) ?? + User.FindFirst("email"); + + return Ok(new { Email = emailClaim?.Value, IsAuthenticated = true }); + } + } +} \ No newline at end of file diff --git a/Server/Controllers/ShiftController.cs b/Server/Controllers/ShiftController.cs index f4617b2..46a48d6 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 ShiftService _shiftService; diff --git a/Server/Program.cs b/Server/Program.cs index 7e52165..eb53516 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,10 +10,12 @@ 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(); // Register services builder.Services.AddSingleton(shifts); builder.Services.AddSingleton(transportConfig); +builder.Services.AddSingleton(authorizedEmails); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); @@ -18,6 +24,35 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Configure authentication +builder.Services.AddAuthentication(options => +{ + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; +}) +.AddCookie() +.AddGoogle(googleOptions => +{ + googleOptions.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? ""; + googleOptions.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? ""; +}); + +// 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(); @@ -42,6 +77,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",