diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index e224be3..d944dd8 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -1,4 +1,5 @@ using System.Web; +using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; using NodaTime; @@ -34,11 +35,19 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger string? Tumblr ); - internal record AuthResponse( + public record AuthResponse( UserRendererService.UserResponse User, string Token, Instant ExpiresAt ); + public record CallbackResponse( + bool HasAccount, // If true, user has an account, but it's deleted + string Ticket, + string? RemoteUsername + ); + + public record OauthRegisterRequest(string Ticket, string Username); + public record CallbackRequest(string Code, string State); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index b3f93ae..1a46798 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -1,7 +1,10 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; @@ -18,6 +21,10 @@ public class DiscordAuthController( UserRendererService userRendererSvc) : ApiControllerBase { [HttpPost("callback")] + // TODO: duplicating attribute doesn't work, find another way to mark both as possible response + // leaving it here for documentation purposes + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req) { CheckRequirements(); @@ -30,7 +37,29 @@ public class DiscordAuthController( logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, remoteUser.Id); - throw new NotImplementedException(); + var ticket = OauthUtils.RandomToken(); + await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20)); + + return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username)); + } + + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) + { + var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}"); + if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket"); + if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) + { + logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", + remoteUser.Id); + throw new FoxnounsError("Discord ticket was issued for user with existing link"); + } + + var user = await authSvc.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id, + remoteUser.Username); + + return Ok(await GenerateUserTokenAsync(user)); } private async Task GenerateUserTokenAsync(User user) diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs new file mode 100644 index 0000000..bea0ec6 --- /dev/null +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -0,0 +1,21 @@ +using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; +using NodaTime; + +namespace Foxnouns.Backend.Extensions; + +public static class KeyCacheExtensions +{ + public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheSvc) + { + var state = OauthUtils.RandomToken(); + await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); + return state; + } + + public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state) + { + var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true); + if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index b3c623b..4f31480 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -6,7 +6,6 @@ using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -using NodaTime; // Read version information from .version in the repository root await BuildInfo.ReadBuildInfo(); diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 69c8dc0..a61fbb9 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -34,6 +34,37 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator return user; } + + /// + /// Creates a new user with the given username and remote authentication method. + /// To create a user with email authentication, use + /// This method does not save the resulting user, the caller must still call . + /// + public async Task CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId, + string remoteUsername, FediverseApplication? instance = null) + { + AssertValidAuthType(authType, instance); + + if (await db.Users.AnyAsync(u => u.Username == username)) + throw new ApiError.BadRequest("Username is already taken"); + + var user = new User + { + Id = snowflakeGenerator.GenerateSnowflake(), + Username = username, + AuthMethods = + { + new AuthMethod + { + Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId, + RemoteUsername = remoteUsername, FediverseApplication = instance + } + } + }; + + db.Add(user); + return user; + } /// /// Authenticates a user with email and password. @@ -81,10 +112,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator public async Task AuthenticateUserAsync(AuthType authType, string remoteId, FediverseApplication? instance = null) { - if (authType == AuthType.Fediverse && instance == null) - throw new FoxnounsError("Fediverse authentication requires an instance."); - if (authType != AuthType.Fediverse && instance != null) - throw new FoxnounsError("Non-Fediverse authentication does not require an instance."); + AssertValidAuthType(authType, instance); return await db.Users.FirstOrDefaultAsync(u => u.AuthMethods.Any(a => @@ -115,4 +143,12 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator return (token, hash); } + + private static void AssertValidAuthType(AuthType authType, FediverseApplication? instance) + { + if (authType == AuthType.Fediverse && instance == null) + throw new FoxnounsError("Fediverse authentication requires an instance."); + if (authType != AuthType.Fediverse && instance != null) + throw new FoxnounsError("Non-Fediverse authentication does not require an instance."); + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index fabc316..4b0d4b3 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -2,6 +2,7 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; using NodaTime; namespace Foxnouns.Backend.Services; @@ -42,16 +43,18 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) if (count != 0) logger.Information("Removed {Count} expired keys from the database", count); } - public async Task GenerateAuthStateAsync() + public Task SetKeyAsync(string key, T obj, Duration expiresAt) where T : class => + SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt); + + public async Task SetKeyAsync(string key, T obj, Instant expires) where T : class { - var state = OauthUtils.RandomToken(); - await SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); - return state; + var value = JsonConvert.SerializeObject(obj); + await SetKeyAsync(key, value, expires); } - public async Task ValidateAuthStateAsync(string state) + public async Task GetKeyAsync(string key, bool delete = false) where T : class { - var val = await GetKeyAsync($"oauth_state:{state}", delete: true); - if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); + var value = await GetKeyAsync(key, delete: false); + return value == null ? default : JsonConvert.DeserializeObject(value); } } \ No newline at end of file diff --git a/Foxnouns.Frontend/src/lib/api/auth.ts b/Foxnouns.Frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..9e5b2d1 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/api/auth.ts @@ -0,0 +1,18 @@ +import type { User } from "./user"; + +export type CallbackRequest = { + code: string; + state: string; +}; + +export type CallbackResponse = { + has_account: boolean; + ticket: string; + remote_username: string | null; +}; + +export type AuthResponse = { + user: User; + token: string; + expires_at: string; +}; diff --git a/Foxnouns.Frontend/src/routes/+layout.server.ts b/Foxnouns.Frontend/src/routes/+layout.server.ts index 2d1e4ba..78f6517 100644 --- a/Foxnouns.Frontend/src/routes/+layout.server.ts +++ b/Foxnouns.Frontend/src/routes/+layout.server.ts @@ -2,12 +2,12 @@ import type Meta from "$lib/api/meta"; import type { User } from "$lib/api/user"; import request from "$lib/request"; -export async function load({ fetch }) { +export async function load({ fetch, locals }) { const meta = await request(fetch, "GET", "/meta"); let user: User | undefined; try { user = await request(fetch, "GET", "/users/@me"); } catch {} - return { meta, user }; + return { meta, user, token: locals.token }; } diff --git a/Foxnouns.Frontend/src/routes/auth/login/+page.svelte b/Foxnouns.Frontend/src/routes/auth/login/+page.svelte index 14e4040..bead6b4 100644 --- a/Foxnouns.Frontend/src/routes/auth/login/+page.svelte +++ b/Foxnouns.Frontend/src/routes/auth/login/+page.svelte @@ -9,7 +9,7 @@

Log in with email address

-
+
diff --git a/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts index 2ebde3c..75e97ec 100644 --- a/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts +++ b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.server.ts @@ -1,10 +1,55 @@ -import { fastRequest } from "$lib/request"; +import request from "$lib/request"; +import type { AuthResponse, CallbackResponse } from "$lib/api/auth"; -export const load = async ({ fetch, url }) => { - await fastRequest(fetch, "POST", "/auth/discord/callback", { - body: { - code: url.searchParams.get("code"), - state: url.searchParams.get("state"), +export const load = async ({ fetch, url, cookies, parent }) => { + const data = await parent(); + if (data.user) { + return { loggedIn: true, token: data.token, user: data.user }; + } + + const resp = await request( + fetch, + "POST", + "/auth/discord/callback", + { + body: { + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }, }, - }); + ); + + console.log(JSON.stringify(resp)); + + if ("token" in resp) { + const authResp = resp as AuthResponse; + cookies.set("pronounscc-token", authResp.token, { path: "/" }); + return { loggedIn: true, token: authResp.token, user: authResp.user }; + } + + const callbackResp = resp as CallbackResponse; + return { + loggedIn: false, + hasAccount: callbackResp.has_account, + ticket: resp.ticket, + remoteUsername: resp.remote_username, + }; +}; + +export const actions = { + register: async ({ cookies, request: req, fetch, locals }) => { + const data = await req.formData(); + const username = data.get("username"); + const ticket = data.get("ticket"); + + console.log(JSON.stringify({ username, ticket })); + + const resp = await request(fetch, "POST", "/auth/discord/register", { + body: { username, ticket }, + }); + cookies.set("pronounscc-token", resp.token, { path: "/" }); + locals.token = resp.token; + + return { token: resp.token, user: resp.user }; + }, }; diff --git a/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte index 3cb3fbc..c7968b1 100644 --- a/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte +++ b/Foxnouns.Frontend/src/routes/auth/login/discord/+page.svelte @@ -1,5 +1,79 @@ -

omg its a login page

+
+ {#if form?.user} +

Successfully created account!

+

Welcome, @{form.user.username}!

+

+ You should automatically be redirected to your profile in a few seconds. If you're not + redirected, please press the link above. +

+ {:else if data.loggedIn} +

Successfully logged in!

+

You are now logged in as @{data.user?.username}.

+

+ You should automatically be redirected to your profile in a few seconds. If you're not + redirected, please press the link above. +

+ {:else} +

Finish signing up with a Discord account

+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+
+ + {/if} +
diff --git a/Foxnouns.Frontend/svelte.config.js b/Foxnouns.Frontend/svelte.config.js index 626d6f1..446cf25 100644 --- a/Foxnouns.Frontend/svelte.config.js +++ b/Foxnouns.Frontend/svelte.config.js @@ -15,6 +15,9 @@ const config = { env: { privatePrefix: "PRIVATE_", }, + csrf: { + checkOrigin: false, + }, }, };