Add team roles and content management updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user