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-nav-buttons {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: 60px;
|
||||
right: 24px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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