✨ Parse JWT for expiry & roles
This commit is contained in:
parent
dfc9c3538c
commit
75b23cdaed
@ -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<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 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
|
||||
/// </summary>
|
||||
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.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<string>();
|
||||
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<bool> 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Version>1.23</Version>
|
||||
<Version>1.24</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user