Files
vtuber-awards/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs
T
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

469 lines
19 KiB
C#

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> DisconnectTwitchBinding(
HttpContext context,
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 boundTwitchUserId = NormalizeTwitchUserId(member.BoundTwitchUserId);
if (string.IsNullOrWhiteSpace(boundTwitchUserId))
{
return Results.Ok(new TwitchBindingDisconnectResponse(
false,
false,
await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
}
var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId);
var currentSessionUsesBoundTwitch = string.IsNullOrWhiteSpace(currentTeamLogin)
&& string.Equals(NormalizeTwitchUserId(session.TwitchUserId), boundTwitchUserId, StringComparison.OrdinalIgnoreCase);
foreach (var linkedSession in db.UserSessions.Where(item => item.TwitchUserId == boundTwitchUserId))
{
linkedSession.IsActive = false;
}
member.BoundTwitchUserId = null;
member.BoundTwitchDisplayName = null;
member.TwitchBoundAt = null;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
if (currentSessionUsesBoundTwitch)
{
session.IsActive = false;
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new TwitchBindingDisconnectResponse(
true,
currentSessionUsesBoundTwitch,
currentSessionUsesBoundTwitch
? null
: await ToAuthSessionDtoAsync(db, userSessionService, session, cancellationToken: context.RequestAborted)));
}
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);
}