diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 09bd152..99d4c3d 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -48,6 +48,7 @@ public class AuthController( string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); string? discord = null; string? google = null; + string? tumblr = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) { discord = @@ -67,7 +68,16 @@ public class AuthController( + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}"; } - return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, null)); + if (config.TumblrAuth is { ClientId: not null, ClientSecret: not null }) + { + tumblr = + "https://www.tumblr.com/oauth2/authorize?response_type=code" + + $"&client_id={config.TumblrAuth.ClientId}" + + $"&scope=basic&state={state}" + + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}"; + } + + return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, tumblr)); } [HttpPost("force-log-out")] diff --git a/Foxnouns.Backend/Controllers/Authentication/TumblrAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/TumblrAuthController.cs new file mode 100644 index 0000000..02abddd --- /dev/null +++ b/Foxnouns.Backend/Controllers/Authentication/TumblrAuthController.cs @@ -0,0 +1,163 @@ +using System.Net; +using System.Web; +using EntityFramework.Exceptions.Common; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto; +using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; +using Foxnouns.Backend.Services.Auth; +using Foxnouns.Backend.Utils; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace Foxnouns.Backend.Controllers.Authentication; + +[Route("/api/internal/auth/tumblr")] +public class TumblrAuthController( + [UsedImplicitly] Config config, + ILogger logger, + DatabaseContext db, + KeyCacheService keyCacheService, + AuthService authService, + RemoteAuthService remoteAuthService +) : ApiControllerBase +{ + private readonly ILogger _logger = logger.ForContext(); + + [HttpPost("callback")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task CallbackAsync([FromBody] CallbackRequest req) + { + CheckRequirements(); + await keyCacheService.ValidateAuthStateAsync(req.State); + + RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync( + req.Code + ); + User? user = await authService.AuthenticateUserAsync(AuthType.Tumblr, remoteUser.Id); + if (user != null) + return Ok(await authService.GenerateUserTokenAsync(user)); + + _logger.Debug( + "Tumblr user {Username} ({Id}) authenticated with no local account", + remoteUser.Username, + remoteUser.Id + ); + + string ticket = AuthUtils.RandomToken(); + await keyCacheService.SetKeyAsync($"tumblr:{ticket}", remoteUser, Duration.FromMinutes(20)); + + return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null)); + } + + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RegisterAsync([FromBody] OauthRegisterRequest req) + { + RemoteAuthService.RemoteUser? remoteUser = + await keyCacheService.GetKeyAsync($"tumblr:{req.Ticket}"); + if (remoteUser == null) + throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); + if ( + await db.AuthMethods.AnyAsync(a => + a.AuthType == AuthType.Tumblr && a.RemoteId == remoteUser.Id + ) + ) + { + _logger.Error( + "Tumblr user {Id} has valid ticket but is already linked to an existing account", + remoteUser.Id + ); + throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); + } + + User user = await authService.CreateUserWithRemoteAuthAsync( + req.Username, + AuthType.Tumblr, + remoteUser.Id, + remoteUser.Username + ); + + return Ok(await authService.GenerateUserTokenAsync(user)); + } + + [HttpGet("add-account")] + [Authorize("*")] + public async Task AddTumblrAccountAsync() + { + CheckRequirements(); + + string state = await remoteAuthService.ValidateAddAccountRequestAsync( + CurrentUser!.Id, + AuthType.Tumblr + ); + + string url = + "https://www.tumblr.com/oauth2/authorize?response_type=code" + + $"&client_id={config.TumblrAuth.ClientId}" + + $"&scope=basic&state={state}" + + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}"; + + return Ok(new SingleUrlResponse(url)); + } + + [HttpPost("add-account/callback")] + [Authorize("*")] + public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req) + { + CheckRequirements(); + + await remoteAuthService.ValidateAddAccountStateAsync( + req.State, + CurrentUser!.Id, + AuthType.Tumblr + ); + + RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync( + req.Code + ); + try + { + AuthMethod authMethod = await authService.AddAuthMethodAsync( + CurrentUser.Id, + AuthType.Tumblr, + remoteUser.Id, + remoteUser.Username + ); + _logger.Debug( + "Added new Tumblr auth method {AuthMethodId} to user {UserId}", + authMethod.Id, + CurrentUser.Id + ); + + return Ok( + new AddOauthAccountResponse( + authMethod.Id, + AuthType.Tumblr, + authMethod.RemoteId, + authMethod.RemoteUsername + ) + ); + } + catch (UniqueConstraintException) + { + throw new ApiError( + "That account is already linked.", + HttpStatusCode.BadRequest, + ErrorCode.AccountAlreadyLinked + ); + } + } + + private void CheckRequirements() + { + if (!config.TumblrAuth.Enabled) + { + throw new ApiError.BadRequest("Tumblr authentication is not enabled on this instance."); + } + } +} diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs index c06a6f6..e43ea21 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs @@ -53,14 +53,12 @@ public partial class RemoteAuthService throw new FoxnounsError("Invalid Discord OAuth response"); } - DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync( - ct - ); + OauthTokenResponse? token = await resp.Content.ReadFromJsonAsync(ct); if (token == null) throw new FoxnounsError("Discord token response was null"); var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); - req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); + req.Headers.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); resp2.EnsureSuccessStatusCode(); diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs new file mode 100644 index 0000000..be752af --- /dev/null +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs @@ -0,0 +1,111 @@ +// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +using System.Text.Json.Serialization; + +namespace Foxnouns.Backend.Services.Auth; + +public partial class RemoteAuthService +{ + private readonly Uri _tumblrTokenUri = new("https://api.tumblr.com/v2/oauth2/token"); + private readonly Uri _tumblrUserUri = new("https://api.tumblr.com/v2/user/info"); + + public async Task RequestTumblrTokenAsync( + string code, + CancellationToken ct = default + ) + { + var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr"; + HttpResponseMessage resp = await _httpClient.PostAsync( + _tumblrTokenUri, + new FormUrlEncodedContent( + new Dictionary + { + { "client_id", config.TumblrAuth.ClientId! }, + { "client_secret", config.TumblrAuth.ClientSecret! }, + { "grant_type", "authorization_code" }, + { "code", code }, + { "scope", "basic" }, + { "redirect_uri", redirectUri }, + } + ), + ct + ); + if (!resp.IsSuccessStatusCode) + { + string respBody = await resp.Content.ReadAsStringAsync(ct); + _logger.Error( + "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", + (int)resp.StatusCode, + respBody + ); + throw new FoxnounsError("Invalid Tumblr OAuth response"); + } + + OauthTokenResponse? token = await resp.Content.ReadFromJsonAsync(ct); + if (token == null) + throw new FoxnounsError("Tumblr token response was null"); + + var req = new HttpRequestMessage(HttpMethod.Get, _tumblrUserUri); + req.Headers.Add("Authorization", $"Bearer {token.AccessToken}"); + + HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); + if (!resp2.IsSuccessStatusCode) + { + string respBody = await resp2.Content.ReadAsStringAsync(ct); + _logger.Error( + "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", + (int)resp2.StatusCode, + respBody + ); + throw new FoxnounsError("Invalid Tumblr user response"); + } + + TumblrData? data = await resp2.Content.ReadFromJsonAsync(ct); + if (data == null) + throw new FoxnounsError("Tumblr user response was null"); + + TumblrBlog? blog = data.Response.User.Blogs.FirstOrDefault(b => b.Primary); + if (blog == null) + throw new FoxnounsError("Tumblr user doesn't have a primary blog"); + + return new RemoteUser(blog.Uuid, blog.Name); + } + + private record OauthTokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("token_type")] string TokenType + ); + + // tumblr why + private record TumblrData( + [property: JsonPropertyName("meta")] TumblrMeta Meta, + [property: JsonPropertyName("response")] TumblrResponse Response + ); + + private record TumblrMeta( + [property: JsonPropertyName("status")] int Status, + [property: JsonPropertyName("msg")] string Message + ); + + private record TumblrResponse([property: JsonPropertyName("user")] TumblrUser User); + + private record TumblrUser([property: JsonPropertyName("blogs")] TumblrBlog[] Blogs); + + private record TumblrBlog( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("primary")] bool Primary, + [property: JsonPropertyName("uuid")] string Uuid + ); +} diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs index f50d15c..083faf3 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs @@ -19,7 +19,6 @@ using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Utils; using Humanizer; -using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Services.Auth; diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index df0fd1b..659007e 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -54,7 +54,9 @@ "confirm-password-label": "Confirm password", "register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue.", "register-with-google": "Register with a Google account", - "remote-google-account-label": "Your Google account" + "remote-google-account-label": "Your Google account", + "register-with-tumblr": "Register with a Tumblr account", + "remote-tumblr-account-label": "Your Tumblr account" }, "error": { "bad-request-header": "Something was wrong with your input", diff --git a/Foxnouns.Frontend/src/lib/index.ts b/Foxnouns.Frontend/src/lib/index.ts index 3e7b36e..fdf8885 100644 --- a/Foxnouns.Frontend/src/lib/index.ts +++ b/Foxnouns.Frontend/src/lib/index.ts @@ -3,8 +3,7 @@ import type { Cookies } from "@sveltejs/kit"; import { DateTime } from "luxon"; -// export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token"; -export const TOKEN_COOKIE_NAME = "pronounscc-token"; +export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token"; export const setToken = (cookies: Cookies, token: string) => cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" }); diff --git a/Foxnouns.Frontend/src/routes/auth/callback/tumblr/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/callback/tumblr/+page.server.ts new file mode 100644 index 0000000..49346f1 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/callback/tumblr/+page.server.ts @@ -0,0 +1,8 @@ +import createCallbackLoader from "$lib/actions/callback"; +import createRegisterAction from "$lib/actions/register"; + +export const load = createCallbackLoader("tumblr"); + +export const actions = { + default: createRegisterAction("/auth/tumblr/register"), +}; diff --git a/Foxnouns.Frontend/src/routes/auth/callback/tumblr/+page.svelte b/Foxnouns.Frontend/src/routes/auth/callback/tumblr/+page.svelte new file mode 100644 index 0000000..c7c53e9 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/callback/tumblr/+page.svelte @@ -0,0 +1,31 @@ + + + + {$t("auth.register-with-tumblr")} • pronouns.cc + + +
+ {#if data.error} +

{$t("auth.register-with-tumblr")}

+ + {:else if data.isLinkRequest} + + {:else} + + {/if} +
diff --git a/Foxnouns.Frontend/src/routes/settings/auth/add-tumblr/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/auth/add-tumblr/+page.server.ts new file mode 100644 index 0000000..75421b8 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/auth/add-tumblr/+page.server.ts @@ -0,0 +1,12 @@ +import { apiRequest } from "$api"; +import { redirect } from "@sveltejs/kit"; + +export const load = async ({ fetch, cookies }) => { + const { url } = await apiRequest<{ url: string }>("GET", "/auth/tumblr/add-account", { + isInternal: true, + fetch, + cookies, + }); + + redirect(303, url); +};