Completely replaced the previous static auth flow with Twitch OAuth to enable users to login within the application

This commit is contained in:
Enrico Ludwig 2024-08-28 17:27:52 +02:00
parent c236dabb0e
commit baa2a15d02
8 changed files with 324 additions and 75 deletions

View File

@ -8,88 +8,97 @@ using Avalonia.Markup.Xaml;
using BetterRaid.ViewModels; using BetterRaid.ViewModels;
using BetterRaid.Views; using BetterRaid.Views;
using TwitchLib.Api; using TwitchLib.Api;
using TwitchLib.Client;
using TwitchLib.Client.Events;
using TwitchLib.Client.Models;
using TwitchLib.Communication.Clients;
using TwitchLib.Communication.Models;
namespace BetterRaid; namespace BetterRaid;
public partial class App : Application public partial class App : Application
{ {
public static int AutoUpdateDelay = 10_000; internal static TwitchAPI? TwitchApi = null;
public static string TwitchBroadcasterId = ""; internal static int AutoUpdateDelay = 10_000;
public static string TwitchChannelName = ""; internal static string TwitchOAuthAccessToken = "";
public static string TokenClientId = ""; internal static string TwitchOAuthAccessTokenFilePath = "";
public static string TokenClientSecret = ""; internal static string TokenClientId = "kkxu4jorjrrc5jch1ito5i61hbev2o";
public static string TokenClientAccess = ""; internal static readonly string TwitchOAuthRedirectUrl = "http://localhost:9900";
public static TwitchClient? TwitchClient = null; internal static readonly string TwitchOAuthResponseType = "token";
public static TwitchAPI? TwitchAPI = null; internal static readonly string[] TwitchOAuthScopes = [ "channel:manage:raids", "user:read:chat" ];
internal static readonly string TwitchOAuthUrl = $"https://id.twitch.tv/oauth2/authorize"
+ $"?client_id={TokenClientId}"
+ "&redirect_uri=http://localhost:9900"
+ $"&response_type={TwitchOAuthResponseType}"
+ $"&scope={string.Join("+", TwitchOAuthScopes)}";
public override void Initialize() public override void Initialize()
{ {
try var userHomeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var betterRaidDir = "";
switch (Environment.OSVersion.Platform)
{ {
var tokenFile = "zn_twitch.secret"; case PlatformID.Win32NT:
var profilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); betterRaidDir = Path.Combine(userHomeDir, "AppData", "Roaming", "BetterRaid");
var tokenFilePath = Path.Combine(profilePath, tokenFile); break;
var tokenFileLines = File.ReadAllLines(tokenFilePath); case PlatformID.Unix:
TwitchChannelName = tokenFileLines[0].Split('=')[1]; betterRaidDir = Path.Combine(userHomeDir, ".config", "BetterRaid");
TokenClientId = tokenFileLines[1].Split('=')[1]; break;
TokenClientSecret = tokenFileLines[2].Split('=')[1]; case PlatformID.MacOSX:
TokenClientAccess = tokenFileLines[3].Split('=')[1]; betterRaidDir = Path.Combine(userHomeDir, "Library", "Application Support", "BetterRaid");
} break;
catch (Exception)
{
Console.WriteLine("[ERROR] Failed to read token from secret file!");
Environment.Exit(1);
} }
var creds = new ConnectionCredentials(TwitchChannelName, TokenClientAccess); if (!Directory.Exists(betterRaidDir))
var clientOptions = new ClientOptions Directory.CreateDirectory(betterRaidDir);
TwitchOAuthAccessTokenFilePath = Path.Combine(betterRaidDir, ".access_token");
if (File.Exists(TwitchOAuthAccessTokenFilePath))
{ {
MessagesAllowedInPeriod = 750, TwitchOAuthAccessToken = File.ReadAllText(TwitchOAuthAccessTokenFilePath);
ThrottlingPeriod = TimeSpan.FromSeconds(30) InitTwitchClient();
};
var customClient = new WebSocketClient(clientOptions);
TwitchClient = new TwitchClient(customClient);
TwitchClient.Initialize(creds, TwitchChannelName);
TwitchClient.OnConnected += OnConnected;
TwitchClient.OnConnectionError += OnConnectionError;
TwitchClient.Connect();
TwitchAPI = new TwitchAPI();
TwitchAPI.Settings.ClientId = TokenClientId;
TwitchAPI.Settings.AccessToken = TokenClientAccess;
var channels = TwitchAPI.Helix.Search.SearchChannelsAsync(TwitchChannelName).Result;
var exactChannel = channels.Channels.FirstOrDefault(c => c.BroadcasterLogin == TwitchChannelName);
if (exactChannel != null)
{
TwitchBroadcasterId = exactChannel.Id;
Console.WriteLine($"TWITCH BROADCASTER ID SET TO {TwitchBroadcasterId}");
}
else
{
Console.WriteLine("FAILED TO SET BROADCASTER ID!");
} }
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
private void OnConnectionError(object? sender, OnConnectionErrorArgs e) public static void InitTwitchClient(bool overrideToken = false)
{ {
Console.WriteLine("[ERROR] Twitch Client failed to connect!"); Console.WriteLine("[INFO] Initializing Twitch Client...");
}
private void OnConnected(object? sender, OnConnectedArgs e) TwitchApi = new TwitchAPI();
{ TwitchApi.Settings.ClientId = TokenClientId;
Console.WriteLine("[INFO] Twitch Client connected!"); TwitchApi.Settings.AccessToken = TwitchOAuthAccessToken;
Console.WriteLine("[INFO] Testing Twitch API connection...");
var user = TwitchApi.Helix.Users.GetUsersAsync().Result.Users.FirstOrDefault();
if (user == null)
{
TwitchApi = null;
Console.WriteLine("[ERROR] Failed to connect to Twitch API!");
return;
}
Console.WriteLine("[INFO] Connected to Twitch API as '{0}'!", user.DisplayName);
if (overrideToken)
{
File.WriteAllText(TwitchOAuthAccessTokenFilePath, TwitchOAuthAccessToken);
switch (Environment.OSVersion.Platform)
{
case PlatformID.Win32NT:
File.SetAttributes(TwitchOAuthAccessTokenFilePath, File.GetAttributes(TwitchOAuthAccessTokenFilePath) | FileAttributes.Hidden);
break;
case PlatformID.Unix:
#pragma warning disable CA1416 // Validate platform compatibility
File.SetUnixFileMode(TwitchOAuthAccessTokenFilePath, UnixFileMode.UserRead);
#pragma warning restore CA1416 // Validate platform compatibility
break;
case PlatformID.MacOSX:
File.SetAttributes(TwitchOAuthAccessTokenFilePath, File.GetAttributes(TwitchOAuthAccessTokenFilePath) | FileAttributes.Hidden);
break;
}
}
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.5.002.0 VisualStudioVersion = 17.5.002.0
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{6BE742A5-079D-4617-BDC1-2933274CDCB7}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterRaid", "BetterRaid.csproj", "{EDFD12AB-9E05-4D87-9139-C220A703CFDB}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -11,10 +11,10 @@ Global
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6BE742A5-079D-4617-BDC1-2933274CDCB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6BE742A5-079D-4617-BDC1-2933274CDCB7}.Debug|Any CPU.Build.0 = Debug|Any CPU {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6BE742A5-079D-4617-BDC1-2933274CDCB7}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6BE742A5-079D-4617-BDC1-2933274CDCB7}.Release|Any CPU.Build.0 = Release|Any CPU {EDFD12AB-9E05-4D87-9139-C220A703CFDB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

170
Misc/Tools.cs Normal file
View File

@ -0,0 +1,170 @@
using System;
using System.Diagnostics;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace BetterRaid.Misc;
public static class Tools
{
private static HttpListener? _oauthListener;
private static Task? _oauthWaiterTask;
// Source: https://stackoverflow.com/a/43232486
public static void StartOAuthLogin(string url, Action? callback = null, CancellationToken? token = null)
{
if (_oauthListener != null)
return;
var _token = token ?? CancellationToken.None;
_oauthListener = new HttpListener();
_oauthListener.Prefixes.Add("http://localhost:9900/");
_oauthListener.Start();
_oauthWaiterTask = WaitForCallback(callback, _token);
try
{
Process.Start(url);
}
catch
{
// hack because of this: https://github.com/dotnet/corefx/issues/10361
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
url = url.Replace("&", "^&");
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Process.Start("xdg-open", url);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", url);
}
else
{
throw;
}
}
}
private static async Task WaitForCallback(Action? callback, CancellationToken token)
{
if (_oauthListener == null)
return;
if (token.IsCancellationRequested)
return;
Console.WriteLine("Starting token listener");
while (!token.IsCancellationRequested)
{
var ctx = await _oauthListener.GetContextAsync();
var req = ctx.Request;
var res = ctx.Response;
if (req.Url == null)
continue;
Console.WriteLine("{0} {1}", req.HttpMethod, req.Url);
// Response, that may contain the access token as fragment
// It must be extracted client-side in browser
if (req.Url.LocalPath == "/")
{
var buf = new byte[1024];
var data = new StringBuilder();
int bytesRead;
while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0)
{
data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead));
}
req.InputStream.Close();
Console.WriteLine(data.ToString());
res.StatusCode = 200;
await res.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(OAUTH_CLIENT_DOCUMENT).AsMemory(0, OAUTH_CLIENT_DOCUMENT.Length), token);
res.Close();
}
if (req.Url.LocalPath == "/login")
{
var buf = new byte[1024];
var data = new StringBuilder();
int bytesRead;
while ((bytesRead = await req.InputStream.ReadAsync(buf, token)) > 0)
{
data.Append(Encoding.UTF8.GetString(buf, 0, bytesRead));
}
req.InputStream.Close();
var json = data.ToString();
var jsonData = JsonObject.Parse(json);
if (jsonData == null)
{
Console.WriteLine("[ERROR] Failed to parse JSON data:");
Console.WriteLine(json);
res.StatusCode = 400;
res.Close();
continue;
}
if (jsonData["access_token"] == null)
{
Console.WriteLine("[ERROR] Missing access_token in JSON data.");
res.StatusCode = 400;
res.Close();
continue;
}
var accessToken = jsonData["access_token"]?.ToString();
App.TwitchOAuthAccessToken = accessToken!;
res.StatusCode = 200;
res.Close();
Console.WriteLine("[INFO] Received access token!");
callback?.Invoke();
_oauthListener.Stop();
return;
}
}
}
private const string OAUTH_CLIENT_DOCUMENT =
@"
<!DOCTYPE html>
<header>
<title>BetterRaid Twitch Login</title>
</header>
<body>
<h1>Successfully logged in!</h1>
<script>
var urlParams = new URLSearchParams(window.location.hash.substr(1));
var accessToken = urlParams.get('access_token');
var xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:9900/login', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ access_token: accessToken }));
</script>
</body>
";
}

