Add Google authentication with authorized email configuration

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-08-26 04:15:48 +00:00
parent 1cae2c7ad2
commit 2443cc06a7
14 changed files with 350 additions and 3 deletions

View file

@ -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>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
await builder.Build().RunAsync();

View file

@ -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<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var userInfo = await _httpClient.GetFromJsonAsync<UserInfo>("api/auth/user");
if (userInfo?.IsAuthenticated == true && !string.IsNullOrEmpty(userInfo.Email))
{
var claims = new List<Claim>
{
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; }
}
}

View file

@ -0,0 +1,32 @@
@inject HttpClient Http
@inject IJSRuntime JSRuntime
<div class="auth-header">
<div class="user-info">
Welcome, @Email
</div>
<button class="btn btn-outline-danger logout-btn" @onclick="Logout">
Sign Out
</button>
</div>
@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");
}
}
}

View file

@ -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;
}

View file

@ -0,0 +1,29 @@
@inject IJSRuntime JSRuntime
<div class="login-container">
<div class="login-card">
<h2>Welcome to Shift Scheduler</h2>
<p>Please sign in with your Google account to access the application.</p>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger">
@ErrorMessage
</div>
}
<button class="btn btn-primary login-btn" @onclick="Login">
<span class="google-icon">🔐</span>
Sign in with Google
</button>
</div>
</div>
@code {
[Parameter] public string? ErrorMessage { get; set; }
private async Task Login()
{
await JSRuntime.InvokeVoidAsync("window.location.href", "/api/auth/login");
}
}

View file

@ -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;
}

View file

@ -1,9 +1,33 @@
@inherits LayoutComponentBase
@inject IJSRuntime JSRuntime
<div class="page">
<main>
<CascadingAuthenticationState>
<AuthorizeView>
<Authorized>
<AuthHeader Email="@GetEmailFromContext(context)" />
<article class="content px-4">
@Body
</article>
</Authorized>
<NotAuthorized>
<LoginDisplay ErrorMessage="@GetErrorMessage()" />
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>
</main>
</div>
@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
}
}

View file

@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.8" PrivateAssets="all" />
</ItemGroup>

View file

@ -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

View file

@ -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<string> _authorizedEmails;
public AuthController(List<string> authorizedEmails)
{
_authorizedEmails = authorizedEmails;
}
[HttpGet("login")]
public IActionResult Login()
{
return Challenge(new AuthenticationProperties
{
RedirectUri = "/api/auth/callback"
}, GoogleDefaults.AuthenticationScheme);
}
[HttpGet("callback")]
public async Task<IActionResult> 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<IActionResult> 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 });
}
}
}

View file

@ -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;

View file

@ -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<List<Shift>>() ?? new();
var transportConfig = builder.Configuration.GetSection("Transport").Get<TransportConfiguration>() ?? new();
var authorizedEmails = builder.Configuration.GetSection("Authentication:AuthorizedEmails").Get<List<string>>() ?? new();
// Register services
builder.Services.AddSingleton(shifts);
builder.Services.AddSingleton(transportConfig);
builder.Services.AddSingleton(authorizedEmails);
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportApiService>();
builder.Services.AddSingleton<ShiftService>();
@ -18,6 +24,35 @@ builder.Services.AddSingleton<PdfExportService>();
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
builder.Services.AddSingleton<ITransportService, TransportService>();
// 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();

View file

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
</ItemGroup>

View file

@ -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",