feat: log in with tumblr

This commit is contained in:
sam 2024-12-09 21:48:07 +01:00
parent d30ebacc72
commit 3338243cea
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
10 changed files with 342 additions and 9 deletions

View file

@ -48,6 +48,7 @@ public class AuthController(
string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct));
string? discord = null; string? discord = null;
string? google = null; string? google = null;
string? tumblr = null;
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
{ {
discord = discord =
@ -67,7 +68,16 @@ public class AuthController(
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}"; + $"&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")] [HttpPost("force-log-out")]

View file

@ -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<TumblrAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"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<IActionResult> 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<IActionResult> 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.");
}
}
}

View file

@ -53,14 +53,12 @@ public partial class RemoteAuthService
throw new FoxnounsError("Invalid Discord OAuth response"); throw new FoxnounsError("Invalid Discord OAuth response");
} }
DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>( OauthTokenResponse? token = await resp.Content.ReadFromJsonAsync<OauthTokenResponse>(ct);
ct
);
if (token == null) if (token == null)
throw new FoxnounsError("Discord token response was null"); throw new FoxnounsError("Discord token response was null");
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); 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); HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
resp2.EnsureSuccessStatusCode(); resp2.EnsureSuccessStatusCode();

View file

@ -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 <https://www.gnu.org/licenses/>.
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<RemoteUser> RequestTumblrTokenAsync(
string code,
CancellationToken ct = default
)
{
var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr";
HttpResponseMessage resp = await _httpClient.PostAsync(
_tumblrTokenUri,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "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<OauthTokenResponse>(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<TumblrData>(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
);
}

View file

@ -19,7 +19,6 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Humanizer; using Humanizer;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Services.Auth; namespace Foxnouns.Backend.Services.Auth;

View file

@ -54,7 +54,9 @@
"confirm-password-label": "Confirm password", "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-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", "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": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",

View file

@ -3,8 +3,7 @@
import type { Cookies } from "@sveltejs/kit"; import type { Cookies } from "@sveltejs/kit";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
// export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token"; export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token";
export const TOKEN_COOKIE_NAME = "pronounscc-token";
export const setToken = (cookies: Cookies, token: string) => export const setToken = (cookies: Cookies, token: string) =>
cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" }); cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" });

View file

@ -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"),
};

View file

@ -0,0 +1,31 @@
<script lang="ts">
import Error from "$components/Error.svelte";
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.register-with-tumblr")} • pronouns.cc</title>
</svelte:head>
<div class="container">
{#if data.error}
<h1>{$t("auth.register-with-tumblr")}</h1>
<Error error={data.error} />
{:else if data.isLinkRequest}
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
{:else}
<OauthRegistrationForm
title={$t("auth.register-with-tumblr")}
remoteLabel={$t("auth.remote-tumblr-account-label")}
remoteUser={data.remoteUser!}
ticket={data.ticket!}
error={form?.error}
/>
{/if}
</div>

View file

@ -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);
};