Add Google OAuth authentication with configurable authorized emails (#29)

* Initial plan

* Add Google authentication with authorized email configuration

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Complete Google authentication implementation with documentation

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix CORS error by removing [Authorize] from auth user endpoint

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Add authorization attribute to ConfigurationController for security

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix JavaScript error in login button by correcting window.location.href usage

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix Google OAuth redirect URI configuration to resolve authentication error

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix OAuth state validation error by specifying authentication scheme in callback

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix OAuth state validation error by improving authentication configuration

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix authentication setup documentation with correct redirect URI

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Fix navigation buttons overlapping with authentication header

Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>

* Remove unused unsing

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
Co-authored-by: Claudio Schaad <clayschaad@users.noreply.github.com>
Co-authored-by: Claudio Schaad <c.schaad@pog.ch>
This commit is contained in:
Copilot 2025-08-27 21:18:50 +02:00 committed by GitHub
parent d5b123bbd9
commit e8db13191e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 436 additions and 4 deletions

View file

@ -1,7 +1,7 @@
/* Top navigation buttons */
.top-nav-buttons {
position: fixed;
top: 0;
top: 60px;
right: 24px;
display: flex;
gap: 10px;

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("eval", "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>
<article class="content px-4">
@Body
</article>
<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,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<string> _authorizedEmails;
public AuthController(List<string> authorizedEmails)
{
_authorizedEmails = authorizedEmails;
}
[HttpGet("login")]
public IActionResult Login()
{
return Challenge(new AuthenticationProperties
{
RedirectUri = "/"
}, GoogleDefaults.AuthenticationScheme);
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> 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 });
}
}
}

View file

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

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 IcsExportService _icsService;

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,6 +10,7 @@ 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();
// Create application configuration
var appConfiguration = new ApplicationConfiguration
@ -15,6 +20,7 @@ var appConfiguration = new ApplicationConfiguration
};
// Register services
builder.Services.AddSingleton(authorizedEmails);
builder.Services.AddSingleton<IConfigurationService>(new ConfigurationService(appConfiguration));
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportApiService>();
@ -23,6 +29,60 @@ 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(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();

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

71
authentication-setup.md Normal file
View file

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