From 75b23cdaedf7aa2d73a3030b032fe2ba2c8644e9 Mon Sep 17 00:00:00 2001 From: Liza Carvelli Date: Sun, 11 Dec 2022 15:22:41 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Parse=20JWT=20for=20expiry=20&=20ro?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Pal.Client/Configuration.cs | 37 ++++++++++++- Pal.Client/Net/JwtClaims.cs | 95 +++++++++++++++++++++++++++++++++ Pal.Client/Net/RemoteApi.cs | 93 ++++++++++++++++++++++++-------- Pal.Client/Pal.Client.csproj | 2 +- Pal.Client/Plugin.cs | 7 +-- Pal.Common/Protos/account.proto | 2 +- 6 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 Pal.Client/Net/JwtClaims.cs diff --git a/Pal.Client/Configuration.cs b/Pal.Client/Configuration.cs index 67275aa..afbdfde 100644 --- a/Pal.Client/Configuration.cs +++ b/Pal.Client/Configuration.cs @@ -1,13 +1,15 @@ using Dalamud.Configuration; +using Dalamud.Logging; using System; using System.Collections.Generic; +using System.Linq; using System.Numerics; namespace Pal.Client { public class Configuration : IPluginConfiguration { - public int Version { get; set; } = 2; + public int Version { get; set; } = 3; #region Saved configuration values public bool FirstUse { get; set; } = true; @@ -19,7 +21,9 @@ namespace Pal.Client [Obsolete] public string? AccountId { private get; set; } - public Dictionary AccountIds { get; set; } = new(); + [Obsolete] + public Dictionary AccountIds { private get; set; } = new(); + public Dictionary Accounts { get; set; } = new(); public bool ShowTraps { get; set; } = true; public Vector4 TrapColor { get; set; } = new Vector4(1, 0, 0, 0.4f); @@ -42,6 +46,8 @@ namespace Pal.Client { if (Version == 1) { + PluginLog.Information("Updating config to version 2"); + if (DebugAccountId != null && Guid.TryParse(DebugAccountId, out Guid debugAccountId)) AccountIds["http://localhost:5145"] = debugAccountId; @@ -51,6 +57,18 @@ namespace Pal.Client Version = 2; Save(); } + + if (Version == 2) + { + PluginLog.Information("Updating config to version 3"); + + Accounts = AccountIds.ToDictionary(x => x.Key, x => new AccountInfo + { + Id = x.Value + }); + Version = 3; + Save(); + } } #pragma warning restore CS0612 // Type or member is obsolete @@ -72,5 +90,20 @@ namespace Pal.Client /// Offline = 2, } + + public class AccountInfo + { + public Guid? Id { get; set; } + + /// + /// This is taken from the JWT, and is only refreshed on a successful login. + /// + /// If you simply reload the plugin without any server interaction, this doesn't change. + /// + /// This has no impact on what roles the JWT actually contains, but is just to make it + /// easier to draw a consistent UI. The server will still reject unauthorized calls. + /// + public List CachedRoles { get; set; } = new List(); + } } } diff --git a/Pal.Client/Net/JwtClaims.cs b/Pal.Client/Net/JwtClaims.cs new file mode 100644 index 0000000..538d87d --- /dev/null +++ b/Pal.Client/Net/JwtClaims.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Pal.Client.Net +{ + internal class JwtClaims + { + [JsonPropertyName("nameid")] + public Guid NameId { get; set; } + + [JsonPropertyName("role")] + [JsonConverter(typeof(JwtRoleConverter))] + public List Roles { get; set; } = new(); + + [JsonPropertyName("nbf")] + [JsonConverter(typeof(JwtDateConverter))] + public DateTimeOffset NotBefore { get; set; } + + [JsonPropertyName("exp")] + [JsonConverter(typeof(JwtDateConverter))] + public DateTimeOffset ExpiresAt { get; set; } + + public static JwtClaims FromAuthToken(string authToken) + { + if (string.IsNullOrEmpty(authToken)) + throw new ArgumentException("Server sent no auth token", nameof(authToken)); + + string[] parts = authToken.Split('.'); + if (parts.Length != 3) + throw new ArgumentException("Unsupported token type", nameof(authToken)); + + // fix padding manually + string payload = parts[1].Replace(",", "=").Replace("-", "+").Replace("/", "_"); + if (payload.Length % 4 == 2) + payload += "=="; + else if (payload.Length % 4 == 3) + payload += "="; + + string content = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + return JsonSerializer.Deserialize(content) ?? throw new InvalidOperationException("token deserialization returned null"); + } + } + + internal class JwtRoleConverter : JsonConverter> + { + public override List? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + return new List { reader.GetString() ?? throw new JsonException("no value present") }; + else if (reader.TokenType == JsonTokenType.StartArray) + { + List result = new(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + result.Sort(); + return result; + } + + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("string expected"); + + result.Add(reader.GetString() ?? throw new JsonException("no value present")); + } + + throw new JsonException("read to end of document"); + } + else + throw new JsonException("bad token type"); + } + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) => throw new NotImplementedException(); + } + + public class JwtDateConverter : JsonConverter + { + static readonly DateTimeOffset Zero = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.Number) + throw new JsonException("bad token type"); + + return Zero.AddSeconds(reader.GetInt64()); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) => throw new NotImplementedException(); + } +} diff --git a/Pal.Client/Net/RemoteApi.cs b/Pal.Client/Net/RemoteApi.cs index 0f721a1..91f64ea 100644 --- a/Pal.Client/Net/RemoteApi.cs +++ b/Pal.Client/Net/RemoteApi.cs @@ -11,6 +11,9 @@ using System.Net.Http; using System.Net.Security; using System.Numerics; using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -28,23 +31,25 @@ namespace Pal.Client.Net private readonly ILoggerFactory _grpcToPluginLogLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new GrpcLoggerProvider()).AddFilter("Grpc", LogLevel.Trace)); private GrpcChannel? _channel; - private LoginReply? _lastLoginReply; + private LoginInfo _loginInfo = new LoginInfo(null); private bool _warnedAboutUpgrade = false; - public Guid? AccountId + public Configuration.AccountInfo? Account { - get => Service.Configuration.AccountIds.TryGetValue(remoteUrl, out Guid accountId) ? accountId : null; + get => Service.Configuration.Accounts.TryGetValue(remoteUrl, out Configuration.AccountInfo? accountInfo) ? accountInfo : null; set { if (value != null) - Service.Configuration.AccountIds[remoteUrl] = value.Value; + Service.Configuration.Accounts[remoteUrl] = value; else - Service.Configuration.AccountIds.Remove(remoteUrl); + Service.Configuration.Accounts.Remove(remoteUrl); } } + public Guid? AccountId => Account?.Id; + private string PartialAccountId => - AccountId?.ToString()?.PadRight(14).Substring(0, 13) ?? "[no account id]"; + Account?.Id?.ToString()?.PadRight(14).Substring(0, 13) ?? "[no account id]"; private async Task<(bool Success, string Error)> TryConnect(CancellationToken cancellationToken, ILoggerFactory? loggerFactory = null, bool retry = true) { @@ -80,7 +85,10 @@ namespace Pal.Client.Net var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); if (createAccountReply.Success) { - AccountId = Guid.Parse(createAccountReply.AccountId); + Account = new Configuration.AccountInfo + { + Id = Guid.Parse(createAccountReply.AccountId), + }; PluginLog.Information($"TryConnect: Account created with id {PartialAccountId}"); Service.Configuration.Save(); @@ -103,20 +111,29 @@ namespace Pal.Client.Net return (false, "No account-id after account was attempted to be created."); } - if (_lastLoginReply == null || string.IsNullOrEmpty(_lastLoginReply.AuthToken) || _lastLoginReply.ExpiresAt.ToDateTime().ToLocalTime() < DateTime.Now) + if (!_loginInfo.IsValid) { PluginLog.Information($"TryConnect: Logging in with account id {PartialAccountId}"); - _lastLoginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); - if (_lastLoginReply.Success) + LoginReply loginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken); + if (loginReply.Success) { PluginLog.Information($"TryConnect: Login successful with account id: {PartialAccountId}"); + _loginInfo = new LoginInfo(loginReply.AuthToken); + + var account = Account; + if (account != null) + { + account.CachedRoles = _loginInfo.Claims?.Roles?.ToList() ?? new List(); + Service.Configuration.Save(); + } } else { - PluginLog.Error($"TryConnect: Login failed with error {_lastLoginReply.Error}"); - if (_lastLoginReply.Error == LoginError.InvalidAccountId) + PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}"); + _loginInfo = new LoginInfo(null); + if (loginReply.Error == LoginError.InvalidAccountId) { - AccountId = null; + Account = null; Service.Configuration.Save(); if (retry) { @@ -126,26 +143,22 @@ namespace Pal.Client.Net else return (false, "Invalid account id."); } - if (_lastLoginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade) + if (loginReply.Error == LoginError.UpgradeRequired && !_warnedAboutUpgrade) { Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer."); _warnedAboutUpgrade = true; } - return (false, $"Could not log in ({_lastLoginReply.Error})."); + return (false, $"Could not log in ({loginReply.Error})."); } } - if (_lastLoginReply == null) + if (!_loginInfo.IsValid) { - PluginLog.Error("TryConnect: No account available"); + PluginLog.Error($"TryConnect: Login state is loggedIn={_loginInfo.IsLoggedIn}, expired={_loginInfo.IsExpired}"); return (false, "No login information available."); } - bool success = !string.IsNullOrEmpty(_lastLoginReply?.AuthToken); - if (!success) - return (success, "Login reply did not include auth token."); - - return (success, string.Empty); + return (true, string.Empty); } private async Task Connect(CancellationToken cancellationToken) @@ -239,7 +252,7 @@ namespace Pal.Client.Net private Metadata AuthorizedHeaders() => new Metadata { - { "Authorization", $"Bearer {_lastLoginReply?.AuthToken}" }, + { "Authorization", $"Bearer {_loginInfo?.AuthToken}" }, { "User-Agent", UserAgent }, }; @@ -276,11 +289,45 @@ namespace Pal.Client.Net #endif } + public bool HasRoleOnCurrentServer(string role) + { + if (Service.Configuration.Mode != Configuration.EMode.Online) + return false; + + var account = Account; + return account == null || account.CachedRoles.Contains(role); + } + public void Dispose() { PluginLog.Debug("Disposing gRPC channel"); _channel?.Dispose(); _channel = null; } + + internal class LoginInfo + { + public LoginInfo(string? authToken) + { + if (!string.IsNullOrEmpty(authToken)) + { + IsLoggedIn = true; + AuthToken = authToken; + Claims = JwtClaims.FromAuthToken(authToken!); + } + else + IsLoggedIn = false; + } + + public bool IsLoggedIn { get; } + public string? AuthToken { get; } + public JwtClaims? Claims { get; } + public DateTimeOffset ExpiresAt => Claims?.ExpiresAt.Subtract(TimeSpan.FromMinutes(5)) ?? DateTimeOffset.MinValue; + public bool IsExpired => ExpiresAt < DateTimeOffset.UtcNow; + + public bool IsValid => IsLoggedIn && !IsExpired; + } + + } } diff --git a/Pal.Client/Pal.Client.csproj b/Pal.Client/Pal.Client.csproj index d15dbad..f7a8d5d 100644 --- a/Pal.Client/Pal.Client.csproj +++ b/Pal.Client/Pal.Client.csproj @@ -3,7 +3,7 @@ net6.0-windows 9.0 - 1.23 + 1.24 enable diff --git a/Pal.Client/Plugin.cs b/Pal.Client/Plugin.cs index a79d28c..1fca5f8 100644 --- a/Pal.Client/Plugin.cs +++ b/Pal.Client/Plugin.cs @@ -14,6 +14,7 @@ using ECommons.SplatoonAPI; using Grpc.Core; using ImGuiNET; using Lumina.Excel.GeneratedSheets; +using Pal.Client.Net; using Pal.Client.Windows; using Pal.Common; using System; @@ -556,9 +557,9 @@ namespace Pal.Client private async Task FetchFloorStatistics() { - if (Service.Configuration.Mode != Configuration.EMode.Online) + if (!Service.RemoteApi.HasRoleOnCurrentServer("statistics:view")) { - Service.Chat.Print($"[Palace Pal] You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window."); + Service.Chat.Print("[Palace Pal] You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window."); return; } @@ -578,7 +579,7 @@ namespace Pal.Client } catch (RpcException e) when (e.StatusCode == StatusCode.PermissionDenied) { - Service.Chat.Print($"[Palace Pal] You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window."); + Service.Chat.Print("[Palace Pal] You can view statistics for the floor you're currently on by opening the 'Debug' tab in the configuration window."); } catch (Exception e) { diff --git a/Pal.Common/Protos/account.proto b/Pal.Common/Protos/account.proto index 21ea121..81be76d 100644 --- a/Pal.Common/Protos/account.proto +++ b/Pal.Common/Protos/account.proto @@ -42,7 +42,7 @@ message LoginRequest { message LoginReply { bool success = 1; string authToken = 2; - google.protobuf.Timestamp expiresAt = 3; + google.protobuf.Timestamp expiresAt = 3 [deprecated = true]; LoginError error = 4; }