From 2cbd35be45dfaa4040626eaddd9c52c368a59bb2 Mon Sep 17 00:00:00 2001 From: vnugent Date: Thu, 16 May 2024 22:11:33 -0400 Subject: first ideas on an iframe widget for favorites --- back-end/src/Endpoints/WidgetEndpoint.cs | 85 ++++++++++++++ back-end/src/Model/Widget/WidgetAuthManager.cs | 152 +++++++++++++++++++++++++ back-end/src/SimpleBookmark.json | 30 +++-- back-end/src/SimpleBookmarkEntry.cs | 1 + 4 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 back-end/src/Endpoints/WidgetEndpoint.cs create mode 100644 back-end/src/Model/Widget/WidgetAuthManager.cs (limited to 'back-end') diff --git a/back-end/src/Endpoints/WidgetEndpoint.cs b/back-end/src/Endpoints/WidgetEndpoint.cs new file mode 100644 index 0000000..d72030b --- /dev/null +++ b/back-end/src/Endpoints/WidgetEndpoint.cs @@ -0,0 +1,85 @@ +W// Copyright (C) 2024 Vaughn Nugent +// +// 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.Net; +using System.Threading.Tasks; + +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Essentials.Extensions; + +using SimpleBookmark.Model; +using SimpleBookmark.Model.Widget; + +namespace SimpleBookmark.Endpoints +{ + [ConfigurationName("widgets")] + internal sealed class WidgetEndpoint : UnprotectedWebEndpoint + { + private readonly BookmarkStore bookmarks; + private readonly WidgetAuthManager authManager; + private readonly bool Enabled; + private readonly string? CorsAclHeaerDomains; + + public WidgetEndpoint(PluginBase plugin, IConfigScope config) + { + string path = config.GetRequiredProperty("path", static p => p.GetString()!); + InitPathAndLog(path, plugin.Log.CreateScope("widget-endpoint")); + + Enabled = config.GetValueOrDefault("enabled", static p => p.GetBoolean(), false); + CorsAclHeaerDomains = config.GetValueOrDefault("cors-urls", static p => p.GetString(), null); + + bookmarks = plugin.GetOrCreateSingleton(); + authManager = plugin.GetOrCreateSingleton(); + } + + protected override async ValueTask GetAsync(HttpEntity entity) + { + if (!Enabled) + { + return VfReturnType.NotFound; + } + + /* if (!await authManager.IsTokenValidAsync(entity)) + { + return VirtualClose(entity, HttpStatusCode.Unauthorized); + }*/ + + //Widgets might be loaded in an iframe, so we need to allow cross-site requests + if(CorsAclHeaerDomains is not null && entity.Server.IsCrossSite()) + { + entity.Server.Headers.Append("Access-Control-Allow-Origin", CorsAclHeaerDomains); + } + + /* + * For now this just returns bookmarks with the "favorite" tag + * an assume the auth manager has set the session's user-id field + */ + + BookmarkEntry[] boomarks = await bookmarks.SearchBookmarksAsync( + entity.Session.UserID, + query: null, + tags: ["favorite"], + limit: 25, + page: 1, + entity.EventCancellation + ); + + return VirtualCloseJson(entity, boomarks, HttpStatusCode.OK); + } + } +} diff --git a/back-end/src/Model/Widget/WidgetAuthManager.cs b/back-end/src/Model/Widget/WidgetAuthManager.cs new file mode 100644 index 0000000..31e0037 --- /dev/null +++ b/back-end/src/Model/Widget/WidgetAuthManager.cs @@ -0,0 +1,152 @@ +// Copyright (C) 2024 Vaughn Nugent +// +// 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; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; + +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Utils; +using VNLib.Utils.Extensions; +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Sessions; +using VNLib.Plugins.Essentials.Users; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Loading.Users; +using VNLib.Plugins.Essentials.Extensions; + +namespace SimpleBookmark.Model.Widget +{ + [ConfigurationName("widgets")] + internal sealed class WidgetAuthManager(PluginBase plugin, IConfigScope config) + { + /* + * This auth manager attempts to cache auth data by storing user-related data in + * the session, instead of hitting the database on every request. This is done to + * reduce the number of database queries and improve performance. + */ + + const string AuthTokenQueryArgName = "t"; + const string TimestampKey = "sb.w.ts"; + const string SignatureKey = "sb.w.sig"; + const string UserSigningKeyKey = "sb.w.usk"; + + private static readonly TimeSpan _authCacheValidFor = TimeSpan.FromMinutes(25); + + private readonly IUserManager users = plugin.GetOrCreateSingleton(); + + private readonly int SigningKeySize = config.GetValueOrDefault("key_size", static p => p.GetInt32(), 16); + + public async ValueTask IsTokenValidAsync(HttpEntity entity) + { + string? token = GetTokenString(entity); + + if (string.IsNullOrWhiteSpace(token)) + { + //No token provided + return false; + } + + if (HasExistingAuth(entity, token)) + { + return true; + } + + return await AuthorizeSession(entity, token); + } + + private static bool HasExistingAuth(HttpEntity entity, string token) + { + //New sessions cannot be trusted yet + if (entity.Session.IsNew) + { + return false; + } + + /* + * See if existing auth data exists in the session + */ + DateTimeOffset expires = GetTimestamp(in entity.Session); + + if (expires < entity.RequestedTimeUtc) + { + return false; + } + + string cachedToken = GetCachedToken(in entity.Session); + + return string.Equals(cachedToken, token, StringComparison.Ordinal); + } + + private async Task AuthorizeSession(HttpEntity entity, string token) + { + try + { + using JsonWebToken jwt = JsonWebToken.Parse(token); + using JsonDocument jwtData = jwt.GetPayload(); + + string? userId = jwtData.RootElement.GetPropString("sub"); + + if (string.IsNullOrWhiteSpace(userId)) + { + return false; + } + + using IUser? user = await users.GetUserFromIDAsync(userId, entity.EventCancellation); + + return false; + } + catch (FormatException) + { + return false; + } + } + + public void GenerateWidgetKey(IUser user) + { + /* + * A base32 number ensures easy serialization by the user-system + * instead of base64 which can cause json overhead + */ + user[UserSigningKeyKey] = RandomHash.GetRandomBase32(SigningKeySize); + } + + /// + /// Gets a value that indicates if the user has a widget signing key enabled + /// + /// The user instance to verify + /// True if the user has a widget signing key enabled + public static bool HasSigningKey(IUser user) => !string.IsNullOrEmpty(user[UserSigningKeyKey]); + + private static DateTimeOffset GetTimestamp(ref readonly SessionInfo session) + { + long timestamp = VnEncoding.FromBase32String(session[TimestampKey]); + return DateTimeOffset.FromUnixTimeSeconds(timestamp); + } + + private static void SetTimestamp(ref readonly SessionInfo session, DateTimeOffset timestamp) + => session[TimestampKey] = VnEncoding.ToBase32String(timestamp.ToUnixTimeSeconds()); + + private static string GetCachedToken(ref readonly SessionInfo session) => session[SignatureKey]; + + private static void SetSignature(ref readonly SessionInfo session, string signature) => session[SignatureKey] = signature; + + private static string? GetTokenString(HttpEntity entity) => entity.QueryArgs.GetValueOrDefault(AuthTokenQueryArgName); + + } +} diff --git a/back-end/src/SimpleBookmark.json b/back-end/src/SimpleBookmark.json index 116587d..e7448da 100644 --- a/back-end/src/SimpleBookmark.json +++ b/back-end/src/SimpleBookmark.json @@ -1,29 +1,37 @@ { //Comments are allowed - "debug": false, //Enables obnoxious debug logging + "debug": false, //Enables obnoxious debug logging "bm_endpoint": { - "path": "/bookmarks", //Path for the bookmarks endpoint + "path": "/bookmarks", //Path for the bookmarks endpoint "config": { - "max_limit": 100, //Max results per page - "default_limit": 20, //Default results per page - "user_quota": 5000 //Max bookmarks per user + "max_limit": 100, //Max results per page + "default_limit": 20, //Default results per page + "user_quota": 5000 //Max bookmarks per user } }, + "widgets": { + "enabled": true, //Enables the widgets endpoint + "path": "/widgets", //Path for the widgets endpoint + "cors-urls": "*" //CORS allowed urls + }, + //System website lookup endpoint (aka curl) "curl": { "path": "/lookup", - "exe_path": "curl", //Path to the curl executable + "exe_path": "curl", //Path to the curl executable "extra_args": [ - "--globoff", //Disables unsafe url globbing - "--no-keepalive", //Disables keepalive, uneeded for a single lookup request - "--max-filesize", "100K", //Max file size 100K - "--max-redirs", "5", //Max redirects 5 - "--location", //Follow redirects + "--globoff", //Disables unsafe url globbing + "--no-keepalive", //Disables keepalive, uneeded for a single lookup request + "--max-filesize", + "100K", //Max file size 100K + "--max-redirs", + "5", //Max redirects 5 + "--location" //Follow redirects ] }, diff --git a/back-end/src/SimpleBookmarkEntry.cs b/back-end/src/SimpleBookmarkEntry.cs index 13b94a5..27813d7 100644 --- a/back-end/src/SimpleBookmarkEntry.cs +++ b/back-end/src/SimpleBookmarkEntry.cs @@ -51,6 +51,7 @@ namespace SimpleBookmark this.Route(); this.Route(); this.Route(); + this.Route(); //Ensure database is created after a delay this.ObserveWork(() => this.EnsureDbCreatedAsync(this), 1500); -- cgit