✨ Parse JWT for expiry & roles
This commit is contained in:
parent
dfc9c3538c
commit
75b23cdaed
@ -1,13 +1,15 @@
|
|||||||
using Dalamud.Configuration;
|
using Dalamud.Configuration;
|
||||||
|
using Dalamud.Logging;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
namespace Pal.Client
|
namespace Pal.Client
|
||||||
{
|
{
|
||||||
public class Configuration : IPluginConfiguration
|
public class Configuration : IPluginConfiguration
|
||||||
{
|
{
|
||||||
public int Version { get; set; } = 2;
|
public int Version { get; set; } = 3;
|
||||||
|
|
||||||
#region Saved configuration values
|
#region Saved configuration values
|
||||||
public bool FirstUse { get; set; } = true;
|
public bool FirstUse { get; set; } = true;
|
||||||
@ -19,7 +21,9 @@ namespace Pal.Client
|
|||||||
[Obsolete]
|
[Obsolete]
|
||||||
public string? AccountId { private get; set; }
|
public string? AccountId { private get; set; }
|
||||||
|
|
||||||
public Dictionary<string, Guid> AccountIds { get; set; } = new();
|
[Obsolete]
|
||||||
|
public Dictionary<string, Guid> AccountIds { private get; set; } = new();
|
||||||
|
public Dictionary<string, AccountInfo> Accounts { get; set; } = new();
|
||||||
|
|
||||||
public bool ShowTraps { get; set; } = true;
|
public bool ShowTraps { get; set; } = true;
|
||||||
public Vector4 TrapColor { get; set; } = new Vector4(1, 0, 0, 0.4f);
|
public Vector4 TrapColor { get; set; } = new Vector4(1, 0, 0, 0.4f);
|
||||||
@ -42,6 +46,8 @@ namespace Pal.Client
|
|||||||
{
|
{
|
||||||
if (Version == 1)
|
if (Version == 1)
|
||||||
{
|
{
|
||||||
|
PluginLog.Information("Updating config to version 2");
|
||||||
|
|
||||||
if (DebugAccountId != null && Guid.TryParse(DebugAccountId, out Guid debugAccountId))
|
if (DebugAccountId != null && Guid.TryParse(DebugAccountId, out Guid debugAccountId))
|
||||||
AccountIds["http://localhost:5145"] = debugAccountId;
|
AccountIds["http://localhost:5145"] = debugAccountId;
|
||||||
|
|
||||||
@ -51,6 +57,18 @@ namespace Pal.Client
|
|||||||
Version = 2;
|
Version = 2;
|
||||||
Save();
|
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
|
#pragma warning restore CS0612 // Type or member is obsolete
|
||||||
|
|
||||||
@ -72,5 +90,20 @@ namespace Pal.Client
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Offline = 2,
|
Offline = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AccountInfo
|
||||||
|
{
|
||||||
|
public Guid? Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> CachedRoles { get; set; } = new List<string>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
95
Pal.Client/Net/JwtClaims.cs
Normal file
95
Pal.Client/Net/JwtClaims.cs
Normal file
@ -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<string> 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<JwtClaims>(content) ?? throw new InvalidOperationException("token deserialization returned null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class JwtRoleConverter : JsonConverter<List<string>>
|
||||||
|
{
|
||||||
|
public override List<string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
return new List<string> { reader.GetString() ?? throw new JsonException("no value present") };
|
||||||
|
else if (reader.TokenType == JsonTokenType.StartArray)
|
||||||
|
{
|
||||||
|
List<string> 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<string> value, JsonSerializerOptions options) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JwtDateConverter : JsonConverter<DateTimeOffset>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,9 @@ using System.Net.Http;
|
|||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
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 readonly ILoggerFactory _grpcToPluginLogLoggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new GrpcLoggerProvider()).AddFilter("Grpc", LogLevel.Trace));
|
||||||
|
|
||||||
private GrpcChannel? _channel;
|
private GrpcChannel? _channel;
|
||||||
private LoginReply? _lastLoginReply;
|
private LoginInfo _loginInfo = new LoginInfo(null);
|
||||||
private bool _warnedAboutUpgrade = false;
|
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
|
set
|
||||||
{
|
{
|
||||||
if (value != null)
|
if (value != null)
|
||||||
Service.Configuration.AccountIds[remoteUrl] = value.Value;
|
Service.Configuration.Accounts[remoteUrl] = value;
|
||||||
else
|
else
|
||||||
Service.Configuration.AccountIds.Remove(remoteUrl);
|
Service.Configuration.Accounts.Remove(remoteUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Guid? AccountId => Account?.Id;
|
||||||
|
|
||||||
private string PartialAccountId =>
|
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)
|
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);
|
var createAccountReply = await accountClient.CreateAccountAsync(new CreateAccountRequest(), headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
||||||
if (createAccountReply.Success)
|
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}");
|
PluginLog.Information($"TryConnect: Account created with id {PartialAccountId}");
|
||||||
|
|
||||||
Service.Configuration.Save();
|
Service.Configuration.Save();
|
||||||
@ -103,20 +111,29 @@ namespace Pal.Client.Net
|
|||||||
return (false, "No account-id after account was attempted to be created.");
|
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}");
|
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);
|
LoginReply loginReply = await accountClient.LoginAsync(new LoginRequest { AccountId = AccountId?.ToString() }, headers: UnauthorizedHeaders(), deadline: DateTime.UtcNow.AddSeconds(10), cancellationToken: cancellationToken);
|
||||||
if (_lastLoginReply.Success)
|
if (loginReply.Success)
|
||||||
{
|
{
|
||||||
PluginLog.Information($"TryConnect: Login successful with account id: {PartialAccountId}");
|
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<string>();
|
||||||
|
Service.Configuration.Save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
PluginLog.Error($"TryConnect: Login failed with error {_lastLoginReply.Error}");
|
PluginLog.Error($"TryConnect: Login failed with error {loginReply.Error}");
|
||||||
if (_lastLoginReply.Error == LoginError.InvalidAccountId)
|
_loginInfo = new LoginInfo(null);
|
||||||
|
if (loginReply.Error == LoginError.InvalidAccountId)
|
||||||
{
|
{
|
||||||
AccountId = null;
|
Account = null;
|
||||||
Service.Configuration.Save();
|
Service.Configuration.Save();
|
||||||
if (retry)
|
if (retry)
|
||||||
{
|
{
|
||||||
@ -126,26 +143,22 @@ namespace Pal.Client.Net
|
|||||||
else
|
else
|
||||||
return (false, "Invalid account id.");
|
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.");
|
Service.Chat.PrintError("[Palace Pal] Your version of Palace Pal is outdated, please update the plugin using the Plugin Installer.");
|
||||||
_warnedAboutUpgrade = true;
|
_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.");
|
return (false, "No login information available.");
|
||||||
}
|
}
|
||||||
|
|
||||||
bool success = !string.IsNullOrEmpty(_lastLoginReply?.AuthToken);
|
return (true, string.Empty);
|
||||||
if (!success)
|
|
||||||
return (success, "Login reply did not include auth token.");
|
|
||||||
|
|
||||||
return (success, string.Empty);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> Connect(CancellationToken cancellationToken)
|
private async Task<bool> Connect(CancellationToken cancellationToken)
|
||||||
@ -239,7 +252,7 @@ namespace Pal.Client.Net
|
|||||||
|
|
||||||
private Metadata AuthorizedHeaders() => new Metadata
|
private Metadata AuthorizedHeaders() => new Metadata
|
||||||
{
|
{
|
||||||
{ "Authorization", $"Bearer {_lastLoginReply?.AuthToken}" },
|
{ "Authorization", $"Bearer {_loginInfo?.AuthToken}" },
|
||||||
{ "User-Agent", UserAgent },
|
{ "User-Agent", UserAgent },
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -276,11 +289,45 @@ namespace Pal.Client.Net
|
|||||||
#endif
|
#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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
PluginLog.Debug("Disposing gRPC channel");
|
PluginLog.Debug("Disposing gRPC channel");
|
||||||
_channel?.Dispose();
|
_channel?.Dispose();
|
||||||
_channel = null;
|
_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net6.0-windows</TargetFramework>
|
||||||
<LangVersion>9.0</LangVersion>
|
<LangVersion>9.0</LangVersion>
|
||||||
<Version>1.23</Version>
|
<Version>1.24</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ using ECommons.SplatoonAPI;
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using ImGuiNET;
|
using ImGuiNET;
|
||||||
using Lumina.Excel.GeneratedSheets;
|
using Lumina.Excel.GeneratedSheets;
|
||||||
|
using Pal.Client.Net;
|
||||||
using Pal.Client.Windows;
|
using Pal.Client.Windows;
|
||||||
using Pal.Common;
|
using Pal.Common;
|
||||||
using System;
|
using System;
|
||||||
@ -556,9 +557,9 @@ namespace Pal.Client
|
|||||||
|
|
||||||
private async Task FetchFloorStatistics()
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -578,7 +579,7 @@ namespace Pal.Client
|
|||||||
}
|
}
|
||||||
catch (RpcException e) when (e.StatusCode == StatusCode.PermissionDenied)
|
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
@ -42,7 +42,7 @@ message LoginRequest {
|
|||||||
message LoginReply {
|
message LoginReply {
|
||||||
bool success = 1;
|
bool success = 1;
|
||||||
string authToken = 2;
|
string authToken = 2;
|
||||||
google.protobuf.Timestamp expiresAt = 3;
|
google.protobuf.Timestamp expiresAt = 3 [deprecated = true];
|
||||||
LoginError error = 4;
|
LoginError error = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user