50f5307aac
PR Checks / test-and-build (pull_request) Successful in 5m47s
- Bump version to 2.8.0 across all versioned files - Fix AuthorizedSessionServiceTests for platform-agnostic identity - Update Razor Pages to use *ForCurrentUserAsync APIs - Add backward-compatible constructors to WebGameGroup/WebGroupManager - Make DiscordOAuthOptions properties non-required for config binding Bump version → 2.8.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
270 lines
8.5 KiB
C#
270 lines
8.5 KiB
C#
using GmRelay.Web;
|
|
using GmRelay.Web.Components;
|
|
using GmRelay.Web.Health;
|
|
using GmRelay.Web.Services;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Telegram.Bot;
|
|
using Npgsql;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Add Aspire service defaults
|
|
builder.AddServiceDefaults();
|
|
|
|
// Add HttpClient
|
|
builder.Services.AddHttpClient();
|
|
|
|
// Add HttpContextAccessor for platform-agnostic identity resolution
|
|
builder.Services.AddHttpContextAccessor();
|
|
|
|
// Add health checks
|
|
builder.Services.AddHealthChecks()
|
|
.AddCheck<NpgsqlHealthCheck>("npgsql");
|
|
|
|
// Add Data Protection
|
|
builder.Services.AddDataProtection()
|
|
.PersistKeysToFileSystem(new DirectoryInfo("/app/dataprotection-keys"));
|
|
|
|
// Add Npgsql
|
|
builder.AddNpgsqlDataSource("gmrelaydb");
|
|
|
|
// Add Services
|
|
builder.Services.AddSingleton<TelegramAuthService>();
|
|
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
|
builder.Services.AddSingleton<DiscordAuthService>();
|
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
|
builder.Services.AddScoped<CalendarSubscriptionService>();
|
|
|
|
// Add Bot Client
|
|
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
|
|
{
|
|
var config = sp.GetRequiredService<IConfiguration>();
|
|
var token = config["Telegram__BotToken"] ?? config["Telegram:BotToken"]
|
|
?? throw new InvalidOperationException("Telegram__BotToken is required.");
|
|
return new TelegramBotClient(token);
|
|
});
|
|
|
|
// Add Authentication with hardened cookie settings
|
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/login";
|
|
options.AccessDeniedPath = "/access-denied";
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
|
options.Cookie.SameSite = SameSiteMode.Strict;
|
|
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
|
options.SlidingExpiration = true;
|
|
});
|
|
|
|
builder.Services.AddAuthorization();
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
|
|
// Add services to the container.
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Configure the HTTP request pipeline.
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
app.UseHsts();
|
|
}
|
|
|
|
app.UseHttpsRedirection();
|
|
|
|
// Security headers middleware
|
|
app.Use(async (context, next) =>
|
|
{
|
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
|
context.Response.Headers["X-Frame-Options"] = "DENY";
|
|
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
|
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
|
|
await next();
|
|
});
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
app.MapStaticAssets();
|
|
app.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
// Health check endpoints
|
|
app.MapHealthChecks("/health", new HealthCheckOptions
|
|
{
|
|
ResponseWriter = async (context, report) =>
|
|
{
|
|
context.Response.ContentType = "application/json";
|
|
var response = new
|
|
{
|
|
status = report.Status == HealthStatus.Healthy ? "healthy" : "unhealthy",
|
|
timestamp = DateTimeOffset.UtcNow.ToString("O")
|
|
};
|
|
await context.Response.WriteAsJsonAsync(response);
|
|
}
|
|
});
|
|
|
|
app.MapHealthChecks("/alive", new HealthCheckOptions
|
|
{
|
|
Predicate = r => r.Tags.Contains("live")
|
|
});
|
|
|
|
// Endpoint to handle Telegram Login callback
|
|
app.MapGet("/auth/telegram", async (HttpContext context, TelegramAuthService authService) =>
|
|
{
|
|
if (authService.Verify(context.Request.Query, out var telegramId, out var name))
|
|
{
|
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
|
await context.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
CreateTelegramPrincipal(telegramId, name),
|
|
authProperties);
|
|
return Results.Redirect("/");
|
|
}
|
|
|
|
return Results.Redirect("/login?error=auth_failed");
|
|
});
|
|
|
|
app.MapPost("/auth/telegram-webapp", async (
|
|
HttpContext context,
|
|
TelegramAuthService authService,
|
|
TelegramWebAppAuthRequest request) =>
|
|
{
|
|
if (!authService.VerifyWebAppInitData(request.InitData, out var telegramId, out var name))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
|
await context.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
CreateTelegramPrincipal(telegramId, name),
|
|
authProperties);
|
|
|
|
return Results.Ok(new { redirectUrl = "/" });
|
|
}).DisableAntiforgery();
|
|
|
|
app.MapPost("/auth/telegram-login", async (
|
|
HttpContext context,
|
|
TelegramAuthService authService,
|
|
TelegramLoginPayload request) =>
|
|
{
|
|
if (!authService.VerifyLoginPayload(request, out var telegramId, out var name))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
|
await context.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
CreateTelegramPrincipal(telegramId, name),
|
|
authProperties);
|
|
|
|
return Results.Ok(new { redirectUrl = "/" });
|
|
}).DisableAntiforgery();
|
|
|
|
app.MapGet("/auth/status", (HttpContext context) =>
|
|
Results.Ok(new { authenticated = context.User.Identity?.IsAuthenticated == true }));
|
|
|
|
app.MapPost("/auth/logout", async (HttpContext context) =>
|
|
{
|
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Results.Redirect("/");
|
|
});
|
|
|
|
// Discord OAuth endpoints
|
|
app.MapGet("/auth/discord", (DiscordAuthService discordAuth) =>
|
|
{
|
|
var state = Guid.NewGuid().ToString("N");
|
|
var url = discordAuth.BuildAuthorizeUrl(state);
|
|
return Results.Redirect(url);
|
|
});
|
|
|
|
app.MapGet("/auth/discord/callback", async (
|
|
HttpContext context,
|
|
DiscordAuthService discordAuth,
|
|
ISessionStore sessionStore) =>
|
|
{
|
|
var code = context.Request.Query["code"].ToString();
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
return Results.Redirect("/login?error=auth_failed");
|
|
|
|
var user = await discordAuth.ExchangeCodeAsync(code);
|
|
if (user is null)
|
|
return Results.Redirect("/login?error=auth_failed");
|
|
|
|
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
|
|
|
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
|
await context.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl),
|
|
authProperties);
|
|
|
|
return Results.Redirect("/");
|
|
});
|
|
|
|
// Public calendar subscription endpoint (no auth required)
|
|
app.MapGet("/calendar/{token}.ics", async (
|
|
string token,
|
|
CalendarSubscriptionService service,
|
|
CancellationToken ct) =>
|
|
{
|
|
try
|
|
{
|
|
var ics = await service.GetIcsAsync(token, ct);
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(ics);
|
|
return Results.File(bytes, "text/calendar", "schedule.ics");
|
|
}
|
|
catch (SubscriptionNotFoundException)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
});
|
|
|
|
app.Run();
|
|
|
|
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
|
new(ClaimTypes.Name, name),
|
|
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
|
new("Platform", "Telegram")
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return new ClaimsPrincipal(claimsIdentity);
|
|
}
|
|
|
|
static ClaimsPrincipal CreateDiscordPrincipal(string discordId, string name, string? avatarUrl)
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, discordId),
|
|
new(ClaimTypes.Name, name),
|
|
new("DiscordId", discordId),
|
|
new("Platform", "Discord")
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(avatarUrl))
|
|
claims.Add(new Claim("AvatarUrl", avatarUrl));
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return new ClaimsPrincipal(claimsIdentity);
|
|
}
|
|
|
|
public sealed record TelegramWebAppAuthRequest(string InitData);
|