diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js b/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js index 49dd21c5..c70d827f 100644 --- a/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js +++ b/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js @@ -96,7 +96,8 @@ function renderUser() {

Create a CelesteNet account to show your profile picture in-game and to let the server remember your last channel and command settings.

- Link your Discord account
+ Link your Discord account
+ Link your Twitch account
Linking your account is fully optional and requires telling your browser to store a "cookie." This cookie is only used to keep you logged in. diff --git a/CelesteNet.Server.FrontendModule/Frontend.cs b/CelesteNet.Server.FrontendModule/Frontend.cs index f016a131..264389b6 100644 --- a/CelesteNet.Server.FrontendModule/Frontend.cs +++ b/CelesteNet.Server.FrontendModule/Frontend.cs @@ -506,7 +506,7 @@ public NameValueCollection ParseQueryString(string url) { NameValueCollection nvc = new(); int indexOfSplit = url.IndexOf('?'); - if (indexOfSplit == -1) + if (indexOfSplit == -1) return nvc; url = url.Substring(indexOfSplit + 1); diff --git a/CelesteNet.Server.FrontendModule/FrontendSettings.cs b/CelesteNet.Server.FrontendModule/FrontendSettings.cs index a9ba2c55..4898cfa0 100644 --- a/CelesteNet.Server.FrontendModule/FrontendSettings.cs +++ b/CelesteNet.Server.FrontendModule/FrontendSettings.cs @@ -24,12 +24,19 @@ public class FrontendSettings : CelesteNetServerModuleSettings { // TODO: Separate Discord auth module! [YamlIgnore] - public string DiscordOAuthURL => $"https://discord.com/oauth2/authorize?client_id={DiscordOAuthClientID}&redirect_uri={Uri.EscapeDataString(DiscordOAuthRedirectURL)}&response_type=code&scope=identify"; + public string DiscordOAuthURL => $"https://discord.com/oauth2/authorize?client_id={DiscordOAuthClientID}&redirect_uri={Uri.EscapeDataString(DiscordOAuthRedirectURL)}&response_type=code&scope=identify&state=discord"; [YamlIgnore] - public string DiscordOAuthRedirectURL => $"{CanonicalAPIRoot}/discordauth"; + public string DiscordOAuthRedirectURL => $"{CanonicalAPIRoot}/standardauth"; public string DiscordOAuthClientID { get; set; } = ""; public string DiscordOAuthClientSecret { get; set; } = ""; + [YamlIgnore] + public string TwitchOAuthURL => $"https://id.twitch.tv/oauth2/authorize?response_type=code&client_id={TwitchOAuthClientID}&redirect_uri={Uri.EscapeDataString(TwitchOAuthRedirectURL)}&response_type=code&state=twitch"; + [YamlIgnore] + public string TwitchOAuthRedirectURL => $"{CanonicalAPIRoot}/standardauth"; + public string TwitchOAuthClientID { get; set; } = ""; + public string TwitchOAuthClientSecret { get; set; } = ""; + public HashSet ExecOnlySettings { get; set; } = new(); } diff --git a/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs b/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs index 7fc8d1d7..31fa2dbd 100644 --- a/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs +++ b/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Specialized; using System.IO; using System.Linq; @@ -17,16 +18,53 @@ namespace Celeste.Mod.CelesteNet.Server.Control { public static partial class RCEndpoints { - [RCEndpoint(false, "/discordauth", "", "", "Discord OAuth2", "User auth using Discord.")] - public static void DiscordOAuth(Frontend f, HttpRequestEventArgs c) { + [RCEndpoint(false, "/standardauth", "", "", "OAuth2", "User auth for all platforms.")] + public static void StandardOAuth(Frontend f, HttpRequestEventArgs c) { + string[] providers = { "discord", "twitch" }; NameValueCollection args = f.ParseQueryString(c.Request.RawUrl); + Logger.Log(LogLevel.DBG, "frontend-standardauth", $"{c.Request.RawUrl}"); + Logger.Log(LogLevel.DBG, "frontend-standardauth", $"{f.ParseQueryString(c.Request.RawUrl)}"); if (args.Count == 0) { - // c.Response.Redirect(f.Settings.OAuthURL); - c.Response.StatusCode = (int) HttpStatusCode.Redirect; - c.Response.Headers.Set("Location", f.Settings.DiscordOAuthURL); + c.Response.StatusCode = (int) HttpStatusCode.Unauthorized; f.RespondJSON(c, new { - Info = $"Redirecting to {f.Settings.DiscordOAuthURL}" + Error = "Unauthorized - no OAuth provider stated." + }); + return; + } + + if (args.Count == 1) { + if (args["state"] == "discord") { + // c.Response.Redirect(f.Settings.OAuthURL); + c.Response.StatusCode = (int)HttpStatusCode.Redirect; + c.Response.Headers.Set("Location", f.Settings.DiscordOAuthURL); + f.RespondJSON(c, new + { + Info = $"Redirecting to {f.Settings.DiscordOAuthURL}" + }); + return; + } + + if (args["state"] == "twitch") + { + // c.Response.Redirect(f.Settings.OAuthURL); + c.Response.StatusCode = (int)HttpStatusCode.Redirect; + c.Response.Headers.Set("Location", f.Settings.TwitchOAuthURL); + f.RespondJSON(c, new + { + Info = $"Redirecting to {f.Settings.TwitchOAuthURL}" + }); + + return; + } + } + + if (args["state"].IsNullOrEmpty() || (args["state"] != "discord" && args["state"] != "twitch")) + { + c.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + f.RespondJSON(c, new + { + Error = "Unauthorized - Invalid OAuth provider." }); return; } @@ -53,106 +91,196 @@ public static void DiscordOAuth(Frontend f, HttpRequestEventArgs c) { dynamic? tokenData; dynamic? userData; - - using (HttpClient client = new()) { + + string requestUri = args["state"] == "discord" ? "https://discord.com/api/oauth2/token" : + args["state"] == "twitch" ? "https://id.twitch.tv/oauth2/token" : ""; + string clientId = args["state"] == "discord" ? f.Settings.DiscordOAuthClientID : + args["state"] == "twitch" ? f.Settings.TwitchOAuthClientID : ""; + string clientSecret = args["state"] == "discord" ? f.Settings.DiscordOAuthClientSecret : + args["state"] == "twitch" ? f.Settings.TwitchOAuthClientSecret : ""; + string redirectUri = args["state"] == "discord" ? f.Settings.DiscordOAuthRedirectURL : + args["state"] == "twitch" ? f.Settings.TwitchOAuthRedirectURL : ""; + string scopes = args["state"] == "discord" ? "identity" : args["state"] == "twitch" ? "user:read:chat" : ""; + + using (HttpClient client = new()) + { #pragma warning disable CS8714 // new FormUrlEncodedContent expects nullable. - using (Stream s = client.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(new Dictionary() { + using (Stream s = client.PostAsync(requestUri, + new FormUrlEncodedContent(new Dictionary() + { #pragma warning restore CS8714 - { "client_id", f.Settings.DiscordOAuthClientID }, - { "client_secret", f.Settings.DiscordOAuthClientSecret }, - { "grant_type", "authorization_code" }, - { "code", code }, - { "redirect_uri", f.Settings.DiscordOAuthRedirectURL }, - { "scope", "identity" } - })).Await().Content.ReadAsStreamAsync().Await()) - using (StreamReader sr = new(s)) - using (JsonTextReader jtr = new(sr)) - tokenData = f.Serializer.Deserialize(jtr); - - if (tokenData?.access_token?.ToString() is not string token || - tokenData?.token_type?.ToString() is not string tokenType || - token.IsNullOrEmpty() || - tokenType.IsNullOrEmpty()) { - Logger.Log(LogLevel.CRI, "frontend-discordauth", $"Failed to obtain token: {tokenData}"); - c.Response.StatusCode = (int) HttpStatusCode.InternalServerError; - f.RespondJSON(c, new { - Error = "Couldn't obtain access token from Discord." + { "client_id", clientId }, + { "client_secret", clientSecret }, + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", redirectUri }, + { "scope", scopes } + })).Await().Content.ReadAsStreamAsync().Await()) + using (StreamReader sr = new(s)) + using (JsonTextReader jtr = new(sr)) + tokenData = f.Serializer.Deserialize(jtr); + + Logger.Log(LogLevel.DBG, "frontend-standardauth", $"Request URI: {requestUri}"); + + if (tokenData?.access_token?.ToString() is not string token || + tokenData?.token_type?.ToString() is not string tokenType || + token.IsNullOrEmpty() || + tokenType.IsNullOrEmpty()) + { + Logger.Log(LogLevel.CRI, "frontend-standardauth", $"Failed to obtain token: {tokenData}"); + c.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + f.RespondJSON(c, new + { + Error = "Couldn't obtain access token." + }); + return; + } + + // Twitch hates it if "bearer" is lowercase + if (tokenType == "bearer" && args["state"] == "twitch") + tokenType = "Bearer"; + + requestUri = args["state"] == "discord" ? "https://discord.com/api/users/@me" : + args["state"] == "twitch" ? "https://api.twitch.tv/helix/users" : ""; + + var request = new HttpRequestMessage + { + RequestUri = new Uri(requestUri), + Method = HttpMethod.Get + }; + + if (args["state"] == "discord") + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(tokenType, token); + } else if (args["state"] == "twitch") + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(tokenType, token); + request.Headers.Add("Client-ID", f.Settings.TwitchOAuthClientID); + } + + using (Stream s = client.SendAsync(request).Await().Content.ReadAsStreamAsync().Await()) + using (StreamReader sr = new(s)) + using (JsonTextReader jtr = new(sr)) + userData = f.Serializer.Deserialize(jtr); + } + + if (!string.IsNullOrEmpty(userData?.error)) + { + Logger.Log(LogLevel.CRI, "frontend-standardauth", $"Status: {userData?.status}. Error: {userData?.error}"); + c.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + f.RespondJSON(c, new + { + Error = $"The OAuth provider you have chosen returned an error with status code {userData?.status}. This means that it came back as {userData?.error}" + }); + return; + } + try + { + userData = userData?.data[0]; + } catch (Exception ex) { + Logger.Log(LogLevel.DEV, "frontend-standardauth", $"No \"data\" array in userData: {ex}"); + } + + if (!(userData?.id?.ToString() is string uid) || + uid.IsNullOrEmpty()) + { + Logger.Log(LogLevel.CRI, "frontend-standardauth", $"Failed to obtain ID: {userData}"); + c.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + f.RespondJSON(c, new + { + Error = "Couldn't obtain user ID." }); return; } + string pfpId = uid; + uid = $"{uid}-{args["state"]}"; + string key = f.Server.UserData.Create(uid, false); + BasicUserInfo info = f.Server.UserData.Load(uid); - using (Stream s = client.SendAsync(new HttpRequestMessage { - RequestUri = new("https://discord.com/api/users/@me"), - Method = HttpMethod.Get, - Headers = { - { "Authorization", $"{tokenType} {token}" } - } - }).Await().Content.ReadAsStreamAsync().Await()) - using (StreamReader sr = new(s)) - using (JsonTextReader jtr = new(sr)) - userData = f.Serializer.Deserialize(jtr); - } - - if (!(userData?.id?.ToString() is string uid) || - uid.IsNullOrEmpty()) { - Logger.Log(LogLevel.CRI, "frontend-discordauth", $"Failed to obtain ID: {userData}"); - c.Response.StatusCode = (int) HttpStatusCode.InternalServerError; - f.RespondJSON(c, new { - Error = "Couldn't obtain user ID from Discord." - }); - return; - } + if ((userData.global_name?.ToString() ?? userData.display_name?.ToString() ?? userData.login?.ToString()) is string global_name && !global_name.IsNullOrEmpty()) + { + info.Name = global_name; + } + else + { + info.Name = userData.username.ToString(); + } - string key = f.Server.UserData.Create(uid, false); - BasicUserInfo info = f.Server.UserData.Load(uid); + if (info.Name.Length > 32) + { + info.Name = info.Name.Substring(0, 32); + } - if (userData.global_name?.ToString() is string global_name && !global_name.IsNullOrEmpty()) { - info.Name = global_name; - } else { - info.Name = userData.username.ToString(); - } - if (info.Name.Length > 32) { - info.Name = info.Name.Substring(0, 32); - } - info.Discrim = userData.discriminator.ToString(); - f.Server.UserData.Save(uid, info); - - Image avatarOrig; - using (HttpClient client = new()) { - try { - using Stream s = client.GetAsync( - $"https://cdn.discordapp.com/avatars/{uid}/{userData.avatar.ToString()}.png?size=64" - ).Await().Content.ReadAsStreamAsync().Await(); - avatarOrig = Image.Load(s); - } catch { - using Stream s = client.GetAsync( - $"https://cdn.discordapp.com/embed/avatars/{((int) userData.discriminator) % 6}.png" - ).Await().Content.ReadAsStreamAsync().Await(); - avatarOrig = Image.Load(s); + // Since ALL discord accounts do not have discriminators, we can seperate DISCORD and TWITCH accounts with the discrim value + info.Discrim = args["state"]?.ToUpper() ?? "FALLBACK"; + f.Server.UserData.Save(uid, info); + + Image avatarOrig; + using (HttpClient client = new()) + { + if (args["state"] == "discord") { + try + { + using Stream s = client.GetAsync( + $"https://cdn.discordapp.com/avatars/{pfpId}/{userData.avatar.ToString()}.png?size=64" + ).Await().Content.ReadAsStreamAsync().Await(); + avatarOrig = Image.Load(s); + } + catch + { + using Stream s = client.GetAsync( + $"https://cdn.discordapp.com/embed/avatars/{((int)userData.discriminator) % 6}.png" + ).Await().Content.ReadAsStreamAsync().Await(); + avatarOrig = Image.Load(s); + } + } else if (args["state"] == "twitch") + { + try + { + using Stream s = client.GetAsync( + $"{userData?.profile_image_url}" + ).Await().Content.ReadAsStreamAsync().Await(); + avatarOrig = Image.Load(s); + } + catch + { + using Stream s = client.GetAsync( + $"https://static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-300x300.png" + ).Await().Content.ReadAsStreamAsync().Await(); + avatarOrig = Image.Load(s); + } + } + else + { + using Stream s = client.GetAsync( + $"https://static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-300x300.png" + ).Await().Content.ReadAsStreamAsync().Await(); + avatarOrig = Image.Load(s); + } } - } - using (avatarOrig) - using (Image avatarScale = avatarOrig.Clone(x => x.Resize(64, 64, sampler: KnownResamplers.Lanczos3))) - using (Image avatarFinal = avatarScale.Clone(x => x.ApplyRoundedCorners().ApplyTagOverlays(f, info))) { + using (avatarOrig) + using (Image avatarScale = avatarOrig.Clone(x => x.Resize(64, 64, sampler: KnownResamplers.Lanczos3))) + using (Image avatarFinal = avatarScale.Clone(x => x.ApplyRoundedCorners().ApplyTagOverlays(f, info))) + { - using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.orig.png")) - avatarScale.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha }); + using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.orig.png")) + avatarScale.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha }); - using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.png")) - avatarFinal.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha }); - } + using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.png")) + avatarFinal.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha }); + } - c.Response.StatusCode = (int) HttpStatusCode.Redirect; - c.Response.Headers.Set("Location", $"http://{c.Request.UserHostName}/"); - f.SetKeyCookie(c, key); - f.SetDiscordAuthCookie(c, code); - f.RespondJSON(c, new { - Info = "Success - redirecting to /" - }); + c.Response.StatusCode = (int)HttpStatusCode.Redirect; + c.Response.Headers.Set("Location", $"http://{c.Request.UserHostName}/"); + f.SetKeyCookie(c, key); + f.SetDiscordAuthCookie(c, code); + f.RespondJSON(c, new + { + Info = "Success - redirecting to /" + }); } - private static IImageProcessingContext ApplyTagOverlays(this IImageProcessingContext context, Frontend f, BasicUserInfo info) { foreach (string tagName in info.Tags) { using Stream? asset = f.OpenContent($"frontend/assets/tags/{tagName}.png", out _, out _, out _);