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.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("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>
|
||||
<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
|
||||
|
|
|
|||
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 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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue