From 06cb160f9566a1be09b7b88433dfa3d4f123ee61 Mon Sep 17 00:00:00 2001 From: Iris System Date: Sun, 17 Aug 2025 02:47:01 -0700 Subject: [PATCH] [WIP] feat: scoped api keys --- Cargo.lock | 54 ++++++ Cargo.toml | 2 + .../AuthorizationTokenHandlerMiddleware.cs | 17 +- PluralKit.API/Controllers/PKControllerBase.cs | 36 +++- .../Controllers/v2/GroupControllerV2.cs | 2 +- .../Controllers/v2/GroupMemberControllerV2.cs | 5 +- .../Controllers/v2/MemberControllerV2.cs | 2 +- .../Controllers/v2/SwitchControllerV2.cs | 6 +- PluralKit.Bot/CommandMeta/CommandHelp.cs | 5 + PluralKit.Bot/CommandMeta/CommandTree.cs | 38 ++++ PluralKit.Bot/Commands/Api.cs | 178 +++++++++++++++++- PluralKit.Bot/Modules.cs | 1 + PluralKit.Core/CoreConfig.cs | 2 + .../Repository/ModelRepository.ApiKey.cs | 55 ++++++ PluralKit.Core/Models/PKApiKey.cs | 15 ++ PluralKit.Core/Models/Patch/ApiKeyPatch.cs | 24 +++ PluralKit.Core/Services/ApiKeyService.cs | 72 +++++++ crates/api/Cargo.toml | 4 + crates/api/src/auth.rs | 41 +++- crates/api/src/endpoints/internal.rs | 114 +++++++++++ crates/api/src/endpoints/mod.rs | 1 + crates/api/src/endpoints/private.rs | 79 +++++--- crates/api/src/error.rs | 2 + crates/api/src/main.rs | 53 +++--- crates/api/src/middleware/auth.rs | 168 +++++++++++++++-- crates/api/src/middleware/cors.rs | 2 +- .../src/middleware/ignore_invalid_routes.rs | 1 + crates/api/src/middleware/ratelimit.rs | 47 +++-- crates/libpk/Cargo.toml | 1 + crates/libpk/src/_config.rs | 5 + crates/libpk/src/db/repository/auth.rs | 23 +++ crates/macros/src/api.rs | 12 ++ crates/macros/src/lib.rs | 7 +- crates/migrate/data/migrations/52.sql | 2 +- crates/migrate/data/migrations/53.sql | 38 ++++ crates/models/Cargo.toml | 2 + crates/models/src/_util.rs | 16 +- crates/models/src/api_key.rs | 104 ++++++++++ crates/models/src/lib.rs | 37 ++-- crates/models/src/oauth2_app.rs | 28 +++ crates/models/src/system.rs | 3 +- docs/content/.vuepress/config.js | 3 +- docs/content/api/models.md | 13 +- docs/content/api/reference.md | 19 +- docs/content/api/tokens.md | 79 ++++++++ 45 files changed, 1264 insertions(+), 154 deletions(-) create mode 100644 PluralKit.Core/Database/Repository/ModelRepository.ApiKey.cs create mode 100644 PluralKit.Core/Models/PKApiKey.cs create mode 100644 PluralKit.Core/Models/Patch/ApiKeyPatch.cs create mode 100644 PluralKit.Core/Services/ApiKeyService.cs create mode 100644 crates/api/src/endpoints/internal.rs create mode 100644 crates/migrate/data/migrations/53.sql create mode 100644 crates/models/src/api_key.rs create mode 100644 crates/models/src/oauth2_app.rs create mode 100644 docs/content/api/tokens.md diff --git a/Cargo.lock b/Cargo.lock index 52ab48dc..aec30906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,9 +82,12 @@ version = "0.1.0" dependencies = [ "anyhow", "axum 0.8.4", + "base64 0.22.1", + "chrono", "fred", "hyper 1.6.0", "hyper-util", + "jsonwebtoken", "lazy_static", "libpk", "metrics", @@ -102,6 +105,7 @@ dependencies = [ "tower-http", "tracing", "twilight-http", + "uuid", ] [[package]] @@ -1963,6 +1967,21 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring 0.17.14", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2008,6 +2027,7 @@ dependencies = [ "config", "fred", "json-subscriber", + "jsonwebtoken", "lazy_static", "metrics", "metrics-exporter-prometheus", @@ -2260,6 +2280,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -2435,6 +2465,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2568,7 +2608,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" name = "pluralkit_models" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", + "jsonwebtoken", "pk_macros", "sea-query", "serde", @@ -3684,6 +3726,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "sketches-ddsketch" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 23ab960f..d4a2299b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,10 +7,12 @@ resolver = "2" [workspace.dependencies] anyhow = "1" axum-macros = "0.4.1" +base64 = "0.22.1" bytes = "1.6.0" chrono = "0.4" fred = { version = "9.3.0", default-features = false, features = ["tracing", "i-keys", "i-hashes", "i-scripts", "sha-1"] } futures = "0.3.30" +jsonwebtoken = { version = "9.3.0", features = ["pem"] } lazy_static = "1.4.0" metrics = "0.23.0" reqwest = { version = "0.12.7" , default-features = false, features = ["rustls-tls", "trust-dns"]} diff --git a/PluralKit.API/AuthorizationTokenHandlerMiddleware.cs b/PluralKit.API/AuthorizationTokenHandlerMiddleware.cs index 5f1c4011..a6f0cf5d 100644 --- a/PluralKit.API/AuthorizationTokenHandlerMiddleware.cs +++ b/PluralKit.API/AuthorizationTokenHandlerMiddleware.cs @@ -15,11 +15,18 @@ public class AuthorizationTokenHandlerMiddleware public async Task Invoke(HttpContext ctx, IDatabase db, ApiConfig cfg) { - if (cfg.TrustAuth - && ctx.Request.Headers.TryGetValue("X-PluralKit-SystemId", out var sidHeaders) - && sidHeaders.Count > 0 - && int.TryParse(sidHeaders[0], out var systemId)) - ctx.Items.Add("SystemId", new SystemId(systemId)); + if (cfg.TrustAuth) + { + if (ctx.Request.Headers.TryGetValue("X-PluralKit-SystemId", out var sidHeaders) + && sidHeaders.Count > 0 + && int.TryParse(sidHeaders[0], out var systemId)) + ctx.Items.Add("SystemId", new SystemId(systemId)); + + if (ctx.Request.Headers.TryGetValue("X-PluralKit-PrivacyLevel", out var levelHeaders) + && levelHeaders.Count > 0) + ctx.Items.Add("LookupContext", + levelHeaders[0].ToLower().Trim() == "private" ? LookupContext.ByOwner : LookupContext.ByNonOwner); + } if (cfg.TrustAuth && ctx.Request.Headers.TryGetValue("X-PluralKit-AppId", out var aidHeaders) diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index 3698377c..7fc398df 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -101,24 +101,48 @@ public class PKControllerBase: ControllerBase return null; } + protected bool IsAuthenticatedAs(SystemId system) + { + HttpContext.Items.TryGetValue("SystemId", out var systemId); + return systemId != null && (SystemId)systemId == system; + } + protected LookupContext ContextFor(PKSystem system) { HttpContext.Items.TryGetValue("SystemId", out var systemId); - if (systemId == null) return LookupContext.ByNonOwner; - return (SystemId)systemId == system.Id ? LookupContext.ByOwner : LookupContext.ByNonOwner; + HttpContext.Items.TryGetValue("LookupContext", out var lookupCtx); + if (systemId != null && (SystemId)systemId == system.Id) + { + if (lookupCtx != null) return (LookupContext)lookupCtx; + return LookupContext.ByOwner; + } + + return LookupContext.ByNonOwner; } protected LookupContext ContextFor(PKMember member) { HttpContext.Items.TryGetValue("SystemId", out var systemId); - if (systemId == null) return LookupContext.ByNonOwner; - return (SystemId)systemId == member.System ? LookupContext.ByOwner : LookupContext.ByNonOwner; + HttpContext.Items.TryGetValue("LookupContext", out var lookupCtx); + if (systemId != null && (SystemId)systemId == member.System) + { + if (lookupCtx != null) return (LookupContext)lookupCtx; + return LookupContext.ByOwner; + } + + return LookupContext.ByNonOwner; } protected LookupContext ContextFor(PKGroup group) { HttpContext.Items.TryGetValue("SystemId", out var systemId); - if (systemId == null) return LookupContext.ByNonOwner; - return (SystemId)systemId == group.System ? LookupContext.ByOwner : LookupContext.ByNonOwner; + HttpContext.Items.TryGetValue("LookupContext", out var lookupCtx); + if (systemId != null && (SystemId)systemId == group.System) + { + if (lookupCtx != null) return (LookupContext)lookupCtx; + return LookupContext.ByOwner; + } + + return LookupContext.ByNonOwner; } } \ No newline at end of file diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index f3a6de67..2eacd6e0 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -21,7 +21,7 @@ public class GroupControllerV2: PKControllerBase var ctx = ContextFor(system); - if (!system.GroupListPrivacy.CanAccess(ContextFor(system))) + if (!IsAuthenticatedAs(system.Id) && !system.GroupListPrivacy.CanAccess(ContextFor(system))) throw Errors.UnauthorizedGroupList; var groups = _repo.GetSystemGroups(system.Id); diff --git a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs index d2c8dd2e..e982339d 100644 --- a/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupMemberControllerV2.cs @@ -19,10 +19,9 @@ public class GroupMemberControllerV2: PKControllerBase if (group == null) throw Errors.GroupNotFound; - var ctx = ContextFor(group); - if (!group.ListPrivacy.CanAccess(ctx)) + if (!IsAuthenticatedAs(group.System) && !group.ListPrivacy.CanAccess(ctx)) throw Errors.UnauthorizedGroupMemberList; var system = await _repo.GetSystem(group.System); @@ -154,7 +153,7 @@ public class GroupMemberControllerV2: PKControllerBase var ctx = ContextFor(member); var system = await _repo.GetSystem(member.System); - if (!system.GroupListPrivacy.CanAccess(ctx)) + if (!IsAuthenticatedAs(member.System) && !system.GroupListPrivacy.CanAccess(ctx)) throw Errors.UnauthorizedGroupList; var groups = _repo.GetMemberGroups(member.Id).Where(g => g.Visibility.CanAccess(ctx)); diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index 6c37fa81..fa900767 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -26,7 +26,7 @@ public class MemberControllerV2: PKControllerBase var ctx = ContextFor(system); - if (!system.MemberListPrivacy.CanAccess(ContextFor(system))) + if (!IsAuthenticatedAs(system.Id) && !system.MemberListPrivacy.CanAccess(ContextFor(system))) throw Errors.UnauthorizedMemberList; var members = _repo.GetSystemMembers(system.Id); diff --git a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs index 192cf460..516d16fb 100644 --- a/PluralKit.API/Controllers/v2/SwitchControllerV2.cs +++ b/PluralKit.API/Controllers/v2/SwitchControllerV2.cs @@ -28,7 +28,7 @@ public class SwitchControllerV2: PKControllerBase var ctx = ContextFor(system); - if (!system.FrontHistoryPrivacy.CanAccess(ctx)) + if (!IsAuthenticatedAs(system.Id) && !system.FrontHistoryPrivacy.CanAccess(ctx)) throw Errors.UnauthorizedFrontHistory; if (before == null) @@ -59,7 +59,7 @@ public class SwitchControllerV2: PKControllerBase var ctx = ContextFor(system); - if (!system.FrontPrivacy.CanAccess(ctx)) + if (!IsAuthenticatedAs(system.Id) && !system.FrontPrivacy.CanAccess(ctx)) throw Errors.UnauthorizedCurrentFronters; var sw = await _repo.GetLatestSwitch(system.Id); @@ -145,7 +145,7 @@ public class SwitchControllerV2: PKControllerBase var ctx = ContextFor(system); - if (!system.FrontHistoryPrivacy.CanAccess(ctx)) + if (!IsAuthenticatedAs(system.Id) && !system.FrontHistoryPrivacy.CanAccess(ctx)) throw Errors.SwitchNotFoundPublic; var members = _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)); diff --git a/PluralKit.Bot/CommandMeta/CommandHelp.cs b/PluralKit.Bot/CommandMeta/CommandHelp.cs index 65fa79b6..7e83d0fb 100644 --- a/PluralKit.Bot/CommandMeta/CommandHelp.cs +++ b/PluralKit.Bot/CommandMeta/CommandHelp.cs @@ -33,6 +33,11 @@ public partial class CommandTree public static Command ConfigProxySwitch = new Command("config proxyswitch", "config proxyswitch [new|add|off]", "Switching behavior when proxy tags are used"); public static Command ConfigNameFormat = new Command("config nameformat", "config nameformat [format]", "Changes your system's username formatting"); public static Command ConfigServerNameFormat = new Command("config servernameformat", "config servernameformat [format]", "Changes your system's username formatting in the current server"); + public static Command ApiKeyCreate = new Command("system apikey new", "system apikey new ", "Create a new API key"); + public static Command ApiKeyList = new Command("system apikey list", "system apikey list", "Show current API keys"); + public static Command ApiKeyRename = new Command("system apikey rename", "system apikey rename ", "Rename an existing API key"); + public static Command ApiKeyDelete = new Command("system apikey delete", "system apikey delete", "Delete an existing API key"); + public static Command ApiKeyDeleteAll = new Command("system apikey deleteall", "system apikey deleteall", "Delete all existing API keys"); public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server"); public static Command AutoproxyOff = new Command("autoproxy off", "autoproxy off", "Disables autoproxying for your system in the current server"); public static Command AutoproxyFront = new Command("autoproxy front", "autoproxy front", "Sets your system's autoproxy in this server to proxy the first member currently registered as front"); diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 8d111ef4..da0adb00 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -218,6 +218,8 @@ public partial class CommandTree // todo: these aren't deprecated but also shouldn't be here else if (ctx.Match("webhook", "hook")) await ctx.Execute(null, m => m.SystemWebhook(ctx)); + else if (ctx.Match("apikey", "apikeys", "apitoken", "apitokens")) + await HandleSystemApiKeyCommand(ctx); else if (ctx.Match("proxy")) await ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); @@ -322,6 +324,42 @@ public partial class CommandTree await ctx.CheckSystem(target).Execute(MemberRandom, m => m.Member(ctx, target)); } + private async Task HandleSystemApiKeyCommand(Context ctx) + { + ctx.CheckSystem(); + if (ctx.Match("new", "n", "add", "create", "register")) + await ctx.Execute(ApiKeyCreate, c => c.ApiKeyCreate(ctx)); + else if (ctx.Match("list", "ls", "l")) + await ctx.Execute(ApiKeyList, c => c.ApiKeyList(ctx)); + else if (ctx.Match("deleteall", "removeall", "destroyall", "eraseall", "revokeall", "yeetall")) + await ctx.Execute(ApiKeyDeleteAll, c => c.ApiKeyDeleteAll(ctx)); + else if (!ctx.HasNext()) + await PrintCommandExpectedError(ctx, ApiKeyCreate, ApiKeyList, ApiKeyRename, ApiKeyDelete, ApiKeyDeleteAll); + else + { + PKApiKey? key = null!; + var input = ctx.PeekArgument(); + if (Guid.TryParse(input, out var keyId)) + key = await ctx.Repository.GetApiKey(keyId); + else if (await ctx.Repository.GetApiKeyByName(ctx.System.Id, input) is PKApiKey keyByName) + key = keyByName; + + if (key == null || key.System != ctx.System.Id) + { + await ctx.Reply($"{Emojis.Error} API key with name \"{ctx.PopArgument()}\" not found."); + return; + } + + ctx.PopArgument(); + if (ctx.Match("rename", "name", "changename", "setname", "rn")) + await ctx.Execute(ApiKeyRename, c => c.ApiKeyRename(ctx, key)); + else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) + await ctx.Execute(ApiKeyDelete, c => c.ApiKeyDelete(ctx, key)); + else + await PrintCommandNotFoundError(ctx, ApiKeyRename, ApiKeyDelete); + } + } + private async Task HandleMemberCommand(Context ctx) { if (ctx.Match("new", "n", "add", "create", "register")) diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index 626bcd4a..ca8782e7 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -1,28 +1,41 @@ +using System.Text; using System.Text.RegularExpressions; +using Myriad.Builders; using Myriad.Extensions; using Myriad.Rest.Exceptions; +using Myriad.Rest.Types; using Myriad.Rest.Types.Requests; using Myriad.Types; +using NodaTime; + +using SqlKata; + using PluralKit.Core; namespace PluralKit.Bot; public class Api { + private record PaginatedApiKey(Guid Id, string Name, string[] Scopes, string? AppName, Instant Created); + private static readonly Regex _webhookRegex = new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)"); private readonly BotConfig _botConfig; private readonly DispatchService _dispatch; + private readonly InteractionDispatchService _interactions; private readonly PrivateChannelService _dmCache; + private readonly ApiKeyService _apiKey; - public Api(BotConfig botConfig, DispatchService dispatch, PrivateChannelService dmCache) + public Api(BotConfig botConfig, DispatchService dispatch, InteractionDispatchService interactions, PrivateChannelService dmCache, ApiKeyService apiKey) { _botConfig = botConfig; _dispatch = dispatch; + _interactions = interactions; _dmCache = dmCache; + _apiKey = apiKey; } public async Task GetToken(Context ctx) @@ -172,4 +185,167 @@ public class Api await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system."); } + + public async Task ApiKeyCreate(Context ctx) + { + if (!ctx.HasNext()) + throw new PKSyntaxError($"An API key name must be provided."); + + var rawScopes = ctx.MatchFlag("scopes", "scope"); + var keyName = ctx.PopArgument(); + List keyScopes = new(); + + if (!ctx.HasNext()) + throw new PKSyntaxError($"A list of API key scopes must be provided."); + + var scopestr = ctx.RemainderOrNull()!.NormalizeLineEndSpacing().Trim(); + if (rawScopes) + keyScopes = scopestr.Split(" ").Distinct().ToList(); + else + keyScopes.Add(scopestr switch + { + "full" => "write:all", + "read private" => "read:all", + "read public" => "readpublic:all", + "identify" => "identify", + _ => throw new PKError( + $"Couldn't find a scope preset named {scopestr}."), + }); + + string? check = null!; + try + { + check = await _apiKey.CreateUserApiKey(ctx.System.Id, keyName, keyScopes.ToArray(), check: true); + if (check != null) + throw new PKError("API key validation failed: unknown error"); + } + catch (Exception ex) + { + if (ex.Message.StartsWith("API key")) + throw new PKError(ex.Message); + throw; + } + + async Task cb(InteractionContext ictx) + { + if (ictx.User.Id != ctx.Author.Id) + { + await ictx.Ignore(); + return; + } + + var newKey = await _apiKey.CreateUserApiKey(ctx.System.Id, keyName, keyScopes.ToArray()); + await ictx.Reply($"Your new API key is below. You will only be shown this once, so please save it!\n\n||`{newKey}`||"); + await ctx.Rest.EditMessage(ictx.ChannelId, ictx.MessageId!.Value, new MessageEditRequest + { + Components = new MessageComponent[] { }, + }); + } + + var content = + $"Ready to create a new API key named **{keyName}**, " + + $"with these scopes: {(String.Join(", ", keyScopes.Select(x => x.AsCode())))}\n" + + "To create this API key, press the button below."; + + await ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest + { + Content = content, + AllowedMentions = new() { Parse = new AllowedMentions.ParseType[] { }, RepliedUser = false }, + Components = new[] { + new MessageComponent + { + Type = ComponentType.ActionRow, + Components = new[] + { + new MessageComponent + { + Type = ComponentType.Button, + Style = ButtonStyle.Primary, + Label = "Create API key", + CustomId = _interactions.Register(cb), + }, + } + } + }, + }); + } + + public async Task ApiKeyList(Context ctx) + { + var keys = await ctx.Repository.GetSystemApiKeys(ctx.System.Id) + .Select(k => new PaginatedApiKey(k.Id, k.Name, k.Scopes, null, k.Created)) + .ToListAsync(); + + await ctx.Paginate( + keys.ToAsyncEnumerable(), + keys.Count, + 10, + "Current API keys for your system", + ctx.System.Color, + (eb, l) => + { + var description = new StringBuilder(); + + foreach (var item in l) + { + description.Append($"**{item.Name}** (`{item.Id}`)"); + description.AppendLine(); + + description.Append("- Scopes: "); + description.Append(String.Join(", ", item.Scopes.Select(sc => $"`{sc}`"))); + description.AppendLine(); + description.Append("- Created: "); + description.Append(item.Created.FormatZoned(ctx.Zone)); + description.AppendLine(); + description.AppendLine(); + } + + eb.Description(description.ToString()); + return Task.CompletedTask; + } + ); + } + + public async Task ApiKeyRename(Context ctx, PKApiKey key) + { + if (!ctx.HasNext()) + throw new PKError("You must provide a new name for this API key."); + + var name = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + await ctx.Repository.UpdateApiKey(key.Id, new ApiKeyPatch { Name = name }); + await ctx.Reply($"{Emojis.Success} API key renamed."); + } + + public async Task ApiKeyDelete(Context ctx, PKApiKey key) + { + if (!await ctx.PromptYesNo($"Really delete API key **{key.Name}** `{key.Id}`?", "Delete", matchFlag: false)) + { + await ctx.Reply($"{Emojis.Error} Deletion cancelled."); + return; + } + + await ctx.Repository.DeleteApiKey(key.Id); + await ctx.Reply($"{Emojis.Success} Successfully deleted API key."); + } + + public async Task ApiKeyDeleteAll(Context ctx) + { + if (!await ctx.PromptYesNo($"Really delete *all manually-created* API keys for your system?", "Delete", matchFlag: false)) + { + await ctx.Reply($"{Emojis.Error} Deletion cancelled."); + return; + } + + await ctx.BusyIndicator(async () => + { + var query = new Query("api_keys") + .AsDelete() + .WhereRaw("[kind]::text not in ( 'dashboard', 'external_app' )") + .Where("system", ctx.System.Id); + + await ctx.Database.ExecuteQuery(query); + }); + + await ctx.Reply($"{Emojis.Success} Successfully deleted all manually-created API keys."); + } } \ No newline at end of file diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 668ea9c3..7a080bd0 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -162,6 +162,7 @@ public class BotModule: Module builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); // Sentry stuff builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope(); diff --git a/PluralKit.Core/CoreConfig.cs b/PluralKit.Core/CoreConfig.cs index 1e77271b..f8611727 100644 --- a/PluralKit.Core/CoreConfig.cs +++ b/PluralKit.Core/CoreConfig.cs @@ -16,6 +16,8 @@ public class CoreConfig public string? SeqLogUrl { get; set; } public string? DispatchProxyUrl { get; set; } public string? DispatchProxyToken { get; set; } + public string? InternalApiBaseUrl { get; set; } + public string? InternalApiToken { get; set; } public LogEventLevel ConsoleLogLevel { get; set; } = LogEventLevel.Debug; public LogEventLevel ElasticLogLevel { get; set; } = LogEventLevel.Information; diff --git a/PluralKit.Core/Database/Repository/ModelRepository.ApiKey.cs b/PluralKit.Core/Database/Repository/ModelRepository.ApiKey.cs new file mode 100644 index 00000000..f4505c16 --- /dev/null +++ b/PluralKit.Core/Database/Repository/ModelRepository.ApiKey.cs @@ -0,0 +1,55 @@ +using Dapper; + +using SqlKata; + +namespace PluralKit.Core; + +public partial class ModelRepository +{ + public async Task GetApiKey(Guid id) + { + var query = new Query("api_keys") + .Select("id", "system", "scopes", "app", "name", "created") + .SelectRaw("[kind]::text") + .Where("id", id); + + return await _db.QueryFirst(query); + } + + public async Task GetApiKeyByName(SystemId system, string name) + { + var query = new Query("api_keys") + .Select("id", "system", "scopes", "app", "name", "created") + .SelectRaw("[kind]::text") + .Where("system", system) + .WhereRaw("lower(name) = lower(?)", name.ToLower()); + + return await _db.QueryFirst(query); + } + + public IAsyncEnumerable GetSystemApiKeys(SystemId system) + { + var query = new Query("api_keys") + .Select("id", "system", "scopes", "app", "name", "created") + .SelectRaw("[kind]::text") + .Where("system", system) + .WhereRaw("[kind]::text not in ( 'dashboard' )") + .OrderByDesc("created"); + + return _db.QueryStream(query); + } + + public async Task UpdateApiKey(Guid id, ApiKeyPatch patch) + { + _logger.Information("Updated API key {keyId}: {@ApiKeyPatch}", id, patch); + var query = patch.Apply(new Query("api_keys").Where("id", id)); + await _db.ExecuteQuery(query, "returning *"); + } + + public async Task DeleteApiKey(Guid id) + { + var query = new Query("api_keys").AsDelete().Where("id", id); + await _db.ExecuteQuery(query); + _logger.Information("Deleted ApiKey {keyId}", id); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKApiKey.cs b/PluralKit.Core/Models/PKApiKey.cs new file mode 100644 index 00000000..d4bf86d2 --- /dev/null +++ b/PluralKit.Core/Models/PKApiKey.cs @@ -0,0 +1,15 @@ +using NodaTime; + +namespace PluralKit.Core; + +public class PKApiKey +{ + public Guid Id { get; private set; } + public SystemId System { get; private set; } + public string Kind { get; private set; } + public string[] Scopes { get; private set; } + public Guid? App { get; private set; } + public string Name { get; private set; } + + public Instant Created { get; private set; } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/ApiKeyPatch.cs b/PluralKit.Core/Models/Patch/ApiKeyPatch.cs new file mode 100644 index 00000000..83e898f0 --- /dev/null +++ b/PluralKit.Core/Models/Patch/ApiKeyPatch.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json.Linq; + +using SqlKata; + +namespace PluralKit.Core; + +public class ApiKeyPatch: PatchObject +{ + public Partial Name { get; set; } + + public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper + .With("name", Name) + ); + + public JObject ToJson() + { + var o = new JObject(); + + if (Name.IsPresent) + o.Add("name", Name.Value); + + return o; + } +} \ No newline at end of file diff --git a/PluralKit.Core/Services/ApiKeyService.cs b/PluralKit.Core/Services/ApiKeyService.cs new file mode 100644 index 00000000..fdb94c38 --- /dev/null +++ b/PluralKit.Core/Services/ApiKeyService.cs @@ -0,0 +1,72 @@ +using Autofac; + +using System.Text; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using NodaTime; + +using Serilog; + +namespace PluralKit.Core; + +public class ApiKeyService +{ + private readonly HttpClient _client; + private readonly ILogger _logger; + private readonly CoreConfig _cfg; + private readonly ILifetimeScope _provider; + + public ApiKeyService(ILogger logger, ILifetimeScope provider, CoreConfig cfg) + { + _logger = logger; + _cfg = cfg; + _provider = provider; + + _client = new HttpClient(); + _client.DefaultRequestHeaders.Add("User-Agent", "PluralKitInternal"); + } + + public async Task CreateUserApiKey(SystemId systemId, string keyName, string[] keyScopes, bool check = false) + { + if (_cfg.InternalApiBaseUrl == null || _cfg.InternalApiToken == null) + throw new Exception("internal API config not set!"); + + if (!Uri.TryCreate(new Uri(_cfg.InternalApiBaseUrl), "/internal/apikey/user", out var uri)) + throw new Exception("internal API base invalid!?"); + + var repo = _provider.Resolve(); + var system = await repo.GetSystem(systemId); + if (system == null) + return null; + + var reqData = new JObject(); + reqData.Add("check", check); + reqData.Add("system", system.Id.Value); + reqData.Add("name", keyName); + reqData.Add("scopes", new JArray(keyScopes)); + + var req = new HttpRequestMessage() + { + RequestUri = uri, + Method = HttpMethod.Post, + Content = new StringContent(JsonConvert.SerializeObject(reqData), Encoding.UTF8, "application/json"), + }; + req.Headers.Add("X-Pluralkit-InternalAuth", _cfg.InternalApiToken); + + var res = await _client.SendAsync(req); + var data = JsonConvert.DeserializeObject(await res.Content.ReadAsStringAsync()); + + if (data.ContainsKey("error")) + throw new Exception($"API key validation failed: {(data.Value("error"))}"); + + if (data.Value("valid") != true) + throw new Exception("API key validation failed: unknown error"); + + if (!data.ContainsKey("token")) + return null; + + return data.Value("token"); + } +} \ No newline at end of file diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 9413d62c..146cda6d 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -10,8 +10,12 @@ libpk = { path = "../libpk" } anyhow = { workspace = true } axum = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } fred = { workspace = true } +jsonwebtoken = { workspace = true } lazy_static = { workspace = true } +uuid = { workspace = true } metrics = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/crates/api/src/auth.rs b/crates/api/src/auth.rs index 4e12a287..3ced9675 100644 --- a/crates/api/src/auth.rs +++ b/crates/api/src/auth.rs @@ -1,20 +1,45 @@ +use uuid::Uuid; + use pluralkit_models::{PKSystem, PrivacyLevel, SystemId}; pub const INTERNAL_SYSTEMID_HEADER: &'static str = "x-pluralkit-systemid"; pub const INTERNAL_APPID_HEADER: &'static str = "x-pluralkit-appid"; +pub const INTERNAL_TOKENID_HEADER: &'static str = "x-pluralkit-tid"; +pub const INTERNAL_PRIVACYLEVEL_HEADER: &'static str = "x-pluralkit-privacylevel"; + +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub enum AccessLevel { + None = 0, + PublicRead, + PrivateRead, + Full, +} + +impl AccessLevel { + pub fn privacy_level(&self) -> PrivacyLevel { + match self { + Self::None | Self::PublicRead => PrivacyLevel::Public, + Self::PrivateRead | Self::Full => PrivacyLevel::Private, + } + } +} #[derive(Clone)] pub struct AuthState { system_id: Option, - app_id: Option, + app_id: Option, + api_key_id: Option, + access_level: AccessLevel, internal: bool, } impl AuthState { - pub fn new(system_id: Option, app_id: Option, internal: bool) -> Self { + pub fn new(system_id: Option, app_id: Option, api_key_id: Option, access_level: AccessLevel, internal: bool) -> Self { Self { system_id, app_id, + api_key_id, + access_level, internal, } } @@ -23,10 +48,18 @@ impl AuthState { self.system_id } - pub fn app_id(&self) -> Option { + pub fn app_id(&self) -> Option { self.app_id } + pub fn api_key_id(&self) -> Option { + self.api_key_id + } + + pub fn access_level(&self) -> AccessLevel { + self.access_level.clone() + } + pub fn internal(&self) -> bool { self.internal } @@ -37,7 +70,7 @@ impl AuthState { .map(|id| id == a.authable_system_id()) .unwrap_or(false) { - PrivacyLevel::Private + self.access_level.privacy_level() } else { PrivacyLevel::Public } diff --git a/crates/api/src/endpoints/internal.rs b/crates/api/src/endpoints/internal.rs new file mode 100644 index 00000000..0d62ba9a --- /dev/null +++ b/crates/api/src/endpoints/internal.rs @@ -0,0 +1,114 @@ +use crate::{util::json_err, AuthState, ApiContext}; +use pluralkit_models::{ApiKeyType, PKApiKey, PKSystem, SystemId}; +use pk_macros::api_internal_endpoint; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Json, Response}, + Extension, +}; + +use sqlx::Postgres; + +#[derive(serde::Deserialize)] +pub struct NewApiKeyRequestData { + #[serde(default)] + check: bool, + system: SystemId, + name: Option, + scopes: Vec, +} + +#[api_internal_endpoint] +pub async fn create_api_key_user( + State(ctx): State, + Extension(auth): Extension, + Json(req): Json, +) -> Response { + let system: Option = sqlx::query_as("select * from systems where id = $1") + .bind(req.system) + .fetch_optional(&ctx.db) + .await + .expect("failed to query system"); + + if system.is_none() { + return Ok(json_err( + StatusCode::BAD_REQUEST, + r#"{"message": "no system found!?", "internal": true}"#.to_string(), + )); + } + + let system = system.unwrap(); + + // sanity check requested scopes + if req.scopes.len() < 1 { + return Ok(json_err( + StatusCode::BAD_REQUEST, + r#"{"message": "no scopes provided", "internal": true}"#.to_string(), + )); + } + for scope in req.scopes.iter() { + let parts = scope.split(":").collect::>(); + let ok = match &parts[..] { + ["identify"] => true, + ["publicread", n] | ["read", n] | ["write", n] => match *n { + "all" => true, + "system" => true, + "members" => true, + "groups" => true, + "fronters" => true, + "switches" => true, + _ => false, + }, + _ => false, + }; + + if !ok { + return Err(crate::error::GENERIC_BAD_REQUEST); + } + } + + if req.check { + return Ok(( + StatusCode::OK, + serde_json::to_string(&serde_json::json!({ + "valid": true, + })) + .expect("should not error"), + ).into_response()); + } + + let token: PKApiKey = sqlx::query_as( + r#" + insert into api_keys + ( + system, + kind, + scopes, + name + ) + values + ($1, $2::api_key_type, $3::text[], $4) + returning * + "#, + ) + .bind(system.id) + .bind(ApiKeyType::UserCreated) + .bind(req.scopes) + .bind(req.name) + .fetch_one(&ctx.db) + .await + .expect("failed to create token"); + + let token = token.to_header_str(system.clone().uuid, &ctx.token_privatekey); + + Ok(( + StatusCode::OK, + serde_json::to_string(&serde_json::json!({ + "valid": true, + "token": token, + })) + .expect("should not error"), + ).into_response()) +} diff --git a/crates/api/src/endpoints/mod.rs b/crates/api/src/endpoints/mod.rs index c311367c..59782842 100644 --- a/crates/api/src/endpoints/mod.rs +++ b/crates/api/src/endpoints/mod.rs @@ -1,2 +1,3 @@ +pub mod internal; pub mod private; pub mod system; diff --git a/crates/api/src/endpoints/private.rs b/crates/api/src/endpoints/private.rs index 067fef57..cebcaaf2 100644 --- a/crates/api/src/endpoints/private.rs +++ b/crates/api/src/endpoints/private.rs @@ -1,18 +1,20 @@ -use crate::ApiContext; -use axum::{extract::State, response::Json}; +use crate::{util::json_err, ApiContext}; +use libpk::config; +use pluralkit_models::{PrivacyLevel, PKApiKey, PKSystem, PKSystemConfig}; + +use axum::{ + extract::{self, State}, + response::{IntoResponse, Json, Response}, +}; use fred::interfaces::*; +use hyper::StatusCode; use libpk::state::ShardState; use pk_macros::api_endpoint; +use reqwest::ClientBuilder; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct ClusterStats { - pub guild_count: i32, - pub channel_count: i32, -} +use std::time::Duration; #[api_endpoint] pub async fn discord_state(State(ctx): State) -> Json { @@ -43,18 +45,6 @@ pub async fn meta(State(ctx): State) -> Json { Ok(Json(stats)) } -use std::time::Duration; - -use crate::util::json_err; -use axum::{ - extract, - response::{IntoResponse, Response}, -}; -use hyper::StatusCode; -use libpk::config; -use pluralkit_models::{PKSystem, PKSystemConfig, PrivacyLevel}; -use reqwest::ClientBuilder; - #[derive(serde::Deserialize, Debug)] pub struct CallbackRequestData { redirect_domain: String, @@ -71,6 +61,7 @@ struct CallbackDiscordData { code: String, } +#[api_endpoint] pub async fn discord_callback( State(ctx): State, extract::Json(request_data): extract::Json, @@ -107,7 +98,7 @@ pub async fn discord_callback( }; if !discord_data.contains_key("access_token") { - return json_err( + return Ok(json_err( StatusCode::BAD_REQUEST, format!( "{{\"error\":\"{}\"\"}}", @@ -116,7 +107,7 @@ pub async fn discord_callback( .expect("missing error_description from discord") .to_string() ), - ); + )); }; let token = format!( @@ -152,10 +143,10 @@ pub async fn discord_callback( .expect("failed to query"); let Some(system) = system else { - return json_err( + return Ok(json_err( StatusCode::BAD_REQUEST, - "user does not have a system registered".to_string(), - ); + r#"{"message": "user does not have a system registered", "code": 0}"#.to_string(), + )); }; let system_config: Option = sqlx::query_as( @@ -170,11 +161,38 @@ pub async fn discord_callback( let system_config = system_config.unwrap(); - // create dashboard token for system + let token: PKApiKey = sqlx::query_as( + r#" + insert into api_keys + ( + system, + kind, + discord_id, + discord_access_token, + discord_refresh_token, + discord_expires_at + ) + values + ($1, $2::api_key_type, $3, $4, $5, $6) + returning * + "#, + ) + .bind(system.id) + .bind("dashboard") + .bind(user.id.get() as i64) + .bind(discord_data.get("access_token").unwrap().as_str()) + .bind(discord_data.get("refresh_token").unwrap().as_str()) + .bind( + chrono::Utc::now() + + chrono::Duration::seconds(discord_data.get("expires_in").unwrap().as_i64().unwrap()), + ) + .fetch_one(&ctx.db) + .await + .expect("failed to create token"); - let token = system.clone().token; + let token = token.to_header_str(system.clone().uuid, &ctx.token_privatekey); - ( + Ok(( StatusCode::OK, serde_json::to_string(&serde_json::json!({ "system": system.to_json(PrivacyLevel::Private), @@ -183,6 +201,5 @@ pub async fn discord_callback( "token": token, })) .expect("should not error"), - ) - .into_response() + ).into_response()) } diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs index fc481d0c..a5339ba2 100644 --- a/crates/api/src/error.rs +++ b/crates/api/src/error.rs @@ -83,4 +83,6 @@ macro_rules! define_error { } define_error! { GENERIC_BAD_REQUEST, StatusCode::BAD_REQUEST, 0, "400: Bad Request" } +// define_error! { GENERIC_UNAUTHORIZED, StatusCode::UNAUTHORIZED, 0, "401: Missing or invalid Authorization header" } +define_error! { FORBIDDEN_INTERNAL_ROUTE, StatusCode::FORBIDDEN, 0, "403: Forbidden to access this endpoint" } define_error! { GENERIC_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR, 0, "500: Internal Server Error" } diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 07e47f89..1dca7ade 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -1,10 +1,10 @@ #![feature(let_chains)] -use auth::{AuthState, INTERNAL_APPID_HEADER, INTERNAL_SYSTEMID_HEADER}; +use auth::{AuthState, INTERNAL_APPID_HEADER, INTERNAL_SYSTEMID_HEADER, INTERNAL_TOKENID_HEADER, INTERNAL_PRIVACYLEVEL_HEADER}; use axum::{ body::Body, extract::{Request as ExtractRequest, State}, - http::Uri, + http::{HeaderValue, Uri}, response::{IntoResponse, Response}, routing::{delete, get, patch, post}, Extension, Router, @@ -13,8 +13,8 @@ use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, rt::TokioExecutor, }; -use tracing::info; - +use jsonwebtoken::{DecodingKey, EncodingKey}; +use tracing::{error, info}; use pk_macros::api_endpoint; mod auth; @@ -30,6 +30,9 @@ pub struct ApiContext { rproxy_uri: String, rproxy_client: Client, + + token_privatekey: EncodingKey, + token_publickey: DecodingKey, } #[api_endpoint] @@ -53,14 +56,21 @@ async fn rproxy( headers.remove(INTERNAL_SYSTEMID_HEADER); headers.remove(INTERNAL_APPID_HEADER); + headers.remove(INTERNAL_TOKENID_HEADER); + headers.remove(INTERNAL_PRIVACYLEVEL_HEADER); if let Some(sid) = auth.system_id() { headers.append(INTERNAL_SYSTEMID_HEADER, sid.into()); + headers.append(INTERNAL_PRIVACYLEVEL_HEADER, HeaderValue::from_str(&auth.access_level().privacy_level().to_string())?); } if let Some(aid) = auth.app_id() { - headers.append(INTERNAL_APPID_HEADER, aid.into()); + headers.append(INTERNAL_APPID_HEADER, HeaderValue::from_str(&format!("{}", aid))?); } + + if let Some(tid) = auth.api_key_id() { + headers.append(INTERNAL_TOKENID_HEADER, HeaderValue::from_str(&format!("{}", tid))?); + } Ok(ctx.rproxy_client.request(req).await?.into_response()) } @@ -124,11 +134,13 @@ fn router(ctx: ApiContext) -> Router { .route("/private/discord/shard_state", get(endpoints::private::discord_state)) .route("/private/stats", get(endpoints::private::meta)) - .route("/v2/systems/{system_id}/oembed.json", get(rproxy)) - .route("/v2/members/{member_id}/oembed.json", get(rproxy)) - .route("/v2/groups/{group_id}/oembed.json", get(rproxy)) + .route("/internal/apikey/user", post(endpoints::internal::create_api_key_user)) - .layer(middleware::ratelimit::ratelimiter(middleware::ratelimit::do_request_ratelimited)) // this sucks + .route("/v2/systems/:system_id/oembed.json", get(rproxy)) + .route("/v2/members/:member_id/oembed.json", get(rproxy)) + .route("/v2/groups/:group_id/oembed.json", get(rproxy)) + + .layer(middleware::ratelimit::ratelimiter(ctx.clone(), middleware::ratelimit::do_request_ratelimited)) .layer(axum::middleware::from_fn(middleware::ignore_invalid_routes::ignore_invalid_routes)) .layer(axum::middleware::from_fn(middleware::logger::logger)) @@ -149,14 +161,9 @@ async fn main() -> anyhow::Result<()> { let db = libpk::db::init_data_db().await?; let redis = libpk::db::init_redis().await?; - let rproxy_uri = Uri::from_static( - &libpk::config - .api - .as_ref() - .expect("missing api config") - .remote_url, - ) - .to_string(); + let cfg = libpk::config.api.as_ref().expect("missing api config"); + + let rproxy_uri = Uri::from_static(cfg.remote_url.as_str()).to_string(); let rproxy_client = hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new()) .build(HttpConnector::new()); @@ -166,16 +173,16 @@ async fn main() -> anyhow::Result<()> { rproxy_uri: rproxy_uri[..rproxy_uri.len() - 1].to_string(), rproxy_client, + + token_privatekey: EncodingKey::from_ec_pem(cfg.token_privatekey.as_bytes()) + .expect("failed to load private key"), + token_publickey: DecodingKey::from_ec_pem(cfg.token_publickey.as_bytes()) + .expect("failed to load public key"), }; let app = router(ctx); - let addr: &str = libpk::config - .api - .as_ref() - .expect("missing api config") - .addr - .as_ref(); + let addr: &str = cfg.addr.as_ref(); let listener = tokio::net::TcpListener::bind(addr).await?; info!("listening on {}", addr); diff --git a/crates/api/src/middleware/auth.rs b/crates/api/src/middleware/auth.rs index 0992757f..46435ff7 100644 --- a/crates/api/src/middleware/auth.rs +++ b/crates/api/src/middleware/auth.rs @@ -1,20 +1,129 @@ use axum::{ - extract::{Request, State}, + extract::{Request, State, MatchedPath}, http::StatusCode, middleware::Next, response::Response, }; +use uuid::Uuid; use subtle::ConstantTimeEq; use tracing::error; +use sqlx::Postgres; -use crate::auth::AuthState; +use pluralkit_models::{ApiKeyType, PKApiKey}; +use crate::auth::{AccessLevel, AuthState}; use crate::{util::json_err, ApiContext}; +pub fn is_part_path<'a, 'b>(part: &'a str, endpoint: &'b str) -> bool { + if !endpoint.starts_with("/v2/") { + return false; + } + + let path_frags = endpoint[4..].split("/").collect::>(); + match part { + "system" => match &path_frags[..] { + ["systems", _] => true, + ["systems", _, "settings"] => true, + ["systems", _, "autoproxy"] => true, + ["systems", _, "guilds", ..] => true, + _ => false, + }, + "members" => match &path_frags[..] { + ["systems", _, "members"] => true, + ["members"] => true, + ["members", _, "groups"] => false, + ["members", _, "groups", ..] => false, + ["members", ..] => true, + _ => false, + }, + "groups" => match &path_frags[..] { + ["systems", _, "groups"] => true, + ["groups"] => true, + ["groups", ..] => true, + ["members", _, "groups"] => true, + ["members", _, "groups", ..] => true, + _ => false, + }, + "fronters" => match &path_frags[..] { + ["systems", _, "fronters"] => true, + _ => false, + }, + "switches" => match &path_frags[..] { + // switches implies fronters + ["systems", _, "fronters"] => true, + ["systems", _, "switches"] => true, + ["systems", _, "switches", ..] => true, + _ => false, + }, + _ => false, + } +} + +pub fn apikey_can_access(token: &PKApiKey, method: String, endpoint: String) -> AccessLevel { + if token.kind == ApiKeyType::Dashboard { + return AccessLevel::Full; + } + + let mut access = AccessLevel::None; + for rscope in token.scopes.iter() { + let scope = rscope.split(":").collect::>(); + let na = match (method.as_str(), &scope[..]) { + ("GET", ["identify"]) => { + if &endpoint == "/v2/systems/:system_id" { + AccessLevel::PublicRead + } else { + AccessLevel::None + } + } + + ("GET", ["publicread", part]) => { + if *part == "all" || is_part_path(part.as_ref(), endpoint.as_ref()) { + AccessLevel::PublicRead + } else { + AccessLevel::None + } + } + + ("GET", ["read", part]) => { + if *part == "all" || is_part_path(part.as_ref(), endpoint.as_ref()) { + AccessLevel::PrivateRead + } else { + AccessLevel::None + } + } + + (_, ["write", part]) => { + if *part == "all" || is_part_path(part.as_ref(), endpoint.as_ref()) { + AccessLevel::Full + } else { + AccessLevel::None + } + } + + _ => AccessLevel::None, + }; + + if na > access { + access = na; + } + } + + access +} + pub async fn auth(State(ctx): State, mut req: Request, next: Next) -> Response { + let endpoint = req + .extensions() + .get::() + .cloned() + .map(|v| v.as_str().to_string()) + .unwrap_or("unknown".to_string()); + let mut authed_system_id: Option = None; - let mut authed_app_id: Option = None; + let mut authed_app_id: Option = None; + let mut authed_api_key_id: Option = None; + let mut access_level = AccessLevel::None; // fetch user authorization if let Some(system_auth_header) = req @@ -22,7 +131,24 @@ pub async fn auth(State(ctx): State, mut req: Request, next: Next) - .get("authorization") .map(|h| h.to_str().ok()) .flatten() - && let Some(system_id) = + { + if system_auth_header.starts_with("Bearer ") + && let Some(tid) = + PKApiKey::parse_header_str(system_auth_header[7..].to_string(), &ctx.token_publickey) + && let Some(token) = + sqlx::query_as::("select * from api_keys where id = $1") + .bind(&tid) + .fetch_optional(&ctx.db) + .await + .expect("failed to query apitoken in postgres") + { + authed_api_key_id = Some(tid); + access_level = apikey_can_access(&token, req.method().to_string(), endpoint.clone()); + if access_level != AccessLevel::None { + authed_system_id = Some(token.system); + } + } + else if let Some(system_id) = match libpk::db::repository::legacy_token_auth(&ctx.db, system_auth_header).await { Ok(val) => val, Err(err) => { @@ -33,29 +159,31 @@ pub async fn auth(State(ctx): State, mut req: Request, next: Next) - ); } } - { - authed_system_id = Some(system_id); - } + { + authed_system_id = Some(system_id); + access_level = AccessLevel::Full; + } + } // fetch app authorization - // todo: actually fetch it from db if let Some(app_auth_header) = req .headers() .get("x-pluralkit-app") .map(|h| h.to_str().ok()) .flatten() - && let Some(config_token2) = libpk::config - .api - .as_ref() - .expect("missing api config") - .temp_token2 - .as_ref() - && app_auth_header - .as_bytes() - .ct_eq(config_token2.as_bytes()) - .into() + && let Some(app_id) = + match libpk::db::repository::app_token_auth(&ctx.db, app_auth_header).await { + Ok(val) => val, + Err(err) => { + error!(?err, "failed to query authorization token in postgres"); + return json_err( + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"message": "500: Internal Server Error", "code": 0}"#.to_string(), + ); + } + } { - authed_app_id = Some(1); + authed_app_id = Some(app_id); } // todo: fix syntax @@ -74,7 +202,7 @@ pub async fn auth(State(ctx): State, mut req: Request, next: Next) - }; req.extensions_mut() - .insert(AuthState::new(authed_system_id, authed_app_id, internal)); + .insert(AuthState::new(authed_system_id, authed_app_id, authed_api_key_id, access_level, internal)); next.run(req).await } diff --git a/crates/api/src/middleware/cors.rs b/crates/api/src/middleware/cors.rs index 2632dea6..bdd67dd1 100644 --- a/crates/api/src/middleware/cors.rs +++ b/crates/api/src/middleware/cors.rs @@ -11,7 +11,7 @@ fn add_cors_headers(headers: &mut HeaderMap) { headers.append("Access-Control-Allow-Methods", HeaderValue::from_static("*")); headers.append("Access-Control-Allow-Credentials", HeaderValue::from_static("true")); headers.append("Access-Control-Allow-Headers", HeaderValue::from_static("Content-Type, Authorization, sentry-trace, User-Agent")); - headers.append("Access-Control-Expose-Headers", HeaderValue::from_static("X-PluralKit-Version, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Scope")); + headers.append("Access-Control-Expose-Headers", HeaderValue::from_static("X-PluralKit-Version, X-PluralKit-Authentication, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Scope")); headers.append("Access-Control-Max-Age", HeaderValue::from_static("86400")); } diff --git a/crates/api/src/middleware/ignore_invalid_routes.rs b/crates/api/src/middleware/ignore_invalid_routes.rs index f5630d66..c542d584 100644 --- a/crates/api/src/middleware/ignore_invalid_routes.rs +++ b/crates/api/src/middleware/ignore_invalid_routes.rs @@ -42,6 +42,7 @@ pub async fn ignore_invalid_routes(request: Request, next: Next) -> Response { // we ignored v1 routes earlier, now let's ignore all non-v2 routes else if !request.uri().clone().path().starts_with("/v2") && !request.uri().clone().path().starts_with("/private") + && !request.uri().clone().path().starts_with("/internal") { return ( StatusCode::BAD_REQUEST, diff --git a/crates/api/src/middleware/ratelimit.rs b/crates/api/src/middleware/ratelimit.rs index 1638ecc9..a0889ea7 100644 --- a/crates/api/src/middleware/ratelimit.rs +++ b/crates/api/src/middleware/ratelimit.rs @@ -8,12 +8,15 @@ use axum::{ }; use fred::{clients::RedisPool, interfaces::ClientLike, prelude::LuaInterface, util::sha1_hash}; use metrics::counter; +use sqlx::Postgres; use tracing::{debug, error, info, warn}; use crate::{ + ApiContext, auth::AuthState, util::{header_or_unknown, json_err}, }; +use pluralkit_models::PKExternalApp; const LUA_SCRIPT: &str = include_str!("ratelimit.lua"); @@ -22,7 +25,10 @@ lazy_static::lazy_static! { } // this is awful but it works -pub fn ratelimiter(f: F) -> FromFnLayer, T> { +pub fn ratelimiter( + ctx: ApiContext, + f: F, +) -> FromFnLayer), T> { let redis = libpk::config .api .as_ref() @@ -52,14 +58,14 @@ pub fn ratelimiter(f: F) -> FromFnLayer, T> { warn!("running without request rate limiting!"); } - axum::middleware::from_fn_with_state(redis, f) + axum::middleware::from_fn_with_state((ctx, redis), f) } enum RatelimitType { GenericGet, GenericUpdate, Message, - TempCustom, + AppCustom(i32), } impl RatelimitType { @@ -68,7 +74,7 @@ impl RatelimitType { RatelimitType::GenericGet => "generic_get", RatelimitType::GenericUpdate => "generic_update", RatelimitType::Message => "message", - RatelimitType::TempCustom => "token2", // this should be "app_custom" or something + RatelimitType::AppCustom(_) => "app_custom", } .to_string() } @@ -78,21 +84,41 @@ impl RatelimitType { RatelimitType::GenericGet => 10, RatelimitType::GenericUpdate => 3, RatelimitType::Message => 10, - RatelimitType::TempCustom => 20, + RatelimitType::AppCustom(n) => *n, } } } pub async fn do_request_ratelimited( - State(redis): State>, + State((ctx, redis)): State<(ApiContext, Option)>, request: Request, next: Next, ) -> Response { if let Some(redis) = redis { let headers = request.headers().clone(); - let source_ip = header_or_unknown(headers.get("X-PluralKit-Client-IP")); + if headers.get("x-pluralkit-internal").is_some() { + // bypass ratelimiting entirely for internal requests + return next.run(request).await; + } let extensions = request.extensions().clone(); + let source_ip = header_or_unknown(headers.get("X-PluralKit-Client-IP")); + + let mut app_rate: Option = None; + if let Some(app_header) = request.headers().clone().get("x-pluralkit-app") { + let app_token = app_header.to_str().unwrap_or("invalid"); + if app_token.starts_with("pkap2:") + && let Some(app) = sqlx::query_as::( + "select * from external_apps where api_rl_token = $1", + ) + .bind(&app_token[6..]) + .fetch_optional(&ctx.db) + .await + .expect("failed to query external app in postgres") + { + app_rate = Some(app.api_rl_rate.expect("external app has no api_rl_rate")); + } + }; let endpoint = extensions .get::() @@ -109,11 +135,8 @@ pub async fn do_request_ratelimited( // todo: key should probably be chosen by app_id when it's present // todo: make x-ratelimit-scope actually meaningful - // hack: for now, we only have one "registered app", so we hardcode the app id - let rlimit = if let Some(app_id) = auth.app_id() - && app_id == 1 - { - RatelimitType::TempCustom + let rlimit = if let Some(r) = app_rate { + RatelimitType::AppCustom(r) } else if endpoint == "/v2/messages/:message_id" { RatelimitType::Message } else if request.method() == Method::GET { diff --git a/crates/libpk/Cargo.toml b/crates/libpk/Cargo.toml index 30d77ae0..ccce5a4f 100644 --- a/crates/libpk/Cargo.toml +++ b/crates/libpk/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = { workspace = true } fred = { workspace = true } +jsonwebtoken = { workspace = true } lazy_static = { workspace = true } metrics = { workspace = true } pk_macros = { path = "../macros" } diff --git a/crates/libpk/src/_config.rs b/crates/libpk/src/_config.rs index 7f992d95..a15d95f9 100644 --- a/crates/libpk/src/_config.rs +++ b/crates/libpk/src/_config.rs @@ -62,6 +62,11 @@ pub struct ApiConfig { #[serde(default)] pub temp_token2: Option, + + pub token_privatekey: String, + pub token_publickey: String, + + pub internal_request_secret: String, } #[derive(Deserialize, Clone, Debug)] diff --git a/crates/libpk/src/db/repository/auth.rs b/crates/libpk/src/db/repository/auth.rs index b2d82fa4..6e33621e 100644 --- a/crates/libpk/src/db/repository/auth.rs +++ b/crates/libpk/src/db/repository/auth.rs @@ -1,3 +1,5 @@ +use uuid::Uuid; + pub async fn legacy_token_auth( pool: &sqlx::postgres::PgPool, token: &str, @@ -18,3 +20,24 @@ pub async fn legacy_token_auth( struct LegacyTokenDbResponse { id: i32, } + +pub async fn app_token_auth( + pool: &sqlx::postgres::PgPool, + token: &str, +) -> anyhow::Result> { + let mut app: Vec = + sqlx::query_as("select id from external_apps where api_rl_token = $1") + .bind(token) + .fetch_all(pool) + .await?; + Ok(if let Some(app) = app.pop() { + Some(app.id) + } else { + None + }) +} + +#[derive(sqlx::FromRow)] +struct AppTokenDbResponse { + id: Uuid, +} diff --git a/crates/macros/src/api.rs b/crates/macros/src/api.rs index 7f797b8c..3eeb2c5d 100644 --- a/crates/macros/src/api.rs +++ b/crates/macros/src/api.rs @@ -9,6 +9,7 @@ fn pretty_print(ts: &proc_macro2::TokenStream) -> String { pub fn macro_impl( _args: proc_macro::TokenStream, input: proc_macro::TokenStream, + is_internal: bool, ) -> proc_macro::TokenStream { let input = parse_macro_input!(input as ItemFn); @@ -34,6 +35,16 @@ pub fn macro_impl( }) .collect(); + let internal_res = if is_internal { + quote! { + if !auth.internal() { + return crate::error::FORBIDDEN_INTERNAL_ROUTE.into_response(); + } + } + } else { + quote!() + }; + let res = quote! { #[allow(unused_mut)] pub async fn #fn_name(#fn_params) -> axum::response::Response { @@ -41,6 +52,7 @@ pub fn macro_impl( #fn_body } + #internal_res match inner(#(#pms),*).await { Ok(res) => res.into_response(), Err(err) => err.into_response(), diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs index ad3c1064..32c3b763 100644 --- a/crates/macros/src/lib.rs +++ b/crates/macros/src/lib.rs @@ -6,7 +6,12 @@ mod model; #[proc_macro_attribute] pub fn api_endpoint(args: TokenStream, input: TokenStream) -> TokenStream { - api::macro_impl(args, input) + api::macro_impl(args, input, false) +} + +#[proc_macro_attribute] +pub fn api_internal_endpoint(args: TokenStream, input: TokenStream) -> TokenStream { + api::macro_impl(args, input, true) } #[proc_macro_attribute] diff --git a/crates/migrate/data/migrations/52.sql b/crates/migrate/data/migrations/52.sql index 7c603021..aa6f988a 100644 --- a/crates/migrate/data/migrations/52.sql +++ b/crates/migrate/data/migrations/52.sql @@ -18,4 +18,4 @@ create table command_messages ( create index command_messages_by_original on command_messages(original_mid); create index command_messages_by_sender on command_messages(sender); -update info set schema_version = 52; +update info set schema_version = 52; \ No newline at end of file diff --git a/crates/migrate/data/migrations/53.sql b/crates/migrate/data/migrations/53.sql new file mode 100644 index 00000000..46e45004 --- /dev/null +++ b/crates/migrate/data/migrations/53.sql @@ -0,0 +1,38 @@ +-- database version 53 +-- +-- scoped API keys + skeleton for oauth2 for third-party apps + +create table external_apps ( + id uuid primary key default gen_random_uuid(), + name text not null, + homepage_url text not null, + oauth2_secret text, + oauth2_allowed_redirects text[] not null default array[]::text[], + oauth2_scopes text[] not null default array[]::text[], + api_rl_token text, + api_rl_rate int +); + +create type api_key_type as enum ( + 'dashboard', + 'user_created', + 'external_app' +); + +create table api_keys ( + id uuid primary key default gen_random_uuid(), + system int references systems(id) on delete cascade, + kind api_key_type not null, + scopes text[] not null default array[]::text[], + app uuid references external_apps(id) on delete cascade, + name text, + + discord_id bigint, + discord_access_token text, + discord_refresh_token text, + discord_expires_at timestamp, + + created timestamp with time zone not null default (current_timestamp at time zone 'utc') +); + +update info set schema_version = 53; diff --git a/crates/models/Cargo.toml b/crates/models/Cargo.toml index 0fbc358c..1766a7b3 100644 --- a/crates/models/Cargo.toml +++ b/crates/models/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2021" [dependencies] +base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } pk_macros = { path = "../macros" } +jsonwebtoken = { workspace = true } sea-query = "0.32.1" serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/models/src/_util.rs b/crates/models/src/_util.rs index 2d4921ad..5d3416e5 100644 --- a/crates/models/src/_util.rs +++ b/crates/models/src/_util.rs @@ -6,9 +6,9 @@ // note: caller needs to implement From for their type macro_rules! fake_enum_impls { ($n:ident) => { - impl Type for $n { - fn type_info() -> PgTypeInfo { - PgTypeInfo::with_name("INT4") + impl ::sqlx::Type<::sqlx::Postgres> for $n { + fn type_info() -> ::sqlx::postgres::PgTypeInfo { + ::sqlx::postgres::PgTypeInfo::with_name("INT4") } } @@ -18,14 +18,14 @@ macro_rules! fake_enum_impls { } } - impl<'r, DB: Database> Decode<'r, DB> for $n + impl<'r, DB: ::sqlx::Database> ::sqlx::Decode<'r, DB> for $n where - i32: Decode<'r, DB>, + i32: ::sqlx::Decode<'r, DB>, { fn decode( - value: ::ValueRef<'r>, - ) -> Result> { - let value = >::decode(value)?; + value: ::ValueRef<'r>, + ) -> Result> { + let value = >::decode(value)?; Ok(Self::from(value)) } } diff --git a/crates/models/src/api_key.rs b/crates/models/src/api_key.rs new file mode 100644 index 00000000..4c54bcd6 --- /dev/null +++ b/crates/models/src/api_key.rs @@ -0,0 +1,104 @@ +use pk_macros::pk_model; + +use chrono::{DateTime, Utc, NaiveDateTime}; +use uuid::Uuid; +use base64::{prelude::BASE64_STANDARD, Engine}; +use jsonwebtoken::{ + crypto::{sign, verify}, + DecodingKey, EncodingKey, +}; + +use crate::SystemId; + +#[derive(sqlx::Type, Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +#[sqlx(rename_all = "snake_case")] +#[sqlx(type_name = "api_key_type")] +pub enum ApiKeyType { + Dashboard, + UserCreated, + ExternalApp, +} + +#[pk_model] +struct ApiKey { + #[json = "id"] + id: Uuid, + system: SystemId, + #[json = "type"] + kind: ApiKeyType, + #[json = "scopes"] + scopes: Vec, + #[json = "app"] + app: Option, + #[json = "name"] + #[patchable] + name: Option, + + #[json = "discord_id"] + discord_id: Option, + #[private_patchable] + discord_access_token: Option, + #[private_patchable] + discord_refresh_token: Option, + #[private_patchable] + discord_expires_at: Option, + + #[json = "created"] + created: DateTime, +} + +const SIGNATURE_ALGORITHM: jsonwebtoken::Algorithm = jsonwebtoken::Algorithm::ES256; + +impl PKApiKey { + pub fn to_header_str(self, system_uuid: Uuid, key: &EncodingKey) -> String { + let b64 = BASE64_STANDARD.encode( + serde_json::to_vec(&serde_json::json!({ + "tid": self.id.to_string(), + "sid": system_uuid.to_string(), + "type": self.kind, + "scopes": self.scopes, + })) + .expect("should not fail"), + ); + + let signature = sign(b64.as_bytes(), key, SIGNATURE_ALGORITHM).expect("should not fail"); + + format!("pkapi:{b64}:{signature}") + } + + /// Parse a header string into a token uuid + pub fn parse_header_str(token: String, key: &DecodingKey) -> Option { + let mut parts = token.split(":"); + let pkapi = parts.next(); + if pkapi.is_none_or(|v| v != "pkapi") { + return None; + } + + let Some(jsonblob) = parts.next() else { + return None; + }; + let Some(sig) = parts.next() else { + return None; + }; + + // verify signature before doing anything else + let valid = verify(sig, jsonblob.as_bytes(), key, SIGNATURE_ALGORITHM); + if valid.is_err() || matches!(valid, Ok(false)) { + return None; + } + + let Ok(bytes) = BASE64_STANDARD.decode(jsonblob) else { + return None; + }; + + let Ok(obj) = serde_json::from_slice::(bytes.as_slice()) else { + return None; + }; + + obj.get("tid") + .map(|v| v.as_str().map(|f| Uuid::parse_str(f).ok())) + .flatten() + .flatten() + } +} diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index 0bd1f92b..f589f5da 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -1,14 +1,5 @@ mod _util; - -macro_rules! model { - ($n:ident) => { - mod $n; - pub use $n::*; - }; -} - -model!(system); -model!(system_config); +use _util::fake_enum_impls; #[derive(serde::Serialize, Debug, Clone)] #[serde(rename_all = "snake_case")] @@ -17,10 +8,7 @@ pub enum PrivacyLevel { Private, } -// this sucks, put it somewhere else -use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type}; -use std::error::Error; -_util::fake_enum_impls!(PrivacyLevel); +fake_enum_impls!(PrivacyLevel); impl From for PrivacyLevel { fn from(value: i32) -> Self { @@ -31,3 +19,24 @@ impl From for PrivacyLevel { } } } + +impl PrivacyLevel { + pub fn to_string(&self) -> String { + match self { + PrivacyLevel::Public => "public".into(), + PrivacyLevel::Private => "private".into(), + } + } +} + +macro_rules! model { + ($n:ident) => { + mod $n; + pub use $n::*; + }; +} + +model!(api_key); +model!(oauth2_app); +model!(system); +model!(system_config); diff --git a/crates/models/src/oauth2_app.rs b/crates/models/src/oauth2_app.rs new file mode 100644 index 00000000..cbfcc3c8 --- /dev/null +++ b/crates/models/src/oauth2_app.rs @@ -0,0 +1,28 @@ +use pk_macros::pk_model; +use uuid::Uuid; + +#[pk_model] +struct ExternalApp { + #[json = "id"] + id: Uuid, + #[json = "name"] + #[patchable] + name: String, + #[json = "homepage_url"] + #[patchable] + homepage_url: String, + + #[private_patchable] + oauth2_secret: Option, + #[json = "oauth2_allowed_redirects"] + #[patchable] + oauth2_allowed_redirects: Vec, + #[json = "oauth2_scopes"] + #[patchable] + oauth2_scopes: Vec, + + #[private_patchable] + api_rl_token: Option, + #[private_patchable] + api_rl_rate: Option, +} diff --git a/crates/models/src/system.rs b/crates/models/src/system.rs index ddaf1980..137f5251 100644 --- a/crates/models/src/system.rs +++ b/crates/models/src/system.rs @@ -1,10 +1,9 @@ use pk_macros::pk_model; +use crate::PrivacyLevel; use chrono::NaiveDateTime; use uuid::Uuid; -use crate::PrivacyLevel; - // todo: fix this pub type SystemId = i32; diff --git a/docs/content/.vuepress/config.js b/docs/content/.vuepress/config.js index 5bdc945e..5f117fa8 100644 --- a/docs/content/.vuepress/config.js +++ b/docs/content/.vuepress/config.js @@ -77,7 +77,8 @@ module.exports = { "/api/endpoints", "/api/models", "/api/errors", - "/api/dispatch" + "/api/tokens", + "/api/dispatch", ] }, ["https://discord.gg/PczBt78", "Join the support server"], diff --git a/docs/content/api/models.md b/docs/content/api/models.md index 65fba699..8239f930 100644 --- a/docs/content/api/models.md +++ b/docs/content/api/models.md @@ -42,7 +42,7 @@ The PluralKit Discord bot can be configured to display short IDs in uppercase, o |---|---|---| |id|string|| |uuid|string|| -|?system|string|id of system this member is registered in (only returned in `/members/:id` endpoint)| +|system|string|id of system this member is registered in| |name|string|100-character limit| |display_name|?string|100-character limit| |color|?string|6-character hex code, no `#` at the beginning| @@ -78,7 +78,7 @@ The PluralKit Discord bot can be configured to display short IDs in uppercase, o |---|---|---| |id|string|| |uuid|string|| -|?system|string|id of system this group is registered in (only returned in `/groups/:id` endpoint)| +|system|string|id of system this group is registered in| |name|string|100-character limit| |display_name|?string|100-character limit| |description|?string|1000-character limit| @@ -96,7 +96,7 @@ The PluralKit Discord bot can be configured to display short IDs in uppercase, o |---|---|---| |id|uuid|| |timestamp|datetime|| -| members | list of id/Member | Is sometimes in plain ID list form (eg. `GET /systems/:id/switches`), sometimes includes the full Member model (eg. `GET /systems/:id/fronters`). | +|members|list of id/Member|Is sometimes in plain ID list form (eg. `GET /systems/:id/switches`), sometimes includes the full Member model (eg. `GET /systems/:id/fronters`)| ### Message model @@ -121,6 +121,13 @@ The PluralKit Discord bot can be configured to display short IDs in uppercase, o |member_default_private*|boolean|whether members created through the bot have privacy settings set to private by default| |group_default_private*|boolean|whether groups created through the bot have privacy settings set to private by default| |show_private_info|boolean|whether the bot shows the system's own private information without a `-private` flag| +|case_sensitive_proxy_tags|boolean|whether the system's member proxy tags are parsed as case sensitive| +|proxy_error_message_enabled|boolean|whether to show proxying-specific error messages| +|hid_display_split|boolean|if enabled, the system prefers 6-character IDs to be displayed with a hyphen splitting each group of 3 characters| +|hid_display_caps|boolean|if enabled, the system prefers short IDs to be displayed in all-caps| +|hid_list_padding|one of "off", "left", "right"|system preference for padding short IDs in lists| +|proxy_switch|one of "off", "new", "add"|| +|name_format|string|formatting template for display names for the system's proxied messages| |member_limit|int|read-only, defaults to 1000| |group_limit|int|read-only, defaults to 250| |case_sensitive_proxy_tags|bool|whether the bot will match proxy tags matching only the case used in the trigger message| diff --git a/docs/content/api/reference.md b/docs/content/api/reference.md index 29ca86c6..16817850 100644 --- a/docs/content/api/reference.md +++ b/docs/content/api/reference.md @@ -8,11 +8,6 @@ permalink: /api PluralKit has a basic HTTP REST API for querying and modifying your system. The root endpoint of the API is `https://api.pluralkit.me/v2/`. -#### Authorization header token example -``` -Authorization: z865MC7JNhLtZuSq1NXQYVe+FgZJHBfeBCXOPYYRwH4liDCDrsd7zdOuR45mX257 -``` - Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints, missing fields from the JSON request will be ignored and preserved as is, but on `POST` endpoints will be set to `null` or cleared. @@ -29,14 +24,12 @@ If you are developing an application exposed to the public, we would appreciate ## Authentication -Authentication is done with a simple "system token". You can get your system token by running `pk;token` using the -Discord bot, either in a channel with the bot or in DMs. Then, pass this token in the `Authorization` HTTP header -on requests that require it. Failure to do so on endpoints that require authentication will return a `401 Unauthorized`. +Authentication is done with an API key provided in the `Authorization` HTTP header - [see the API key section of the documentation for details.](/api/tokens) -Some endpoints show information that a given system may have set to private. If this is a specific field -(eg. description), the field will simply contain `null` rather than the true value. If this applies to entire endpoint -responses (eg. fronter, switches, member list), the entire request will return `403 Forbidden`. Authenticating with the -system's token (as described above) will override these privacy settings and show the full information. +Some endpoints show information that a given system may have set to private. For unauthenticated requests, and for requests authenticated with an API key that does not have permission to read private data, the following rules apply: + +- For fields with specific privacy settings (e.g. descriptions), the field will simply contain `null` rather than the true value +- For entire endpoints which show private data (e.g. member/group lists), a `403 Forbidden` response will be returned ## Rate Limiting @@ -46,7 +39,7 @@ To protect against abuse and manage server resources, PluralKit's API limits the - **10/second** for requests to the [Get Proxied Message Information](/api/endpoints/#get-proxied-message-information) endpoint (`message` scope) - **3/second** for any `POST`, `PATCH`, or `DELETE` requests (`generic_update` scope) -We may raise the limits for individual users in a case-by-case basis; please ask [in the support server](https://discord.gg/PczBt78) if you need a higher limit. +We may raise the limits for individual API clients on a case-by-case basis; please ask [in the support server](https://discord.gg/PczBt78) if you need a higher limit. ::: tip If you are looking to query a specific resource in your system repeatedly (polling), please consider using [Dispatch Webhooks](/api/dispatch) instead. diff --git a/docs/content/api/tokens.md b/docs/content/api/tokens.md new file mode 100644 index 00000000..ae8e814f --- /dev/null +++ b/docs/content/api/tokens.md @@ -0,0 +1,79 @@ +--- +title: API keys / tokens +permalink: /api/tokens +--- + +# API keys / tokens + +There are currently two types of API keys / tokens used by PluralKit - "legacy" tokens from the `pk;token` command (64 characters, a system can only have one valid token at a time); and "modern" API keys (variable length, always start with `pkapi:'). + +## "Legacy" tokens + +"Legacy" PluralKit tokens look similar to the following: + +``` +LvWacQm3Yu+Jbhl8B7LR97Q4kfpAasTiB8/BY5/HJCppHFggzwOai6QBxehAJ53C +``` + +These tokens are supplied *as-is* in the `Authorization` HTTP header when talking to the PluralKit API (e.g. `Authorization: LvWacQm3Y...`) + +Each PluralKit system can only have *one* valid "legacy" token at a time, and that token holds the keys to the entire castle - it grants full read/write privileges. + +**PluralKit's API will stop accepting "legacy" tokens for authentication in the near future!** We do not yet have a deprecation plan set in stone, but there will be a significant notice period before this happens. + +## "Modern" API keys + +A "modern" PluralKit API key is made up of three components, separated by colons: + +- The string `"pkapi"` +- A Base64-encoded JSON blob containing information about the API key +- An opaque signature + +As an example: + +``` +pkapi:eyJ0aWQiOiI3NWEzODZlNy1mMjNlLTRmM2EtYjkwNC1jYTgwMzE0OWFmNWEiLCJzaWQiOiIyMmIwYjA3Yi00ZmE3LTRmYTEtYmYyNS1lZWI4NjY1ZjMyYzEiLCJ0eXBlIjoidXNlcl9jcmVhdGVkIiwic2NvcGVzIjpbIndyaXRlOmFsbCJdfQ==:nUjJPPtBOyPb1bYFhm24bU87N2Fb_oSaNnHEZkB-6ZSCSlAJvkyb32MTfmdEv3U6wNBlBQtQb0Fkv2nSvbNsCw +``` + +These tokens must be supplied with a "Bearer" prefix in the `Authorization` HTTP header when talking to the PluralKit API (e.g. `Authorization: Bearer pkapi:eyJ0aW...`). + +The JSON blob in the above example API key contains the following: + +```js +{ + // API key ID + "tid": "75a386e7-f23e-4f3a-b904-ca803149af5a", + + // UUID of the PluralKit system the token belongs to + "sid": "22b0b07b-4fa7-4fa1-bf25-eeb8665f32c1", + + // "user_created" for manually generated API keys, + // "external_app" for OAuth2 user API keys (coming soon!) + "type": "user_created", + + // One or more scopes (see below) + "scopes": ["write:all"] +} +``` + +### Scopes + +In the below table, `` refers to a *permission level* - one of the following: + +- `publicread`: read-only access to *public* information +- `read`: read-only access to all (public *and* private) information +- `write`: read-write access to all information (implies `read`) + +|scope|notes| +|---|---| +|`identify`|Read-only access to `/v2/systems/@me` - for proving the user providing the token has control of the PluralKit system| +|`:system`|Access to core system data, system settings (including autoproxy), and server-specific settings| +|`:members`|Access to member information, *not including group membership*| +|`:groups`|Access to group information| +|`:fronters`|Access to current system fronters| +|`:switches`|Access to full system switch history (implies `:fronters`)| +|`:all`|Includes all other scopes| + +### Issuing new API keys + +TODO