feat: log in with google

This commit is contained in:
sam 2024-12-09 21:07:53 +01:00
parent bb2fa55cd5
commit 8a8b4caa18
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
11 changed files with 403 additions and 74 deletions

View file

@ -33,6 +33,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;
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
{ {
discord = discord =
@ -42,7 +43,17 @@ public class AuthController(
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}"; + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
} }
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null)); if (config.GoogleAuth is { ClientId: not null, ClientSecret: not null })
{
google =
"https://accounts.google.com/o/oauth2/auth?response_type=code"
+ $"&client_id={config.GoogleAuth.ClientId}"
+ $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
+ $"&prompt=select_account&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
}
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, null));
} }
[HttpPost("force-log-out")] [HttpPost("force-log-out")]

View file

@ -0,0 +1,164 @@
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/google")]
public class GoogleAuthController(
[UsedImplicitly] Config config,
ILogger logger,
DatabaseContext db,
KeyCacheService keyCacheService,
AuthService authService,
RemoteAuthService remoteAuthService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<GoogleAuthController>();
[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.RequestGoogleTokenAsync(
req.Code
);
User? user = await authService.AuthenticateUserAsync(AuthType.Google, remoteUser.Id);
if (user != null)
return Ok(await authService.GenerateUserTokenAsync(user));
_logger.Debug(
"Google user {Username} ({Id}) authenticated with no local account",
remoteUser.Username,
remoteUser.Id
);
string ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"google:{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>($"google:{req.Ticket}");
if (remoteUser == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
if (
await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Google && a.RemoteId == remoteUser.Id
)
)
{
_logger.Error(
"Google 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.Google,
remoteUser.Id,
remoteUser.Username
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
[HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddGoogleAccountAsync()
{
CheckRequirements();
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Google
);
string url =
"https://accounts.google.com/o/oauth2/auth?response_type=code"
+ $"&client_id={config.GoogleAuth.ClientId}"
+ $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
+ $"&prompt=select_account&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
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.Google
);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
req.Code
);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Google,
remoteUser.Id,
remoteUser.Username
);
_logger.Debug(
"Added new Google auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AddOauthAccountResponse(
authMethod.Id,
AuthType.Google,
authMethod.RemoteId,
authMethod.RemoteUsername
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
private void CheckRequirements()
{
if (!config.GoogleAuth.Enabled)
{
throw new ApiError.BadRequest("Google authentication is not enabled on this instance.");
}
}
}

View file

@ -0,0 +1,90 @@
// 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.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
namespace Foxnouns.Backend.Services.Auth;
public partial class RemoteAuthService
{
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
public async Task<RemoteUser> RequestDiscordTokenAsync(
string code,
CancellationToken ct = default
)
{
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
HttpResponseMessage resp = await _httpClient.PostAsync(
_discordTokenUri,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "client_id", config.DiscordAuth.ClientId! },
{ "client_secret", config.DiscordAuth.ClientSecret! },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "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 Discord OAuth response");
}
DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(
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}");
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
resp2.EnsureSuccessStatusCode();
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
if (user == null)
throw new FoxnounsError("Discord user response was null");
return new RemoteUser(user.id, user.username);
}
[SuppressMessage(
"ReSharper",
"InconsistentNaming",
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
)]
[UsedImplicitly]
private record DiscordTokenResponse(string access_token, string token_type);
[SuppressMessage(
"ReSharper",
"InconsistentNaming",
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
)]
[UsedImplicitly]
private record DiscordUserResponse(string id, string username);
}

View file

@ -0,0 +1,80 @@
// 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.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Foxnouns.Backend.Services.Auth;
public partial class RemoteAuthService
{
private readonly Uri _googleTokenUri = new("https://oauth2.googleapis.com/token");
public async Task<RemoteUser> RequestGoogleTokenAsync(
string code,
CancellationToken ct = default
)
{
var redirectUri = $"{config.BaseUrl}/auth/callback/google";
HttpResponseMessage resp = await _httpClient.PostAsync(
_googleTokenUri,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "client_id", config.GoogleAuth.ClientId! },
{ "client_secret", config.GoogleAuth.ClientSecret! },
{ "grant_type", "authorization_code" },
{ "scope", "openid https://www.googleapis.com/auth/userinfo.email" },
{ "code", code },
{ "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 Google OAuth response");
}
GoogleTokenResponse? token = await resp.Content.ReadFromJsonAsync<GoogleTokenResponse>(ct);
if (token == null)
throw new FoxnounsError("Google token response was null");
byte[] rawIdToken = Convert.FromBase64String(token.IdToken.Split(".")[1]);
GoogleUser? user = JsonSerializer.Deserialize<GoogleUser>(
Encoding.UTF8.GetString(rawIdToken)
);
if (user == null)
throw new FoxnounsError("Google user was null");
return new RemoteUser(user.Id, user.Email);
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
private record GoogleTokenResponse([property: JsonPropertyName("id_token")] string IdToken);
private record GoogleUser(
[property: JsonPropertyName("sub")] string Id,
[property: JsonPropertyName("email")] string Email
);
}

View file

@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Services.Auth; namespace Foxnouns.Backend.Services.Auth;
public class RemoteAuthService( public partial class RemoteAuthService(
Config config, Config config,
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
@ -20,75 +20,6 @@ public class RemoteAuthService(
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>(); private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
private readonly HttpClient _httpClient = new(); private readonly HttpClient _httpClient = new();
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
public async Task<RemoteUser> RequestDiscordTokenAsync(
string code,
CancellationToken ct = default
)
{
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
HttpResponseMessage resp = await _httpClient.PostAsync(
_discordTokenUri,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "client_id", config.DiscordAuth.ClientId! },
{ "client_secret", config.DiscordAuth.ClientSecret! },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "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 Discord OAuth response");
}
resp.EnsureSuccessStatusCode();
DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(
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}");
HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct);
resp2.EnsureSuccessStatusCode();
DiscordUserResponse? user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
if (user == null)
throw new FoxnounsError("Discord user response was null");
return new RemoteUser(user.id, user.username);
}
[SuppressMessage(
"ReSharper",
"InconsistentNaming",
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
)]
[UsedImplicitly]
private record DiscordTokenResponse(string access_token, string token_type);
[SuppressMessage(
"ReSharper",
"InconsistentNaming",
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
)]
[UsedImplicitly]
private record DiscordUserResponse(string id, string username);
public record RemoteUser(string Id, string Username); public record RemoteUser(string Id, string Username);
/// <summary> /// <summary>

View file

@ -3,7 +3,6 @@ import { PUBLIC_API_BASE } from "$env/static/public";
import type { HandleFetch } from "@sveltejs/kit"; import type { HandleFetch } from "@sveltejs/kit";
export const handleFetch: HandleFetch = async ({ request, fetch }) => { export const handleFetch: HandleFetch = async ({ request, fetch }) => {
console.log(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST, PRIVATE_API_HOST);
if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) { if (request.url.startsWith(`${PUBLIC_API_BASE}/internal`)) {
request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST), request); request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST), request);
} else if (request.url.startsWith(PUBLIC_API_BASE)) { } else if (request.url.startsWith(PUBLIC_API_BASE)) {

View file

@ -52,7 +52,9 @@
"register-with-email": "Register with an email address", "register-with-email": "Register with an email address",
"email-label": "Your email address", "email-label": "Your email address",
"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",
"remote-google-account-label": "Your Google account"
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",

View file

@ -3,7 +3,8 @@
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("google");
export const actions = {
default: createRegisterAction("/auth/google/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-google")} • pronouns.cc</title>
</svelte:head>
<div class="container">
{#if data.error}
<h1>{$t("auth.register-with-google")}</h1>
<Error error={data.error} />
{:else if data.isLinkRequest}
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
{:else}
<OauthRegistrationForm
title={$t("auth.register-with-google")}
remoteLabel={$t("auth.remote-google-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/google/add-account", {
isInternal: true,
fetch,
cookies,
});
redirect(303, url);
};