Parse JWT for expiry & roles

This commit is contained in:
Liza 2022-12-11 15:22:41 +01:00
parent dfc9c3538c
commit 75b23cdaed
6 changed files with 206 additions and 30 deletions

View File

@ -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>();
}
} }
} }

View 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();
}
}

View File

@ -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;
}
} }
} }

View File

@ -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>

View File

@ -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)
{ {

View File

@ -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;
} }