Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
+11 -4
View File
@@ -1,5 +1,6 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Security;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
@@ -8,8 +9,14 @@ public static class AdminDashboardEndpoints
{
public static RouteGroupBuilder MapAdminDashboardEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/dashboard", GetDashboard).WithName("GetAdminDashboard").WithOpenApi();
group.MapGet("/audit-entries", GetAuditEntries).WithName("GetAdminAuditEntries").WithOpenApi();
group.MapGet("/dashboard", GetDashboard)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Dashboard))
.WithName("GetAdminDashboard")
.WithOpenApi();
group.MapGet("/audit-entries", GetAuditEntries)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Audit))
.WithName("GetAdminAuditEntries")
.WithOpenApi();
return group;
}
@@ -24,7 +31,7 @@ public static class AdminDashboardEndpoints
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.Status == "pending");
var riskFlagCount = await db.RiskFlags.CountAsync(item => item.Status == "open");
var topCategoryNames = await db.VoteEntries
@@ -76,7 +83,7 @@ public static class AdminDashboardEndpoints
new AdminMetricDto("Nominierungen", nominationCount, "Gespeicherte Einreichungen im aktuellen Public-Jahr"),
new AdminMetricDto("Stimmen", voteCount, "Abgegebene Stimmen im aktuellen Public-Jahr"),
new AdminMetricDto("Kategorien", categoryCount, "Aktive Kategorien im aktuellen Public-Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext-Nominierungen mit Review-Bedarf"),
new AdminMetricDto("Reviews offen", reviewCount, "Offene Nominierungen mit Review-Bedarf"),
new AdminMetricDto("Risikohinweise", riskFlagCount, "Offene Risk Flags ueber alle Quellen"),
},
activityItems,
@@ -1,5 +1,6 @@
using Backend.Domain;
using Backend.Security;
using Backend.Data;
namespace Backend.Endpoints;
@@ -7,4 +8,54 @@ internal static class AdminEndpointConventions
{
public static UserSession CurrentSession(HttpContext context) =>
context.GetCurrentSession() ?? throw new InvalidOperationException("Admin session missing from request context.");
public static async ValueTask<object?> RequirePermission(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next,
string permissionKey)
{
var session = CurrentSession(context.HttpContext);
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
if (!await AdminPermissionCatalog.HasPermissionAsync(db, session.Role, permissionKey, context.HttpContext.RequestAborted))
{
return Results.Json(
new { message = $"Diese Admin-Aktion braucht die Berechtigung '{permissionKey}'." },
statusCode: StatusCodes.Status403Forbidden);
}
return await next(context);
}
public static async ValueTask<object?> RequireWritePermission(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next,
string permissionKey)
{
var session = CurrentSession(context.HttpContext);
if (AdminRoles.Normalize(session.Role) == AdminRoles.OrganizationTeam)
{
return Results.Json(
new { message = "Organisation Team hat fuer diesen Bereich nur Leserechte." },
statusCode: StatusCodes.Status403Forbidden);
}
return await RequirePermission(context, next, permissionKey);
}
public static async ValueTask<object?> RequireAnyPermission(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next,
string[] permissionKeys)
{
var session = CurrentSession(context.HttpContext);
var db = context.HttpContext.RequestServices.GetRequiredService<AwardsDbContext>();
if (!await AdminPermissionCatalog.HasAnyPermissionAsync(db, session.Role, permissionKeys, context.HttpContext.RequestAborted))
{
return Results.Json(
new { message = "Diese Admin-Aktion braucht eine passende Rollenberechtigung." },
statusCode: StatusCodes.Status403Forbidden);
}
return await next(context);
}
}
+4 -19
View File
@@ -11,26 +11,11 @@ public static class AdminEndpoints
group.MapAdminSiteSettingsEndpoints();
var managerGroup = group.MapGroup(string.Empty)
.AddEndpointFilter(RequireAdminWorkspaceRole);
managerGroup.MapAdminDashboardEndpoints();
managerGroup.MapAdminSeasonManagementEndpoints();
managerGroup.MapAdminModerationEndpoints();
group.MapAdminDashboardEndpoints();
group.MapAdminSeasonManagementEndpoints();
group.MapAdminModerationEndpoints();
group.MapAdminTeamEndpoints();
return app;
}
private static async ValueTask<object?> RequireAdminWorkspaceRole(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var session = AdminEndpointConventions.CurrentSession(context.HttpContext);
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
{
return Results.Json(new { message = "This admin area requires an admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
}
return await next(context);
}
}
+36 -9
View File
@@ -4,15 +4,42 @@ public static partial class AdminModerationEndpoints
{
public static RouteGroupBuilder MapAdminModerationEndpoints(this RouteGroupBuilder group)
{
group.MapDelete("/clips/{clipId:int}", DeleteClip).WithName("DeleteAdminClip").WithOpenApi();
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus).WithName("UpdateAdminClipStatus").WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination).WithName("ApproveAdminNomination").WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination).WithName("RejectAdminNomination").WithOpenApi();
group.MapGet("/risk-flags", GetRiskFlags).WithName("GetAdminRiskFlags").WithOpenApi();
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag).WithName("ResolveRiskFlag").WithOpenApi();
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags).WithName("BulkResolveRiskFlags").WithOpenApi();
group.MapGet("/risk-rules", GetRiskRules).WithName("GetAdminRiskRules").WithOpenApi();
group.MapPut("/risk-rules", UpdateRiskRules).WithName("UpdateAdminRiskRules").WithOpenApi();
group.MapDelete("/clips/{clipId:int}", DeleteClip)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
.WithName("DeleteAdminClip")
.WithOpenApi();
group.MapPost("/clips/{clipId:int}/status", UpdateClipStatus)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Clips))
.WithName("UpdateAdminClipStatus")
.WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/approve", ApproveNomination)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("ApproveAdminNomination")
.WithOpenApi();
group.MapPost("/nominations/{nominationId:int}/reject", RejectNomination)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Nominations))
.WithName("RejectAdminNomination")
.WithOpenApi();
group.MapGet("/risk-flags", GetRiskFlags)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("GetAdminRiskFlags")
.WithOpenApi();
group.MapPost("/risk-flags/{riskFlagId:int}/resolve", ResolveRiskFlag)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("ResolveRiskFlag")
.WithOpenApi();
group.MapPost("/risk-flags/bulk-resolve", BulkResolveRiskFlags)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("BulkResolveRiskFlags")
.WithOpenApi();
group.MapGet("/risk-rules", GetRiskRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("GetAdminRiskRules")
.WithOpenApi();
group.MapPut("/risk-rules", UpdateRiskRules)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Risk))
.WithName("UpdateAdminRiskRules")
.WithOpenApi();
return group;
}
}
@@ -26,9 +26,7 @@ public static partial class AdminModerationEndpoints
return Results.NotFound();
}
var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
? nomination.CandidateText
: request.DisplayName.Trim();
var rawDisplayName = request.DisplayName?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(rawDisplayName))
{
@@ -37,11 +35,19 @@ public static partial class AdminModerationEndpoints
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
var normalizedDisplayName = rawDisplayName.ToLower();
var normalizedChannelSlug = channelSlug.ToLower();
var normalizedPlatform = platform.ToLower();
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryId == nomination.CategoryId
&& item.DisplayName.ToLower() == rawDisplayName.ToLower());
&& (
item.DisplayName.ToLower() == normalizedDisplayName
|| (!string.IsNullOrWhiteSpace(normalizedChannelSlug)
&& item.ChannelSlug.ToLower() == normalizedChannelSlug
&& item.Platform.ToLower() == normalizedPlatform)
));
var candidate = existingCandidate;
if (candidate is null)
@@ -60,6 +66,8 @@ public static partial class AdminModerationEndpoints
}
else
{
candidate.DisplayName = rawDisplayName;
if (!string.IsNullOrWhiteSpace(channelSlug))
{
candidate.ChannelSlug = channelSlug;
@@ -262,11 +262,6 @@ public static partial class AdminModerationEndpoints
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanManageAdminWorkspace(session.Role))
{
return Results.Json(new { message = "Risk-Regeln koennen nur Admins oder Owner aendern." }, statusCode: StatusCodes.Status403Forbidden);
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
@@ -64,14 +64,14 @@ public static partial class AdminSeasonManagementEndpoints
var pendingNominations = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.Status == "pending" && item.CandidateText != null)
.Where(item => item.SeasonId == seasonId && item.Status == "pending")
.OrderByDescending(item => item.CreatedAt)
.Select(item => new AdminNominationReviewItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.SubmittedByTwitchId,
item.CandidateText!,
item.CandidateText ?? string.Empty,
item.StreamUrl,
item.Status,
item.CreatedAt,
@@ -4,19 +4,58 @@ public static partial class AdminSeasonManagementEndpoints
{
public static RouteGroupBuilder MapAdminSeasonManagementEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/seasons", GetSeasons).WithName("GetAdminSeasons").WithOpenApi();
group.MapPost("/seasons", CreateSeason).WithName("CreateAdminSeason").WithOpenApi();
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail).WithName("GetAdminSeasonDetail").WithOpenApi();
group.MapPut("/seasons/{seasonId:int}", UpdateSeason).WithName("UpdateAdminSeason").WithOpenApi();
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason).WithName("DeleteAdminSeason").WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory).WithName("CreateAdminCategory").WithOpenApi();
group.MapPut("/categories/{categoryId:int}", UpdateCategory).WithName("UpdateAdminCategory").WithOpenApi();
group.MapDelete("/categories/{categoryId:int}", DeleteCategory).WithName("DeleteAdminCategory").WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate).WithName("CreateAdminCandidate").WithOpenApi();
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate).WithName("UpdateAdminCandidate").WithOpenApi();
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate).WithName("DeleteAdminCandidate").WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/results", SetResult).WithName("SetAdminResult").WithOpenApi();
group.MapDelete("/results/{resultId:int}", DeleteResult).WithName("DeleteAdminResult").WithOpenApi();
group.MapGet("/seasons", GetSeasons)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
.WithName("GetAdminSeasons")
.WithOpenApi();
group.MapPost("/seasons", CreateSeason)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
.WithName("CreateAdminSeason")
.WithOpenApi();
group.MapGet("/seasons/{seasonId:int}", GetSeasonDetail)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireAnyPermission(context, next, Backend.Security.AdminPermissionCatalog.SeasonReadPermissionKeys))
.WithName("GetAdminSeasonDetail")
.WithOpenApi();
group.MapPut("/seasons/{seasonId:int}", UpdateSeason)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
.WithName("UpdateAdminSeason")
.WithOpenApi();
group.MapDelete("/seasons/{seasonId:int}", DeleteSeason)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Years))
.WithName("DeleteAdminSeason")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/categories", CreateCategory)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("CreateAdminCategory")
.WithOpenApi();
group.MapPut("/categories/{categoryId:int}", UpdateCategory)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("UpdateAdminCategory")
.WithOpenApi();
group.MapDelete("/categories/{categoryId:int}", DeleteCategory)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Categories))
.WithName("DeleteAdminCategory")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/candidates", CreateCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("CreateAdminCandidate")
.WithOpenApi();
group.MapPut("/candidates/{candidateId:int}", UpdateCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("UpdateAdminCandidate")
.WithOpenApi();
group.MapDelete("/candidates/{candidateId:int}", DeleteCandidate)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Candidates))
.WithName("DeleteAdminCandidate")
.WithOpenApi();
group.MapPost("/seasons/{seasonId:int}/results", SetResult)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("SetAdminResult")
.WithOpenApi();
group.MapDelete("/results/{resultId:int}", DeleteResult)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, Backend.Security.AdminPermissionCatalog.Winners))
.WithName("DeleteAdminResult")
.WithOpenApi();
return group;
}
}
@@ -29,7 +29,7 @@ public static partial class AdminSeasonManagementEndpoints
if (!IsKnownSeasonPhase(request.CurrentPhase))
{
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
}
if (!SeasonMappings.IsSeasonScheduleValid(
@@ -61,7 +61,7 @@ public static partial class AdminSeasonManagementEndpoints
if (!IsKnownSeasonPhase(request.CurrentPhase))
{
return Results.BadRequest(new { message = "Current phase must be nomination, voting, review, show, or completed." });
return Results.BadRequest(new { message = "Current phase must be nomination, voting, preparation, show, or completed." });
}
if (!SeasonMappings.IsSeasonScheduleValid(
@@ -92,6 +92,9 @@ public static partial class AdminSeasonManagementEndpoints
|| value.Contains("archiv")
|| value.Contains("complete")
|| value.Contains("ended")
|| value.Contains("aufbereit")
|| value.Contains("vorbereit")
|| value.Contains("pause")
|| value.Contains("review")
|| value.Contains("auswert")
|| value.Contains("vot")
@@ -70,7 +70,7 @@ public static partial class AdminSeasonManagementEndpoints
season.ShowDate = request.ShowDate;
season.ShowStartsAt = request.ShowStartsAt;
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent && !wasCurrent, seasonId, context.RequestAborted);
await UnsetOtherCurrentSeasonsAsync(db, request.IsCurrent, seasonId, context.RequestAborted);
season.IsCurrent = request.IsCurrent;
var actionType = "season.update";
+138 -15
View File
@@ -16,10 +16,22 @@ public static class AdminSiteSettingsEndpoints
public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/site-settings", GetSiteSettings).WithName("GetAdminSiteSettings").WithOpenApi();
group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi();
group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi();
group.MapPut("/operational-settings", UpdateOperationalSettings).WithName("UpdateAdminOperationalSettings").WithOpenApi();
group.MapGet("/site-settings", GetSiteSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Content))
.WithName("GetAdminSiteSettings")
.WithOpenApi();
group.MapPut("/site-settings", UpdateSiteSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content))
.WithName("UpdateAdminSiteSettings")
.WithOpenApi();
group.MapGet("/operational-settings", GetOperationalSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("GetAdminOperationalSettings")
.WithOpenApi();
group.MapPut("/operational-settings", UpdateOperationalSettings)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
.WithName("UpdateAdminOperationalSettings")
.WithOpenApi();
return group;
}
@@ -40,8 +52,11 @@ public static class AdminSiteSettingsEndpoints
settings.PrivacyPolicyUpdatedBy,
settings.PrivacyPolicyUpdatedAt,
settings.ImprintUrl,
settings.ImprintContent,
settings.ContactUrl,
settings.ContactContent,
settings.SponsorsUrl,
settings.SponsorsContent,
SeasonMappings.ReadSocialLinks(settings),
SeasonMappings.ReadFaqItems(settings)));
}
@@ -53,10 +68,6 @@ public static class AdminSiteSettingsEndpoints
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanManageContent(session.Role))
{
return Results.Json(new { message = "Landingpage content requires a content admin, admin or owner role." }, statusCode: StatusCodes.Status403Forbidden);
}
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
@@ -78,8 +89,11 @@ public static class AdminSiteSettingsEndpoints
}
settings.ImprintUrl = request.ImprintUrl.Trim();
settings.ImprintContent = request.ImprintContent.Trim();
settings.ContactUrl = request.ContactUrl.Trim();
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = request.SponsorsUrl.Trim();
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
@@ -111,6 +125,7 @@ public static class AdminSiteSettingsEndpoints
}
var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings);
var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration);
return Results.Ok(new AdminOperationalSettingsResponse(
usesDatabaseDemo,
usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration),
@@ -118,6 +133,12 @@ public static class AdminSiteSettingsEndpoints
usesDatabaseDemo ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")),
usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"),
usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"),
settings.TwitchAuthManagedByDatabase,
twitchSettings.Configured,
twitchSettings.ClientId,
twitchSettings.ClientSecretSet,
twitchSettings.RedirectUri,
twitchSettings.Scope,
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage)
@@ -144,12 +165,18 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var before = CreateOperationalSettingsSnapshot(settings);
var before = CreateOperationalSettingsSnapshot(settings, configuration);
var loginIdentifier = request.DemoLoginEmail.Trim();
var twitchUserId = request.DemoLoginTwitchUserId.Trim();
var displayName = request.DemoLoginDisplayName.Trim();
var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty;
var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD");
var twitchClientId = request.TwitchClientId.Trim();
var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty;
var twitchRedirectUri = request.TwitchRedirectUri.Trim();
var twitchScope = request.TwitchScope.Trim();
var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
if (request.DemoLoginEnabled)
{
@@ -168,9 +195,22 @@ public static class AdminSiteSettingsEndpoints
}
}
if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 12)
if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 10)
{
return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 12 Zeichen lang sein." });
return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 10 Zeichen lang sein." });
}
if (HasAnyTwitchAuthSetting(twitchClientId, twitchClientSecret, twitchRedirectUri, twitchScope)
&& string.IsNullOrWhiteSpace(twitchClientId))
{
return Results.BadRequest(new { message = "Twitch OAuth braucht eine Client-ID." });
}
if (!string.IsNullOrWhiteSpace(twitchClientId)
&& string.IsNullOrWhiteSpace(twitchClientSecret)
&& !existingTwitchSecretAvailable)
{
return Results.BadRequest(new { message = "Twitch OAuth braucht beim ersten Speichern ein Client Secret." });
}
settings.DemoLoginManagedByDatabase = true;
@@ -178,6 +218,22 @@ public static class AdminSiteSettingsEndpoints
settings.DemoLoginEmail = loginIdentifier;
settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId;
settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName;
settings.TwitchClientId = twitchClientId;
settings.TwitchRedirectUri = twitchRedirectUri;
settings.TwitchScope = twitchScope;
if (!string.IsNullOrWhiteSpace(twitchClientSecret))
{
settings.TwitchClientSecret = twitchClientSecret;
}
else if (string.IsNullOrWhiteSpace(twitchClientId) && string.IsNullOrWhiteSpace(twitchRedirectUri) && string.IsNullOrWhiteSpace(twitchScope))
{
settings.TwitchClientSecret = string.Empty;
}
settings.TwitchAuthManagedByDatabase = HasAnyTwitchAuthSetting(
settings.TwitchClientId,
settings.TwitchClientSecret,
settings.TwitchRedirectUri,
settings.TwitchScope);
var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword)
? newPassword
@@ -201,8 +257,9 @@ public static class AdminSiteSettingsEndpoints
var changes = BuildOperationalSettingChanges(
before,
CreateOperationalSettingsSnapshot(settings),
!string.IsNullOrWhiteSpace(passwordToPersist));
CreateOperationalSettingsSnapshot(settings, configuration),
!string.IsNullOrWhiteSpace(passwordToPersist),
!string.IsNullOrWhiteSpace(twitchClientSecret));
adminAuditService.AddEntry(
session.TwitchUserId,
@@ -225,6 +282,7 @@ public static class AdminSiteSettingsEndpoints
{
saved = true,
demoLoginPasswordSet = HasDatabaseDemoCredentials(settings),
twitchClientSecretSet = HasEffectiveTwitchClientSecret(settings, configuration),
});
}
@@ -250,6 +308,9 @@ public static class AdminSiteSettingsEndpoints
private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) =>
configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty;
private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) =>
configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty;
private static string ReadDemoLoginIdentifier(IConfiguration configuration)
{
var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN");
@@ -263,7 +324,35 @@ public static class AdminSiteSettingsEndpoints
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash)
&& !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt);
private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings) =>
private static bool HasAnyTwitchAuthSetting(params string[] values) =>
values.Any(value => !string.IsNullOrWhiteSpace(value));
private static bool HasEffectiveTwitchClientSecret(SiteSettings settings, IConfiguration configuration) =>
!string.IsNullOrWhiteSpace(settings.TwitchClientSecret)
|| !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"));
private static TwitchSettingsSnapshot ReadEffectiveTwitchSettings(SiteSettings settings, IConfiguration configuration)
{
var clientId = !string.IsNullOrWhiteSpace(settings.TwitchClientId)
? settings.TwitchClientId
: ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID");
var redirectUri = !string.IsNullOrWhiteSpace(settings.TwitchRedirectUri)
? settings.TwitchRedirectUri
: ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI");
var scope = !string.IsNullOrWhiteSpace(settings.TwitchScope)
? settings.TwitchScope
: ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE");
var secretSet = HasEffectiveTwitchClientSecret(settings, configuration);
return new(
clientId,
secretSet,
redirectUri,
scope,
!string.IsNullOrWhiteSpace(clientId) && secretSet);
}
private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings, IConfiguration configuration) =>
new(
settings.DemoLoginManagedByDatabase,
settings.DemoLoginEnabled,
@@ -271,6 +360,11 @@ public static class AdminSiteSettingsEndpoints
HasDatabaseDemoCredentials(settings),
settings.DemoLoginTwitchUserId,
settings.DemoLoginDisplayName,
settings.TwitchAuthManagedByDatabase,
settings.TwitchClientId,
HasEffectiveTwitchClientSecret(settings, configuration),
settings.TwitchRedirectUri,
settings.TwitchScope,
settings.MaintenanceModeEnabled,
string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle,
string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage);
@@ -278,7 +372,8 @@ public static class AdminSiteSettingsEndpoints
private static object[] BuildOperationalSettingChanges(
OperationalSettingsSnapshot before,
OperationalSettingsSnapshot after,
bool passwordChanged)
bool passwordChanged,
bool twitchClientSecretChanged)
{
var changes = new List<object>();
@@ -287,6 +382,22 @@ public static class AdminSiteSettingsEndpoints
AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail);
AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId);
AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName);
AddOperationalChange(changes, "twitchAuthManagedByDatabase", "Twitch OAuth Quelle", before.TwitchAuthManagedByDatabase, after.TwitchAuthManagedByDatabase);
AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId);
AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri);
AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope);
if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged)
{
changes.Add(new
{
field = "twitchClientSecret",
label = "Twitch Client Secret",
@from = before.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt",
to = twitchClientSecretChanged ? "neu gesetzt" : after.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt",
sensitive = true,
});
}
if (passwordChanged)
{
@@ -347,7 +458,19 @@ public static class AdminSiteSettingsEndpoints
bool DemoLoginPasswordSet,
string DemoLoginTwitchUserId,
string DemoLoginDisplayName,
bool TwitchAuthManagedByDatabase,
string TwitchClientId,
bool TwitchClientSecretSet,
string TwitchRedirectUri,
string TwitchScope,
bool MaintenanceModeEnabled,
string MaintenanceTitle,
string MaintenanceMessage);
private sealed record TwitchSettingsSnapshot(
string ClientId,
bool ClientSecretSet,
string RedirectUri,
string Scope,
bool Configured);
}
+562
View File
@@ -0,0 +1,562 @@
using System.Security.Cryptography;
using System.Text.Json;
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static class AdminTeamEndpoints
{
private const int MinPasswordLength = 10;
private const string PersonalOwnerLogin = "jayuhime";
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
private static readonly AdminTeamPermissionDto[] PermissionCatalog =
[
new(AdminPermissionCatalog.Dashboard, "Dashboard", "Live-Lage, Aufgaben und Checks sehen.", "/admin/dashboard", true),
new(AdminPermissionCatalog.Years, "Jahre", "Award-Jahre anlegen und pflegen.", "/admin/years", false),
new(AdminPermissionCatalog.Nominations, "Nominierungen", "Nominierungen prüfen und entscheiden.", "/admin/nominations", false),
new(AdminPermissionCatalog.Categories, "Kategorien", "Kategorien und Limits verwalten.", "/admin/categories", false),
new(AdminPermissionCatalog.Candidates, "Kandidaten", "Kandidatenbasis bearbeiten.", "/admin/candidates", false),
new(AdminPermissionCatalog.Clips, "Clips", "Clip-Einreichungen prüfen.", "/admin/clips", false),
new(AdminPermissionCatalog.Risk, "Risiko", "Flags, Regeln und Moderationsrisiken sehen.", "/admin/risk", true),
new(AdminPermissionCatalog.Audit, "Audit-Log", "Admin-Aktionen nachvollziehen.", "/admin/users-logs", true),
new(AdminPermissionCatalog.Analytics, "Analytics", "Metriken und Rankings lesen.", "/admin/analytics", true),
new(AdminPermissionCatalog.Winners, "Gewinner", "Finale Ergebnisse pflegen und freigeben.", "/admin/winners", false),
new(AdminPermissionCatalog.Content, "Landingpage", "FAQ, Links, Datenschutz und öffentliche Inhalte pflegen.", "/admin/content", false),
new(AdminPermissionCatalog.Settings, "Einstellungen", "Systemchecks, Demo-Zugang und Wartung sehen.", "/admin/settings", true),
new(AdminPermissionCatalog.Team, "Team", "Mitglieder, Rollen und Berechtigungen verwalten.", "/admin/team", false),
];
private static readonly AdminTeamRoleDto[] DefaultRoles =
[
new(AdminRoles.Owner, "Owner", "Jayuhime: vollständige Kontrolle inklusive Passwortreset und Betriebseinstellungen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Owner)),
new(AdminRoles.Creator, "Creator", "Persönliche Jayuhime-Rolle mit denselben Rechten wie Owner.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Creator)),
new(AdminRoles.Admin, "Admins", "Operatives Kernteam mit Schreibrechten in allen Award-Bereichen.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Admin)),
new(AdminRoles.Member, "Mitglied", "Internes Team für Pflege, Nominierungen und Clip-Arbeit.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Member)),
new(AdminRoles.Reviewer, "Reviewer", "Review-Fokus für Nominierungen, Clips und Risiko-Hinweise.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.Reviewer)),
new(AdminRoles.OrganizationTeam, "Organisation Team", "Externe Personen mit Read-only-Sicht auf ausgewählte Bereiche.", true, AdminPermissionCatalog.DefaultPermissionKeys(AdminRoles.OrganizationTeam)),
];
public static RouteGroupBuilder MapAdminTeamEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/team", GetTeam)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Team))
.WithName("GetAdminTeam")
.WithOpenApi();
group.MapPost("/team/members", CreateMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("CreateAdminTeamMember")
.WithOpenApi();
group.MapPut("/team/members/{memberId:int}", UpdateMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("UpdateAdminTeamMember")
.WithOpenApi();
group.MapPost("/team/members/{memberId:int}/reset-password", ResetPassword)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("ResetAdminTeamMemberPassword")
.WithOpenApi();
group.MapDelete("/team/members/{memberId:int}", DeleteMember)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("DeleteAdminTeamMember")
.WithOpenApi();
group.MapPut("/team/roles", UpdateRoles)
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Team))
.WithName("UpdateAdminTeamRoles")
.WithOpenApi();
return group;
}
private static async Task<IResult> GetTeam(AwardsDbContext db)
{
var members = await db.TeamMembers
.AsNoTracking()
.OrderByDescending(item => item.Role == AdminRoles.Owner)
.ThenBy(item => item.Role)
.ThenBy(item => item.DisplayName)
.Select(item => ToMemberDto(item))
.ToArrayAsync();
return Results.Ok(new AdminTeamResponse(
members,
await BuildRoleDtosAsync(db),
PermissionCatalog));
}
private static async Task<IResult> CreateMember(
HttpContext context,
AwardsDbContext db,
CreateTeamMemberRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var normalizedLogin = NormalizeLogin(request.Login);
var normalizedRole = AdminRoles.Normalize(request.Role);
var displayName = NormalizeDisplayName(request.DisplayName);
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
if (validationError is not null)
{
return validationError;
}
if (AdminRoles.IsPrivilegedFullControlRole(normalizedRole) && !AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator angelegt werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, null, normalizedLogin, normalizedRole, context.RequestAborted);
if (privilegedValidationError is not null)
{
return privilegedValidationError;
}
if (await db.TeamMembers.AnyAsync(item => item.Login == normalizedLogin, context.RequestAborted))
{
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
}
var generatedPassword = GeneratePassword();
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
var member = new TeamMember
{
Login = normalizedLogin,
DisplayName = displayName,
Role = normalizedRole,
PasswordHash = credentials.Hash,
PasswordSalt = credentials.Salt,
MustChangePassword = true,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
CreatedByTwitchId = session.TwitchUserId,
};
db.TeamMembers.Add(member);
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.create",
"team-member",
normalizedLogin,
$"Team-Login {displayName} wurde angelegt.",
new { member.Login, member.DisplayName, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
}
private static async Task<IResult> UpdateMember(
int memberId,
HttpContext context,
AwardsDbContext db,
UpdateTeamMemberRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
var normalizedLogin = NormalizeLogin(request.Login);
var normalizedRole = AdminRoles.Normalize(request.Role);
var displayName = NormalizeDisplayName(request.DisplayName);
var validationError = ValidateMemberFields(normalizedLogin, displayName, normalizedRole);
if (validationError is not null)
{
return validationError;
}
var loginChanged = !string.Equals(member.Login, normalizedLogin, StringComparison.OrdinalIgnoreCase);
if (loginChanged && !AdminRoles.CanDeleteTeamMembers(session.Role))
{
return Results.Json(new { message = "Logins können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
}
if (loginChanged && await db.TeamMembers.AnyAsync(item => item.Id != member.Id && item.Login == normalizedLogin, context.RequestAborted))
{
return Results.Conflict(new { message = "Dieser Login existiert bereits." });
}
if ((AdminRoles.IsPrivilegedFullControlRole(member.Role) || AdminRoles.IsPrivilegedFullControlRole(normalizedRole)) && !AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Owner- und Creator-Konten können nur von Owner oder Creator bearbeitet werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var previousLogin = member.Login;
var privilegedValidationError = await ValidatePrivilegedMemberChangeAsync(db, member.Id, normalizedLogin, normalizedRole, context.RequestAborted);
if (privilegedValidationError is not null)
{
return privilegedValidationError;
}
member.Login = normalizedLogin;
member.DisplayName = displayName;
member.Role = normalizedRole;
member.IsActive = request.IsActive;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.update",
"team-member",
member.Id.ToString(),
$"Team-Login {member.DisplayName} wurde aktualisiert.",
new { previousLogin, member.Login, member.DisplayName, member.Role, member.IsActive },
RequestMetadataReader.Read(context));
if (!member.IsActive)
{
DeactivateMemberSessions(db, member, previousLogin);
}
else
{
await SyncMemberSessionRolesAsync(db, member, previousLogin, context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, member = ToMemberDto(member) });
}
private static async Task<IResult> ResetPassword(
int memberId,
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanResetTeamPasswords(session.Role))
{
return Results.Json(new { message = "Passwort-Reset ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
var generatedPassword = GeneratePassword();
var credentials = DemoCredentialHasher.HashPassword(generatedPassword);
member.PasswordHash = credentials.Hash;
member.PasswordSalt = credentials.Salt;
member.MustChangePassword = true;
member.PasswordResetAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.password-reset",
"team-member",
member.Id.ToString(),
$"Passwort für {member.DisplayName} wurde zurückgesetzt.",
new { member.Login, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TeamMemberPasswordResponse(true, member.Id, generatedPassword, member.MustChangePassword));
}
private static async Task<IResult> DeleteMember(
int memberId,
HttpContext context,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.CanDeleteTeamMembers(session.Role))
{
return Results.Json(new { message = "Mitglieder löschen ist Owner und Creator vorbehalten." }, statusCode: StatusCodes.Status403Forbidden);
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Id == memberId, context.RequestAborted);
if (member is null)
{
return Results.NotFound();
}
if (AdminRoles.IsPrivilegedFullControlRole(member.Role) && !CanDeletePrivilegedMember(session, member))
{
return Results.Json(new { message = "Creator darf Owner-Konten löschen. Creator-Konten können nur vom eigenen Creator-Account gelöscht werden." }, statusCode: StatusCodes.Status403Forbidden);
}
DeactivateMemberSessions(db, member);
db.TeamMembers.Remove(member);
adminAuditService.AddEntry(
session.TwitchUserId,
"team.member.delete",
"team-member",
member.Id.ToString(),
$"Team-Login {member.DisplayName} wurde gelöscht.",
new { member.Login, member.DisplayName, member.Role },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new DeleteTeamMemberResponse(true, memberId));
}
private static async Task<IResult> UpdateRoles(
HttpContext context,
AwardsDbContext db,
UpdateTeamRolesRequest request,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
foreach (var roleUpdate in request.Roles ?? [])
{
var role = AdminRoles.Normalize(roleUpdate.Key);
if (!allowedRoles.Contains(role))
{
return Results.BadRequest(new { message = $"Unbekannte Rolle: {roleUpdate.Key}" });
}
var permissionKeys = (roleUpdate.PermissionKeys ?? [])
.Select(item => item.Trim())
.Where(item => allowedPermissions.Contains(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
{
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
}
if (!existingRows.TryGetValue(role, out var row))
{
row = new TeamRolePermission { Role = role };
db.TeamRolePermissions.Add(row);
}
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
row.UpdatedAt = DateTimeOffset.UtcNow;
row.UpdatedByTwitchId = session.TwitchUserId;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"team.roles.update",
"team-role",
"matrix",
"Team-Rollenberechtigungen wurden gespeichert.",
new { roleCount = request.Roles?.Length ?? 0 },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, roles = await BuildRoleDtosAsync(db) });
}
public static async Task<AdminTeamRoleDto[]> BuildRoleDtosAsync(AwardsDbContext db)
{
var overrides = await db.TeamRolePermissions
.AsNoTracking()
.ToDictionaryAsync(item => item.Role, item => item.PermissionsJson);
return DefaultRoles
.Select(role =>
{
var permissions = overrides.TryGetValue(role.Key, out var json)
? ReadPermissionKeys(json, role.PermissionKeys)
: role.PermissionKeys;
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
{
permissions = AdminPermissionCatalog.AllPermissionKeys;
}
return role with { PermissionKeys = permissions };
})
.ToArray();
}
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
new(
member.Id,
member.Login,
member.DisplayName,
member.Role,
member.BoundTwitchUserId,
member.BoundTwitchDisplayName,
member.IsActive,
member.MustChangePassword,
member.CreatedAt,
member.UpdatedAt,
member.LastLoginAt,
member.TwitchBoundAt,
member.PasswordResetAt);
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
{
try
{
return JsonSerializer.Deserialize<string[]>(json) ?? fallback;
}
catch (JsonException)
{
return fallback;
}
}
private static IResult? ValidateMemberFields(string login, string displayName, string role)
{
if (string.IsNullOrWhiteSpace(login) || login.Length > 80)
{
return Results.BadRequest(new { message = "Login ist erforderlich und darf maximal 80 Zeichen haben." });
}
if (!login.All(value => char.IsLetterOrDigit(value) || value is '_' or '-' or '.'))
{
return Results.BadRequest(new { message = "Login darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten." });
}
if (string.IsNullOrWhiteSpace(displayName) || displayName.Length > 120)
{
return Results.BadRequest(new { message = "Anzeigename ist erforderlich und darf maximal 120 Zeichen haben." });
}
if (!AdminRoles.IsKnownRole(role) || role == AdminRoles.Viewer || role == AdminRoles.ContentAdmin)
{
return Results.BadRequest(new { message = "Bitte wähle Owner, Creator, Admins, Mitglied, Reviewer oder Organisation Team." });
}
return null;
}
private static async Task<IResult?> ValidatePrivilegedMemberChangeAsync(
AwardsDbContext db,
int? memberId,
string login,
string role,
CancellationToken cancellationToken)
{
if (role == AdminRoles.Owner)
{
if (!string.Equals(login, PersonalOwnerLogin, StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = $"Die Owner-Rolle ist nur für @{PersonalOwnerLogin} vorgesehen." });
}
var ownerExists = await db.TeamMembers.AnyAsync(
item => item.Role == AdminRoles.Owner && (!memberId.HasValue || item.Id != memberId.Value),
cancellationToken);
return ownerExists
? Results.Conflict(new { message = "Es darf nur einen Owner-Account geben." })
: null;
}
if (role != AdminRoles.Creator)
{
return null;
}
if (!PersonalCreatorLogins.Contains(login, StringComparer.OrdinalIgnoreCase))
{
return Results.BadRequest(new { message = $"Die Creator-Rolle ist nur für @{string.Join(" oder @", PersonalCreatorLogins)} vorgesehen." });
}
var creatorExists = await db.TeamMembers.AnyAsync(
item => item.Role == AdminRoles.Creator && (!memberId.HasValue || item.Id != memberId.Value),
cancellationToken);
return creatorExists
? Results.Conflict(new { message = "Es darf nur einen Creator-Account geben." })
: null;
}
private static void DeactivateMemberSessions(AwardsDbContext db, TeamMember member, string? previousLogin = null)
{
var teamSessionId = $"team:{member.Login}";
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
var twitchUserId = member.BoundTwitchUserId;
foreach (var session in db.UserSessions.Where(item =>
item.TwitchUserId == teamSessionId
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
{
session.IsActive = false;
}
}
private static bool IsOwnTeamAccount(UserSession session, TeamMember member)
{
var teamLogin = AuthEndpoints.ReadTeamLoginFromSession(session.TwitchUserId);
if (!string.IsNullOrWhiteSpace(teamLogin)
&& string.Equals(teamLogin, member.Login, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.IsNullOrWhiteSpace(member.BoundTwitchUserId)
&& string.Equals(session.TwitchUserId, member.BoundTwitchUserId, StringComparison.OrdinalIgnoreCase);
}
private static bool CanDeletePrivilegedMember(UserSession session, TeamMember member)
{
if (IsOwnTeamAccount(session, member))
{
return true;
}
return AdminRoles.Normalize(session.Role) == AdminRoles.Creator
&& AdminRoles.Normalize(member.Role) == AdminRoles.Owner;
}
private static async Task SyncMemberSessionRolesAsync(AwardsDbContext db, TeamMember member, string? previousLogin, CancellationToken cancellationToken)
{
var teamSessionId = $"team:{member.Login}";
var previousTeamSessionId = string.IsNullOrWhiteSpace(previousLogin) ? string.Empty : $"team:{NormalizeLogin(previousLogin)}";
var twitchUserId = member.BoundTwitchUserId;
var sessions = await db.UserSessions
.Where(item => item.IsActive
&& (item.TwitchUserId == teamSessionId
|| (!string.IsNullOrWhiteSpace(previousTeamSessionId) && item.TwitchUserId == previousTeamSessionId)
|| (!string.IsNullOrWhiteSpace(twitchUserId) && item.TwitchUserId == twitchUserId)))
.ToArrayAsync(cancellationToken);
foreach (var session in sessions)
{
if (session.TwitchUserId == previousTeamSessionId)
{
session.TwitchUserId = teamSessionId;
}
session.DisplayName = member.DisplayName;
session.Role = AdminRoles.Normalize(member.Role);
}
}
private static string NormalizeLogin(string value) =>
value.Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeDisplayName(string value) =>
value.Trim();
private static string GeneratePassword()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!?#%";
Span<char> chars = stackalloc char[MinPasswordLength];
Span<byte> bytes = stackalloc byte[MinPasswordLength];
RandomNumberGenerator.Fill(bytes);
for (var index = 0; index < chars.Length; index++)
{
chars[index] = alphabet[bytes[index] % alphabet.Length];
}
return new string(chars);
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ public static partial class AuthEndpoints
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(ToAuthSessionDto(session));
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static bool IsDemoLoginEnabled(IConfiguration configuration)
@@ -96,6 +96,6 @@ public static partial class AuthEndpoints
await db.SaveChangesAsync(context.RequestAborted);
}
return Results.Ok(ToAuthSessionDto(session));
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
}
+30
View File
@@ -18,6 +18,36 @@ public static partial class AuthEndpoints
.WithName("DemoLogin")
.WithOpenApi();
group.MapPost("/team-login", TeamLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("TeamLogin")
.WithOpenApi();
group.MapPost("/team-twitch-login", TeamTwitchLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("TeamTwitchLogin")
.WithOpenApi();
group.MapPost("/password/change", ChangePassword)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("ChangePassword")
.WithOpenApi();
group.MapPost("/team/twitch-binding", BindTeamTwitch)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("BindTeamTwitch")
.WithOpenApi();
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("StartTwitchAuthorization")
.WithOpenApi();
group.MapGet("/twitch/callback", CompleteTwitchAuthorization)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("CompleteTwitchAuthorization")
.WithOpenApi();
group.MapGet("/session", GetSession)
.WithName("GetSession")
.WithOpenApi();
+25 -6
View File
@@ -1,12 +1,17 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private static async Task<IResult> GetSession(HttpContext context, IUserSessionService userSessionService)
private static async Task<IResult> GetSession(
HttpContext context,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
@@ -14,7 +19,7 @@ public static partial class AuthEndpoints
return Results.Unauthorized();
}
return Results.Ok(ToAuthSessionDto(session));
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static async Task<IResult> Logout(HttpContext context, IUserSessionService userSessionService)
@@ -29,10 +34,24 @@ public static partial class AuthEndpoints
return Results.Ok(new { loggedOut = true });
}
private static AuthSessionDto ToAuthSessionDto(UserSession session) =>
new(
private static async Task<AuthSessionDto> ToAuthSessionDtoAsync(
AwardsDbContext db,
UserSession session,
bool mustChangePassword = false,
CancellationToken cancellationToken = default)
{
var teamMember = await FindTeamMemberForSessionAsync(db, session, cancellationToken);
var sessionRole = teamMember?.Role ?? session.Role;
var permissionKeys = await AdminPermissionCatalog.GetPermissionKeysAsync(db, sessionRole, cancellationToken);
return new(
session.SessionToken,
session.TwitchUserId,
session.DisplayName,
session.Role);
teamMember?.DisplayName ?? session.DisplayName,
AdminRoles.Normalize(sessionRole),
permissionKeys,
teamMember?.MustChangePassword ?? mustChangePassword,
teamMember?.Login,
teamMember?.BoundTwitchUserId,
teamMember?.BoundTwitchDisplayName);
}
}
+221
View File
@@ -0,0 +1,221 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private const string TeamSessionPrefix = "team:";
private const int MinTeamPasswordLength = 10;
private static async Task<IResult> TeamLogin(
HttpContext context,
TeamLoginRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var login = NormalizeTeamLogin(request.Login);
var password = request.Password ?? string.Empty;
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
{
return Results.Unauthorized();
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
if (member is null
|| !member.IsActive
|| !DemoCredentialHasher.VerifyPassword(password, member.PasswordHash, member.PasswordSalt))
{
return Results.Unauthorized();
}
member.LastLoginAt = DateTimeOffset.UtcNow;
var session = await userSessionService.CreateSessionAsync(
BuildTeamSessionId(member.Login),
member.DisplayName,
member.Role,
RequestMetadataReader.Read(context),
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> TeamTwitchLogin(
HttpContext context,
TeamTwitchLoginRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.Unauthorized();
}
var member = await db.TeamMembers.FirstOrDefaultAsync(
item => item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.Unauthorized();
}
member.LastLoginAt = DateTimeOffset.UtcNow;
var session = await userSessionService.CreateSessionAsync(
twitchUserId,
member.DisplayName,
member.Role,
RequestMetadataReader.Read(context),
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> ChangePassword(
HttpContext context,
ChangePasswordRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.BadRequest(new { message = "Dieser Account nutzt keinen aktiven Team-Login." });
}
var currentPassword = request.CurrentPassword ?? string.Empty;
var newPassword = request.NewPassword?.Trim() ?? string.Empty;
if (!DemoCredentialHasher.VerifyPassword(currentPassword, member.PasswordHash, member.PasswordSalt))
{
return Results.BadRequest(new { message = "Das aktuelle Passwort stimmt nicht." });
}
if (newPassword.Length < MinTeamPasswordLength)
{
return Results.BadRequest(new { message = $"Das neue Passwort muss mindestens {MinTeamPasswordLength} Zeichen lang sein." });
}
if (DemoCredentialHasher.FixedTimePlainTextEquals(currentPassword, newPassword))
{
return Results.BadRequest(new { message = "Das neue Passwort muss sich vom aktuellen Passwort unterscheiden." });
}
var credentials = DemoCredentialHasher.HashPassword(newPassword);
member.PasswordHash = credentials.Hash;
member.PasswordSalt = credentials.Salt;
member.MustChangePassword = false;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
session.DisplayName = member.DisplayName;
session.Role = AdminRoles.Normalize(member.Role);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static async Task<IResult> BindTeamTwitch(
HttpContext context,
BindTeamTwitchRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
}
if (member.MustChangePassword)
{
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
}
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
}
var alreadyBound = await db.TeamMembers.AnyAsync(
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (alreadyBound)
{
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
}
var previousTwitchUserId = member.BoundTwitchUserId;
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
{
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
{
oldSession.IsActive = false;
}
}
member.BoundTwitchUserId = twitchUserId;
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
member.TwitchBoundAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static string BuildTeamSessionId(string login) =>
$"{TeamSessionPrefix}{login}";
internal static string ReadTeamLoginFromSession(string twitchUserId) =>
twitchUserId.StartsWith(TeamSessionPrefix, StringComparison.OrdinalIgnoreCase)
? NormalizeTeamLogin(twitchUserId[TeamSessionPrefix.Length..])
: string.Empty;
internal static async Task<TeamMember?> FindTeamMemberForSessionAsync(
AwardsDbContext db,
UserSession session,
CancellationToken cancellationToken = default)
{
var login = ReadTeamLoginFromSession(session.TwitchUserId);
if (!string.IsNullOrWhiteSpace(login))
{
return await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, cancellationToken);
}
var twitchUserId = NormalizeTwitchUserId(session.TwitchUserId);
return string.IsNullOrWhiteSpace(twitchUserId)
? null
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
}
private static string NormalizeTeamLogin(string value) =>
value.Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchUserId(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchDisplayName(string? value) =>
(value ?? string.Empty).Trim();
}
@@ -0,0 +1,412 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Backend.Common;
using Backend.Configuration;
using Backend.Contracts;
using Backend.Data;
using Backend.Security;
using Backend.Services;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace Backend.Endpoints;
public static partial class AuthEndpoints
{
private const string TwitchLoginPurpose = "team-login";
private const string TwitchBindingPurpose = "team-binding";
private const string TwitchStateCachePrefix = "twitch-oauth-state:";
private static readonly TimeSpan TwitchStateLifetime = TimeSpan.FromMinutes(10);
private static async Task<IResult> StartTwitchAuthorization(
HttpContext context,
TwitchAuthorizeRequest request,
IMemoryCache memoryCache,
IOptions<TwitchAuthOptions> twitchOptions,
IConfiguration configuration,
IWebHostEnvironment environment,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
if (!TwitchAuthConfigured(options))
{
return Results.Json(
new { message = "Twitch OAuth ist noch nicht konfiguriert. Bitte TwitchAuth:ClientId und TwitchAuth:ClientSecret setzen." },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var purpose = NormalizeTwitchPurpose(request.Purpose);
if (purpose is null)
{
return Results.BadRequest(new { message = "Unbekannter Twitch-Login-Zweck." });
}
var frontendOrigin = NormalizeFrontendOrigin(request.FrontendOrigin, configuration, environment);
if (frontendOrigin is null)
{
return Results.BadRequest(new { message = "Frontend-Origin ist fuer Twitch OAuth nicht erlaubt." });
}
var sessionToken = string.Empty;
if (purpose == TwitchBindingPurpose)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
sessionToken = session.SessionToken;
}
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
memoryCache.Set(
$"{TwitchStateCachePrefix}{state}",
new TwitchOAuthState(purpose, NormalizeReturnUrl(request.ReturnUrl), frontendOrigin, sessionToken),
TwitchStateLifetime);
var authorizationParams = new Dictionary<string, string?>
{
["client_id"] = options.ClientId,
["redirect_uri"] = ResolveRedirectUri(context, options),
["response_type"] = "code",
["state"] = state,
};
if (!string.IsNullOrWhiteSpace(options.Scope))
{
authorizationParams["scope"] = options.Scope.Trim();
}
var authorizationUrl = QueryHelpers.AddQueryString(
"https://id.twitch.tv/oauth2/authorize",
authorizationParams);
return Results.Ok(new TwitchAuthorizeResponse(authorizationUrl));
}
private static async Task<IResult> CompleteTwitchAuthorization(
HttpContext context,
string? code,
string? state,
string? error,
string? error_description,
IMemoryCache memoryCache,
IOptions<TwitchAuthOptions> twitchOptions,
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AwardsDbContext db,
IUserSessionService userSessionService)
{
if (string.IsNullOrWhiteSpace(state)
|| !memoryCache.TryGetValue<TwitchOAuthState>($"{TwitchStateCachePrefix}{state}", out var oauthState)
|| oauthState is null)
{
return RedirectToTwitchCallbackError("/", "Der Twitch-Login ist abgelaufen. Bitte starte ihn erneut.");
}
memoryCache.Remove($"{TwitchStateCachePrefix}{state}");
if (!string.IsNullOrWhiteSpace(error))
{
return RedirectToTwitchCallbackError(
oauthState,
string.IsNullOrWhiteSpace(error_description)
? "Twitch hat den Login abgebrochen."
: error_description);
}
if (string.IsNullOrWhiteSpace(code))
{
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen Login-Code zurueckgegeben.");
}
var options = await ResolveEffectiveTwitchOptionsAsync(db, twitchOptions.Value, configuration, context.RequestAborted);
if (!TwitchAuthConfigured(options))
{
return RedirectToTwitchCallbackError(oauthState, "Twitch OAuth ist auf dem Server nicht vollstaendig konfiguriert.");
}
var httpClient = httpClientFactory.CreateClient();
var token = await ExchangeTwitchCodeAsync(httpClient, context, options, code, context.RequestAborted);
if (token is null)
{
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte den Login-Code nicht bestaetigen.");
}
var twitchUser = await LoadTwitchUserAsync(httpClient, options.ClientId, token.AccessToken, context.RequestAborted);
if (twitchUser is null)
{
return RedirectToTwitchCallbackError(oauthState, "Twitch konnte dein Profil nicht laden.");
}
var twitchLogin = NormalizeTwitchUserId(twitchUser.Login);
if (string.IsNullOrWhiteSpace(twitchLogin))
{
return RedirectToTwitchCallbackError(oauthState, "Twitch hat keinen gueltigen Login-Namen geliefert.");
}
if (oauthState.Purpose == TwitchBindingPurpose)
{
return await CompleteTwitchBindingAsync(context, oauthState, twitchLogin, twitchUser.DisplayName, db);
}
return await CompleteTwitchTeamLoginAsync(context, oauthState, twitchLogin, db, userSessionService);
}
private static async Task<IResult> CompleteTwitchBindingAsync(
HttpContext context,
TwitchOAuthState oauthState,
string twitchLogin,
string twitchDisplayName,
AwardsDbContext db)
{
var session = await db.UserSessions.FirstOrDefaultAsync(
item => item.SessionToken == oauthState.SessionToken && item.IsActive,
context.RequestAborted);
if (session is null)
{
return RedirectToTwitchCallbackError(oauthState, "Deine Team-Session ist abgelaufen. Bitte melde dich erneut an.");
}
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
if (member is null || !member.IsActive)
{
return RedirectToTwitchCallbackError(oauthState, "Dieser Account ist kein aktiver Team-Login.");
}
if (member.MustChangePassword)
{
return RedirectToTwitchCallbackError(oauthState, "Bitte aendere zuerst dein temporaeres Passwort.");
}
var alreadyBound = await db.TeamMembers.AnyAsync(
item => item.Id != member.Id && item.BoundTwitchUserId == twitchLogin,
context.RequestAborted);
if (alreadyBound)
{
return RedirectToTwitchCallbackError(oauthState, "Dieser Twitch-Account ist bereits mit einem Team-Account verbunden.");
}
var previousTwitchUserId = member.BoundTwitchUserId;
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
&& !string.Equals(previousTwitchUserId, twitchLogin, StringComparison.OrdinalIgnoreCase))
{
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
{
oldSession.IsActive = false;
}
}
member.BoundTwitchUserId = twitchLogin;
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchLogin : twitchDisplayName;
member.TwitchBoundAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
await db.SaveChangesAsync(context.RequestAborted);
return RedirectToTwitchCallback(oauthState, "connected");
}
private static async Task<IResult> CompleteTwitchTeamLoginAsync(
HttpContext context,
TwitchOAuthState oauthState,
string twitchLogin,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var member = await db.TeamMembers.FirstOrDefaultAsync(
item => item.BoundTwitchUserId == twitchLogin,
context.RequestAborted);
if (member is null || !member.IsActive)
{
return RedirectToTwitchCallbackError(oauthState, "Fuer diesen Twitch-Account ist kein aktiver Team-Account gebunden.");
}
member.LastLoginAt = DateTimeOffset.UtcNow;
var session = await userSessionService.CreateSessionAsync(
twitchLogin,
member.DisplayName,
member.Role,
RequestMetadataReader.Read(context),
context.RequestAborted);
return RedirectToTwitchCallback(oauthState, "authenticated", session.SessionToken);
}
private static async Task<TwitchTokenResponse?> ExchangeTwitchCodeAsync(
HttpClient httpClient,
HttpContext context,
TwitchAuthOptions options,
string code,
CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsync(
"https://id.twitch.tv/oauth2/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = options.ClientId,
["client_secret"] = options.ClientSecret,
["code"] = code,
["grant_type"] = "authorization_code",
["redirect_uri"] = ResolveRedirectUri(context, options),
}),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync<TwitchTokenResponse>(stream, cancellationToken: cancellationToken);
}
private static async Task<TwitchUserResponseItem?> LoadTwitchUserAsync(
HttpClient httpClient,
string clientId,
string accessToken,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.twitch.tv/helix/users");
request.Headers.Add("Client-Id", clientId);
request.Headers.Authorization = new("Bearer", accessToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var payload = await JsonSerializer.DeserializeAsync<TwitchUsersResponse>(stream, cancellationToken: cancellationToken);
return payload?.Data.FirstOrDefault();
}
private static IResult RedirectToTwitchCallback(TwitchOAuthState state, string status, string? sessionToken = null, string? message = null)
{
var values = new Dictionary<string, string>
{
["status"] = status,
["returnUrl"] = state.ReturnUrl,
};
if (!string.IsNullOrWhiteSpace(sessionToken))
{
values["sessionToken"] = sessionToken;
}
if (!string.IsNullOrWhiteSpace(message))
{
values["message"] = message;
}
var fragment = string.Join('&', values.Select(item => $"{item.Key}={Uri.EscapeDataString(item.Value)}"));
return Results.Redirect($"{state.FrontendOrigin}/auth/twitch/callback#{fragment}");
}
private static IResult RedirectToTwitchCallbackError(TwitchOAuthState state, string message) =>
RedirectToTwitchCallback(state, "error", message: message);
private static IResult RedirectToTwitchCallbackError(string returnUrl, string message) =>
RedirectToTwitchCallback(new TwitchOAuthState(TwitchLoginPurpose, NormalizeReturnUrl(returnUrl), ApplicationDefaults.FrontendOrigins[0], string.Empty), "error", message: message);
private static bool TwitchAuthConfigured(TwitchAuthOptions options) =>
!string.IsNullOrWhiteSpace(options.ClientId)
&& !string.IsNullOrWhiteSpace(options.ClientSecret);
private static async Task<TwitchAuthOptions> ResolveEffectiveTwitchOptionsAsync(
AwardsDbContext db,
TwitchAuthOptions configuredOptions,
IConfiguration configuration,
CancellationToken cancellationToken)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
return new TwitchAuthOptions
{
ClientId = FirstConfigured(settings?.TwitchClientId, ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"), configuredOptions.ClientId),
ClientSecret = FirstConfigured(settings?.TwitchClientSecret, ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET"), configuredOptions.ClientSecret),
RedirectUri = FirstConfigured(settings?.TwitchRedirectUri, ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"), configuredOptions.RedirectUri),
Scope = FirstConfigured(settings?.TwitchScope, ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"), configuredOptions.Scope),
};
}
private static string FirstConfigured(params string?[] values) =>
values.Select(value => value?.Trim() ?? string.Empty).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) =>
configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty;
private static string ResolveRedirectUri(HttpContext context, TwitchAuthOptions options)
{
if (!string.IsNullOrWhiteSpace(options.RedirectUri))
{
return options.RedirectUri.Trim();
}
return $"{context.Request.Scheme}://{context.Request.Host}/api/auth/twitch/callback";
}
private static string? NormalizeTwitchPurpose(string? purpose)
{
var normalized = (purpose ?? string.Empty).Trim().ToLowerInvariant();
return normalized is TwitchLoginPurpose or TwitchBindingPurpose ? normalized : null;
}
private static string NormalizeReturnUrl(string? returnUrl)
{
var normalized = (returnUrl ?? "/admin").Trim();
return normalized.StartsWith("/", StringComparison.Ordinal)
&& !normalized.StartsWith("//", StringComparison.Ordinal)
&& !normalized.StartsWith("/auth/twitch/callback", StringComparison.OrdinalIgnoreCase)
? normalized
: "/admin";
}
private static string? NormalizeFrontendOrigin(
string? frontendOrigin,
IConfiguration configuration,
IWebHostEnvironment environment)
{
if (!Uri.TryCreate(frontendOrigin?.Trim(), UriKind.Absolute, out var uri)
|| uri.Scheme is not ("http" or "https")
|| string.IsNullOrWhiteSpace(uri.Host))
{
return null;
}
var origin = uri.GetLeftPart(UriPartial.Authority);
var configuredOrigins = configuration
.GetSection(FrontendOptions.SectionName)
.Get<FrontendOptions>()?
.AllowedOrigins ?? [];
var allowedOrigins = configuredOrigins.Length > 0 || !environment.IsDevelopment()
? configuredOrigins
: ApplicationDefaults.FrontendOrigins;
return allowedOrigins.Any(item => string.Equals(item.TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase))
? origin
: null;
}
private sealed record TwitchOAuthState(
string Purpose,
string ReturnUrl,
string FrontendOrigin,
string SessionToken);
private sealed record TwitchTokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken);
private sealed record TwitchUsersResponse(
[property: JsonPropertyName("data")] TwitchUserResponseItem[] Data);
private sealed record TwitchUserResponseItem(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("login")] string Login,
[property: JsonPropertyName("display_name")] string DisplayName);
}
+23 -21
View File
@@ -21,12 +21,12 @@ public static partial class PublicEndpoints
if (submittedNominations.Length is 0 or > 3)
{
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 stream links." });
}
if (submittedNominations.Any(item => item.Name.Length > 120))
if (submittedNominations.Any(item => item.Name is { Length: > 120 }))
{
return Results.BadRequest(new { message = "Nominee names must stay below 120 characters." });
return Results.BadRequest(new { message = "Legacy nominee names must stay below 120 characters." });
}
if (submittedNominations.Any(item => item.StreamUrl.Length > 300))
@@ -34,19 +34,14 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "Stream links must stay below 300 characters." });
}
if (request.Nominations is { Length: > 0 } && submittedNominations.Any(item => string.IsNullOrWhiteSpace(item.StreamUrl)))
{
return Results.BadRequest(new { message = "A stream link is required for every nomination." });
}
var distinctNomineeNames = submittedNominations
.Select(item => item.Name)
var distinctStreamUrls = submittedNominations
.Select(item => item.StreamUrl)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (distinctNomineeNames.Length != submittedNominations.Length)
if (distinctStreamUrls.Length != submittedNominations.Length)
{
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
return Results.BadRequest(new { message = "Duplicate stream links are not allowed inside one category." });
}
var invalidStreamUrl = submittedNominations
@@ -93,12 +88,10 @@ public static partial class PublicEndpoints
SeasonId = category.SeasonId,
CategoryId = category.Id,
SubmittedByTwitchId = submitterId,
CandidateText = nomination.Name,
CandidateText = string.IsNullOrWhiteSpace(nomination.Name) ? null : nomination.Name,
StreamUrl = string.IsNullOrWhiteSpace(nomination.StreamUrl) ? null : nomination.StreamUrl,
Status = "pending",
ReviewNote = string.IsNullOrWhiteSpace(nomination.StreamUrl)
? null
: $"Stream-Link: {nomination.StreamUrl}",
ReviewNote = $"Stream-Link: {nomination.StreamUrl}",
CreatedAt = DateTimeOffset.UtcNow,
}).ToArray();
@@ -151,7 +144,7 @@ public static partial class PublicEndpoints
return Results.Ok(new { saved = submittedNominations.Length, category = category.Name, collectedSignal = existingNominationCount > 0 });
}
private readonly record struct SubmittedNomination(string Name, string StreamUrl);
private readonly record struct SubmittedNomination(string? Name, string StreamUrl);
private static SubmittedNomination[] NormalizeSubmittedNominations(CreateNominationRequest request)
{
@@ -167,15 +160,24 @@ public static partial class PublicEndpoints
streamUrl = normalizedUrl;
}
return new SubmittedNomination(name, streamUrl);
return new SubmittedNomination(string.IsNullOrWhiteSpace(name) ? null : name, streamUrl);
})
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
.ToArray();
}
return (request.Nominees ?? [])
.Select(item => new SubmittedNomination(item.Trim(), string.Empty))
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.Select(item =>
{
var streamUrl = item.Trim();
if (!string.IsNullOrWhiteSpace(streamUrl) && TryNormalizeExternalUrl(streamUrl, out var normalizedUrl))
{
streamUrl = normalizedUrl;
}
return new SubmittedNomination(null, streamUrl);
})
.Where(item => !string.IsNullOrWhiteSpace(item.StreamUrl))
.ToArray();
}
}
+19 -1
View File
@@ -14,6 +14,7 @@ public static partial class PublicEndpoints
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.ThenInclude(category => category.Candidates)
.OrderByDescending(item => item.Year)
.FirstOrDefaultAsync(item => item.IsCurrent);
if (season is null)
@@ -54,6 +55,22 @@ public static partial class PublicEndpoints
SeasonMappings.BuildProfileUrl(result.WinnerPlatform, result.WinnerSlug)))
.ToArray();
var archiveYearRows = await db.Results
.AsNoTracking()
.Where(result => result.Season.Year < season.Year)
.GroupBy(result => result.Season.Year)
.Select(group => new
{
Year = group.Key,
WinnerCount = group.Count(),
})
.OrderByDescending(item => item.Year)
.ToArrayAsync();
var archiveYears = archiveYearRows
.Select(item => new ArchiveYearDto(item.Year, item.WinnerCount))
.ToArray();
var phaseKey = SeasonMappings.NormalizePhaseKey(season.CurrentPhase);
var publicCategories = season.Categories
.Where(category => ShouldExposePublicCategory(phaseKey, category.Candidates.Count))
@@ -72,7 +89,7 @@ public static partial class PublicEndpoints
{
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, SeasonMappings.ResolveTimelineState("nomination", phaseKey)),
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, SeasonMappings.ResolveTimelineState("voting", phaseKey)),
new TimelineItem("review", "Review & Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("review", phaseKey)),
new TimelineItem("preparation", "Aufbereitung", season.ReviewStartsAt, season.ReviewEndsAt, SeasonMappings.ResolveTimelineState("preparation", phaseKey)),
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, SeasonMappings.ResolveTimelineState("show", phaseKey)),
},
publicCategories
@@ -84,6 +101,7 @@ public static partial class PublicEndpoints
category.MaxNomineesPerUser))
.ToArray(),
winnerPreviewItems,
archiveYears,
new PublicSiteContentDto(
siteSettings.HostDisplayName,
siteSettings.HostTagline,
@@ -39,7 +39,7 @@ public static partial class PublicEndpoints
item.Status,
Nominee = item.CandidateId != null
? item.Candidate!.DisplayName
: item.CandidateText,
: item.CandidateText ?? item.StreamUrl,
})
.ToArrayAsync();