b53c7fb736
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>
469 lines
19 KiB
C#
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);
|
|
}
|