feat: implement session persistence with "Remember Me" functionality and logout support

This commit is contained in:
HueByte
2026-02-21 17:29:16 +01:00
parent 23abb9b0bb
commit d4cf35e04e
6 changed files with 211 additions and 22 deletions
+10
View File
@@ -43,6 +43,16 @@
- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating) - Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating)
- Current channel shows its type: `#channel - public` or `#channel - private` - Current channel shows its type: `#channel - public` or `#channel - private`
### Remember Me (Session Persistence)
- "Remember me" checkbox in the connect dialog — saves a 30-day refresh token so users can reconnect without entering their password
- Saved servers with active sessions show `[session]` indicator in the connect dialog and saved servers list
- Token-based login: selecting a saved server with a session lets you click Login with an empty password
- Graceful expiry handling: if the saved session is expired or revoked, shows an error and prompts for password
- Refresh token rotation: rotated tokens are automatically persisted to config so the session stays valid across refreshes
- New "Logout" menu item (Server menu): revokes the refresh token server-side and clears the saved session
- Removed dead `SavedServer.Token` field (stored 15-min access token that was never read back)
### #general Channel Protection ### #general Channel Protection
- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow) - `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow)
+99 -5
View File
@@ -78,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
{ {
_mainWindow.OnConnectRequested += HandleConnect; _mainWindow.OnConnectRequested += HandleConnect;
_mainWindow.OnDisconnectRequested += HandleDisconnect; _mainWindow.OnDisconnectRequested += HandleDisconnect;
_mainWindow.OnLogoutRequested += HandleLogout;
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
_mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnChannelSelected += HandleChannelSelected;
_mainWindow.OnProfileRequested += HandleProfileRequested; _mainWindow.OnProfileRequested += HandleProfileRequested;
@@ -401,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating...")); InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
var loginResponse = result.IsRegister LoginResponse loginResponse;
? await _apiClient.RegisterAsync(result.Username, result.Password)
: await _apiClient.LoginAsync(result.Username, result.Password); if (result.SavedRefreshToken is not null)
{
try
{
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken);
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
}
catch (Exception ex)
{
Log.Warning(ex, "Saved session expired or revoked");
ClearSavedToken(result.ServerUrl);
InvokeUI(() =>
{
_mainWindow.UpdateStatusBar("Disconnected");
MessageBox.ErrorQuery(_app, "Session Expired",
"Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
});
_apiClient.Dispose();
_apiClient = null;
return;
}
}
else if (result.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
}
_currentUsername = loginResponse.Username; _currentUsername = loginResponse.Username;
// Persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += () =>
{
if (_apiClient?.RefreshToken is null) return;
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
if (server is not null && server.RememberMe)
{
server.RefreshToken = _apiClient.RefreshToken;
ConfigManager.Save(config);
}
};
// Fetch encryption key for E2E message encryption // Fetch encryption key for E2E message encryption
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key...")); InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
try try
@@ -489,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable
}, "Disconnect error", "Disconnect"); }, "Disconnect error", "Disconnect");
} }
private void HandleLogout()
{
Log.Information("Logging out from server");
RunAsync(async () =>
{
if (_apiClient is not null)
{
var baseUrl = _apiClient.BaseUrl;
await _apiClient.LogoutAsync();
ClearSavedToken(baseUrl);
}
if (_connection is not null)
{
await _connection.DisconnectAsync();
await _connection.DisposeAsync();
_connection = null;
}
_apiClient?.Dispose();
_apiClient = null;
_joinedChannels.Clear();
InvokeUI(() =>
{
_mainWindow.ClearAll();
_mainWindow.UpdateStatusBar("Disconnected");
});
}, "Logout error", "Logout");
}
private void HandleMessageSubmitted(string channelName, string content) private void HandleMessageSubmitted(string channelName, string content)
{ {
if (!IsConnected) if (!IsConnected)
@@ -734,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable
} }
var serverLines = _config.SavedServers var serverLines = _config.SavedServers
.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}") .Select(s =>
{
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : "";
return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}";
})
.ToList(); .ToList();
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK"); MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
@@ -1003,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable
Name = new Uri(result.ServerUrl).Host, Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl, Url = result.ServerUrl,
Username = result.Username, Username = result.Username,
Token = _apiClient!.Token, RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
RememberMe = result.RememberMe,
LastConnected = DateTimeOffset.Now LastConnected = DateTimeOffset.Now
}; };
ConfigManager.SaveServer(savedServer); ConfigManager.SaveServer(savedServer);
_config = ConfigManager.Load(); _config = ConfigManager.Load();
Log.Information("Connected successfully to {Url}", result.ServerUrl); Log.Information("Connected successfully to {Url}", result.ServerUrl);
} }
private void ClearSavedToken(string serverUrl)
{
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
if (server is not null)
{
server.RefreshToken = null;
ConfigManager.Save(config);
_config = config;
}
}
} }
+2 -1
View File
@@ -20,7 +20,8 @@ public class SavedServer
public required string Name { get; set; } public required string Name { get; set; }
public required string Url { get; set; } public required string Url { get; set; }
public string? Username { get; set; } public string? Username { get; set; }
public string? Token { get; set; } public string? RefreshToken { get; set; }
public bool RememberMe { get; set; }
public DateTimeOffset LastConnected { get; set; } public DateTimeOffset LastConnected { get; set; }
} }
+16
View File
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
public string? RefreshToken => _refreshToken; public string? RefreshToken => _refreshToken;
public string BaseUrl { get; } public string BaseUrl { get; }
public event Action? OnTokensRefreshed;
public ApiClient(string baseUrl) public ApiClient(string baseUrl)
{ {
BaseUrl = baseUrl.TrimEnd('/'); BaseUrl = baseUrl.TrimEnd('/');
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
SetTokens(result); SetTokens(result);
} }
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
{
var request = new RefreshRequest(refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
?? throw new InvalidOperationException("Token refresh returned empty response.");
SetTokens(result);
return result;
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
if (!string.IsNullOrEmpty(_refreshToken)) if (!string.IsNullOrEmpty(_refreshToken))
@@ -313,6 +328,7 @@ public sealed class ApiClient : IDisposable
_refreshToken = result.RefreshToken; _refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt; _expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
OnTokensRefreshed?.Invoke();
} }
/// <summary> /// <summary>
+78 -16
View File
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
/// <summary> /// <summary>
/// Result returned from the connect dialog. /// Result returned from the connect dialog.
/// </summary> /// </summary>
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister); public record ConnectDialogResult(
string ServerUrl, string Username, string Password,
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
/// <summary> /// <summary>
/// A Terminal.Gui dialog for entering server connection and authentication details. /// A Terminal.Gui dialog for entering server connection and authentication details.
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
savedServers ??= []; savedServers ??= [];
var hasSavedServers = savedServers.Count > 0; var hasSavedServers = savedServers.Count > 0;
var dialogHeight = hasSavedServers ? 20 : 16; var dialogHeight = hasSavedServers ? 22 : 18;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight }; var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0; int yOffset = 0;
SavedServer? selectedSavedServer = null;
// -- Saved Servers section (if any) ----------------------------------- // -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null; ListView? savedServerList = null;
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
dialog.Add(savedLabel); dialog.Add(savedLabel);
var serverDisplayNames = savedServers var serverDisplayNames = savedServers
.Select(s => $"{s.Name} ({s.Username ?? "?"})") .Select(s =>
{
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
return $"{s.Name} ({s.Username ?? "?"}){session}";
})
.ToList(); .ToList();
savedServerList = new ListView savedServerList = new ListView
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
Secret = true Secret = true
}; };
var tokenHintLabel = new Label
{
Text = "Session saved \u2014 password optional",
X = 15,
Y = yOffset + 6,
Width = Dim.Fill(2),
Visible = false
};
var rememberMeCheckbox = new CheckBox
{
Text = "Remember me",
X = 15,
Y = yOffset + 7,
Value = CheckState.UnChecked
};
var displayLabel = new Label var displayLabel = new Label
{ {
Text = "Display Name:", Text = "Display Name:",
X = 1, X = 1,
Y = yOffset + 7 Y = yOffset + 9
}; };
var displayField = new TextField var displayField = new TextField
{ {
Text = "", Text = "",
X = 15, X = 15,
Y = yOffset + 7, Y = yOffset + 9,
Width = Dim.Fill(2) Width = Dim.Fill(2)
}; };
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
Text = "Login", Text = "Login",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 20, X = Pos.Center() - 20,
Y = yOffset + 9 Y = yOffset + 11
}; };
var registerButton = new Button var registerButton = new Button
{ {
Text = "Register", Text = "Register",
X = Pos.Center() - 5, X = Pos.Center() - 5,
Y = yOffset + 9 Y = yOffset + 11
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 10, X = Pos.Center() + 10,
Y = yOffset + 9 Y = yOffset + 11
}; };
// Wire saved server selection to auto-fill fields // Wire saved server selection to auto-fill fields
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
var index = e.NewValue; var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{ {
var server = savedServers[index.Value]; selectedSavedServer = savedServers[index.Value];
urlField.Text = server.Url; urlField.Text = selectedSavedServer.Url;
userField.Text = server.Username ?? ""; userField.Text = selectedSavedServer.Username ?? "";
rememberMeCheckbox.Value = selectedSavedServer.RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
{
passField.Text = "";
tokenHintLabel.Visible = true;
}
else
{
tokenHintLabel.Visible = false;
}
} }
}; };
// Pre-fill with the first saved server // Pre-fill with the first saved server
selectedSavedServer = savedServers[0];
urlField.Text = savedServers[0].Url; urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? ""; userField.Text = savedServers[0].Username ?? "";
rememberMeCheckbox.Value = savedServers[0].RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
tokenHintLabel.Visible = true;
} }
loginButton.Accepting += (s, e) => loginButton.Accepting += (s, e) =>
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty; var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty; var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
{ {
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK"); MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
e.Handled = true; e.Handled = true;
return; return;
} }
result = new ConnectDialogResult(url, user, pass, IsRegister: false); // Determine if we can use a saved token
string? savedRefreshToken = null;
if (string.IsNullOrEmpty(pass)
&& selectedSavedServer is not null
&& !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
&& string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
&& string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
{
savedRefreshToken = selectedSavedServer.RefreshToken;
}
if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
{
MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty; var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty; var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{ {
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
return; return;
} }
result = new ConnectDialogResult(url, user, pass, IsRegister: true); result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
}; };
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField, dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
displayLabel, displayField, loginButton, registerButton, cancelButton); tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null) if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus(); savedServerList.SetFocus();
+6
View File
@@ -85,6 +85,11 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnDisconnectRequested; public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to logout (disconnect + revoke session).
/// </summary>
public event Action? OnLogoutRequested;
/// <summary> /// <summary>
/// Fired when the user requests to open their profile panel. /// Fired when the user requests to open their profile panel.
/// </summary> /// </summary>
@@ -322,6 +327,7 @@ public sealed class MainWindow : Runnable
{ {
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
new Line(), new Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty), new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),