View File

@ -1,7 +1,8 @@
using System; using System;
using Avalonia; using System.Threading;
using Avalonia.Controls; using Avalonia.Controls;
using BetterRaid.Extensions; using BetterRaid.Extensions;
using BetterRaid.Misc;
using BetterRaid.Models; using BetterRaid.Models;
using BetterRaid.Views; using BetterRaid.Views;
@ -25,6 +26,8 @@ public partial class MainWindowViewModel : ViewModelBase
set => SetProperty(ref _filter, value); set => SetProperty(ref _filter, value);
} }
public bool IsLoggedIn => App.TwitchApi != null;
public void ExitApplication() public void ExitApplication()
{ {
//TODO polish later //TODO polish later
@ -37,4 +40,16 @@ public partial class MainWindowViewModel : ViewModelBase
about.ShowDialog(owner); about.ShowDialog(owner);
about.CenterToOwner(); about.CenterToOwner();
} }
public void LoginWithTwitch()
{
Tools.StartOAuthLogin(App.TwitchOAuthUrl, OnTwitchLoginCallback, CancellationToken.None);
}
public void OnTwitchLoginCallback()
{
App.InitTwitchClient(overrideToken: true);
OnPropertyChanged(nameof(IsLoggedIn));
}
} }

View File

@ -94,13 +94,13 @@ public class RaidButtonViewModel : ViewModelBase
private async Task<Channel?> GetChannelAsync(string channelName) private async Task<Channel?> GetChannelAsync(string channelName)
{ {
if (App.TwitchAPI == null) if (App.TwitchApi == null)
return null; return null;
if (string.IsNullOrEmpty(channelName)) if (string.IsNullOrEmpty(channelName))
return null; return null;
var channels = await App.TwitchAPI.Helix.Search.SearchChannelsAsync(channelName); var channels = await App.TwitchApi.Helix.Search.SearchChannelsAsync(channelName);
var exactChannel = channels.Channels.FirstOrDefault(c => c.BroadcasterLogin.Equals(channelName, StringComparison.CurrentCultureIgnoreCase)); var exactChannel = channels.Channels.FirstOrDefault(c => c.BroadcasterLogin.Equals(channelName, StringComparison.CurrentCultureIgnoreCase));
return exactChannel; return exactChannel;
@ -108,13 +108,13 @@ public class RaidButtonViewModel : ViewModelBase
private async Task<Stream?> GetStreamAsync(Channel currentChannelData) private async Task<Stream?> GetStreamAsync(Channel currentChannelData)
{ {
if (App.TwitchAPI == null) if (App.TwitchApi == null)
return null; return null;
if (currentChannelData == null) if (currentChannelData == null)
return null; return null;
var streams = await App.TwitchAPI.Helix.Streams.GetStreamsAsync(userLogins: [currentChannelData.BroadcasterLogin]); var streams = await App.TwitchApi.Helix.Streams.GetStreamsAsync(userLogins: [currentChannelData.BroadcasterLogin]);
var exactStream = streams.Streams.FirstOrDefault(s => s.UserLogin == currentChannelData.BroadcasterLogin); var exactStream = streams.Streams.FirstOrDefault(s => s.UserLogin == currentChannelData.BroadcasterLogin);
return exactStream; return exactStream;
@ -122,7 +122,7 @@ public class RaidButtonViewModel : ViewModelBase
public async Task RaidChannel() public async Task RaidChannel()
{ {
if (App.TwitchAPI == null) if (App.TwitchApi == null)
return; return;
if (Channel == null) if (Channel == null)
@ -132,7 +132,8 @@ public class RaidButtonViewModel : ViewModelBase
try try
{ {
raid = await App.TwitchAPI.Helix.Raids.StartRaidAsync(App.TwitchBroadcasterId, Channel.BroadcasterId); // TODO: Get own broadcaster id
raid = await App.TwitchApi.Helix.Raids.StartRaidAsync("", Channel.BroadcasterId);
} }
catch (Exception e) catch (Exception e)
{ {

View File

@ -48,6 +48,29 @@
Orientation="Horizontal" Orientation="Horizontal"
Spacing="5"> Spacing="5">
<Button Command="{Binding LoginWithTwitch}"
IsVisible="{Binding !IsLoggedIn, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<Button.Styles>
<Style Selector="Button">
<Setter Property="Background" Value="#6441a5" />
</Style>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#b9a3e3" />
</Style>
</Button.Styles>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Login"
Foreground="#f1f1f1"
FontSize="14" />
<Image Source="avares://BetterRaid/Assets/glitch_flat_white.png"
Width="16"
Height="16"
Margin="5, 0, 0, 0" />
</StackPanel>
</Button>
<CheckBox Content="Only Online" <CheckBox Content="Only Online"
IsChecked="{Binding Database.OnlyOnline, Mode=TwoWay}" /> IsChecked="{Binding Database.OnlyOnline, Mode=TwoWay}" />
@ -61,9 +84,21 @@
</StackPanel> </StackPanel>
<TextBlock Text="Please login first!"
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="1"
IsVisible="{Binding !IsLoggedIn, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TextAlignment="Center"
FontSize="24"
Foreground="#f1f1f1" />
<ScrollViewer Grid.Column="0" <ScrollViewer Grid.Column="0"
Grid.ColumnSpan="2" Grid.ColumnSpan="2"
Grid.Row="1" Grid.Row="1"
IsVisible="{Binding IsLoggedIn, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
HorizontalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"> VerticalScrollBarVisibility="Auto">

View File

@ -58,10 +58,19 @@ public partial class MainWindow : Window
{ {
GenerateRaidGrid(); GenerateRaidGrid();
} }
if (e.PropertyName == nameof(MainWindowViewModel.IsLoggedIn) && DataContext is MainWindowViewModel { IsLoggedIn: true })
{
InitializeRaidChannels();
GenerateRaidGrid();
}
} }
private void InitializeRaidChannels() private void InitializeRaidChannels()
{ {
if (DataContext is MainWindowViewModel { IsLoggedIn: false })
return;
if (_autoUpdater?.IsBusy == false) if (_autoUpdater?.IsBusy == false)
{ {
_autoUpdater?.CancelAsync(); _autoUpdater?.CancelAsync();
@ -100,6 +109,9 @@ public partial class MainWindow : Window
private void GenerateRaidGrid() private void GenerateRaidGrid()
{ {
if (DataContext is MainWindowViewModel { IsLoggedIn: false })
return;
foreach (var child in raidGrid.Children) foreach (var child in raidGrid.Children)
{ {
if (child is Button btn) if (child is Button btn)
@ -216,6 +228,13 @@ public partial class MainWindow : Window
public void UpdateChannelData() public void UpdateChannelData()
{ {
var loggedIn = Dispatcher.UIThread.Invoke(() => {
return (DataContext as MainWindowViewModel)?.IsLoggedIn ?? false;
});
if (loggedIn == false)
return;
foreach (var vm in _raidButtonVMs) foreach (var vm in _raidButtonVMs)
{ {
Task.Run(vm.GetOrUpdateChannelAsync); Task.Run(vm.GetOrUpdateChannelAsync);