Add Google authentication with authorized email configuration
Co-authored-by: clayschaad <11883505+clayschaad@users.noreply.github.com>
This commit is contained in:
parent
1cae2c7ad2
commit
2443cc06a7
14 changed files with 350 additions and 3 deletions
|
|
@ -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("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>
|
||||||
<article class="content px-4">
|
<CascadingAuthenticationState>
|
||||||
@Body
|
<AuthorizeView>
|
||||||
</article>
|
<Authorized>
|
||||||
|
<AuthHeader Email="@GetEmailFromContext(context)" />
|
||||||
|
<article class="content px-4">
|
||||||
|
@Body
|
||||||
|
</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
|
||||||
|
|
|
||||||
68
Server/Controllers/AuthController.cs
Normal file
68
Server/Controllers/AuthController.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ShiftService _shiftService;
|
private readonly ShiftService _shiftService;
|
||||||
|
|
|
||||||
|
|
@ -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,10 +10,12 @@ 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();
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
builder.Services.AddSingleton(shifts);
|
builder.Services.AddSingleton(shifts);
|
||||||
builder.Services.AddSingleton(transportConfig);
|
builder.Services.AddSingleton(transportConfig);
|
||||||
|
builder.Services.AddSingleton(authorizedEmails);
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddHttpClient<TransportApiService>();
|
builder.Services.AddHttpClient<TransportApiService>();
|
||||||
builder.Services.AddSingleton<ShiftService>();
|
builder.Services.AddSingleton<ShiftService>();
|
||||||
|
|
@ -18,6 +24,35 @@ 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()
|
||||||
|
.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.AddControllersWithViews();
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages();
|
||||||
|
|
||||||
|
|
@ -42,6 +77,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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue