diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 8016a1f..172ef9a 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -33,6 +33,7 @@ public class AuthController( ); string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); string? discord = null; + string? google = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) { discord = @@ -42,7 +43,17 @@ public class AuthController( + $"&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")] diff --git a/Foxnouns.Backend/Controllers/Authentication/GoogleAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/GoogleAuthController.cs new file mode 100644 index 0000000..04c0b7d --- /dev/null +++ b/Foxnouns.Backend/Controllers/Authentication/GoogleAuthController.cs @@ -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(); + + [HttpPost("callback")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task 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(StatusCodes.Status200OK)] + public async Task RegisterAsync([FromBody] OauthRegisterRequest req) + { + RemoteAuthService.RemoteUser? remoteUser = + await keyCacheService.GetKeyAsync($"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 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 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."); + } + } +} diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs new file mode 100644 index 0000000..d4d6f6a --- /dev/null +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Discord.cs @@ -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 . + +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 RequestDiscordTokenAsync( + string code, + CancellationToken ct = default + ) + { + var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; + HttpResponseMessage resp = await _httpClient.PostAsync( + _discordTokenUri, + new FormUrlEncodedContent( + new Dictionary + { + { "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( + 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(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); +} diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs new file mode 100644 index 0000000..bcd881d --- /dev/null +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.Google.cs @@ -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 . + +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 RequestGoogleTokenAsync( + string code, + CancellationToken ct = default + ) + { + var redirectUri = $"{config.BaseUrl}/auth/callback/google"; + HttpResponseMessage resp = await _httpClient.PostAsync( + _googleTokenUri, + new FormUrlEncodedContent( + new Dictionary + { + { "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(ct); + if (token == null) + throw new FoxnounsError("Google token response was null"); + + byte[] rawIdToken = Convert.FromBase64String(token.IdToken.Split(".")[1]); + GoogleUser? user = JsonSerializer.Deserialize( + 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 + ); +} diff --git a/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs index c3ca685..98fb61a 100644 --- a/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Services.Auth; -public class RemoteAuthService( +public partial class RemoteAuthService( Config config, ILogger logger, DatabaseContext db, @@ -20,75 +20,6 @@ public class RemoteAuthService( private readonly ILogger _logger = logger.ForContext(); 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 RequestDiscordTokenAsync( - string code, - CancellationToken ct = default - ) - { - var redirectUri = $"{config.BaseUrl}/auth/callback/discord"; - HttpResponseMessage resp = await _httpClient.PostAsync( - _discordTokenUri, - new FormUrlEncodedContent( - new Dictionary - { - { "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( - 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(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); /// diff --git a/Foxnouns.Frontend/src/hooks.server.ts b/Foxnouns.Frontend/src/hooks.server.ts index eb1f93d..e8ec723 100644 --- a/Foxnouns.Frontend/src/hooks.server.ts +++ b/Foxnouns.Frontend/src/hooks.server.ts @@ -3,7 +3,6 @@ import { PUBLIC_API_BASE } from "$env/static/public"; import type { HandleFetch } from "@sveltejs/kit"; 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`)) { request = new Request(request.url.replace(PUBLIC_API_BASE, PRIVATE_INTERNAL_API_HOST), request); } else if (request.url.startsWith(PUBLIC_API_BASE)) { diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index f3a7d87..df0fd1b 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -52,7 +52,9 @@ "register-with-email": "Register with an email address", "email-label": "Your email address", "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": { "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 fdf8885..3e7b36e 100644 --- a/Foxnouns.Frontend/src/lib/index.ts +++ b/Foxnouns.Frontend/src/lib/index.ts @@ -3,7 +3,8 @@ import type { Cookies } from "@sveltejs/kit"; 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) => cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" }); diff --git a/Foxnouns.Frontend/src/routes/auth/callback/google/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/callback/google/+page.server.ts new file mode 100644 index 0000000..49f963c --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/callback/google/+page.server.ts @@ -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"), +}; diff --git a/Foxnouns.Frontend/src/routes/auth/callback/google/+page.svelte b/Foxnouns.Frontend/src/routes/auth/callback/google/+page.svelte new file mode 100644 index 0000000..284806a --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/callback/google/+page.svelte @@ -0,0 +1,31 @@ + + + + {$t("auth.register-with-google")} • pronouns.cc + + +
+ {#if data.error} +

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

+ + {:else if data.isLinkRequest} + + {:else} + + {/if} +
diff --git a/Foxnouns.Frontend/src/routes/settings/auth/add-google/+page.server.ts b/Foxnouns.Frontend/src/routes/settings/auth/add-google/+page.server.ts new file mode 100644 index 0000000..ca07805 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/settings/auth/add-google/+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/google/add-account", { + isInternal: true, + fetch, + cookies, + }); + + redirect(303, url); +};