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:
parent
d5b123bbd9
commit
e8db13191e
17 changed files with 436 additions and 4 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
/* Top navigation buttons */
|
/* Top navigation buttons */
|
||||||
.top-nav-buttons {
|
.top-nav-buttons {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 60px;
|
||||||
right: 24px;
|
right: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
using ShiftScheduler.Client;
|
using ShiftScheduler.Client;
|
||||||
|
|
@ -7,5 +8,7 @@ builder.RootComponents.Add<App>("#app");
|
||||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||||
|
|
||||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|
|
||||||
55
Client/ServerAuthenticationStateProvider.cs
Normal file
55
Client/ServerAuthenticationStateProvider.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Client/Shared/AuthHeader.razor
Normal file
32
Client/Shared/AuthHeader.razor
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Client/Shared/AuthHeader.razor.css
Normal file
19
Client/Shared/AuthHeader.razor.css
Normal 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;
|
||||||
|
}
|
||||||
29
Client/Shared/LoginDisplay.razor
Normal file
29
Client/Shared/LoginDisplay.razor
Normal 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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Client/Shared/LoginDisplay.razor.css
Normal file
63
Client/Shared/LoginDisplay.razor.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,33 @@
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<main>
|
<main>
|
||||||
|
<CascadingAuthenticationState>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<AuthHeader Email="@GetEmailFromContext(context)" />
|
||||||
<article class="content px-4">
|
<article class="content px-4">
|
||||||
@Body
|
@Body
|
||||||
</article>
|
</article>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<LoginDisplay ErrorMessage="@GetErrorMessage()" />
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</CascadingAuthenticationState>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.8" />
|
<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" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.8" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using ShiftScheduler.Client
|
@using ShiftScheduler.Client
|
||||||
@using ShiftScheduler.Client.Shared
|
@using ShiftScheduler.Client.Shared
|
||||||
|
|
|
||||||
55
Server/Controllers/AuthController.cs
Normal file
55
Server/Controllers/AuthController.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ShiftScheduler.Services;
|
using ShiftScheduler.Services;
|
||||||
using ShiftScheduler.Shared;
|
using ShiftScheduler.Shared;
|
||||||
|
|
@ -7,6 +8,7 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
{
|
{
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(Policy = "AllowedEmails")]
|
||||||
public class ConfigurationController : ControllerBase
|
public class ConfigurationController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IConfigurationService _configurationService;
|
private readonly IConfigurationService _configurationService;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ShiftScheduler.Services;
|
using ShiftScheduler.Services;
|
||||||
using ShiftScheduler.Shared;
|
using ShiftScheduler.Shared;
|
||||||
|
|
@ -6,6 +7,7 @@ namespace ShiftScheduler.Server.Controllers
|
||||||
{
|
{
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(Policy = "AllowedEmails")]
|
||||||
public class ShiftController : ControllerBase
|
public class ShiftController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IcsExportService _icsService;
|
private readonly IcsExportService _icsService;
|
||||||
|
|
|
||||||
|
|
@ -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.Services;
|
||||||
using ShiftScheduler.Shared;
|
using ShiftScheduler.Shared;
|
||||||
|
|
||||||
|
|
@ -6,6 +10,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||||
// Load configurations from appsettings.json
|
// Load configurations from appsettings.json
|
||||||
var shifts = builder.Configuration.GetSection("Shifts").Get<List<Shift>>() ?? new();
|
var shifts = builder.Configuration.GetSection("Shifts").Get<List<Shift>>() ?? new();
|
||||||
var transportConfig = builder.Configuration.GetSection("Transport").Get<TransportConfiguration>() ?? new();
|
var transportConfig = builder.Configuration.GetSection("Transport").Get<TransportConfiguration>() ?? new();
|
||||||
|
var authorizedEmails = builder.Configuration.GetSection("Authentication:AuthorizedEmails").Get<List<string>>() ?? new();
|
||||||
|
|
||||||
// Create application configuration
|
// Create application configuration
|
||||||
var appConfiguration = new ApplicationConfiguration
|
var appConfiguration = new ApplicationConfiguration
|
||||||
|
|
@ -15,6 +20,7 @@ var appConfiguration = new ApplicationConfiguration
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
|
builder.Services.AddSingleton(authorizedEmails);
|
||||||
builder.Services.AddSingleton<IConfigurationService>(new ConfigurationService(appConfiguration));
|
builder.Services.AddSingleton<IConfigurationService>(new ConfigurationService(appConfiguration));
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddHttpClient<TransportApiService>();
|
builder.Services.AddHttpClient<TransportApiService>();
|
||||||
|
|
@ -23,6 +29,60 @@ builder.Services.AddSingleton<PdfExportService>();
|
||||||
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();
|
||||||
builder.Services.AddSingleton<ITransportService, TransportService>();
|
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.AddControllersWithViews();
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages();
|
||||||
|
|
||||||
|
|
@ -47,6 +107,9 @@ app.UseStaticFiles();
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|
||||||
app.MapRazorPages();
|
app.MapRazorPages();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "YOUR_GOOGLE_CLIENT_ID",
|
||||||
|
"ClientSecret": "YOUR_GOOGLE_CLIENT_SECRET"
|
||||||
|
},
|
||||||
|
"AuthorizedEmails": [
|
||||||
|
"example1@gmail.com",
|
||||||
|
"example2@gmail.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
"Transport": {
|
"Transport": {
|
||||||
"StartStation": "Zurich",
|
"StartStation": "Zurich",
|
||||||
"EndStation": "Basel",
|
"EndStation": "Basel",
|
||||||
|
|
|
||||||
71
authentication-setup.md
Normal file
71
authentication-setup.md
Normal 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
|
||||||
Loading…
Reference in a new issue