feat: log in with tumblr
This commit is contained in:
parent
d30ebacc72
commit
3338243cea
10 changed files with 342 additions and 9 deletions
|
@ -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")]
|
||||||
|
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
111
Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs
Normal file
111
Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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: "/" });
|
||||||
|
|
|
@ -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"),
|
||||||
|
};
|
|
@ -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>
|
|
@ -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);
|
||||||
|
};
|
Loading…
Reference in a new issue